Skip to content

Instantly share code, notes, and snippets.

@oktal3700
Last active December 28, 2023 10:07
Show Gist options
  • Save oktal3700/cafe086b49c89f814be4a7507a32a3f7 to your computer and use it in GitHub Desktop.
Save oktal3700/cafe086b49c89f814be4a7507a32a3f7 to your computer and use it in GitHub Desktop.
Perl script for automating the process of creating fixup! commits for use with git rebase -i --autosquash
#!/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
Copy link

yehudasa commented Feb 9, 2017

@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.

@oktal3700
Copy link
Author

@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.

@torbiak
Copy link

torbiak commented Apr 26, 2017

Nice script. I'm wondering if there's a reason to ensure the submodules are synced other than filtering out Subproject commit hunks?

@torbiak
Copy link

torbiak commented May 24, 2017

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

@IgnusG
Copy link

IgnusG commented Apr 8, 2018

@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

@kakra
Copy link

kakra commented Dec 28, 2019

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

@kakra
Copy link

kakra commented Dec 28, 2019

@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.

@oktal3700
Copy link
Author

@kakra done, tyvm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment