File Coverage

git-autofixup
Criterion Covered Total %
statement 249 308 80.8
branch 90 142 63.3
condition 25 33 75.7
subroutine 22 25 88.0
pod n/a
total 386 508 75.9


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2             package main;
3 1     1   6639 use 5.008004;
  1         5  
4 1     1   11 use strict;
  1         1  
  1         41  
5 1     1   10 use warnings FATAL => 'all';
  1         6  
  1         76  
6              
7 1     1   10 use Carp qw(croak);
  1         6  
  1         122  
8 1     1   605 use Pod::Usage;
  1         50660  
  1         234  
9 1     1   723 use Getopt::Long qw(:config bundling);
  1         10505  
  1         4  
10              
11             our $VERSION = 0.002007; # X.YYYZZZ
12              
13             my $verbose;
14              
15             # Strictness levels.
16             my ($CONTEXT, $ADJACENT, $SURROUNDED) = (0..10);
17              
18             my $usage =<<'END';
19             usage: git-autofixup []
20              
21             -h show usage
22             --help show manpage
23             --version show version
24             -v, --verbose increase verbosity (use up to 2 times)
25             -c N, --context N set number of diff context lines (default 3)
26             -s N, --strict N set strictness (default 0)
27             Assign a hunk to fixup a topic branch commit if:
28              
29             0: either only one topic branch commit is blamed in the hunk context or
30             blocks of added lines are adjacent to exactly one topic branch commit.
31             Removing upstream lines is allowed for this level.
32             1: blocks of added lines are adjacent to exactly one topic branch commit
33             2: blocks of added lines are surrounded by exactly one topic branch commit
34              
35             Regardless of strictness level, removed lines are correlated with the
36             commit they're blamed on, and all the blocks of changed lines in a hunk
37             must be correlated with the same topic branch commit in order to be
38             assigned to it. See the --help for more details.
39             END
40              
41             # Parse hunks out of `git diff` output. Return an array of hunk hashrefs.
42             sub parse_hunks {
43 34     34   298 my $fh = shift;
44 34         288 my ($file_a, $file_b);
45 34         0 my @hunks;
46 34         0 my $line;
47 34         68054 while ($line = <$fh>) {
48 187 100       2521 if ($line =~ /^--- (.*)/) {
    100          
    100          
49 36         741 $file_a = $1;
50             } elsif ($line =~ /^\+\+\+ (.*)/) {
51 36         257 $file_b = $1;
52             } elsif ($line =~ /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/) {
53 37         199 my $header = $line;
54              
55 37         127 for ($file_a, $file_b) {
56 74         719 s#^[ab]/##;
57             }
58              
59 37 100       131 next if $file_a ne $file_b; # Ignore creations and deletions.
60              
61 34         266 my $lines = [];
62 34         111 while (1) {
63 122         458 $line = <$fh>;
64 122 100 100     910 if (!defined($line) || $line =~ /^[^ +\\-]/) {
65 34         81 last;
66             }
67 88         155 push @{$lines}, $line;
  88         405  
68             }
69              
70 34 100       1781 push(@hunks, {
71             file => $file_a,
72             start => $1,
73             count => defined($2) ? $2 : 1,
74             header => $header,
75             lines => $lines,
76             });
77             # The next line after a hunk could be a header for the next commit
78             # or hunk.
79 34 100       364 redo if defined $line;
80             }
81             }
82 34         320 return @hunks;
83             }
84              
85             sub get_summary_for_commits {
86 34     34   150 my $rev = shift;
87 34         65 my %commits;
88 34         148903 for (qx(git log --no-merges --format=%H:%s $rev..)) {
89 43         554 chomp;
90 43         789 my ($sha, $msg) = split ':', $_, 2;
91 43         658 $commits{$sha} = $msg;
92             }
93 34         796 return \%commits;
94             }
95              
96             # Return targets of fixup!/squash! commits.
97             sub get_sha_aliases {
98 34     34   129 my $summary_for = shift;
99 34         81 my %aliases;
100 34         57 my @targets = keys(%{$summary_for});
  34         477  
101 34         273 for my $sha (@targets) {
102 43         288 my $summary = $summary_for->{$sha};
103 43 100       583 next if $summary !~ /^(?:fixup|squash)! (.*)/;
104 3         62 my $prefix = $1;
105 3 50       30 if ($prefix =~ /^(?:(?:fixup|squash)! ){2}/) {
106 0         0 die "fixup commits for fixup commits aren't supported: $sha";
107             }
108 3         37 my @matches = grep {startswith($summary_for->{$_}, $prefix)} @targets;
  6         66  
109 3 50       52 if (@matches > 1) {
    50          
    50          
110 0         0 die "ambiguous fixup commit target: multiple commit summaries start with: $prefix\n";
111             } elsif (@matches == 0) {
112 0         0 die "no fixup target: $sha";
113             } elsif (@matches == 1) {
114 3         24 $aliases{$sha} = $matches[0];
115             }
116             }
117 34         235 return \%aliases;
118             }
119              
120             sub get_fixup_sha {
121 34     34   182 my $args = shift;
122 34         101 my $hunk = $args->{hunk};
123 34         73 my $blame = $args->{blame};
124 34         80 my $summary_for = $args->{summary_for};
125 34         57 my $strict = $args->{strict};
126 34 50       103 if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) {
  136         350  
127 0         0 croak 'missing argument';
128             }
129              
130 34         61 my @targets;
131 34 100       205 if ($args->{strict} == $CONTEXT) {
132 15         65 @targets = get_fixup_targets_from_all_context($args);
133 15         34 my @topic_targets = grep {defined $summary_for->{$_}} @targets;
  19         60  
134 15 100       58 if (@topic_targets > 1) {
135             # The context assignment is ambiguous, but an adjacency assignment
136             # might not be.
137 3         34 @targets = get_fixup_targets_from_adjacent_context($args);
138             }
139             } else {
140 19         158 @targets = get_fixup_targets_from_adjacent_context($args);
141             }
142              
143 34         69 my $upstream_is_blamed = grep {!defined $summary_for->{$_}} @targets;
  36         105  
144 34         66 my @topic_targets = grep {defined $summary_for->{$_}} @targets;
  36         109  
145 34 100 100     351 if ($strict && $upstream_is_blamed) {
    50          
    100          
146 4 50       33 $verbose && print hunk_desc($hunk), " changes lines blamed on upstream\n";
147 4         21 return;
148             } elsif (@topic_targets > 1) {
149 0 0       0 $verbose && print hunk_desc($hunk), " has multiple targets\n";
150 0         0 return;
151             } elsif (@topic_targets == 0) {
152 3 50       40 $verbose && print hunk_desc($hunk), " has no targets\n";
153 3         17 return;
154             }
155 27 50       78 if ($verbose) {
156             printf "%s fixes %s %s\n",
157             hunk_desc($hunk),
158             substr($topic_targets[0], 0, 8),
159 0         0 $summary_for->{$topic_targets[0]};
160             }
161 27         69 return $topic_targets[0];
162             }
163              
164             sub hunk_desc {
165 0     0   0 my $hunk = shift;
166             return join " ", (
167             $hunk->{file},
168 0         0 $hunk->{header} =~ /(@@[^@]*@@)/,
169             );
170             }
171              
172             sub get_fixup_targets_from_all_context {
173 15     15   24 my $args = shift;
174 15         28 my ($hunk, $blame, $summary_for) = @{$args}{qw(hunk blame summary_for)};
  15         49  
175 15 50       24 croak 'missing argument' if grep {!defined} ($hunk, $blame, $summary_for);
  45         72  
176              
177 15         27 my @targets = uniq(map {$_->{sha}} values(%{$blame}));
  26         79  
  15         54  
178 15 50       92 return wantarray ? @targets : \@targets;
179             }
180              
181             sub uniq {
182 33     33   49 my %seen;
183 33         122 return grep {!$seen{$_}++} @_;
  48         336  
184             }
185              
186             sub get_fixup_targets_from_adjacent_context {
187 22     22   119 my $args = shift;
188 22         160 my $hunk = $args->{hunk};
189 22         58 my $blame = $args->{blame};
190 22         49 my $summary_for = $args->{summary_for};
191 22         51 my $strict = $args->{strict};
192 22 50       157 if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) {
  88         188  
193 0         0 croak 'missing argument';
194             }
195              
196 22         84 my $blame_indexes = get_blame_indexes($hunk);
197              
198 22         40 my %blamed;
199 22         57 my $diff = $hunk->{lines};
200 22         54 for (my $di = 0; $di < @{$diff}; $di++) { # diff index
  78         153  
201 56         88 my $bi = $blame_indexes->[$di];
202 56         72 my $line = $diff->[$di];
203 56 100       107 if (startswith($line, '-')) {
    100          
204 17         47 my $sha = $blame->{$bi}{sha};
205 17         68 $blamed{$sha} = 1;
206             } elsif (startswith($line, '+')) {
207 18         28 my @lines;
208 18 100 66     328 if ($di > 0 && defined $blame->{$bi-1}) {
209 17         72 push @lines, $bi-1;
210             }
211 18 100       74 if (defined $blame->{$bi}) {
212 5         21 push @lines, $bi;
213             }
214 18         33 my @adjacent_shas = uniq(map {$_->{sha}} @{$blame}{@lines});
  22         91  
  18         89  
215 18         52 my @target_shas = grep {defined $summary_for->{$_}} @adjacent_shas;
  21         67  
216             # Note that lines at the beginning or end of a file can be
217             # "surrounded" by a single line.
218 18   100     249 my $is_surrounded = @target_shas > 0
219             && @target_shas == @adjacent_shas
220             && $target_shas[0] eq $target_shas[-1];
221 18         55 my $is_adjacent = @target_shas == 1;
222 18 100 100     113 if ($is_surrounded || ($strict < $SURROUNDED && $is_adjacent)) {
      100        
223 14         36 $blamed{$target_shas[0]} = 1;
224             }
225 18   100     108 while ($di < @$diff-1 && startswith($diff->[$di+1], '+')) {
226 4         31 $di++;
227             }
228             }
229             }
230 22         73 my @targets = keys %blamed;
231 22 50       123 return wantarray ? @targets : \@targets;
232             }
233              
234             sub startswith {
235 650     650   1453 my ($haystack, $needle) = @_;
236 650         2526 return index($haystack, $needle, 0) == 0;
237             }
238              
239             # Map lines in a hunk's diff to the corresponding `git blame HEAD` output.
240             sub get_blame_indexes {
241 22     22   41 my $hunk = shift;
242 22         37 my @indexes;
243 22         96 my $bi = $hunk->{start};
244 22         78 for (my $di = 0; $di < @{$hunk->{lines}}; $di++) {
  82         215  
245 60         215 push @indexes, $bi;
246 60         218 my $first = substr($hunk->{lines}[$di], 0, 1);
247 60 100 100     446 if ($first eq '-' or $first eq ' ') {
248 38         96 $bi++;
249             }
250             # Don't increment $bi for added lines.
251             }
252 22         146 return \@indexes;
253             }
254              
255             sub print_hunk_blamediff {
256 0     0   0 my $args = shift;
257 0         0 my $fh = $args->{fh};
258 0         0 my $hunk = $args->{hunk};
259 0         0 my $summary_for = $args->{summary_for};
260 0         0 my $blame = $args->{blame};
261 0         0 my $blame_indexes = $args->{blame_indexes};
262 0 0       0 if (grep {!defined} ($fh, $hunk, $summary_for, $blame, $blame_indexes)) {
  0         0  
263 0         0 croak 'missing argument';
264             }
265              
266 0         0 my $format = "%-8.8s|%4.4s|%-30.30s|%-30.30s\n";
267 0         0 for (my $i = 0; $i < @{$hunk->{lines}}; $i++) {
  0         0  
268 0         0 my $line_r = $hunk->{lines}[$i];
269 0         0 my $bi = $blame_indexes->[$i];
270 0 0       0 my $sha = defined $blame->{$bi} ? $blame->{$bi}{sha} : undef;
271              
272 0 0       0 my $display_sha = defined($sha) ? $sha : q{};
273 0         0 my $display_bi = $bi;
274 0 0       0 if (startswith($line_r, '+')) {
275 0         0 $display_sha = q{}; # For added lines.
276 0         0 $display_bi = q{};
277             }
278 0 0 0     0 if (defined($sha) && !defined($summary_for->{$sha})) {
279             # For lines from before the given upstream revision.
280 0         0 $display_sha = '^';
281             }
282              
283 0         0 my $line_l = '';
284 0 0 0     0 if (defined $blame->{$bi} && !startswith($line_r, '+')) {
285 0         0 $line_l = $blame->{$bi}{text};
286             }
287              
288 0         0 for ($line_l, $line_r) {
289             # For the table to line up, tabs need to be converted to a string of fixed width.
290 0         0 s/\t/^I/g;
291             # Remove trailing newlines and carriage returns. If more trailing
292             # whitespace is removed, that's fine.
293 0         0 $_ = rtrim($_);
294             }
295              
296 0         0 printf {$fh} $format, $display_sha, $display_bi, $line_l, $line_r;
  0         0  
297             }
298 0         0 print {$fh} "\n";
  0         0  
299 0         0 return;
300             }
301              
302             sub rtrim {
303 0     0   0 my $s = shift;
304 0         0 $s =~ s/\s+\z//;
305 0         0 return $s;
306             }
307              
308             sub blame {
309 34     34   139 my ($hunk, $alias_for) = @_;
310 34 100       203 if ($hunk->{count} == 0) {
311 1         33 return {};
312             }
313 33         860 my @cmd = (
314             'git', 'blame', '--porcelain',
315             '-L' => "$hunk->{start},+$hunk->{count}",
316             'HEAD', '--',
317             "$hunk->{file}");
318 33         110 my %blame;
319 33         97 my ($sha, $line_num);
320 33 50       76760 open(my $fh, '-|', @cmd) or die "git blame: $!\n";
321 33         77811 while (my $line = <$fh>) {
322 540 100       1861 if ($line =~ /^([0-9a-f]{40}) \d+ (\d+)/) {
323 54         1074 ($sha, $line_num) = ($1, $2);
324             }
325 540 100       1148 if (startswith($line, "\t")) {
326 54 100       190 if (defined $alias_for->{$sha}) {
327 3         35 $sha = $alias_for->{$sha};
328             }
329 54         1806 $blame{$line_num} = {sha => $sha, text => substr($line, 1)};
330             }
331             }
332 33 50       1039 close($fh) or die "git blame: non-zero exit code";
333 33         1598 return \%blame;
334             }
335              
336             sub get_diff_hunks {
337 34     34   217 my $num_context_lines = shift;
338 34         1048 my @cmd = (qw(git diff --ignore-submodules), "-U$num_context_lines");
339 34 50       73073 open(my $fh, '-|', @cmd) or die $!;
340 34         1082 my @hunks = parse_hunks($fh, keep_lines => 1);
341 34 50       1047 close($fh) or die "git diff: non-zero exit code";
342 34 50       1565 return wantarray ? @hunks : \@hunks;
343             }
344              
345             sub commit_fixup {
346 22     22   62 my ($sha, $hunks) = @_;
347 22 50       45182 open my $fh, '|-', 'git apply --unidiff-zero --cached -' or die "git apply: $!\n";
348 22         344 for my $hunk (@{$hunks}) {
  22         289  
349 27         415 print({$fh}
350             "--- a/$hunk->{file}\n",
351             "+++ b/$hunk->{file}\n",
352             $hunk->{header},
353 27         126 @{$hunk->{lines}},
  27         629  
354             );
355             }
356 22 50       52145 close $fh or die "git apply: non-zero exit code\n";
357 22 50       150862 system('git', 'commit', "--fixup=$sha") == 0 or die "git commit: $!\n";
358 22         1540 return;
359             }
360              
361             sub is_index_dirty {
362 34 50   34   74473 open(my $fh, '-|', 'git status --porcelain') or die "git status: $!\n";
363 34         297 my $dirty;
364 34         90637 while (my $line = <$fh>) {
365 39 50       3541 if ($line =~ /^[^?! ]/) {
366 0         0 $dirty = 1;
367 0         0 last;
368             }
369             }
370 34 50       1210 close $fh or die "git status: non-zero exit code\n";
371 34         1779 return $dirty;
372             }
373              
374             sub get_fixup_hunks_by_sha {
375 34     34   136 my $args = shift;
376 34         100 my $hunks = $args->{hunks};
377 34         81 my $blame_for = $args->{blame_for};
378 34         63 my $summary_for = $args->{summary_for};
379 34         82 my $strict = $args->{strict};
380 34 50       341 if (grep {!defined} ($hunks, $blame_for, $summary_for, $strict)) {
  136         401  
381 0         0 croak 'missing argument';
382             }
383              
384 34         87 my %hunks_for;
385 34         97 for my $hunk (@{$hunks}) {
  34         116  
386 34         86 my $blame = $blame_for->{$hunk};
387 34 50       103 if ($verbose > 1) {
388 0         0 print_hunk_blamediff({
389             fh => *STDOUT,
390             hunk => $hunk,
391             summary_for => $summary_for,
392             blame => $blame,
393             blame_indexes => get_blame_indexes($hunk)
394             });
395             }
396 34         593 my $sha = get_fixup_sha({
397             hunk => $hunk,
398             blame => $blame,
399             summary_for => $summary_for,
400             strict => $strict,
401             });
402 34 100       173 next if !$sha;
403 27         45 push @{$hunks_for{$sha}}, $hunk;
  27         152  
404             }
405 34         146 return \%hunks_for;
406             }
407              
408             # Return SHAs in some consistent order.
409             #
410             # Currently they're ordered by how early their assigned hunks appear in the
411             # working dir diff output.
412             sub get_ordered_shas {
413 34     34   57 my $hunks = shift;
414 34         56 my $hunks_for = shift;
415 34         64 my $nsha = scalar keys %{$hunks_for};
  34         100  
416 34         92 my @ordered = ();
417 34         52 for my $tgt (@{$hunks}) {
  34         215  
418 33 100       144 last if (@ordered >= $nsha);
419 22         30 for my $sha (keys %{$hunks_for}) {
  22         66  
420 23         76 my $sha_hunks = $hunks_for->{$sha};
421 23 100       30 if (grep {$_->{file} eq $tgt->{file} && $_->{start} == $tgt->{start}} @{$sha_hunks}) {
  28 100       356  
  23         36  
422 22         127 push @ordered, $sha;
423 22         82 last;
424             }
425             }
426             }
427 34         102 return @ordered;
428             }
429              
430             sub main {
431 34     34   2346268 $verbose = 0;
432 34         360 my $num_context_lines = 3;
433 34         305 my $strict = $CONTEXT;
434 34         212 my $man;
435              
436 34         267 my ($help, $show_version);
437 34 50       955 GetOptions(
438             'h' => \$help,
439             'help' => \$man,
440             'version' => \$show_version,
441             'verbose|v+' => \$verbose,
442             'strict|s=i' => \$strict,
443             'context|c=i' => \$num_context_lines,
444             ) or return 1;
445 34 50       36727 if ($help) {
446 0         0 print $usage;
447 0         0 return 0;
448             }
449 34 50       146 if ($show_version) {
450 0         0 print "$VERSION\n";
451 0         0 return 0;
452             }
453 34 50       152 if ($man) {
454 0         0 pod2usage(-exitval => 0, -verbose => 2);
455             }
456              
457 34 50       281 @ARGV == 1 or die "No upstream commit given.\n";
458 34         129 my $upstream = shift @ARGV;
459 34         176303 qx(git rev-parse --verify ${upstream}^{commit});
460 34 50       1625 $? == 0 or die "Can't resolve given commit.\n";
461              
462 34 50       414 if ($num_context_lines < 0) {
463 0         0 die "invalid number of context lines: $num_context_lines\n";
464             }
465              
466 34 50 66     668 if ($strict < 0) {
    50          
467 0         0 die "invalid strictness level: $strict\n";
468             } elsif ($strict > 0 && $num_context_lines == 0) {
469 0         0 die "strict hunk assignment requires context\n";
470             }
471              
472              
473 34         123080 my $toplevel = qx(git rev-parse --show-toplevel);
474 34         464 chomp $toplevel;
475 34 50       756 $? == 0 or die "Can't get repo toplevel dir\n";
476 34 50       548 chdir $toplevel or die $!;
477              
478 34 50       757 if (is_index_dirty()) {
479 0         0 die "There are staged changes. Clean up the index and try again.\n";
480             }
481              
482 34         197 my $hunks = get_diff_hunks($num_context_lines);
483 34         219 my $summary_for = get_summary_for_commits($upstream);
484 34         557 my $alias_for = get_sha_aliases($summary_for);
485 34         135 my %blame_for = map {$_ => blame($_, $alias_for)} @{$hunks};
  34         377  
  34         340  
486 34         1160 my $hunks_for = get_fixup_hunks_by_sha({
487             hunks => $hunks,
488             blame_for => \%blame_for,
489             summary_for => $summary_for,
490             strict => $strict,
491             });
492 34         185 my @ordered_shas = get_ordered_shas($hunks, $hunks_for);
493 34         157 for my $sha (@ordered_shas) {
494 22         51 my $fixup_hunks = $hunks_for->{$sha};
495 22         89 commit_fixup($sha, $fixup_hunks);
496             }
497 34         3050 return 0;
498             }
499              
500             if (!caller()) {
501             exit main();
502             }
503             1;
504              
505             __END__