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