File Coverage

git-autofixup
Criterion Covered Total %
statement 231 290 79.6
branch 84 136 61.7
condition 24 33 72.7
subroutine 21 24 87.5
pod n/a
total 360 483 74.5


line stmt bran cond sub pod time code
1             #!/usr/bin/perl
2             package main;
3 1     1   5568 use 5.008004;
  1         6  
4 1     1   5 use strict;
  1         4  
  1         40  
5 1     1   4 use warnings FATAL => 'all';
  1         1  
  1         67  
6              
7 1     1   9 use Carp qw(croak);
  1         1  
  1         99  
8 1     1   472 use Pod::Usage;
  1         40308  
  1         187  
9 1     1   642 use Getopt::Long qw(:config bundling);
  1         8386  
  1         3  
10              
11             our $VERSION = 0.002006; # 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 hunk to topic branch commit if:
28             0: exactly one topic branch commit is blamed in hunk context or
29             changed lines are adjacent to exactly one topic branch commit
30             1: changed lines are adjacent to exactly one topic branch commit
31             2: changed lines are surrounded by exactly one topic branch commit
32             END
33              
34             # Parse hunks out of `git diff` output. Return an array of hunk hashrefs.
35             sub parse_hunks {
36 33     33   283 my $fh = shift;
37 33         230 my ($file_a, $file_b);
38 33         0 my @hunks;
39 33         0 my $line;
40 33         72004 while ($line = <$fh>) {
41 181 100       3471 if ($line =~ /^--- (.*)/) {
    100          
    100          
42 35         1041 $file_a = $1;
43             } elsif ($line =~ /^\+\+\+ (.*)/) {
44 35         224 $file_b = $1;
45             } elsif ($line =~ /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/) {
46 35         161 my $header = $line;
47              
48 35         201 for ($file_a, $file_b) {
49 70         775 s#^[ab]/##;
50             }
51              
52 35 100       118 next if $file_a ne $file_b; # Ignore creations and deletions.
53              
54 32         198 my $lines = [];
55 32         55 while (1) {
56 110         2115 $line = <$fh>;
57 110 100 100     662 if (!defined($line) || $line =~ /^[^ +-\\]/) {
58 32         57 last;
59             }
60 78         96 push @{$lines}, $line;
  78         352  
61             }
62              
63 32 100       1536 push(@hunks, {
64             file => $file_a,
65             start => $1,
66             count => defined($2) ? $2 : 1,
67             header => $header,
68             lines => $lines,
69             });
70             # The next line after a hunk could be a header for the next commit
71             # or hunk.
72 32 100       290 redo if defined $line;
73             }
74             }
75 33         260 return @hunks;
76             }
77              
78             sub get_summary_for_commits {
79 33     33   98 my $rev = shift;
80 33         49 my %commits;
81 33         187434 for (qx(git log --no-merges --format=%H:%s $rev..)) {
82 40         494 chomp;
83 40         662 my ($sha, $msg) = split ':', $_, 2;
84 40         1544 $commits{$sha} = $msg;
85             }
86 33         896 return \%commits;
87             }
88              
89             # Return targets of fixup!/squash! commits.
90             sub get_sha_aliases {
91 33     33   123 my $summary_for = shift;
92 33         77 my %aliases;
93 33         55 my @targets = keys(%{$summary_for});
  33         205  
94 33         249 for my $sha (@targets) {
95 40         197 my $summary = $summary_for->{$sha};
96 40 100       362 next if $summary !~ /^(?:fixup|squash)! (.*)/;
97 3         43 my $prefix = $1;
98 3 50       22 if ($prefix =~ /^(?:(?:fixup|squash)! ){2}/) {
99 0         0 die "fixup commits for fixup commits aren't supported: $sha";
100             }
101 3         26 my @matches = grep {startswith($summary_for->{$_}, $prefix)} @targets;
  6         56  
102 3 50       32 if (@matches > 1) {
    50          
    50          
103 0         0 die "ambiguous fixup commit target: multiple commit summaries start with: $prefix\n";
104             } elsif (@matches == 0) {
105 0         0 die "no fixup target: $sha";
106             } elsif (@matches == 1) {
107 3         19 $aliases{$sha} = $matches[0];
108             }
109             }
110 33         155 return \%aliases;
111             }
112              
113             sub get_fixup_sha {
114 32     32   170 my $args = shift;
115 32         68 my $hunk = $args->{hunk};
116 32         68 my $blame = $args->{blame};
117 32         41 my $summary_for = $args->{summary_for};
118 32         43 my $strict = $args->{strict};
119 32 50       58 if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) {
  128         234  
120 0         0 croak 'missing argument';
121             }
122              
123 32         51 my @targets;
124 32 100       85 if ($args->{strict} == $CONTEXT) {
125 13         80 @targets = get_fixup_targets_from_all_context($args);
126 13         32 my @topic_targets = grep {defined $summary_for->{$_}} @targets;
  15         45  
127 13 100       38 if (@topic_targets > 1) {
128             # The context assignment is ambiguous, but an adjacency assignment
129             # might not be.
130 1         23 @targets = get_fixup_targets_from_adjacent_context($args);
131             }
132             } else {
133 19         145 @targets = get_fixup_targets_from_adjacent_context($args);
134             }
135              
136 32         73 my $upstream_is_blamed = grep {!defined $summary_for->{$_}} @targets;
  34         95  
137 32         51 my @topic_targets = grep {defined $summary_for->{$_}} @targets;
  34         84  
138 32 100 100     219 if ($strict && $upstream_is_blamed) {
    50          
    100          
139 4 50       29 $verbose && print hunk_desc($hunk), " changes lines blamed on upstream\n";
140 4         18 return;
141             } elsif (@topic_targets > 1) {
142 0 0       0 $verbose && print hunk_desc($hunk), " has multiple targets\n";
143 0         0 return;
144             } elsif (@topic_targets == 0) {
145 3 50       68 $verbose && print hunk_desc($hunk), " has no targets\n";
146 3         30 return;
147             }
148 25 50       48 if ($verbose) {
149             printf "%s fixes %s %s\n",
150             hunk_desc($hunk),
151             substr($topic_targets[0], 0, 8),
152 0         0 $summary_for->{$topic_targets[0]};
153             }
154 25         65 return $topic_targets[0];
155             }
156              
157             sub hunk_desc {
158 0     0   0 my $hunk = shift;
159             return join " ", (
160             $hunk->{file},
161 0         0 $hunk->{header} =~ /(@@[^@]*@@)/,
162             );
163             }
164              
165             sub get_fixup_targets_from_all_context {
166 13     13   24 my $args = shift;
167 13         26 my ($hunk, $blame, $summary_for) = @{$args}{qw(hunk blame summary_for)};
  13         36  
168 13 50       24 croak 'missing argument' if grep {!defined} ($hunk, $blame, $summary_for);
  39         83  
169              
170 13         29 my @targets = uniq(map {$_->{sha}} values(%{$blame}));
  18         55  
  13         38  
171 13 50       62 return wantarray ? @targets : \@targets;
172             }
173              
174             sub uniq {
175 29     29   63 my %seen;
176 29         55 return grep {!$seen{$_}++} @_;
  37         189  
177             }
178              
179             sub get_fixup_targets_from_adjacent_context {
180 20     20   42 my $args = shift;
181 20         37 my $hunk = $args->{hunk};
182 20         42 my $blame = $args->{blame};
183 20         74 my $summary_for = $args->{summary_for};
184 20         44 my $strict = $args->{strict};
185 20 50       38 if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) {
  80         128  
186 0         0 croak 'missing argument';
187             }
188              
189 20         97 my $blame_indexes = get_blame_indexes($hunk);
190              
191 20         46 my %blamed;
192 20         33 my $diff = $hunk->{lines};
193 20         38 for (my $di = 0; $di < @{$diff}; $di++) { # diff index
  66         114  
194 46         61 my $bi = $blame_indexes->[$di];
195 46         56 my $line = $diff->[$di];
196 46 100       96 if (startswith($line, '-')) {
    100          
197 15         26 my $sha = $blame->{$bi}{sha};
198 15         48 $blamed{$sha} = 1;
199             } elsif (startswith($line, '+')) {
200 16         28 my @lines;
201 16 100 66     532 if ($di > 0 && defined $blame->{$bi-1}) {
202 15         41 push @lines, $bi-1;
203             }
204 16 100       237 if (defined $blame->{$bi}) {
205 4         56 push @lines, $bi;
206             }
207 16         25 my @adjacent_shas = uniq(map {$_->{sha}} @{$blame}{@lines});
  19         140  
  16         76  
208 16         30 my @target_shas = grep {defined $summary_for->{$_}} @adjacent_shas;
  18         52  
209             # Note that lines at the beginning or end of a file can be
210             # "surrounded" by a single line.
211 16   66     215 my $is_surrounded = @target_shas > 0
212             && @target_shas == @adjacent_shas
213             && $target_shas[0] eq $target_shas[-1];
214 16         70 my $is_adjacent = @target_shas == 1;
215 16 100 100     152 if ($is_surrounded || ($strict < $SURROUNDED && $is_adjacent)) {
      100        
216 13         22 $blamed{$target_shas[0]} = 1;
217             }
218 16   100     78 while ($di < @$diff-1 && startswith($diff->[$di+1], '+')) {
219 4         20 $di++;
220             }
221             }
222             }
223 20         61 my @targets = keys %blamed;
224 20 50       240 return wantarray ? @targets : \@targets;
225             }
226              
227             sub startswith {
228 573     573   1046 my ($haystack, $needle) = @_;
229 573         2227 return index($haystack, $needle, 0) == 0;
230             }
231              
232             # Map lines in a hunk's diff to the corresponding `git blame HEAD` output.
233             sub get_blame_indexes {
234 20     20   39 my $hunk = shift;
235 20         33 my @indexes;
236 20         47 my $bi = $hunk->{start};
237 20         49 for (my $di = 0; $di < @{$hunk->{lines}}; $di++) {
  70         148  
238 50         132 push @indexes, $bi;
239 50         206 my $first = substr($hunk->{lines}[$di], 0, 1);
240 50 100 100     286 if ($first eq '-' or $first eq ' ') {
241 30         104 $bi++;
242             }
243             # Don't increment $bi for added lines.
244             }
245 20         176 return \@indexes;
246             }
247              
248             sub print_hunk_blamediff {
249 0     0   0 my $args = shift;
250 0         0 my $fh = $args->{fh};
251 0         0 my $hunk = $args->{hunk};
252 0         0 my $summary_for = $args->{summary_for};
253 0         0 my $blame = $args->{blame};
254 0         0 my $blame_indexes = $args->{blame_indexes};
255 0 0       0 if (grep {!defined} ($fh, $hunk, $summary_for, $blame, $blame_indexes)) {
  0         0  
256 0         0 croak 'missing argument';
257             }
258              
259 0         0 my $format = "%-8.8s|%4.4s|%-30.30s|%-30.30s\n";
260 0         0 for (my $i = 0; $i < @{$hunk->{lines}}; $i++) {
  0         0  
261 0         0 my $line_r = $hunk->{lines}[$i];
262 0         0 my $bi = $blame_indexes->[$i];
263 0 0       0 my $sha = defined $blame->{$bi} ? $blame->{$bi}{sha} : undef;
264              
265 0 0       0 my $display_sha = defined($sha) ? $sha : q{};
266 0         0 my $display_bi = $bi;
267 0 0       0 if (startswith($line_r, '+')) {
268 0         0 $display_sha = q{}; # For added lines.
269 0         0 $display_bi = q{};
270             }
271 0 0 0     0 if (defined($sha) && !defined($summary_for->{$sha})) {
272             # For lines from before the given upstream revision.
273 0         0 $display_sha = '^';
274             }
275              
276 0         0 my $line_l = '';
277 0 0 0     0 if (defined $blame->{$bi} && !startswith($line_r, '+')) {
278 0         0 $line_l = $blame->{$bi}{text};
279             }
280              
281 0         0 for ($line_l, $line_r) {
282             # For the table to line up, tabs need to be converted to a string of fixed width.
283 0         0 s/\t/^I/g;
284             # Remove trailing newlines and carriage returns. If more trailing
285             # whitespace is removed, that's fine.
286 0         0 $_ = rtrim($_);
287             }
288              
289 0         0 printf {$fh} $format, $display_sha, $display_bi, $line_l, $line_r;
  0         0  
290             }
291 0         0 print {$fh} "\n";
  0         0  
292 0         0 return;
293             }
294              
295             sub rtrim {
296 0     0   0 my $s = shift;
297 0         0 $s =~ s/\s+\z//;
298 0         0 return $s;
299             }
300              
301             sub blame {
302 32     32   158 my ($hunk, $alias_for) = @_;
303 32 100       228 if ($hunk->{count} == 0) {
304 1         28 return {};
305             }
306 31         759 my @cmd = (
307             'git', 'blame', '--porcelain',
308             '-L' => "$hunk->{start},+$hunk->{count}",
309             'HEAD', '--',
310             "$hunk->{file}");
311 31         67 my %blame;
312 31         42 my ($sha, $line_num);
313 31 50       118942 open(my $fh, '-|', @cmd) or die "git blame: $!\n";
314 31         65082 while (my $line = <$fh>) {
315 482 100       2168 if ($line =~ /^([0-9a-f]{40}) \d+ (\d+)/) {
316 46         769 ($sha, $line_num) = ($1, $2);
317             }
318 482 100       1243 if (startswith($line, "\t")) {
319 46 100       117 if (defined $alias_for->{$sha}) {
320 3         17 $sha = $alias_for->{$sha};
321             }
322 46         1738 $blame{$line_num} = {sha => $sha, text => substr($line, 1)};
323             }
324             }
325 31 50       864 close($fh) or die "git blame: non-zero exit code";
326 31         1346 return \%blame;
327             }
328              
329             sub get_diff_hunks {
330 33     33   189 my $num_context_lines = shift;
331 33         572 my @cmd = (qw(git diff --ignore-submodules), "-U$num_context_lines");
332 33 50       112374 open(my $fh, '-|', @cmd) or die $!;
333 33         1431 my @hunks = parse_hunks($fh, keep_lines => 1);
334 33 50       2062 close($fh) or die "git diff: non-zero exit code";
335 33 50       1340 return wantarray ? @hunks : \@hunks;
336             }
337              
338             sub commit_fixup {
339 20     20   46 my ($sha, $hunks) = @_;
340 20 50       57968 open my $fh, '|-', 'git apply --unidiff-zero --cached -' or die "git apply: $!\n";
341 20         334 for my $hunk (@{$hunks}) {
  20         215  
342 25         1312 print({$fh}
343             "--- a/$hunk->{file}\n",
344             "+++ b/$hunk->{file}\n",
345             $hunk->{header},
346 25         53 @{$hunk->{lines}},
  25         782  
347             );
348             }
349 20 50       53797 close $fh or die "git apply: non-zero exit code\n";
350 20 50       195453 system('git', 'commit', "--fixup=$sha") == 0 or die "git commit: $!\n";
351 20         1438 return;
352             }
353              
354             sub is_index_dirty {
355 33 50   33   120498 open(my $fh, '-|', 'git status --porcelain') or die "git status: $!\n";
356 33         334 my $dirty;
357 33         102661 while (my $line = <$fh>) {
358 38 50       3445 if ($line =~ /^[^?! ]/) {
359 0         0 $dirty = 1;
360 0         0 last;
361             }
362             }
363 33 50       1299 close $fh or die "git status: non-zero exit code\n";
364 33         1091 return $dirty;
365             }
366              
367             sub get_fixup_hunks_by_sha {
368 33     33   108 my $args = shift;
369 33         78 my $hunks = $args->{hunks};
370 33         114 my $blame_for = $args->{blame_for};
371 33         68 my $summary_for = $args->{summary_for};
372 33         81 my $strict = $args->{strict};
373 33 50       329 if (grep {!defined} ($hunks, $blame_for, $summary_for, $strict)) {
  132         387  
374 0         0 croak 'missing argument';
375             }
376              
377 33         62 my %hunks_for;
378 33         50 for my $hunk (@{$hunks}) {
  33         77  
379 32         79 my $blame = $blame_for->{$hunk};
380 32         412 my $sha = get_fixup_sha({
381             hunk => $hunk,
382             blame => $blame,
383             summary_for => $summary_for,
384             strict => $strict,
385             });
386 32 50       163 if ($verbose > 1) {
387 0         0 print_hunk_blamediff({
388             fh => *STDOUT,
389             hunk => $hunk,
390             summary_for => $summary_for,
391             blame => $blame,
392             blame_indexes => get_blame_indexes($hunk)
393             });
394             }
395 32 100       63 next if !$sha;
396 25         30 push @{$hunks_for{$sha}}, $hunk;
  25         111  
397             }
398 33         98 return \%hunks_for;
399             }
400              
401             sub main {
402 33     33   2974965 $verbose = 0;
403 33         365 my $num_context_lines = 3;
404 33         549 my $strict = $CONTEXT;
405 33         162 my $man;
406              
407 33         173 my ($help, $show_version);
408 33 50       582 GetOptions(
409             'h' => \$help,
410             'help' => \$man,
411             'version' => \$show_version,
412             'verbose|v+' => \$verbose,
413             'strict|s=i' => \$strict,
414             'context|c=i' => \$num_context_lines,
415             ) or return 1;
416 33 50       32253 if ($help) {
417 0         0 print $usage;
418 0         0 return 0;
419             }
420 33 50       123 if ($show_version) {
421 0         0 print "$VERSION\n";
422 0         0 return 0;
423             }
424 33 50       78 if ($man) {
425 0         0 pod2usage(-exitval => 0, -verbose => 2);
426             }
427              
428 33 50       194 @ARGV == 1 or die "No upstream commit given.\n";
429 33         108 my $upstream = shift @ARGV;
430 33         274216 qx(git rev-parse --verify ${upstream}^{commit});
431 33 50       1182 $? == 0 or die "Can't resolve given commit.\n";
432              
433 33 50       221 if ($num_context_lines < 0) {
434 0         0 die "invalid number of context lines: $num_context_lines\n";
435             }
436              
437 33 50 66     510 if ($strict < 0) {
    50          
438 0         0 die "invalid strictness level: $strict\n";
439             } elsif ($strict > 0 && $num_context_lines == 0) {
440 0         0 die "strict hunk assignment requires context\n";
441             }
442              
443              
444 33         183749 my $toplevel = qx(git rev-parse --show-toplevel);
445 33         557 chomp $toplevel;
446 33 50       566 $? == 0 or die "Can't get repo toplevel dir\n";
447 33 50       585 chdir $toplevel or die $!;
448              
449 33 50       560 if (is_index_dirty()) {
450 0         0 die "There are staged changes. Clean up the index and try again.\n";
451             }
452              
453 33         421 my $hunks = get_diff_hunks($num_context_lines);
454 33         449 my $summary_for = get_summary_for_commits($upstream);
455 33         322 my $alias_for = get_sha_aliases($summary_for);
456 33         63 my %blame_for = map {$_ => blame($_, $alias_for)} @{$hunks};
  32         220  
  33         275  
457 33         1007 my $hunks_for = get_fixup_hunks_by_sha({
458             hunks => $hunks,
459             blame_for => \%blame_for,
460             summary_for => $summary_for,
461             strict => $strict,
462             });
463 33         76 while (my ($sha, $fixup_hunks) = each %{$hunks_for}) {
  53         610  
464 20         63 commit_fixup($sha, $fixup_hunks);
465             }
466 33         2869 return 0;
467             }
468              
469             if (!caller()) {
470             exit main();
471             }
472             1;
473              
474             __END__