-
-
Save oktal3700/cafe086b49c89f814be4a7507a32a3f7 to your computer and use it in GitHub Desktop.
#!/usr/bin/perl | |
# Scan unstaged changes in git tracked files, identify which commits they could | |
# be applied to as fixups, and automatically produce the appropriate "fixup!" | |
# commits for use with "git rebase -i --autosquash". | |
# | |
# Copyright (C) 2016, 2017 by Mat Sutcliffe | |
# This program is free software; you can redistribute it and/or modify it under | |
# the GNU General Public License as published by the Free Software Foundation; | |
# either version 2 of the License, or (at your option) any later version. | |
use strict; | |
use warnings; | |
my $base; | |
if (@ARGV == 1 and $ARGV[0] !~ m[^-]) { $base = $ARGV[0] } | |
elsif (@ARGV == 2 and $ARGV[1] eq '--') { $base = $ARGV[1] } | |
else { die "Usage: $0 <base-revision>\n"; } | |
# Make sure the index is empty. | |
open my $fh, 'git status --porcelain |' or die "git status: $!\n"; | |
grep m[^\w], <$fh> and die "You have staged changes. Unstage, stash, or commit them, and try again.\n"; | |
close $fh or die "git status returned non-zero\n"; | |
# Make sure submodules are up-to-date. | |
open $fh, 'git submodule summary |' or die "git submodule: $!\n"; | |
grep m[^\S], <$fh> and die "Can not proceed with submodules out of sync.\n"; | |
close $fh or die "git submodule returned non-zero\n"; | |
# Enumerate all the candidate target SHAs. | |
open $fh, "git log --pretty=oneline '$base..HEAD' |" or die "git log: $!\n"; | |
my(@revs, %msgs); | |
for my $line (<$fh>) | |
{ | |
chomp $line; | |
$line =~ m[^([0-9a-f]{40}) (.*)$] or die "malformed log entry: $line\n"; | |
push @revs, $1; | |
$msgs{$1} = $2; | |
} | |
close $fh or die "git log returned non-zero\n"; | |
@revs or die "No commits to fix up.\n"; | |
# Detect if any of the SHAs are already fixup! commits. | |
my %aliases; | |
for my $rev (@revs) | |
{ | |
$msgs{$rev} =~ m[^(fixup|squash)! (.*)$] or next; | |
my $kind = $1; | |
my $msg = shrinkws($2); | |
my ($sha) = grep { substr(shrinkws($msgs{$_}), 0, length $msg) eq $msg } @revs; | |
defined $sha and $aliases{$rev} = $sha; | |
defined $sha or warn "WARNING: $rev looks like a $kind with no corresponding target: $msg\n\n"; | |
} | |
# Read all changes in the working tree. | |
open $fh, 'git diff --ignore-submodules |' or die "git diff: $!\n"; | |
my @lines = <$fh>; | |
close $fh or die "git diff returned non-zero\n"; | |
@lines or die "Nothing to do.\n"; | |
# Parse changes to produce a data structure of hunks. | |
my($file, @hunks, $binary); | |
for (my $i = 0; $i <= $#lines; $i++) | |
{ | |
my $line = $lines[$i]; | |
chomp $line; | |
if ($line =~ m[^(?:diff|index|old mode|new mode|\+\+\+)]) | |
{} | |
elsif ($line =~ m[^--- a/(.*)]) | |
{ | |
$file = $1; | |
} | |
elsif ($line =~ m[^@@ -([\d,]+) \+([\d,]+) @@]) | |
{ | |
defined $file or die "found @@ before --- in diff line $i\n"; | |
my @hunk = ($line); | |
my(@remlines, @addlines); | |
my($offset, $size) = split ',', $1; | |
my($outoffset, $outsize) = split ',', $2; | |
$size ||= 1; | |
$outsize ||= 1; | |
my $removal = 0; | |
for (my ($j, $k) = (0, 0); $j < $size or $k < $outsize; ) | |
{ | |
$line = $lines[++$i]; | |
chomp $line; | |
$line =~ m[^-] and push @remlines, $offset + $j; | |
$line =~ m[^\+] and ! $removal and push @addlines, $offset + $j; | |
$line !~ m[^\+] and $j++; | |
$line !~ m[^-] and $k++; | |
$removal = $line !~ m[^ ]; | |
push @hunk, $line; | |
} | |
push @hunks, { | |
file => $file, lines => \@hunk, | |
offset => $offset, size => $size, | |
outoffset => $outoffset, outsize => $outsize, | |
remlines => \@remlines, addlines => \@addlines | |
}; | |
} | |
elsif ($line =~ m[^Binary files]) | |
{ | |
$binary = 1; | |
} | |
else { die "malformed diff output line $i:\n$line\n" } | |
} | |
$binary and print "Changes in binary files ignored.\n"; | |
# For each hunk, use git blame to identify the commit(s) that it could fix up. | |
my(%fixups, %fails); | |
for my $hunk (@hunks) | |
{ | |
my @shas; | |
if (@{$hunk->{remlines}}) | |
{ | |
push @shas, map blame($hunk->{file}, $_), @{$hunk->{remlines}}; | |
} | |
elsif (not @{$hunk->{addlines}}) | |
{ | |
die "noop hunk at $hunk->{file}:$hunk->{offset}\n"; | |
} | |
push @shas, map blame($hunk->{file}, $_ - 1), @{$hunk->{addlines}}; | |
push @shas, map blame($hunk->{file}, $_ ), @{$hunk->{addlines}}; | |
@shas = sort revorder uniq(map { aliasof($_) } grep reachable($_), @shas); | |
if (@shas == 1) | |
{ | |
push @{$fixups{$shas[0]}{$hunk->{file}}}, $hunk; | |
} | |
elsif (@shas > 1) | |
{ | |
$fails{$hunk->{file}}{$hunk->{lines}[0]} = [ | |
'ambiguous commit:', | |
map { 'could be '.substr($_,0,7).' '.substr($msgs{$_},0,55) } @shas | |
]; | |
} | |
else | |
{ | |
$fails{$hunk->{file}}{$hunk->{lines}[0]} = ['no relevant commit found']; | |
} | |
} | |
# Apply the hunks to the index and create the fixup! commits. | |
for my $sha (sort revorder keys %fixups) | |
{ | |
print "Fixing up $sha\n"; | |
open $fh, '| git apply --cached -' or die "git apply: $!\n"; | |
for $file (keys %{$fixups{$sha}}) | |
{ | |
print " $file\n"; | |
print " $_->{lines}[0]\n" for @{$fixups{$sha}{$file}}; | |
print $fh "--- a/$file\n"; | |
print $fh "+++ b/$file\n"; | |
for my $hunk (@{$fixups{$sha}{$file}}) | |
{ | |
print $fh join("\n", @{$hunk->{lines}}, ''); | |
} | |
} | |
close $fh or die "git apply returned non-zero\n"; | |
system(qw(git commit), "--fixup=$sha") == 0 or die "git commit: $!\n"; | |
print "\n"; | |
} | |
# Report if git blame failed to find an unambiguous target commit for any hunk. | |
%fails and print "FAILED HUNKS:\n"; | |
for my $file (sort keys %fails) | |
{ | |
print " $file\n"; | |
if (uniq(map { join "\n", @$_ } values %{$fails{$file}}) == 1) | |
{ | |
print " $_\n" for @{(values %{$fails{$file}})[0]}; | |
} | |
else | |
{ | |
for my $hunk (sort hunkorder keys %{$fails{$file}}) | |
{ | |
print " $hunk\n"; | |
print " $_\n" for @{$fails{$file}{$hunk}}; | |
} | |
} | |
} | |
exit(%fixups ? 0 : 1); | |
### Subroutines ################################################################ | |
# Replace consecutive whitespace characters with a single space. | |
sub shrinkws | |
{ | |
my ($str) = @_; | |
$str =~ s[^\s+][]; | |
$str =~ s[\s+$][]; | |
$str =~ s[\s+][ ]g; | |
return $str; | |
} | |
# Invoke git blame to identify the origin of a specific line in a file. | |
sub blame | |
{ | |
my ($file, $line) = @_; | |
$line > 0 or return undef; | |
open my $fh, "git blame -p -l -L $line,+1 HEAD -- '$file' |" or die "git blame: $!\n"; | |
my @blame_porcelain = <$fh>; | |
my $blame = $blame_porcelain[0]; | |
chomp $blame; | |
$blame =~ m[^([0-9a-f]{40})] or die "malformed blame output: $blame\n"; | |
close $fh or die "git blame returned non-zero\n"; | |
return $1; | |
} | |
# If the given SHA is already a fixup! commit, return the SHA of the candidate | |
# commit that it is targetting, else return the given SHA. | |
sub aliasof | |
{ | |
my ($sha) = @_; | |
my $alias = $aliases{$sha}; | |
return defined($alias) ? $alias : $sha; | |
} | |
# Remove duplicate entries from a list. | |
sub uniq | |
{ | |
my %hash; | |
$hash{$_} = 1 for @_; | |
return keys %hash; | |
} | |
# True if the given SHA is one of the candidate SHAs. | |
sub reachable | |
{ | |
my ($sha) = @_; | |
return defined($sha) && grep $_ eq $sha, @revs; | |
} | |
# A comparator for use with sort(), which sorts SHAs by their order in the log. | |
sub revorder | |
{ | |
my ($ai) = grep $revs[$_] eq $a, 0..$#revs; | |
my ($bi) = grep $revs[$_] eq $b, 0..$#revs; | |
return $bi <=> $ai; | |
} | |
# A comparator for use with sort(), which sorts diff hunk @@ lines. | |
sub hunkorder | |
{ | |
$a =~ m[(\d+)]; | |
my $ai = $1; | |
$b =~ m[(\d+)]; | |
my $bi = $1; | |
return $ai <=> $bi; | |
} |
@yehudasa Thanks. I merged your change and I just fixed a bug where the tool would fail to parse a diff that contained a change in a one-line file. How much are you using it, out of curiosity? It probably wouldn't take much for me to promote it to a repo.
Nice script. I'm wondering if there's a reason to ensure the submodules are synced other than filtering out Subproject commit
hunks?
superfixup chokes on the following with malformed diff output
because it's trying to calculate the number of diff lines there are in a hunk from its header, but there can be different numbers of diff lines for the same header. For example, @@ -1 +1,2 @@
could have a context line and an added line, or a deleted line and two added lines.
diff --git a/dotfiles/bash_profile b/dotfiles/bash_profile
index 4b18bd1..eca782e 100644
--- a/dotfiles/bash_profile
+++ b/dotfiles/bash_profile
@@ -1 +1,2 @@
-source $HOME/.bashrc
+export PATH=$HOME/bin:$HOME/code/go/bin:/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH
+[[ "$-" == *i* ]] && source $HOME/.bashrc
@oktal3700 really a great tool! I'm using it almost on a daily basis with smaller changes.
I had some problems with git blame
outputting a sha which was missing the last character - it was a git bug since I tried it with the command directly & it did indeed miss one character.
Anyways I fixed it by using the porcelain mode for the blame command. I also added a --root
option for fixing up initial commits.
You can find the 'proposed' changes here https://gist.github.com/IgnusG/960fe23668cb2541f0576e22ae975b41
I fixed some stuff (one is mentioned by @torbiak above), you may want to merge changes from here:
https://gist.github.com/kakra/b4656932c38876b3c3009ccff3d32c5b/revisions
@oktal3700 this is outside a repo, but I imported it here https://github.com/yehudasa/git-superfixup and committed a fix that I had there.
@yehudasa You can clone and push to gists as for a normal repository. Merges can also be done.
@kakra done, tyvm
@oktal3700 this is outside a repo, but I imported it here https://github.com/yehudasa/git-superfixup and committed a fix that I had there.