From 112396bda2a0bbe6704340fad483c13ee4e79754 Mon Sep 17 00:00:00 2001 From: Daniel Lange Date: Wed, 28 Jan 2026 11:09:00 +0100 Subject: [PATCH 1/4] Allow file paths to be specified on the command line, limiting fixups to these files Closes: #30 (Limit git autofixup to specified files and/or to staged changes) --- README.pod | 4 +- git-autofixup | 52 +++++++++++++++++++++-- t/autofixup.t | 113 +++++++++++++++++++++++++++++++++++++++++++++++++- t/test.pl | 6 ++- 4 files changed, 168 insertions(+), 7 deletions(-) mode change 100755 => 100644 git-autofixup diff --git a/README.pod b/README.pod index a6e6a74..78ee976 100644 --- a/README.pod +++ b/README.pod @@ -6,7 +6,7 @@ App::Git::Autofixup - create fixup commits for topic branches =head1 SYNOPSIS - git-autofixup [] [] + git-autofixup [] [] [--] [...] =head1 DESCRIPTION @@ -14,6 +14,8 @@ F parses hunks of changes in the working directory out of C, then F will only consider staged hunks when trying to create fixup commits. A temporary index is used to create any resulting commits. +If one or more paths are specified, only changes to those files will be considered. The C<--> separator between the revision and paths is optional. + 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. diff --git a/git-autofixup b/git-autofixup old mode 100755 new mode 100644 index 2c6f964..a23a7f2 --- a/git-autofixup +++ b/git-autofixup @@ -24,7 +24,7 @@ my @STRICTNESS_LEVELS = ( ); my $usage =<<'END'; -usage: git-autofixup [] [] +usage: git-autofixup [] [] [--] [...] -h show usage --help show manpage @@ -475,10 +475,14 @@ sub blame { sub diff_hunks { my $num_context_lines = shift; + my @files = @_; my @cmd = git_cmd(qw(diff --no-prefix --no-ext-diff --ignore-submodules), "-U$num_context_lines"); if (is_index_dirty()) { push @cmd, "--cached"; } + if (@files) { + push @cmd, '--', @files; + } 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"; @@ -671,6 +675,18 @@ sub main { my $dryrun; my $use_detailed_exit_codes; + # Before GetOptions processes args, count non-option arguments before -- + # This allows to determine if a revision was specified when -- is used + my $args_before_separator = 0; + my $found_separator = 0; + for my $arg (@ARGV) { + if ($arg eq '--') { + $found_separator = 1; + last; + } + $args_before_separator++ unless $arg =~ /^-/; + } + GetOptions( 'h' => \$help, 'help' => \$man, @@ -712,6 +728,34 @@ EOF # "upstream" revisions as 40 byte SHA1 hex hashes. my @upstreams = (); + my @files = (); + + # Parse arguments: [] [-- ...] or [] [...] + # Note: GetOptions removes the -- separator, but we counted args before it + + if ($found_separator && $args_before_separator == 0) { + # Original command was like: git-autofixup [opts] -- file1 file2 + # All remaining args are files, no revision specified + @files = @ARGV; + @ARGV = (); + } elsif ($found_separator && $args_before_separator == 1) { + # Original command was like: git-autofixup [opts] rev -- file1 file2 + # First arg is revision, rest are files + my $rev = shift @ARGV; + @files = @ARGV; + @ARGV = ($rev); + } + if (!$found_separator && @ARGV >=1) { + # Original command was like: git-autofixup [opts] rev file1 file2 + if (-e $ARGV[0]) { + @files = @ARGV; + @ARGV = (); + } else { + @files = @ARGV[1 .. $#ARGV]; + @ARGV = ($ARGV[0]); + } + } + if (@ARGV == 1) { my $raw_upstream = shift @ARGV; my $upstream = rev_parse("${raw_upstream}^{commit}"); @@ -737,7 +781,7 @@ EOF my $toplevel = toplevel_dir(); chdir $toplevel or die "cd to toplevel: $!\n"; - my $hunks = diff_hunks($num_context_lines); + my $hunks = diff_hunks($num_context_lines, @files); my $summary_for = summary_for_commits(@upstreams); my $alias_for = sha_aliases($summary_for); my $grafts_file = create_grafts_file(@upstreams); @@ -789,7 +833,7 @@ App::Git::Autofixup - create fixup commits for topic branches =head1 SYNOPSIS - git-autofixup [] [] + git-autofixup [] [] [--] [...] =head1 DESCRIPTION @@ -797,6 +841,8 @@ F parses hunks of changes in the working directory out of C, then F will only consider staged hunks when trying to create fixup commits. A temporary index is used to create any resulting commits. +If one or more paths are specified, only changes to those files will be considered. The C<--> separator between the revision and paths is optional. + 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. diff --git a/t/autofixup.t b/t/autofixup.t index ab72819..e9f26c1 100755 --- a/t/autofixup.t +++ b/t/autofixup.t @@ -9,7 +9,7 @@ require './t/test.pl'; require './t/util.pl'; Util::check_test_deps(); -plan tests => 44; +plan tests => 49; Test::autofixup_strict( name => "single-line change gets autofixed", @@ -615,3 +615,114 @@ index da0f8ed..c1827f0 100644 EOF ); } + +Test::autofixup( + name => 'file filtering with -- separator (single file)', + topic_commits => [{a => "a1\n", b => "b1\n"}], + unstaged => {a => "a2\n", b => "b2\n"}, + autofixup_args => ['--', 'a'], + exit_code => 0, + log_want => <<'EOF' +fixup! commit0 + +diff --git a a +index da0f8ed..c1827f0 100644 +--- a ++++ a +@@ -1 +1 @@ +-a1 ++a2 +EOF +); + +Test::autofixup( + name => 'file filtering with -- separator (multiple files)', + topic_commits => [{a => "a1\n", b => "b1\n", c => "c1\n"}], + unstaged => {a => "a2\n", b => "b2\n", c => "c2\n"}, + autofixup_args => ['--', 'a', 'b'], + exit_code => 0, + log_want => <<'EOF' +fixup! commit0 + +diff --git a a +index da0f8ed..c1827f0 100644 +--- a ++++ a +@@ -1 +1 @@ +-a1 ++a2 +diff --git b b +index c9c6af7..e6bfff5 100644 +--- b ++++ b +@@ -1 +1 @@ +-b1 ++b2 +EOF +); + +Test::autofixup( + name => 'file filtering excludes unspecified files', + topic_commits => [{a => "a1\n", b => "b1\n"}], + unstaged => {a => "a2\n", b => "b2\n"}, + autofixup_args => ['--', 'a'], + exit_code => 0, + log_want => <<'EOF', +fixup! commit0 + +diff --git a a +index da0f8ed..c1827f0 100644 +--- a ++++ a +@@ -1 +1 @@ +-a1 ++a2 +EOF + unstaged_want => <<'EOF' +diff --git b b +index c9c6af7..e6bfff5 100644 +--- b ++++ b +@@ -1 +1 @@ +-b1 ++b2 +EOF +); + +Test::autofixup( + name => 'file filtering without the optional -- separator', + topic_commits => [{a => "a1\n", b => "b1\n"}], + unstaged => {a => "a2\n", b => "b2\n"}, + autofixup_args => ['a'], + exit_code => 0, + log_want => <<'EOF' +fixup! commit0 + +diff --git a a +index da0f8ed..c1827f0 100644 +--- a ++++ a +@@ -1 +1 @@ +-a1 ++a2 +EOF +); + +Test::autofixup( + name => 'file filtering with -- at start (no revision)', + topic_commits => [{a => "a1\n", b => "b1\n"}], + unstaged => {a => "a2\n", b => "b2\n"}, + autofixup_args => ['--', 'a'], + exit_code => 0, + log_want => <<'EXPECTED' +fixup! commit0 + +diff --git a a +index da0f8ed..c1827f0 100644 +--- a ++++ a +@@ -1 +1 @@ +-a1 ++a2 +EXPECTED +); diff --git a/t/test.pl b/t/test.pl index 614cee4..2ad34cc 100644 --- a/t/test.pl +++ b/t/test.pl @@ -42,7 +42,8 @@ sub autofixup_strict { # log_want: expected log output for new fixup commited # staged_want: expected log output for the staging area # unstaged_want: expected diff output for the working tree -# autofixup_opts: command-line options to pass thru to autofixup +# autofixup_opts: command-line options to pass thru to autofixup (before revision) +# autofixup_args: additional arguments to pass after revision (i.e. file paths) # git_config: hashref of git config key/value pairs # # The upstream_commits and topic_commits arguments are heterogeneous lists of @@ -64,6 +65,7 @@ sub autofixup { my $unstaged_want = $args{unstaged_want}; my $exit_code_want = $args{exit_code}; my $autofixup_opts = $args{autofixup_opts} || []; + my $autofixup_args = $args{autofixup_args} || []; push @{$autofixup_opts}, '--exit-code'; my $git_config = $args{git_config} || {}; if (!$upstream_commits && !$topic_commits) { @@ -99,7 +101,7 @@ sub autofixup { $repo->write_change($unstaged); Util::run('git', '--no-pager', 'log', "--format='%h %s'", "${upstream_rev}.."); - my $exit_code_got = $repo->autofixup(@{$autofixup_opts}, $upstream_rev); + my $exit_code_got = $repo->autofixup(@{$autofixup_opts}, $upstream_rev, @{$autofixup_args}); my $ok = Util::exit_code_ok(want => $exit_code_want, got => $exit_code_got); my $wants = { From 6f0c8ea09d4314cc1081ef3c4708644b577d7846 Mon Sep 17 00:00:00 2001 From: Daniel Lange Date: Sat, 31 Jan 2026 11:38:23 +0100 Subject: [PATCH 2/4] Add tests for filepath selection with and without revision and the -- separator --- t/filepath_selection.t | 228 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 t/filepath_selection.t diff --git a/t/filepath_selection.t b/t/filepath_selection.t new file mode 100644 index 0000000..df7b92e --- /dev/null +++ b/t/filepath_selection.t @@ -0,0 +1,228 @@ +#!/usr/bin/perl +use strict; +use warnings FATAL => 'all'; + +use English qw(-no_match_vars); +use Test::More; + +require './t/test.pl'; +require './t/util.pl'; +require './t/repo.pl'; +require './git-autofixup'; + +Util::check_test_deps(); +plan tests => 4; + +# Test Case 1: -- present with --strict 2 and an explicit revision +# Command line: git-autofixup --strict 2 -- a +{ + my $name = '-- present, --strict 2, with explicit revision'; + + my $wants = { + fixup_log => <<'EOF', +fixup! commit1 + +diff --git a a +index 76642d4..2cdcdb0 100644 +--- a ++++ a +@@ -1,2 +1,3 @@ + a1 ++a2 + a3 +EOF + staged => '', + unstaged => <<'EOF', # File b changes should remain unstaged +diff --git b b +index c9c6af7..9b89cd5 100644 +--- b ++++ b +@@ -1 +1,2 @@ + b1 ++b2 +EOF + }; + + eval { + my $repo = Repo->new(); + + $repo->create_commits({README => "upstream\n"}); + my $upstream = $repo->current_commit_sha(); + + $repo->create_commits({a => "a1\na3\n", b => "b1\n"}); + my $topic = $repo->current_commit_sha(); + + $repo->write_change({a => "a1\na2\na3\n", b => "b1\nb2\n"}); + + my $exit_code = $repo->autofixup('--exit-code', '-s', '2', $upstream, '--', 'a'); + my $ok = Util::exit_code_ok(want => 0, got => $exit_code); + $ok &&= Util::repo_state_ok($repo, $topic, $wants); + + ok($ok, $name); + }; + if ($@) { + diag($@); + fail($name); + } +} + +# Test Case 2: -- present with --strict 2 without an explicit revision +# Command line: git-autofixup --strict 2 -- a +{ + my $name = '-- present, --strict 2, no explicit revision'; + + my $wants = { + fixup_log => <<'EOF', +fixup! commit1 + +diff --git a a +index 76642d4..2cdcdb0 100644 +--- a ++++ a +@@ -1,2 +1,3 @@ + a1 ++a2 + a3 +EOF + staged => '', + unstaged => <<'EOF', # File b changes should remain unstaged +diff --git b b +index c9c6af7..9b89cd5 100644 +--- b ++++ b +@@ -1 +1,2 @@ + b1 ++b2 +EOF + }; + + eval { + my $repo = Repo->new(); + + $repo->create_commits({README => "upstream\n"}); + my $upstream = $repo->current_commit_sha(); + + $repo->switch_to_downstream_branch('topic'); + $repo->create_commits({a => "a1\na3\n", b => "b1\n"}); + my $topic = $repo->current_commit_sha(); + + $repo->write_change({a => "a1\na2\na3\n", b => "b1\nb2\n"}); + + # No explicit revision - should find it via tracking branch + my $exit_code = $repo->autofixup('--exit-code', '-s', '2', '--', 'a'); + my $ok = Util::exit_code_ok(want => 0, got => $exit_code); + $ok &&= Util::repo_state_ok($repo, $topic, $wants); + + ok($ok, $name); + }; + if ($@) { + diag($@); + fail($name); + } +} + +# Test Case 3: no -- with --strict 2 and an explicit revision +# Command line: git-autofixup --strict 2 +{ + my $name = 'no --, --strict 2, with explicit revision'; + + my $wants = { + fixup_log => <<'EOF', +fixup! commit1 + +diff --git a a +index 76642d4..2cdcdb0 100644 +--- a ++++ a +@@ -1,2 +1,3 @@ + a1 ++a2 + a3 +diff --git b b +index c9c6af7..9b89cd5 100644 +--- b ++++ b +@@ -1 +1,2 @@ + b1 ++b2 +EOF + staged => '', + unstaged => '', + }; + + eval { + my $repo = Repo->new(); + + $repo->create_commits({README => "upstream\n"}); + my $upstream = $repo->current_commit_sha(); + + $repo->create_commits({a => "a1\na3\n", b => "b1\n"}); + my $topic = $repo->current_commit_sha(); + + $repo->write_change({a => "a1\na2\na3\n", b => "b1\nb2\n"}); + + my $exit_code = $repo->autofixup('--exit-code', '-s', '2', $upstream); + my $ok = Util::exit_code_ok(want => 0, got => $exit_code); + $ok &&= Util::repo_state_ok($repo, $topic, $wants); + + ok($ok, $name); + }; + if ($@) { + diag($@); + fail($name); + } +} + +# Test Case 4: no -- with --strict 2 without an explicit revision +# Command line: git-autofixup --strict 2 +{ + my $name = 'no --, --strict 2, no explicit revision'; + + my $wants = { + fixup_log => <<'EOF', +fixup! commit1 + +diff --git a a +index 76642d4..2cdcdb0 100644 +--- a ++++ a +@@ -1,2 +1,3 @@ + a1 ++a2 + a3 +diff --git b b +index c9c6af7..9b89cd5 100644 +--- b ++++ b +@@ -1 +1,2 @@ + b1 ++b2 +EOF + staged => '', + unstaged => '', + }; + + eval { + my $repo = Repo->new(); + + $repo->create_commits({README => "upstream\n"}); + my $upstream = $repo->current_commit_sha(); + + $repo->switch_to_downstream_branch('topic'); + $repo->create_commits({a => "a1\na3\n", b => "b1\n"}); + my $topic = $repo->current_commit_sha(); + + $repo->write_change({a => "a1\na2\na3\n", b => "b1\nb2\n"}); + + # No explicit revision - should find it via tracking branch + my $exit_code = $repo->autofixup('--exit-code', '-s', '2'); + my $ok = Util::exit_code_ok(want => 0, got => $exit_code); + $ok &&= Util::repo_state_ok($repo, $topic, $wants); + + ok($ok, $name); + }; + if ($@) { + diag($@); + fail($name); + } +} From 928cb1a820569b8cf99cdda420df117cb6d78fcf Mon Sep 17 00:00:00 2001 From: Daniel Lange Date: Sat, 31 Jan 2026 12:20:32 +0100 Subject: [PATCH 3/4] Update options parsing logic as per torbiak's suggestion Ref: https://github.com/torbiak/git-autofixup/issues/30#issuecomment-3827513510 --- git-autofixup | 55 +++++++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 37 deletions(-) diff --git a/git-autofixup b/git-autofixup index a23a7f2..a2f0628 100644 --- a/git-autofixup +++ b/git-autofixup @@ -675,17 +675,21 @@ sub main { my $dryrun; my $use_detailed_exit_codes; - # Before GetOptions processes args, count non-option arguments before -- - # This allows to determine if a revision was specified when -- is used - my $args_before_separator = 0; - my $found_separator = 0; + my $raw_upstream; + my @files = (); + + my $found_option_terminator = 0; + my @new_argv = (); for my $arg (@ARGV) { - if ($arg eq '--') { - $found_separator = 1; - last; + if ($found_option_terminator) { + push @files, $arg; + } elsif ($arg eq '--') { + $found_option_terminator = 1; + } else { + push @new_argv, $arg; } - $args_before_separator++ unless $arg =~ /^-/; } + @ARGV = @new_argv; GetOptions( 'h' => \$help, @@ -698,6 +702,11 @@ sub main { 'gitopt|g=s' => \@GIT_OPTIONS, 'exit-code' => \$use_detailed_exit_codes, ) or return 1; + + if (@ARGV) { + $raw_upstream = shift @ARGV; + @files = (@ARGV, @files); + } if ($help) { print $usage; return 0; @@ -728,36 +737,8 @@ EOF # "upstream" revisions as 40 byte SHA1 hex hashes. my @upstreams = (); - my @files = (); - - # Parse arguments: [] [-- ...] or [] [...] - # Note: GetOptions removes the -- separator, but we counted args before it - - if ($found_separator && $args_before_separator == 0) { - # Original command was like: git-autofixup [opts] -- file1 file2 - # All remaining args are files, no revision specified - @files = @ARGV; - @ARGV = (); - } elsif ($found_separator && $args_before_separator == 1) { - # Original command was like: git-autofixup [opts] rev -- file1 file2 - # First arg is revision, rest are files - my $rev = shift @ARGV; - @files = @ARGV; - @ARGV = ($rev); - } - if (!$found_separator && @ARGV >=1) { - # Original command was like: git-autofixup [opts] rev file1 file2 - if (-e $ARGV[0]) { - @files = @ARGV; - @ARGV = (); - } else { - @files = @ARGV[1 .. $#ARGV]; - @ARGV = ($ARGV[0]); - } - } - if (@ARGV == 1) { - my $raw_upstream = shift @ARGV; + if ($raw_upstream) { my $upstream = rev_parse("${raw_upstream}^{commit}"); push @upstreams, $upstream; } else { From 17d867d495699fd37ec88c1e45c6743d63001cf1 Mon Sep 17 00:00:00 2001 From: Daniel Lange Date: Wed, 28 Jan 2026 12:20:47 +0100 Subject: [PATCH 4/4] Version 0.004008 --- Changes | 3 +++ git-autofixup | 2 +- lib/App/Git/Autofixup.pm | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Changes b/Changes index 39888d4..e055629 100644 --- a/Changes +++ b/Changes @@ -1,3 +1,6 @@ +# 0.004008 +- Allow to specify paths on the command line to limit fixup commits to these files only + # 0.004007 - Use the `--no-prefix` option for calls to `git diff` so that git-autofixup works regardless the configured values of the relatively new diff.srcPrefix and diff.dstPrefix variables, added in Git 2.45.0, as well as the older diff.noPrefix and diff.mnemonicPrefix. diff --git a/git-autofixup b/git-autofixup index a2f0628..3a223eb 100644 --- a/git-autofixup +++ b/git-autofixup @@ -11,7 +11,7 @@ use File::Temp; use Getopt::Long qw(:config bundling); use IPC::Open3; -our $VERSION = 0.004007; +our $VERSION = 0.004008; my $VERBOSE; my @GIT_OPTIONS; diff --git a/lib/App/Git/Autofixup.pm b/lib/App/Git/Autofixup.pm index cd79895..1610601 100644 --- a/lib/App/Git/Autofixup.pm +++ b/lib/App/Git/Autofixup.pm @@ -2,7 +2,7 @@ package App::Git::Autofixup; use strict; use warnings FATAL => 'all'; -our $VERSION = 0.004007; +our $VERSION = 0.004008; =head1 NAME