#!/usr/bin/perl package Autofixup; use 5.008004; use strict; use warnings; use Carp qw(croak); use File::Copy; use File::Spec (); use File::Temp; use Getopt::Long qw(:config bundling); use IPC::Open3; our $VERSION = 0.004006; my $VERBOSE; my @GIT_OPTIONS; # Strictness levels. my @STRICTNESS_LEVELS = ( my $CONTEXT = 0, my $ADJACENT = 1, my $SURROUNDED = 2, ); my $usage =<<'END'; usage: git-autofixup [] [] -h show usage --help show manpage --version show version -v, --verbose increase verbosity (use up to 2 times) -c N, --context N set number of diff context lines (default 3) -e, --exit-code use more detailed exit codes (see --help) -s N, --strict N set strictness (default 0) Assign a hunk to fixup a topic branch commit if: 0: either only one topic branch commit is blamed in the hunk context or blocks of added lines are adjacent to exactly one topic branch commit. Removing upstream lines is allowed for this level. 1: blocks of added lines are adjacent to exactly one topic branch commit 2: blocks of added lines are surrounded by exactly one topic branch commit Regardless of strictness level, removed lines are correlated with the commit they're blamed on, and all the blocks of changed lines in a hunk must be correlated with the same topic branch commit in order to be assigned to it. See the --help for more details. -g ARG, --gitopt ARG Specify option for git. Can be used multiple times. Deprecated in favor of GIT_CONFIG_{COUNT,KEY,VALUE} environment variables; see `git help config`. END # Parse hunks out of `git diff` output. Return an array of hunk hashrefs. sub parse_hunks { my $fh = shift; my ($file_a, $file_b); my @hunks; my $line; while ($line = <$fh>) { if ($line =~ /^--- (.*)/) { $file_a = dequote_diff_filename($1); } elsif ($line =~ /^\+\+\+ (.*)/) { $file_b = dequote_diff_filename($1); } elsif ($line =~ /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/) { my $header = $line; next if $file_a ne $file_b; # Ignore creations and deletions. my $lines = []; while (1) { $line = <$fh>; if (!defined($line) || $line =~ /^[^ +\\-]/) { last; } push @{$lines}, $line; } push(@hunks, { file => $file_a, start => $1, count => defined($2) ? $2 : 1, header => $header, lines => $lines, }); # The next line after a hunk could be a header for the next commit # or hunk. redo if defined $line; } } return @hunks; } # Dequote and unescape filenames that appear in diff output. # # If the filename is otherwise "normal" but contains spaces it's followed by a # trailing tab, and if it contains uncommon control characters or non-ASCII # characters, then the filename gets surrounded in double-quotes and non-ASCII # characters get replaced with octal escape sequences. # # For details about exactly what gets quoted, see the sq_lookup array in # git/quote.c. # # Also, remove the side-marker prefix, by default `a/` or `b/`. sub dequote_diff_filename { $_ = shift; s/\t$//m; # Remove trailing tab. if (startswith($_, '"')) { s/^"|"$//gm; # Remove surrounding quotes. # Replace octal and control character escapes. s/\\((?:\d{3})|(?:[abtnvfr"\\]))/"qq(\\$1)"/eeg; } # Remove prefix. # a/b by default, or (i)ndex (w)orktree (c)ommit (o)bject if # diff.mnemonicPrefix is set. s#^[abiwco]/##; return $_; } sub git_cmd { return ('git', @GIT_OPTIONS, @_); } # With a linear git history there'll be a single merge base that's easy to # refer to with @{upstream}, but during an interactive rebase we need to get # the "current" branch from the rebase metadata. # # Unusual cases: # # While there can be multiple merge bases if there have been criss-cross # merges, there'll still be a single fork point unless the relevant reflog # entries have already been garbage-collected. # # When multiple upstreams are configured via `branch..merge` in git's # config the most correct approach is probably to find the fork-point for each # merge value and return those. But it seems unlikely that someone is doing # octopus merges and using git-autofixup, so we're not handling that specially # currently. sub find_merge_bases { my $upstream = '@{upstream}'; # If an interactive rebase is in progress, derive the upstream from the # rebase meatadata. my $gitdir = git_dir(); if (-e "$gitdir/rebase-merge") { my $branch = slurp("$gitdir/rebase-merge/head-name"); chomp $branch; $branch =~ s#^refs/heads/##; $upstream = "$branch\@{upstream}"; } # `git merge-base` will fail if there's no tracking branch. In that case # redirect stderr and communicate failure by returning an empty list. Also, # with the --fork-point option, no merge bases are returned if the relevant # reflog entries have been GC'd, so fall back to normal merge-bases. my @merge_bases = (); my ($out, $err, $exit_code) = capture(qw(git merge-base --all --fork-point), $upstream, 'HEAD'); if ($exit_code == 0) { @merge_bases = map {chomp; $_} split(/\n/, $out); } else { my ($out, $err, $exit_code) = capture(qw(git merge-base --all), $upstream, 'HEAD'); if ($exit_code != 0) { die "git merge-base: $err"; } @merge_bases = map {chomp; $_} split("\n", $out); } return wantarray ? @merge_bases : \@merge_bases; } sub git_dir { my ($out, $err, $exit_code) = capture(qw(git rev-parse --git-dir)); if ($exit_code != 0) { warn "git rev-parse --git-dir: $err\n"; die "Can't find repo's git dir\n"; } chomp $out; return $out; } sub toplevel_dir { my ($out, $err, $exit_code) = capture(qw(git rev-parse --show-toplevel)); if ($exit_code != 0) { warn "git rev-parse --show-toplevel: $err\n"; die "Can't find repo's toplevel dir\n"; } chomp $out; return $out; } # Run the given command, capture stdout and stderr, and return an array of # (stdout, stderr, exit_code). sub capture { open(my $out_fh, '>', undef) or die "create stdout tempfile: $!"; open(my $err_fh, '>', undef) or die "create stderr tempfile: $!"; my $pid = open3(my $in_fh, $out_fh, $err_fh, @_); waitpid $pid, 0; if ($? & 127) { my $signal = $? & 127; die "capture: child died with signal $signal; exiting"; } my $exit_code = $? >> 8; local $/; # slurp my $stdout = readline $out_fh; my $stderr = readline $err_fh; my @array = ($stdout, $stderr, $exit_code); return wantarray ? @array : \@array; } # Return a description of what $? means. sub child_error_desc { my $err = shift; if ($err == -1) { return "failed to execute: $!"; } elsif ($err & 127) { return "died with signal " . ($err & 127); } else { return "exited with " . ($err >> 8); } } sub slurp { my $filename = shift; open my $fh, '<', $filename or die "slurp $filename: $!"; local $/; my $content = readline $fh; return $content; } sub summary_for_commits { my @upstreams = @_; my %commits; my $negative = join(" ", map {"^$_"} @upstreams); my @lines = qx(git log --no-merges --format=%H:%s HEAD $negative); die "git log: " . child_error_desc($?) if $?; for (@lines) { chomp; my ($sha, $msg) = split ':', $_, 2; $commits{$sha} = $msg; } return \%commits; } # Return targets of fixup!/squash! commits. sub sha_aliases { my $summary_for = shift; my %aliases; my @targets = keys(%{$summary_for}); for my $sha (@targets) { my $summary = $summary_for->{$sha}; next if $summary !~ /^(?:fixup|squash)! (.*)/; my $prefix = $1; if ($prefix =~ /^(?:(?:fixup|squash)! ){2}/) { die "fixup commits for fixup commits aren't supported: $sha"; } my @matches = grep {startswith($summary_for->{$_}, $prefix)} @targets; if (@matches > 1) { die "ambiguous fixup commit target: multiple commit summaries start with: $prefix\n"; } elsif (@matches == 0) { die "no fixup target in topic branch: $sha\n"; } elsif (@matches == 1) { $aliases{$sha} = $matches[0]; } } return \%aliases; } # Given hunk, blame, and commit data, return the SHA for the commit the hunk # should fixup, or undef if an appropriate commit isn't found. sub fixup_sha { my $args = shift; my $hunk = $args->{hunk}; my $blame = $args->{blame}; my $summary_for = $args->{summary_for}; my $strict = $args->{strict}; if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) { croak 'missing argument'; } my @targets; if ($args->{strict} == $CONTEXT) { @targets = fixup_targets_from_all_context($args); my @topic_targets = grep {defined $summary_for->{$_}} @targets; if (@topic_targets > 1) { # The context assignment is ambiguous, but an adjacency assignment # might not be. @targets = fixup_targets_from_adjacent_context($args); } } else { @targets = fixup_targets_from_adjacent_context($args); } my $upstream_is_blamed = grep {!defined $summary_for->{$_}} @targets; my @topic_targets = grep {defined $summary_for->{$_}} @targets; if ($strict && $upstream_is_blamed) { $VERBOSE && print hunk_desc($hunk), " changes lines blamed on upstream\n"; return; } elsif (@topic_targets > 1) { $VERBOSE && print hunk_desc($hunk), " has multiple targets\n"; return; } elsif (@topic_targets == 0) { $VERBOSE && print hunk_desc($hunk), " has no targets\n"; return; } return $topic_targets[0]; } sub hunk_desc { my $hunk = shift; return join " ", ( $hunk->{file}, $hunk->{header} =~ /(@@[^@]*@@)/, ); } sub fixup_targets_from_all_context { my $args = shift; my ($hunk, $blame, $summary_for) = @{$args}{qw(hunk blame summary_for)}; croak 'missing argument' if grep {!defined} ($hunk, $blame, $summary_for); my @targets = uniq(map {$_->{sha}} values(%{$blame})); return wantarray ? @targets : \@targets; } sub uniq { my %seen; return grep {!$seen{$_}++} @_; } sub fixup_targets_from_adjacent_context { my $args = shift; my $hunk = $args->{hunk}; my $blame = $args->{blame}; my $summary_for = $args->{summary_for}; my $strict = $args->{strict}; if (grep {!defined} ($hunk, $blame, $summary_for, $strict)) { croak 'missing argument'; } my $blame_indexes = blame_indexes($hunk); my %blamed; my $diff = $hunk->{lines}; for (my $di = 0; $di < @{$diff}; $di++) { # diff index my $bi = $blame_indexes->[$di]; my $line = $diff->[$di]; if (startswith($line, '-')) { my $sha = $blame->{$bi}{sha}; $blamed{$sha} = 1; } elsif (startswith($line, '+')) { my @lines; if ($di > 0 && defined $blame->{$bi-1}) { push @lines, $bi-1; } if (defined $blame->{$bi}) { push @lines, $bi; } my @adjacent_shas = uniq(map {$_->{sha}} @{$blame}{@lines}); my @target_shas = grep {defined $summary_for->{$_}} @adjacent_shas; # Note that lines at the beginning or end of a file can be # "surrounded" by a single line. my $is_surrounded = @target_shas > 0 && @target_shas == @adjacent_shas && $target_shas[0] eq $target_shas[-1]; my $is_adjacent = @target_shas == 1; if ($is_surrounded || ($strict < $SURROUNDED && $is_adjacent)) { $blamed{$target_shas[0]} = 1; } while ($di < @$diff-1 && startswith($diff->[$di+1], '+')) { $di++; } } } my @targets = keys %blamed; return wantarray ? @targets : \@targets; } sub startswith { my ($haystack, $needle) = @_; return index($haystack, $needle, 0) == 0; } # Map lines in a hunk's diff to the corresponding `git blame HEAD` output. sub blame_indexes { my $hunk = shift; my @indexes; my $bi = $hunk->{start}; for (my $di = 0; $di < @{$hunk->{lines}}; $di++) { push @indexes, $bi; my $first = substr($hunk->{lines}[$di], 0, 1); if ($first eq '-' or $first eq ' ') { $bi++; } # Don't increment $bi for added lines. } return \@indexes; } sub print_hunk_blamediff { my $args = shift; my $fh = $args->{fh}; my $hunk = $args->{hunk}; my $summary_for = $args->{summary_for}; my $blame = $args->{blame}; my $blame_indexes = $args->{blame_indexes}; if (grep {!defined} ($fh, $hunk, $summary_for, $blame, $blame_indexes)) { croak 'missing argument'; } my $format = "%-8.8s|%4.4s|%-30.30s|%-30.30s\n"; for (my $i = 0; $i < @{$hunk->{lines}}; $i++) { my $line_r = $hunk->{lines}[$i]; my $bi = $blame_indexes->[$i]; my $sha = defined $blame->{$bi} ? $blame->{$bi}{sha} : undef; my $display_sha = defined($sha) ? $sha : q{}; my $display_bi = $bi; if (startswith($line_r, '+')) { $display_sha = q{}; # For added lines. $display_bi = q{}; } if (defined($sha) && !defined($summary_for->{$sha})) { # For lines from before the given upstream revision. $display_sha = '^'; } my $line_l = ''; if (defined $blame->{$bi} && !startswith($line_r, '+')) { $line_l = $blame->{$bi}{text}; } for ($line_l, $line_r) { # For the table to line up, tabs need to be converted to a string of fixed width. s/\t/^I/g; # Remove trailing newlines and carriage returns. If more trailing # whitespace is removed, that's fine. $_ = rtrim($_); } printf {$fh} $format, $display_sha, $display_bi, $line_l, $line_r; } print {$fh} "\n"; return; } sub rtrim { my $s = shift; $s =~ s/\s+\z//; return $s; } sub blame { my ($hunk, $alias_for, $grafts_file) = @_; if ($hunk->{count} == 0) { return {}; } my @cmd = git_cmd( 'blame', '--porcelain', '-L' => "$hunk->{start},+$hunk->{count}", '-S' => $grafts_file, 'HEAD', '--', "$hunk->{file}"); my %blame; my ($sha, $line_num); open(my $fh, '-|', @cmd) or die "run git blame: $!\n"; while (my $line = <$fh>) { if ($line =~ /^([0-9a-f]{40}) \d+ (\d+)/) { ($sha, $line_num) = ($1, $2); } if (startswith($line, "\t")) { if (defined $alias_for->{$sha}) { $sha = $alias_for->{$sha}; } $blame{$line_num} = {sha => $sha, text => substr($line, 1)}; } } close($fh) or die "git blame: " . child_error_desc($?) . "\n"; return \%blame; } sub diff_hunks { my $num_context_lines = shift; my @cmd = git_cmd(qw(-c diff.noprefix=false diff --no-ext-diff --ignore-submodules), "-U$num_context_lines"); if (is_index_dirty()) { push @cmd, "--cached"; } open(my $fh, '-|', @cmd) or die "run git diff: $!"; my @hunks = parse_hunks($fh, keep_lines => 1); close($fh) or die "git diff: " . child_error_desc($?) . "\n"; return wantarray ? @hunks : \@hunks; } sub commit_fixup { my ($sha, $hunks) = @_; open my $fh, '|-', git_cmd(qw(apply --unidiff-zero --cached -)) or die "git apply: $!\n"; for my $hunk (@{$hunks}) { print({$fh} "--- a/$hunk->{file}\n", "+++ b/$hunk->{file}\n", $hunk->{header}, @{$hunk->{lines}}, ); } close $fh or die "git apply: " . child_error_desc($?) . "\n"; my (undef, $err, $exit_code) = capture(git_cmd('commit', "--fixup=$sha")); if ($exit_code != 0) { warn "git commit: $err\n"; die "git commit exited with $exit_code\n"; } return; } sub is_index_dirty { return system(git_cmd(qw(diff-index --cached HEAD --quiet))) != 0; } sub fixup_hunks_by_sha { my $args = shift; my $hunks = $args->{hunks}; my $blame_for = $args->{blame_for}; my $summary_for = $args->{summary_for}; my $strict = $args->{strict}; if (grep {!defined} ($hunks, $blame_for, $summary_for, $strict)) { croak 'missing argument'; } my %hunks_for; for my $hunk (@{$hunks}) { my $blame = $blame_for->{$hunk}; my $sha = fixup_sha({ hunk => $hunk, blame => $blame, summary_for => $summary_for, strict => $strict, }); if ($sha && $VERBOSE) { printf "%s fixes %s %s\n", hunk_desc($hunk), substr($sha, 0, 8), $summary_for->{$sha}; } if ($VERBOSE > 1) { print_hunk_blamediff({ fh => *STDOUT, hunk => $hunk, summary_for => $summary_for, blame => $blame, blame_indexes => blame_indexes($hunk) }); } next if !$sha; push @{$hunks_for{$sha}}, $hunk; } return \%hunks_for; } # Return SHAs in some consistent order. # # Currently they're ordered by how early their assigned hunks appear in the # diff output. This assumes $hunks is in the order it was parsed from the diff. # This ordering seems nice since it'd be similar to the order a human would # make commits in if they were working their way down the diff. sub ordered_shas { my $hunks = shift; my $sha_for = shift; my @ordered = (); for my $hunk (@{$hunks}) { if (defined $sha_for->{$hunk}) { push @ordered, $sha_for->{$hunk}; } } return uniq(@ordered); } # Reverse the sha->hunks hashef and return a hunk->sha hashref. sub sha_for_hunk_map { my $hunks_for = shift; my %sha_for; for my $sha (keys %{$hunks_for}) { for my $hunk (@{$hunks_for->{$sha}}) { if (defined $sha_for{$hunk}) { die "multiple SHAs for hunk"; # This should never happen. } $sha_for{$hunk} = $sha; } } return \%sha_for; } sub exit_code { my ($hunks, $hunks_for) = @_; my $hunk_count = scalar @{$hunks}; my $assigned_hunk_count = 0; for (values %{$hunks_for}) { $assigned_hunk_count += @{$_}; } my $rc; if ($hunk_count == 0) { $rc = 3; # no hunks to assign } elsif ($assigned_hunk_count == 0) { $rc = 2; # hunks exist, but none assigned } elsif ($assigned_hunk_count < $hunk_count) { $rc = 1; # not all hunks assigned } elsif ($hunk_count == $assigned_hunk_count) { $rc = 0; # all hunks assigned } else { die "unexpected conditions when choosing exit code"; } return $rc; } # Create a temporary index so we can craft commits with already-staged hunks. # Return a File::Temp object so the caller has control over its lifetime. sub create_temp_index { my $old_index = shift; my $tempfile = File::Temp->new( TEMPLATE => 'git-autofixup_index.XXXXXX', DIR => File::Spec->tmpdir()); # The index ought to be equivalent to HEAD. The fastest way to create it # is to start with the current index, and subtract the changes since HEAD. if (not defined($old_index)) { my $gitdir = git_dir(); $old_index = "$gitdir/index"; } close $tempfile or die "close temp index: $!"; copy($old_index, $tempfile->filename()) or die "Can't copy Git index '$old_index' to '$tempfile': $!\n"; $ENV{GIT_INDEX_FILE} = $tempfile->filename(); # Remove any staged changes from the new index - we want to turn them into fixup commits. my $index_changes = qx(git diff-index --patch --no-ext-diff --ignore-submodules --cached HEAD); die "git diff-index: " . child_error_desc($?) if $?; open my $fh, '|-', git_cmd(qw(apply --cached --whitespace=nowarn --reverse -)) or die "run git apply: $!\n"; print $fh $index_changes; close $fh or die "git apply: " . child_error_desc($?) . "\n"; return $tempfile; } # Create a grafts file for `git blame -S` that basically says the upstream # commit doesn't have any parents, resulting in blame only searching back as # far back as the upstream commit. sub create_grafts_file { my @upstreams = @_; my $grafts_file = File::Temp->new( TEMPLATE => 'git-autofixup_grafts.XXXXXX', DIR => File::Spec->tmpdir()); open(my $fh, '>', $grafts_file) or die "Can't open $grafts_file: $!\n"; for (@upstreams) { print $fh $_, "\n"; } close($fh) or die "close grafts file: $!\n"; return $grafts_file; } sub rev_parse { my $rev = shift; my ($out, $err, $exit_code) = capture(qw(git rev-parse --verify --end-of-options), $rev); if ($exit_code != 0) { warn "git rev-parse: $err\n"; die "Can't resolve given revision\n"; } chomp $out; return $out; } sub main { $VERBOSE = 0; my $help; my $man; my $show_version; my $strict = $CONTEXT; my $num_context_lines = 3; my $dryrun; my $use_detailed_exit_codes; GetOptions( 'h' => \$help, 'help' => \$man, 'version' => \$show_version, 'verbose|v+' => \$VERBOSE, 'strict|s=i' => \$strict, 'context|c=i' => \$num_context_lines, 'dryrun|n' => \$dryrun, 'gitopt|g=s' => \@GIT_OPTIONS, 'exit-code' => \$use_detailed_exit_codes, ) or return 1; if ($help) { print $usage; return 0; } if ($show_version) { print "$VERSION\n"; return 0; } if ($man) { eval { require Pod::Usage; }; if ($@) { die <<'EOF'; Pod::Usage unavailable for formatting the manual. The manual can be found at the end of the git-autofixup script. EOF } Pod::Usage::pod2usage(-exitval => 0, -verbose => 2); } if (@GIT_OPTIONS) { warn <<'EOF'; --gitopt|-g is deprecated and will be removed in a future release. Please use the GIT_CONFIG_{COUNT,KEY,VALUE} environment variables instead; see `git help config`. EOF } # "upstream" revisions as 40 byte SHA1 hex hashes. my @upstreams = (); if (@ARGV == 1) { my $raw_upstream = shift @ARGV; my $upstream = rev_parse("${raw_upstream}^{commit}"); push @upstreams, $upstream; } else { @upstreams = find_merge_bases(); if (!@upstreams) { die "Can't find tracking branch. Please specify a revision.\n"; } } if ($num_context_lines < 0) { die "number of context lines must be zero or greater\n"; } if (!grep { $strict == $_ } @STRICTNESS_LEVELS) { die "invalid strictness level: $strict\n"; } elsif ($strict > 0 && $num_context_lines == 0) { die "strict hunk assignment requires context\n"; } my $toplevel = toplevel_dir(); chdir $toplevel or die "cd to toplevel: $!\n"; my $hunks = diff_hunks($num_context_lines); my $summary_for = summary_for_commits(@upstreams); my $alias_for = sha_aliases($summary_for); my $grafts_file = create_grafts_file(@upstreams); my %blame_for = map {$_ => blame($_, $alias_for, $grafts_file)} @{$hunks}; my $hunks_for = fixup_hunks_by_sha({ hunks => $hunks, blame_for => \%blame_for, summary_for => $summary_for, strict => $strict, }); my @ordered_shas = ordered_shas($hunks, sha_for_hunk_map($hunks_for)); if ($dryrun) { if ($use_detailed_exit_codes) { return exit_code($hunks, $hunks_for); } return 0; } my $old_index = $ENV{GIT_INDEX_FILE}; local $ENV{GIT_INDEX_FILE}; # Throw away changes between main() calls. if (is_index_dirty()) { # Limit the tempfile's lifetime to the execution of main(). my $tempfile = create_temp_index($old_index); } for my $sha (@ordered_shas) { my $fixup_hunks = $hunks_for->{$sha}; commit_fixup($sha, $fixup_hunks); } if ($use_detailed_exit_codes) { return exit_code($hunks, $hunks_for); } return 0; } if (!caller()) { exit main(); } 1; __END__ =pod =head1 NAME App::Git::Autofixup - create fixup commits for topic branches =head1 SYNOPSIS git-autofixup [] [] =head1 DESCRIPTION F parses hunks of changes in the working directory out of C output and uses C to assign those hunks to commits in CrevisionE..HEAD>, which will typically represent a topic branch, and then creates fixup commits to be used with C. It is assumed that hunks near changes that were previously committed to the topic branch are related. CrevisionE> defaults to C, but this will only work if the current branch has an upstream/tracking branch. See C for info about how to specify revisions. If any changes have been staged to the index using C, then F will only consider staged hunks when trying to create fixup commits. A temporary index is used to create any resulting commits. By default a hunk will be included in a fixup commit if all the lines in the hunk's context blamed on topic branch commits refer to the same commit, so there's no ambiguity about which commit the hunk corresponds to. If there is ambiguity the assignment behaviour used under C<--strict 1> will be used to attempt to resolve it. If C<--strict 1> is given the same topic branch commit must be blamed for every removed line and at least one of the lines adjacent to each added line, and added lines must not be adjacent to lines blamed on other topic branch commits. All the same restrictions apply when C<--strict 2> is given, but each added line must be surrounded by lines blamed on the same topic branch commit. For example, the added line in the hunk below is adjacent to lines committed by commits C<99f370af> and C. If these are both topic branch commits then it's ambiguous which commit the added line is fixing up and the hunk will be ignored. COMMIT |LINE|HEAD |WORKING DIRECTORY 99f370af| 1|first line | first line | | |+added line a1eadbe2| 2|second line | second line But if that second line were instead blamed on an upstream commit (denoted by C<^>), the hunk would be added to a fixup commit for C<99f370af>: 99f370af| 1|first line | first line | | |+added line ^ | 2|second line | second line Output similar to this example can be generated by setting verbosity to 2 or greater by using the verbosity option multiple times, eg. C, and can be helpful in determining how a hunk will be handled. F is not to be used mindlessly. Always inspect the created fixup commits to ensure hunks have been assigned correctly, especially when used on a working directory that has been changed with a mix of fixups and new work. =head2 Articles =over =item L =item L =back =head1 OPTIONS =over =item -h Show usage. =item --help Show manpage. =item --version Show version. =item -v, --verbose Increase verbosity. Can be used up to two times. =item -c N, --context N Change the number of context lines C uses around hunks. Default: 3. This can change how hunks are assigned to fixup commits, especially with C<--strict 0>. =item -s N, --strict N Set how strict F is about assigning hunks to fixup commits. Default: 0. Strictness levels are described under DESCRIPTION. =item -g ARG, --gitopt ARG Specify option for git. Can be used multiple times. Useful for testing, to override config options that break git-autofixup, or to override global diff options to tweak what git-autofixup considers a hunk. Deprecated in favor of C environment variables; see C. Note ARG won't be wordsplit, so to give multiple arguments, such as for setting a config option like C<-c diff.algorithm>, this option must be used multiple times: C<-g -c -g diff.algorithm=patience>. =item -e, --exit-code Use more detailed exit codes: =over =item 0: All hunks have been assigned. =item 1: Only some hunks have been assigned. =item 2: No hunks have been assigned. =item 3: There was nothing to be assigned. =item 255: Unexpected error occurred. =back =back =head1 INSTALLATION If cpan is available, run C. Otherwise, copy F to a directory in C and ensure it has execute permissions. It can then be invoked as either C or C, since git searches C for appropriately named binaries. Git is distributed with Perl 5 for platforms not expected to already have it installed, but installing modules with cpan requires other tools that might not be available, such as make. This script has no dependencies outside of the standard library, so it is hoped that it works on any platform that Git does without much trouble. Requires a git supporting C: 1.7.4 or later. =head1 BUGS/LIMITATIONS git-autofixup works on Windows, but be careful not to use a perl compiled for cygwin with a git compiled for msys, such as L. It can be used from Git for Windows' "Git Bash" or "Git CMD", or you can install git using Cygwin's package manager and use git-autofixup from Cygwin. Note that while every release gets tested on Cygwin via the CPAN Testers network, testing with Git for Windows requires more effort since it's a constrained environment; thus it doesn't get tested as often. If you run into any issues, please report them on L. If a topic branch adds some lines in one commit and subsequently removes some of them in another, a hunk in the working directory that re-adds those lines will be assigned to fixup the first commit, and during rebasing they'll be removed again by the later commit. =head1 ACKNOWLEDGEMENTS F was inspired by a description of L in the L. While I was working on it I found L, by oktal3700, which was helpful to examine. =head1 COPYRIGHT AND LICENSE Copyright (C) 2017, Jordan Torbiak. This program is free software; you can redistribute it and/or modify it under the terms of the Artistic License v2.0. =cut