fe034027b0c14d143c8a3f83fb470ab7c38cc359
[xonotic/div0-gittools.git] / git-branch-manager
1 #!/usr/bin/perl
2
3 use strict;
4 use warnings;
5 use Getopt::Long qw/:config no_ignore_case no_auto_abbrev gnu_compat/;
6
7 my %color =
8 (
9         '' => "\e[m",
10         'outstanding' => "\e[1;33m",
11         'unmerge' => "\e[1;31m",
12         'merge' => "\e[32m",
13         'base' => "\e[1;34m",
14         'previous' => "\e[34m",
15 );
16
17 my %name =
18 (
19         'outstanding' => "OUTSTANDING",
20         'unmerge' => "UNMERGED",
21         'merge' => "MERGED",
22         'base' => "BASE",
23         'previous' => "PREVIOUS",
24 );
25
26 sub check_defined($$)
27 {
28         my ($msg, $data) = @_;
29         return $data if defined $data;
30         die $msg;
31 }
32
33 sub backtick(@)
34 {
35         open my $fh, '-|', @_
36                 or return undef;
37         undef local $/;
38         my $s = <$fh>;
39         close $fh
40                 or return undef;
41         return $s;
42 }
43
44 sub run(@)
45 {
46         return !system @_;
47 }
48
49 my $width = ($ENV{COLUMNS} || backtick 'tput', 'cols' || 80);
50 chomp(my $branch = backtick 'git', 'symbolic-ref', 'HEAD');
51         $branch =~ s/^refs\/heads\///
52                 or die "Not in a branch";
53 chomp(my $master = (backtick 'git', 'config', '--get', "branch-manager.$branch.master" or 'master'));
54 chomp(my $datefilter = (backtick 'git', 'config', '--get', "branch-manager.$branch.startdate" or ''));
55 my @datefilter = ();
56 my $revprefix = "";
57 if($datefilter eq 'mergebase')
58 {
59         chomp($revprefix = check_defined "git-merge-base: $!", backtick 'git', 'merge-base', $master, "HEAD");
60         $revprefix .= "^..";
61 }
62 elsif($datefilter ne '')
63 {
64         @datefilter = "--since=$datefilter";
65 }
66
67 our $do_commit = 1;
68 my $logcache = undef;
69 sub reset_to_commit($)
70 {
71         my ($r) = @_;
72         #run 'git', 'merge', '-s', 'ours', '--no-commit', $r
73         #       or die "git-merge: $!";
74         run 'git', 'checkout', $r, '--', '.'
75                 or die "git-checkout: $!";
76         if($do_commit)
77         {
78                 $logcache = undef;
79                 run 'git', 'update-ref', 'MERGE_HEAD', $r
80                         or die "git-update-ref: $!";
81                 run 'git', 'commit', '--allow-empty', '-m', "::stable-branch::reset=$r"
82                         or die "git-commit: $!";
83         }
84 }
85
86 sub merge_commit($)
87 {
88         my ($r) = @_;
89         my $cmsg = "";
90         my $author = "";
91         my $email = "";
92         my $date = "";
93         if($do_commit)
94         {
95                 $logcache = undef;
96                 my $msg = backtick 'git', 'log', '-1', '--pretty=fuller', $r
97                         or die "git-log: $!";
98                 for(split /\n/, $msg)
99                 {
100                         if(/^Author:\s*(.*) <(.*)>/)
101                         {
102                                 $author = $1;
103                                 $email = $2;
104                         }
105                         elsif(/^AuthorDate:\s*(.*)/)
106                         {
107                                 $date = $1;
108                         }
109                         elsif(/^    (.*)/)
110                         {
111                                 $cmsg .= "$1\n";
112                         }
113                 }
114                 open my $fh, '>', '.commitmsg'
115                         or die ">.commitmsg: $!";
116                 print $fh "$cmsg" . "::stable-branch::merge=$r\n"
117                         or die ">.commitmsg: $!";
118                 close $fh
119                         or die ">.commitmsg: $!";
120         }
121         local $ENV{GIT_AUTHOR_NAME} = $author;
122         local $ENV{GIT_AUTHOR_EMAIL} = $email;
123         local $ENV{GIT_AUTHOR_DATE} = $date;
124         run 'git', 'cherry-pick', '-n', $r
125                 or run 'git', 'mergetool'
126                         or die "git-mergetool: $!";
127         if($do_commit)
128         {
129                 run 'git', 'commit', '-F', '.commitmsg'
130                         or die "git-commit: $!";
131         }
132 }
133
134 sub unmerge_commit($)
135 {
136         my ($r) = @_;
137         my $cmsg = "";
138         my $author = "";
139         my $email = "";
140         my $date = "";
141         if($do_commit)
142         {
143                 $logcache = undef;
144                 my $msg = backtick 'git', 'log', '-1', '--pretty=fuller', $r
145                         or die "git-log: $!";
146                 for(split /\n/, $msg)
147                 {
148                         if(/^Author:\s*(.*)/)
149                         {
150                                 $author = $1;
151                         }
152                         elsif(/^AuthorDate:\s*(.*)/)
153                         {
154                                 $date = $1;
155                         }
156                         elsif(/^    (.*)/)
157                         {
158                                 $cmsg .= "$1\n";
159                         }
160                 }
161                 open my $fh, '>', '.commitmsg'
162                         or die ">.commitmsg: $!";
163                 print $fh "UNMERGE\n$cmsg" . "::stable-branch::unmerge=$r\n"
164                         or die ">.commitmsg: $!";
165                 close $fh
166                         or die ">.commitmsg: $!";
167         }
168         local $ENV{GIT_AUTHOR_NAME} = $author;
169         local $ENV{GIT_AUTHOR_EMAIL} = $email;
170         local $ENV{GIT_AUTHOR_DATE} = $date;
171         run 'git', 'revert', '-n', $r
172                 or run 'git', 'mergetool'
173                         or die "git-mergetool: $!";
174         if($do_commit)
175         {
176                 run 'git', 'commit', '-F', '.commitmsg'
177                         or die "git-commit: $!";
178         }
179 }
180
181 sub rebase_log($$)
182 {
183         my ($r, $log) = @_;
184
185         my @applied = (0) x @{$log->{order_a}};
186         my $newbase_id = $log->{order_h}{$r};
187
188         my @rlog = ();
189         my @outstanding = ();
190
191         for(0..$newbase_id)
192         {
193                 if(!$log->{bitmap}[$_])
194                 {
195                         unshift @rlog, ['unmerge', $log->{order_a}[$_]];
196                 }
197         }
198
199         for($newbase_id+1 .. @{$log->{order_a}}-1)
200         {
201                 if($log->{bitmap}[$_])
202                 {
203                         push @rlog, ['merge', $log->{order_a}[$_]];
204                 }
205                 else
206                 {
207                         push @outstanding, ['outstanding', $log->{order_a}[$_]];
208                 }
209         }
210
211         return
212         {
213                 %$log,
214                 base => $r,
215                 log => [
216                         @rlog,
217                         @outstanding
218                 ]
219         };
220 }
221
222 sub parse_log()
223 {
224         return $logcache if defined $logcache;
225
226         my $base = undef;
227         my @logdata = ();
228
229         my %history = ();
230         my %logmsg = ();
231         my @history = ();
232
233         my %applied = ();
234         my %unapplied = ();
235
236         my $cur_commit = undef;
237         my $cur_msg = undef;
238         for((split /\n/, check_defined "git-log: $!", backtick 'git', 'log', '--topo-order', '--reverse', '--pretty=fuller', @datefilter, "$revprefix$master"), undef)
239         {
240                 if(defined $cur_commit and (not defined $_ or /^commit (\S+)/))
241                 {
242                         $cur_msg =~ s/\s+$//s;
243                         $history{$cur_commit} = scalar @history;
244                         $logmsg{$cur_commit} = $cur_msg;
245                         push @history, $cur_commit;
246                         $cur_commit = $cur_msg = undef;
247                 }
248                 last if not defined $_;
249                 if(/^commit (\S+)/)
250                 {
251                         $cur_commit = $1;
252                 }
253                 else
254                 {
255                         $cur_msg .= "$_\n";
256                 }
257         }
258         $cur_commit = $cur_msg = undef;
259         my @commits = ();
260         for((split /\n/, check_defined "git-log: $!", backtick 'git', 'log', '--topo-order', '--reverse', '--pretty=fuller', @datefilter, "$revprefix"."HEAD"), undef)
261         {
262                 if(defined $cur_commit and (not defined $_ or /^commit (\S+)/))
263                 {
264                         $cur_msg =~ s/\s+$//s;
265                         $logmsg{$cur_commit} = $cur_msg;
266                         push @commits, $cur_commit;
267                         $cur_commit = $cur_msg = undef;
268                 }
269                 last if not defined $_;
270                 if(/^commit (\S+)/)
271                 {
272                         $cur_commit = $1;
273                 }
274                 else
275                 {
276                         $cur_msg .= "$_\n";
277                 }
278         }
279         my $lastrebase = undef;
280         for(@commits)
281         {
282                 my $data = $logmsg{$_};
283                 if($data =~ /::stable-branch::unmerge=(\S+)/)
284                 {
285                         push @logdata, ['unmerge', $1];
286                 }
287                 elsif($data =~ /::stable-branch::merge=(\S+)/)
288                 {
289                         push @logdata, ['merge', $1];
290                 }
291                 elsif($data =~ /::stable-branch::reset=(\S+)/)
292                 {
293                         @logdata = ();
294                         $base = $1;
295                 }
296                 elsif($data =~ /::stable-branch::rebase=(\S+)/)
297                 {
298                         $lastrebase->[0] = 'ignore'
299                                 if defined $lastrebase;
300                         push @logdata, ($lastrebase = ['rebase', $1]);
301                 }
302         }
303
304         if(not defined $base)
305         {
306                 warn 'This branch is not yet managed by git-branch-manager';
307                 return
308                 {
309                         logmsg => \%logmsg,
310                         order_a => \@history,
311                         order_h => \%history,
312                 };
313         }
314         else
315         {
316                 my $baseid = $history{$base};
317                 my @bitmap = map
318                 {
319                         $_ <= $baseid
320                 }
321                 0..@history-1;
322                 my $i = 0;
323                 while($i < @logdata)
324                 {
325                         my ($cmd, $data) = @{$logdata[$i]};
326                         if($cmd eq 'merge')
327                         {
328                                 $bitmap[$history{$data}] = 1;
329                         }
330                         elsif($cmd eq 'unmerge')
331                         {
332                                 $bitmap[$history{$data}] = 0;
333                         }
334                         elsif($cmd eq 'rebase')
335                         {
336                                 # the bitmap is fine, but generate a new log from the bitmap
337                                 my $pseudolog =
338                                 {
339                                         order_a => \@history,
340                                         order_h => \%history,
341                                         bitmap => \@bitmap,
342                                 };
343                                 my $rebasedlog = rebase_log $data, $pseudolog;
344                                 my @l = grep { $_->[0] ne 'outstanding' } @{$rebasedlog->{log}};
345                                 splice @logdata, 0, $i+1, @l;
346                                 $i = @l-1;
347                                 $base = $data;
348                                 $baseid = $history{$base};
349                         }
350                         ++$i;
351                 }
352
353                 my @outstanding = ();
354                 for($baseid+1 .. @history-1)
355                 {
356                         push @outstanding, ['outstanding', $history[$_]]
357                                 unless $bitmap[$_];
358                 }
359
360                 $logcache =
361                 {
362                         logmsg => \%logmsg,
363                         order_a => \@history,
364                         order_h => \%history,
365
366                         bitmap => \@bitmap,
367                         base => $base,
368                         log => [
369                                 @logdata,
370                                 @outstanding
371                         ]
372                 };
373                 return $logcache;
374         }
375 }
376
377 our $pebkac = 0;
378 our $done = 0;
379
380 sub run_script(@);
381 sub run_script(@)
382 {
383         ++$done;
384         my (@commands) = @_;
385         for(@commands)
386         {
387                 my ($cmd, $r) = @$_;
388                 if($pebkac)
389                 {
390                         $r = backtick 'git', 'rev-parse', $r
391                                 or die "git-rev-parse: $!"
392                                         if defined $r;
393                         chomp $r
394                                 if defined $r;
395                 }
396                 print "Executing: $cmd $r\n";
397                 if($cmd eq 'reset')
398                 {
399                         if($pebkac)
400                         {
401                                 my $l = parse_log();
402                                 die "PEBKAC: invalid revision number, cannot reset"
403                                         unless defined $l->{order_h}{$r};
404                         }
405                         reset_to_commit $r;
406                 }
407                 elsif($cmd eq 'hardreset')
408                 {
409                         if($pebkac)
410                         {
411                                 my $l = parse_log();
412                                 die "PEBKAC: invalid revision number, cannot reset"
413                                         unless defined $l->{order_h}{$r};
414                         }
415                         run 'git', 'reset', '--hard', $r
416                                 or die "git-reset: $!";
417                         reset_to_commit $r;
418                 }
419                 elsif($cmd eq 'merge')
420                 {
421                         if($pebkac)
422                         {
423                                 my $l = parse_log();
424                                 die "PEBKAC: invalid revision number, cannot reset"
425                                         unless defined $l->{order_h}{$r} and not $l->{bitmap}[$l->{order_h}{$r}];
426                                 die "PEBKAC: not initialized"
427                                         unless defined $l->{base};
428                         }
429                         merge_commit $r;
430                 }
431                 elsif($cmd eq 'unmerge')
432                 {
433                         if($pebkac)
434                         {
435                                 my $l = parse_log();
436                                 die "PEBKAC: invalid revision number, cannot reset"
437                                         unless defined $l->{order_h}{$r} and $l->{bitmap}[$l->{order_h}{$r}];
438                                 die "PEBKAC: not initialized"
439                                         unless defined $l->{base};
440                         }
441                         unmerge_commit $r;
442                 }
443                 elsif($cmd eq 'outstanding')
444                 {
445                 }
446                 else
447                 {
448                         die "Invalid command: $cmd $r";
449                 }
450         }
451 }
452
453 sub opt_rebase($$)
454 {
455         ++$done;
456         my ($cmd, $r) = @_;
457         if($pebkac)
458         {
459                 $r = backtick 'git', 'rev-parse', $r
460                         or die "git-rev-parse: $!"
461                         if defined $r;
462                 chomp $r
463                         if defined $r;
464                 my $l = parse_log();
465                 die "PEBKAC: invalid revision number, cannot reset"
466                         unless defined $l->{order_h}{$r};
467                 die "PEBKAC: not initialized"
468                         unless defined $l->{base};
469         }
470         my $msg = backtick 'git', 'log', '-1', '--pretty=fuller', @datefilter, 'HEAD'
471                 or die "git-log: $!";
472         $msg =~ /^commit (\S+)/s
473                 or die "Invalid git log output";
474         my $commit_id = $1;
475         my $l = rebase_log $r, parse_log();
476         local $pebkac = 0;
477         local $do_commit = 0;
478         eval
479         {
480                 reset_to_commit $r;
481                 run_script @{$l->{log}};
482                 run 'git', 'commit', '--allow-empty', '-m', "::stable-branch::rebase=$r"
483                         or die "git-commit: $!";
484                 1;
485         }
486         or do
487         {
488                 my $err = $@;
489                 run 'git', 'reset', '--hard', $commit_id
490                         or die "$err, and then git-reset failed: $!";
491                 die $err;
492         };
493 }
494
495 my $histsize = 20;
496 sub opt_list($$)
497 {
498         ++$done;
499         my ($cmd, $r) = @_;
500         $r = undef if $r eq '';
501         if($pebkac)
502         {
503                 ($r = backtick 'git', 'rev-parse', $r
504                         or die "git-rev-parse: $!")
505                                 if defined $r;
506                 chomp $r
507                         if defined $r;
508                 my $l = parse_log();
509                 die "PEBKAC: invalid revision number, cannot reset"
510                         unless !defined $r or defined $l->{order_h}{$r};
511                 die "PEBKAC: not initialized"
512                         unless defined $l->{base};
513         }
514         my $l = parse_log();
515         $l = rebase_log $r, $l
516                 if defined $r;
517         my $last = $l->{order_h}{$l->{base}};
518         my $first = $last - $histsize;
519         $first = 0
520                 if $first < 0;
521         my %seen = ();
522         for(@{$l->{log}})
523         {
524                 ++$seen{$_->[1]};
525         }
526         my @l = (
527                         (map { $seen{$l->{order_a}[$_]} ? () : ['previous', $l->{order_a}[$_]] } $first..($last-1)),
528                         ['base', $l->{base}],
529                         @{$l->{log}}
530                         );
531         if($cmd eq 'chronology')
532         {
533                 @l = map { [$_->[1], $_->[2]] } sort { $l->{order_h}{$a->[2]} <=> $l->{order_h}{$b->[2]} or $a->[0] <=> $b->[0] } map { [$_, $l[$_]->[0], $l[$_]->[1]] } 0..(@l-1);
534         }
535         elsif($cmd eq 'outstanding')
536         {
537                 my %seen = ();
538                 @l = reverse grep { !$seen{$_->[1]}++ && !$l->{bitmap}->[$l->{order_h}->{$_->[1]}] } reverse map { [$_->[1], $_->[2]] } sort { $l->{order_h}{$a->[2]} <=> $l->{order_h}{$b->[2]} or $a->[0] <=> $b->[0] } map { [$_, $l[$_]->[0], $l[$_]->[1]] } 0..(@l-1);
539         }
540         for(@l)
541         {
542                 my ($action, $r) = @$_;
543                 my $m = $l->{logmsg}->{$r};
544                 my $m_short = join ' ', map { s/^    (?!git-svn-id)(.)/$1/ ? $_ : () } split /\n/, $m;
545                 $m_short = substr $m_short, 0, $width - 11 - 1 - 40 - 1;
546                 printf "%s%-11s%s %s %s\n", $color{$action}, $name{$action}, $color{''}, $r, $m_short;
547         }
548 }
549
550 sub opt_help($$)
551 {
552         my ($cmd, $one) = @_;
553         print STDERR <<EOF;
554 Usage:
555         $0 [{--histsize|-s} n] {--chronology|-c}
556         $0 [{--histsize|-s} n] {--chronology|-c} revision-hash
557         $0 [{--histsize|-s} n] {--log|-l}
558         $0 [{--histsize|-s} n] {--log|-l} revision-hash
559         $0 {--merge|-m} revision-hash
560         $0 {--unmerge|-u} revision-hash
561         $0 {--reset|-R} revision-hash
562         $0 {--hardreset|-H} revision-hash
563         $0 {--rebase|-b} revision-hash
564 EOF
565         exit 1;
566 }
567
568 sub handler($)
569 {
570         my ($sub) = @_;
571         return sub
572         {
573                 my $r;
574                 eval
575                 {
576                         $r = $sub->(@_);
577                         1;
578                 }
579                 or do
580                 {
581                         warn "$@";
582                         exit 1;
583                 };
584                 return $r;
585         };
586 }
587
588 $pebkac = 1;
589 my $result = GetOptions(
590         "chronology|c:s", handler \&opt_list,
591         "log|l:s", handler \&opt_list,
592         "outstanding|o:s", handler \&opt_list,
593         "rebase|b=s", handler \&opt_rebase,
594         "merge|m=s{,}", handler sub { run_script ['merge', $_[1]]; },
595         "unmerge|u=s{,}", handler sub { run_script ['unmerge', $_[1]]; },
596         "reset|R=s", handler sub { run_script ['reset', $_[1]]; },
597         "hardreset|H=s", handler sub { run_script ['hardreset', $_[1]]; },
598         "help|h", handler \&opt_help,
599         "histsize|s=i", \$histsize
600 );
601 if(!$done)
602 {
603         opt_list("outstanding", "");
604 }
605 $pebkac = 0;