From 30eb1e6579559f5fc3127b32c5e9b55b5b418cd6 Mon Sep 17 00:00:00 2001 From: Erik Cervin-Edin Date: Tue, 26 May 2026 12:47:43 +0200 Subject: [PATCH 001/159] commit: allow -m/-F for all kinds of --fixup The ability to provide a commit message for git commit --fixup and its variations is limited: * Plain --fixup only allows using the -m flag * The amend/reword --fixup variants only allow supplying the message using an editor For amend/reword, the -m and -F flags are rejected: -m is caught by a die() in prepare_to_commit(), and -F is caught by die_for_incompatible_opt4() which groups -F with --fixup as mutually exclusive. This makes these modes poorly suited for non-interactive workflows -- notably when using AI coding agents. When support to use the -m option was introduced in [1] it was noted that there could be support for other options but at the time the use case was deemed too niche. Later, when the amend suboption was introduced in [2] -m support for amend fixups was discussed but not pursued, and -F was already caught by the higher-layer incompatibility check grouping it with --fixup. The rejections of these options hark back to when --fixup was introduced in [3] and as noted in [1] -- there's nothing inherently preventing support for them. The current patchwork of which flags work with which --fixup variants has no strong logic to it, and allowing all of them simplifies both the code and the interface. Allow -m and -F to supply the message body for all --fixup variations, mirroring the flow of a regular commit. -c and -C, which are blocked by the same incompatibility check, are handled in the next commit. 1. 30884c9afc (commit: add support for --fixup -m"", 2017-12-22) 2. 494d314a05 (commit: add amend suboption to --fixup to create amend! commit, 2021-03-15) 3. d71b8ba7c9 (commit: --fixup option for use with rebase --autosquash, 2010-11-02) Helped-by: Junio C Hamano Suggested-by: Phillip Wood Signed-off-by: Erik Cervin-Edin Signed-off-by: Junio C Hamano --- Documentation/git-commit.adoc | 19 ++++---- builtin/commit.c | 34 +++++++------- t/t7500-commit-template-squash-signoff.sh | 56 +++++++++++++++++------ 3 files changed, 69 insertions(+), 40 deletions(-) diff --git a/Documentation/git-commit.adoc b/Documentation/git-commit.adoc index 8329c1034b9b30..61efd29e66e7d0 100644 --- a/Documentation/git-commit.adoc +++ b/Documentation/git-commit.adoc @@ -103,20 +103,21 @@ include::diff-context-options.adoc[] The commit created by plain `--fixup=` has a title composed of "fixup!" followed by the title of __, and is recognized specially by `git rebase --autosquash`. The `-m` -option may be used to supplement the log message of the created -commit, but the additional commentary will be thrown away once the -"fixup!" commit is squashed into __ by +or `-F` option may be used to supplement the log message +of the created commit, but the additional commentary will be thrown +away once the "fixup!" commit is squashed into __ by `git rebase --autosquash`. + The commit created by `--fixup=amend:` is similar but its title is instead prefixed with "amend!". The log message of __ is copied into the log message of the "amend!" commit and -opened in an editor so it can be refined. When `git rebase ---autosquash` squashes the "amend!" commit into __, the -log message of __ is replaced by the refined log message -from the "amend!" commit. It is an error for the "amend!" commit's -log message to be empty unless `--allow-empty-message` is -specified. +opened in an editor so it can be refined. The replacement message may +also be supplied directly using `-m` or `-F`, bypassing the +need to open an editor. When `git rebase +--autosquash` squashes the "amend!" commit into __, the log +message of __ is replaced by the refined log message from the +"amend!" commit. It is an error for the "amend!" commit's log message +to be empty unless `--allow-empty-message` is specified. + `--fixup=reword:` is shorthand for `--fixup=amend: --only`. It creates an "amend!" commit with only a log message diff --git a/builtin/commit.c b/builtin/commit.c index 28f61745034506..3f1fca291968de 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -804,18 +804,18 @@ static int prepare_to_commit(const char *index_file, const char *prefix, if (have_option_m && !fixup_message) { strbuf_addbuf(&sb, &message); hook_arg1 = "message"; - } else if (logfile && !strcmp(logfile, "-")) { + } else if (logfile && !fixup_message && !strcmp(logfile, "-")) { if (isatty(0)) fprintf(stderr, _("(reading log message from standard input)\n")); if (strbuf_read(&sb, 0, 0) < 0) die_errno(_("could not read log from standard input")); hook_arg1 = "message"; - } else if (logfile) { + } else if (logfile && !fixup_message) { if (strbuf_read_file(&sb, logfile, 0) < 0) die_errno(_("could not read log file '%s'"), logfile); hook_arg1 = "message"; - } else if (use_message) { + } else if (use_message && !fixup_message) { const char *buffer; buffer = strstr(use_message_buffer, "\n\n"); if (buffer) @@ -837,20 +837,21 @@ static int prepare_to_commit(const char *index_file, const char *prefix, hook_arg1 = "message"; /* - * Only `-m` commit message option is checked here, as - * it supports `--fixup` to append the commit message. - * - * The other commit message options `-c`/`-C`/`-F` are - * incompatible with all the forms of `--fixup` and - * have already errored out while parsing the `git commit` - * options. + * Only `-m` and `-F` are handled here. `-c`/`-C` are + * incompatible with --fixup and have already errored out + * during option parsing. */ - if (have_option_m && !strcmp(fixup_prefix, "fixup")) + if (have_option_m) { strbuf_addbuf(&sb, &message); - - if (!strcmp(fixup_prefix, "amend")) { - if (have_option_m) - die(_("options '%s' and '%s:%s' cannot be used together"), "-m", "--fixup", fixup_message); + } else if (logfile && !strcmp(logfile, "-")) { + if (isatty(0)) + fprintf(stderr, _("(reading log message from standard input)\n")); + if (strbuf_read(&sb, 0, 0) < 0) + die_errno(_("could not read log from standard input")); + } else if (logfile) { + if (strbuf_read_file(&sb, logfile, 0) < 0) + die_errno(_("could not read log file '%s'"), logfile); + } else if (!strcmp(fixup_prefix, "amend")) { prepare_amend_commit(commit, &sb, &ctx); } } else if (!stat(git_path_merge_msg(the_repository), &statbuf)) { @@ -1338,9 +1339,8 @@ static int parse_and_validate_options(int argc, const char *argv[], } if (fixup_message && squash_message) die(_("options '%s' and '%s' cannot be used together"), "--squash", "--fixup"); - die_for_incompatible_opt4(!!use_message, "-C", + die_for_incompatible_opt3(!!use_message, "-C", !!edit_message, "-c", - !!logfile, "-F", !!fixup_message, "--fixup"); die_for_incompatible_opt4(have_option_m, "-m", !!edit_message, "-c", diff --git a/t/t7500-commit-template-squash-signoff.sh b/t/t7500-commit-template-squash-signoff.sh index 66aff8e0976e79..01c7400136d437 100755 --- a/t/t7500-commit-template-squash-signoff.sh +++ b/t/t7500-commit-template-squash-signoff.sh @@ -384,18 +384,24 @@ test_expect_success '--fixup=reword: ignores staged changes' ' test_cmp foo actual ' -test_expect_success '--fixup=reword: error out with -m option' ' +test_expect_success 'commit --fixup=reword: works with -m' ' commit_for_rebase_autosquash_setup && - echo "fatal: options '\''-m'\'' and '\''--fixup:reword'\'' cannot be used together" >expect && - test_must_fail git commit --fixup=reword:HEAD~ -m "reword commit message" 2>actual && - test_cmp expect actual + git commit --fixup=reword:HEAD~ -m "reword commit message" && + test_commit_message HEAD <<-EOF + amend! $(git log -1 --format=%s HEAD~2) + + reword commit message + EOF ' -test_expect_success '--fixup=amend: error out with -m option' ' +test_expect_success 'commit --fixup=amend: works with -m' ' commit_for_rebase_autosquash_setup && - echo "fatal: options '\''-m'\'' and '\''--fixup:amend'\'' cannot be used together" >expect && - test_must_fail git commit --fixup=amend:HEAD~ -m "amend commit message" 2>actual && - test_cmp expect actual + git commit --fixup=amend:HEAD~ -m "amend commit message" && + test_commit_message HEAD <<-EOF + amend! $(git log -1 --format=%s HEAD~2) + + amend commit message + EOF ' test_expect_success 'consecutive amend! commits remove amend! line from commit msg body' ' @@ -432,6 +438,13 @@ test_expect_success 'deny to create amend! commit if its commit msg body is empt test_cmp expected actual ' +test_expect_success 'deny to create amend! commit if -m is empty' ' + commit_for_rebase_autosquash_setup && + echo "Aborting commit due to empty commit message body." >expect && + test_must_fail git commit --fixup=amend:HEAD~ -m "" 2>actual && + test_cmp expect actual +' + test_expect_success 'amend! commit allows empty commit msg body with --allow-empty-message' ' commit_for_rebase_autosquash_setup && cat >expected <<-EOF && @@ -468,10 +481,26 @@ test_expect_success '--fixup=reword: give error with pathsec' ' test_cmp expect actual ' -test_expect_success '--fixup=reword: -F give error message' ' - echo "fatal: options '\''-F'\'' and '\''--fixup'\'' cannot be used together" >expect && - test_must_fail git commit --fixup=reword:HEAD~ -F msg 2>actual && - test_cmp expect actual +test_expect_success 'commit --fixup works with -F' ' + commit_for_rebase_autosquash_setup && + echo "message" >msgfile && + git commit --fixup HEAD~ -F msgfile && + test_commit_message HEAD <<-EOF + fixup! $(git log -1 --format=%s HEAD~2) + + message + EOF +' + +test_expect_success 'commit --fixup=reword: works with -F' ' + commit_for_rebase_autosquash_setup && + echo "message from file" >msgfile && + git commit --fixup=reword:HEAD~ -F msgfile && + test_commit_message HEAD <<-EOF + amend! $(git log -1 --format=%s HEAD~2) + + $(cat msgfile) + EOF ' test_expect_success 'commit --squash works with -F' ' @@ -526,8 +555,7 @@ test_expect_success 'invalid message options when using --fixup' ' git add foo && test_must_fail git commit --fixup HEAD~1 --squash HEAD~2 && test_must_fail git commit --fixup HEAD~1 -C HEAD~2 && - test_must_fail git commit --fixup HEAD~1 -c HEAD~2 && - test_must_fail git commit --fixup HEAD~1 -F log + test_must_fail git commit --fixup HEAD~1 -c HEAD~2 ' cat >expected-template < Date: Tue, 26 May 2026 12:47:44 +0200 Subject: [PATCH 002/159] commit: allow -c/-C for all kinds of --fixup The previous commit allowed -m and -F for all --fixup variations. The -c/-C flags were blocked by the same higher-layer incompatibility check that previously caught -F, namely die_for_incompatible_opt4() grouping them with --fixup. Drop --fixup from that check and route the resolved commit through prepare_amend_commit() in the fixup path, mirroring the no-message-source behaviour of --fixup=amend. With this in place, -m/-F/-c/-C all behave consistently across the plain, amend, and reword --fixup forms. Signed-off-by: Erik Cervin-Edin Signed-off-by: Junio C Hamano --- Documentation/git-commit.adoc | 9 ++-- builtin/commit.c | 13 +++-- t/t7500-commit-template-squash-signoff.sh | 60 +++++++++++++++++++++-- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/Documentation/git-commit.adoc b/Documentation/git-commit.adoc index 61efd29e66e7d0..98c50a3be5c81c 100644 --- a/Documentation/git-commit.adoc +++ b/Documentation/git-commit.adoc @@ -102,8 +102,8 @@ include::diff-context-options.adoc[] + The commit created by plain `--fixup=` has a title composed of "fixup!" followed by the title of __, -and is recognized specially by `git rebase --autosquash`. The `-m` -or `-F` option may be used to supplement the log message +and is recognized specially by `git rebase --autosquash`. The `-m`, +`-F`, `-C`, or `-c` option may be used to supplement the log message of the created commit, but the additional commentary will be thrown away once the "fixup!" commit is squashed into __ by `git rebase --autosquash`. @@ -112,8 +112,9 @@ The commit created by `--fixup=amend:` is similar but its title is instead prefixed with "amend!". The log message of __ is copied into the log message of the "amend!" commit and opened in an editor so it can be refined. The replacement message may -also be supplied directly using `-m` or `-F`, bypassing the -need to open an editor. When `git rebase +also be supplied directly using `-m`, `-F`, or `-C`, bypassing the +need to open an editor, or using `-c` to open the editor pre-populated +with the referenced commit's message. When `git rebase --autosquash` squashes the "amend!" commit into __, the log message of __ is replaced by the refined log message from the "amend!" commit. It is an error for the "amend!" commit's log message diff --git a/builtin/commit.c b/builtin/commit.c index 3f1fca291968de..fcf148eb21b95b 100644 --- a/builtin/commit.c +++ b/builtin/commit.c @@ -837,9 +837,9 @@ static int prepare_to_commit(const char *index_file, const char *prefix, hook_arg1 = "message"; /* - * Only `-m` and `-F` are handled here. `-c`/`-C` are - * incompatible with --fixup and have already errored out - * during option parsing. + * `-m`, `-F`, `-C`, and `-c` provide the message body. + * If none was given and this is an amend, use the target + * commit's body instead. */ if (have_option_m) { strbuf_addbuf(&sb, &message); @@ -851,6 +851,11 @@ static int prepare_to_commit(const char *index_file, const char *prefix, } else if (logfile) { if (strbuf_read_file(&sb, logfile, 0) < 0) die_errno(_("could not read log file '%s'"), logfile); + } else if (use_message) { + struct commit *c = lookup_commit_reference_by_name(use_message); + if (!c) + die(_("could not lookup commit '%s'"), use_message); + prepare_amend_commit(c, &sb, &ctx); } else if (!strcmp(fixup_prefix, "amend")) { prepare_amend_commit(commit, &sb, &ctx); } @@ -1341,7 +1346,7 @@ static int parse_and_validate_options(int argc, const char *argv[], die(_("options '%s' and '%s' cannot be used together"), "--squash", "--fixup"); die_for_incompatible_opt3(!!use_message, "-C", !!edit_message, "-c", - !!fixup_message, "--fixup"); + !!logfile, "-F"); die_for_incompatible_opt4(have_option_m, "-m", !!edit_message, "-c", !!use_message, "-C", diff --git a/t/t7500-commit-template-squash-signoff.sh b/t/t7500-commit-template-squash-signoff.sh index 01c7400136d437..48e1247d9ebb5a 100755 --- a/t/t7500-commit-template-squash-signoff.sh +++ b/t/t7500-commit-template-squash-signoff.sh @@ -492,6 +492,62 @@ test_expect_success 'commit --fixup works with -F' ' EOF ' +test_expect_success 'commit --fixup works with -C' ' + commit_for_rebase_autosquash_setup && + git commit --fixup HEAD~ -C HEAD && + test_commit_message HEAD <<-EOF + fixup! $(git log -1 --format=%s HEAD~2) + + $(get_commit_msg HEAD~) + EOF +' + +test_expect_success 'commit --fixup=amend: works with -c' ' + commit_for_rebase_autosquash_setup && + test_set_editor : && + git commit --fixup=amend:HEAD -c HEAD~ && + test_commit_message HEAD <<-EOF + amend! intermediate commit + + target message subject line + + target message body line 1 + target message body line 2 + EOF +' + +test_expect_success 'commit --fixup=amend:HEAD with -C HEAD and without have the same message' ' + commit_for_rebase_autosquash_setup && + start=$(git rev-parse HEAD) && + + git commit --fixup=amend:HEAD -C HEAD && + git commit --fixup=amend:HEAD -C HEAD && + git log -1 --pretty=%B >with-c && + + git reset --hard "$start" && + test_set_editor : && + git commit --fixup=amend:HEAD && + git commit --fixup=amend:HEAD && + git log -1 --pretty=%B >without-c && + + test_cmp with-c without-c +' + +test_expect_success 'commit --fixup=amend: with -C copies full subject + body of squash commit' ' + commit_for_rebase_autosquash_setup && + git commit --squash HEAD~ -m "inner body" && + echo "extra" >>foo && + git add foo && + git commit --fixup=amend:HEAD -C HEAD && + test_commit_message HEAD <<-EOF + amend! squash! $(git log -1 --format=%s HEAD~3) + + squash! $(git log -1 --format=%s HEAD~3) + + inner body + EOF +' + test_expect_success 'commit --fixup=reword: works with -F' ' commit_for_rebase_autosquash_setup && echo "message from file" >msgfile && @@ -553,9 +609,7 @@ test_expect_success 'invalid message options when using --fixup' ' echo changes >>foo && echo "message" >log && git add foo && - test_must_fail git commit --fixup HEAD~1 --squash HEAD~2 && - test_must_fail git commit --fixup HEAD~1 -C HEAD~2 && - test_must_fail git commit --fixup HEAD~1 -c HEAD~2 + test_must_fail git commit --fixup HEAD~1 --squash HEAD~2 ' cat >expected-template < Date: Fri, 5 Jun 2026 15:55:59 +0200 Subject: [PATCH 003/159] doc: link to config for git-replay(1) This config doc was added in 336ac90c (replay: add replay.refAction config option, 2025-11-06) but never included anywhere. Include it in git-replay(1) and git-config(1). Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/config.adoc | 2 ++ Documentation/git-replay.adoc | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/Documentation/config.adoc b/Documentation/config.adoc index dcea3c0c15e2a9..35fc7b4bf6ad31 100644 --- a/Documentation/config.adoc +++ b/Documentation/config.adoc @@ -511,6 +511,8 @@ include::config/remotes.adoc[] include::config/repack.adoc[] +include::config/replay.adoc[] + include::config/rerere.adoc[] include::config/revert.adoc[] diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index a32f72aead3750..f9ca2db2833a1d 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -209,6 +209,10 @@ This replays the range `aabbcc..ddeeff` onto commit `112233` and updates `refs/heads/mybranch` to point at the result. This can be useful when you want to use bare commit IDs instead of branch names. +CONFIGURATION +------------- +include::config/replay.adoc[] + GIT --- Part of the linkgit:git[1] suite From 2f169b5c22a641cad83b4be657e7265959d60dd8 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Fri, 5 Jun 2026 15:56:00 +0200 Subject: [PATCH 004/159] doc: replay: improve config description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of all, this unordered list for `replay.refAction` introduces a term with a colon. This is exactly what a description list is, structurally. Let’s be stylistically consistent and use the desc. list markup construct. Let’s also drop the harmless but unneeded indentation. We can reuse the `::` delimiter since we use an open block. But for consistency use the typical nested description list delimiter, namely `;;`. Second, let’s replace the inline-verbatim `git replay` with a link to git-replay(1), since we are naming the command. But make that conditional so that we avoid a self-link inside git-replay(1).[1] † 1: See e.g. e7b3a768 (doc: git-init: rework config item init.templateDir, 2024-03-10) for another example of avoiding self-linking Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/config/replay.adoc | 16 ++++++++++------ Documentation/git-replay.adoc | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Documentation/config/replay.adoc b/Documentation/config/replay.adoc index 7d549d2f0e5195..7328da9537dc64 100644 --- a/Documentation/config/replay.adoc +++ b/Documentation/config/replay.adoc @@ -1,11 +1,15 @@ replay.refAction:: - Specifies the default mode for handling reference updates in - `git replay`. The value can be: + Specifies the default mode for handling reference updates. + The value can be: + -- - * `update`: Update refs directly using an atomic transaction (default behavior). - * `print`: Output update-ref commands for pipeline use. +`update`;; Update refs directly using an atomic transaction (default behavior). +`print`;; Output update-ref commands for pipeline use. -- + -This setting can be overridden with the `--ref-action` command-line option. -When not configured, `git replay` defaults to `update` mode. +ifdef::git-replay[] +See `--ref-action`. +endif::git-replay[] +ifndef::git-replay[] +See `--ref-action` for linkgit:git-replay[1] for details. +endif::git-replay[] diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index f9ca2db2833a1d..4de85088d6c4c9 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -211,6 +211,7 @@ to use bare commit IDs instead of branch names. CONFIGURATION ------------- +:git-replay: 1 include::config/replay.adoc[] GIT From 6fa17cca7c72df1c717027887749e8fb73338339 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Fri, 5 Jun 2026 15:56:01 +0200 Subject: [PATCH 005/159] doc: replay: use a nested description list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This bullet list for `--ref-action` introduces a term with a colon. This is exactly what a description list is, structurally. Let’s be stylistically consistent and use the desc. list markup construct. In short, just transform this unordered list in the same way that we did for `replay.refAction` in the previous commit. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-replay.adoc | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index 4de85088d6c4c9..b4fe43ec687859 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -80,10 +80,10 @@ incompatible with `--contained` (which is a modifier for `--onto` only). Control how references are updated. The mode can be: + -- - * `update` (default): Update refs directly using an atomic transaction. - All refs are updated or none are (all-or-nothing behavior). - * `print`: Output update-ref commands for pipeline use. This is the - traditional behavior where output can be piped to `git update-ref --stdin`. +`update` (default);; Update refs directly using an atomic transaction. + All refs are updated or none are (all-or-nothing behavior). +`print`;; Output update-ref commands for pipeline use. This is the + traditional behavior where output can be piped to `git update-ref --stdin`. -- + The default mode can be configured via the `replay.refAction` configuration variable. From 60575c76a5943246fdc36a6ef036e0b6b85d4147 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Fri, 5 Jun 2026 15:56:02 +0200 Subject: [PATCH 006/159] =?UTF-8?q?doc:=20replay:=20move=20=E2=80=9Cdefaul?= =?UTF-8?q?t=E2=80=9D=20to=20the=20right-hand=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is now a description list (see previous commit) and parentheticals like this do not go on the left-hand side. Moving it to the other side makes it stand out just as much and is also more consistent with the rest of the documentation. Let’s also do the same for the `replay.refAction` description list. That makes the two desc. lists identical in the first sentence. Let’s add a comment about that for future editors. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/config/replay.adoc | 5 ++++- Documentation/git-replay.adoc | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Documentation/config/replay.adoc b/Documentation/config/replay.adoc index 7328da9537dc64..40d1695782affd 100644 --- a/Documentation/config/replay.adoc +++ b/Documentation/config/replay.adoc @@ -3,7 +3,10 @@ replay.refAction:: The value can be: + -- -`update`;; Update refs directly using an atomic transaction (default behavior). +//// +These use the first sentences from the description list in git-replay(1). +//// +`update`;; (default) Update refs directly using an atomic transaction. `print`;; Output update-ref commands for pipeline use. -- + diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index b4fe43ec687859..ea4d14baddb6a9 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -80,7 +80,10 @@ incompatible with `--contained` (which is a modifier for `--onto` only). Control how references are updated. The mode can be: + -- -`update` (default);; Update refs directly using an atomic transaction. +//// +Expanded description list compared to 'replay.refAction'. +//// +`update`;; (default) Update refs directly using an atomic transaction. All refs are updated or none are (all-or-nothing behavior). `print`;; Output update-ref commands for pipeline use. This is the traditional behavior where output can be piped to `git update-ref --stdin`. From 79ab4e6cd2eb3439cebd0615521b7ff752b1e591 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:19 +0200 Subject: [PATCH 007/159] doc: interpret-trailers: stop fixating on RFC 822 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This command handles the trailers metadata format. But the command isn’t introduced as such; it is instead introduced by stating that these trailer lines look similar to RFC 822 email headers. This is overwrought; most people do not deal directly with email headers, and certainly not email RFCs. Trailers are just key–value pairs that, like email headers, use colon as the separator. The format in its simplest form is easy to describe directly without comparing it to anything else; we will do that in the upcoming commit “explain the format after the intro”. For now, let’s: • remove the first mention of email headers; • keep the second, innocuous comparison with email line folding in the middle; and • remove the now-unneeded disclaimer that trailers do not share many of the features of RFC 822 email headers—there is no invitation to speculate that trailers would follow any other email format rules since we do not compare them directly any more. *** Talking about trailers as an RFC 822/2822-like format seems to go back to the `--fixes`/`Fixes:` trailer topic,[1] the thread that precipitated this command and in turn the first trailer support in git(1) beyond adding s-o-b lines. † 1: https://lore.kernel.org/all/20131027071407.GA11683@leaf/ Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 77b4f63b05cf5b..1878848ad2acb9 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -14,9 +14,9 @@ git interpret-trailers [--in-place] [--trim-empty] DESCRIPTION ----------- -Add or parse _trailer_ lines that look similar to RFC 822 e-mail -headers, at the end of the otherwise free-form part of a commit -message. For example, in the following commit message +Add or parse _trailer_ lines at the end of the otherwise +free-form part of a commit message. For example, in the following commit +message ------------------------------------------------ subject @@ -107,9 +107,6 @@ key: This is a very long value, with spaces and newlines in it. ------------------------------------------------ -Note that trailers do not follow (nor are they intended to follow) many of the -rules for RFC 822 headers. For example they do not follow the encoding rule. - OPTIONS ------- `--in-place`:: From 5cbdf84ca9347857d8550b245ad85514a1c87ba8 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:20 +0200 Subject: [PATCH 008/159] =?UTF-8?q?doc:=20interpret-trailers:=20replace=20?= =?UTF-8?q?=E2=80=9Clines=E2=80=9D=20with=20=E2=80=9Cmetadata=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We removed the initial comparison to email headers in the previous commit. Now the introduction paragraph just says “trailer lines”, and the only hint that this is metadata/structured information is the “otherwise free-form” phrase. Let’s replace “lines” with “metadata” since that is their purpose. This also makes the introduction more consistent with how I chose to define trailers in the glossary:[1] “Key-value metadata”. (We will introduce “key–value” in the upcoming commit “explain the format after the intro”.) † 1: 68e3c69e (Documentation/glossary: describe "trailer", 2024-11-17) Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 1878848ad2acb9..3f60fd9b720dda 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -14,7 +14,7 @@ git interpret-trailers [--in-place] [--trim-empty] DESCRIPTION ----------- -Add or parse _trailer_ lines at the end of the otherwise +Add or parse trailers metadata at the end of the otherwise free-form part of a commit message. For example, in the following commit message From f1a41814daa7995666fe5abcdd237595548946b5 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:21 +0200 Subject: [PATCH 009/159] =?UTF-8?q?doc:=20interpret-trailers:=20use=20?= =?UTF-8?q?=E2=80=9Cmetadata=E2=80=9D=20in=20Name=20as=20well?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now since the previous commit introduce the format as “trailers metadata”. We can replace “structured information” with “metadata” in the “Name” section to be consistent. While “structured information” does emphasize that the data is not loosely structured, we also say that this command adds to or parses this format. I don’t think that we need to emphasize that it is structured since clearly there is some structure there. Both “metadata” and “structured information” can convey the same information. But “metadata” is shorter and easier to deploy since it’s just one word. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 3f60fd9b720dda..4e92c8299bb21b 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -3,7 +3,7 @@ git-interpret-trailers(1) NAME ---- -git-interpret-trailers - Add or parse structured information in commit messages +git-interpret-trailers - Add or parse metadata in commit messages SYNOPSIS -------- From 206bf41d09a4cb0acd9fead20c28b68760d1da0c Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:22 +0200 Subject: [PATCH 010/159] doc: interpret-trailers: not just for commit messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This command doesn’t interface with commits directly. You can interpret or modify any kind of text, even though commit messages are the most relevant. The git(1) suite also isn’t restricted to only direct commit support since git-tag(1) learned `--trailer` in 066cef77 (builtin/tag: add --trailer option, 2024-05-05) Now, we already introduce the command in the “Name” section as dealing with commit messages as well. That is fine since that intro line needs to remain pretty short. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 4e92c8299bb21b..7329e710e1a6eb 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -15,8 +15,8 @@ git interpret-trailers [--in-place] [--trim-empty] DESCRIPTION ----------- Add or parse trailers metadata at the end of the otherwise -free-form part of a commit message. For example, in the following commit -message +free-form part of a commit message, or any other kind of text. +For example, in the following commit message ------------------------------------------------ subject From b63052380db0feaa42f7c840922161f6ff153593 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:23 +0200 Subject: [PATCH 011/159] doc: interpret-trailers: explain the format after the intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You need to read the entire “Description” section in order to understand the full trailer format. But there are many nuances, so that’s fine. As a starter though we have an introductory example.[1] That turns out to be crucial; the rest of this section talks about the mechanics of the command and only incidentally the format itself. Now, although the example might arguably be self-explanatory, we can add a little preamble which defines the format in its simplest form as well as define the most important terms. Note that we name the “blank line” rule since I want to use that term every time it comes up. It gets very mildly obfuscated if you call it a “blank line” in one place[2] and “empty (or whitespace-only) ...” in another one.[3] We will define the format of the *key* in the next commit. † 1: from d57fa7fc (doc: trailer: add more examples in DESCRIPTION, 2023-06-15) † 2: `Documentation/git-interpret-trailers.adoc:86` in 5361983c (The 22nd batch, 2026-03-27) † 3: `Documentation/git-interpret-trailers.adoc:93` in 5361983c (The 22nd batch, 2026-03-27) Suggested-by: D. Ben Knoble Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 7329e710e1a6eb..bcd79b19bd7752 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -16,7 +16,12 @@ DESCRIPTION ----------- Add or parse trailers metadata at the end of the otherwise free-form part of a commit message, or any other kind of text. -For example, in the following commit message + +A _trailer_ in its simplest form is a key-value pair with a colon as a +separator. A _trailer block_ consists of one or more trailers. The +trailer block needs to be preceded by a blank line, where a _blank line_ +is either an empty or a whitespace-only line. For example, in the +following commit message ------------------------------------------------ subject From 3a333ba1a036575fbe14bebffb190df75e28404d Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:24 +0200 Subject: [PATCH 012/159] doc: interpret-trailers: explain key format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailer key must consist of ASCII alphanumeric characters and hyphens *only*. Let’s document it explicitly instead of relying on readers being conservative and only basing their trailer keys on the documentation examples.[1] The previous commit provided us with an appropriate paragraph to describe the key format. † 1: Technically they would then miss out on using digits in them since all of the example keys just use letters and hyphens Reported-by: Brendan Jackman Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index bcd79b19bd7752..c35fa9c688d28f 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -18,7 +18,8 @@ Add or parse trailers metadata at the end of the otherwise free-form part of a commit message, or any other kind of text. A _trailer_ in its simplest form is a key-value pair with a colon as a -separator. A _trailer block_ consists of one or more trailers. The +separator. The _key_ consists of ASCII alphanumeric characters and +hyphens (`-`). A _trailer block_ consists of one or more trailers. The trailer block needs to be preceded by a blank line, where a _blank line_ is either an empty or a whitespace-only line. For example, in the following commit message From 36d5a3a1958e7151a29d6f1b164a43d723a37a78 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:25 +0200 Subject: [PATCH 013/159] doc: interpret-trailers: add key format example All of the examples speak of the Happy Path where everything works as intended. But failure examples can also be instructive. Especially for explaining again, by example, the key format (see previous commit). This also allows us to demonstrate trailer block detection with a concrete example. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index c35fa9c688d28f..f215cba4bf0dea 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -405,6 +405,29 @@ mv "\$1.new" "\$1" $ chmod +x .git/hooks/commit-msg ------------ +* Here we try to to use three different trailer keys. But it fails + because two of them are not recognized as trailer keys. ++ +---- +$ cat msg.txt +subject + +Skapad-på: some-branch +Hash-in-v6.11: 45c12d3269fe48f22834320c782ffe86c3560f2c +Reviewed-by: Alice +$ git interpret-trailers --only-trailers Date: Wed, 10 Jun 2026 23:21:26 +0200 Subject: [PATCH 014/159] doc: interpret-trailers: join new-trailers again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There are three trailers that talk about how a new trailer is added. But the first one is separated from the other two by two paragraphs about how `key-alias` can make using `--trailer` more convenient. This short how-to does not follow thematically from the previous paragraph, and can wait until we have fully described how a new trailer is added. So let’s move the three paragraphs about the new-trailer topic together and move the how-to paragraphs after that. *** Let’s now review the history of the document. Even if the document is not quite correct in its current state, just doing the apparently obvious edit without considering the history does not respect the effort that went into changing the document in the past. These three paragraphs were originally next to each other, in the first version of the doc.[1] But extra sentences about this how-to topic was added to the first paragraph nine years later:[2] [...] `': '` (one colon followed by one space). For convenience, the can be a shortened string key (e.g., "sign") instead of the full string which should [...] And then it was split into it’s own paragraph a little later.[3] This evolution shows, in my opinion, that this how-to never followed thematically from the existing topic. Which means that there is nothing that was potentially lost to time that we need to restore or respect. † 1: dfd66ddf (Documentation: add documentation for 'git interpret-trailers', 2014-10-13) † 2: eda2c44c (doc: trailer: mention 'key' in DESCRIPTION, 2023-06-15) † 3: 6ccbc667 (trailer doc: is a or , not both, 2023-09-07) Suggested-by: D. Ben Knoble Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index f215cba4bf0dea..759cdb6e18ec79 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -74,19 +74,6 @@ key: value This means that the trimmed __ and __ will be separated by "`:`{nbsp}" (one colon followed by one space). -For convenience, a __ can be configured to make using `--trailer` -shorter to type on the command line. This can be configured using the -`trailer..key` configuration variable. The __ must be a prefix -of the full __ string, although case sensitivity does not matter. For -example, if you have - ------------------------------------------------- -trailer.sign.key "Signed-off-by: " ------------------------------------------------- - -in your configuration, you only need to specify `--trailer="sign: foo"` -on the command line instead of `--trailer="Signed-off-by: foo"`. - By default the new trailer will appear at the end of all the existing trailers. If there is no existing trailer, the new trailer will appear at the end of the input. A blank line will be added before the new @@ -101,6 +88,19 @@ The group must either be at the end of the input or be the last non-whitespace lines before a line that starts with `---` (followed by a space or the end of the line). +For convenience, a __ can be configured to make using `--trailer` +shorter to type on the command line. This can be configured using the +`trailer..key` configuration variable. The __ must be a prefix +of the full __ string, although case sensitivity does not matter. For +example, if you have + +------------------------------------------------ +trailer.sign.key "Signed-off-by: " +------------------------------------------------ + +in your configuration, you only need to specify `--trailer="sign: foo"` +on the command line instead of `--trailer="Signed-off-by: foo"`. + When reading trailers, there can be no whitespace before or inside the __, but any number of regular space and tab characters are allowed between the __ and the separator. There can be whitespaces before, From 53fcba2c4f80142cf2c9429d9eea225ba9fb091c Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:27 +0200 Subject: [PATCH 015/159] =?UTF-8?q?doc:=20interpret-trailers:=20commit=20t?= =?UTF-8?q?o=20=E2=80=9Ctrailer=20block=E2=80=9D=20term?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We chose to introduce the term “trailer block” into the documentation a few commits ago.[1] It is used in the code though, so it is not a newly invented term. That term was useful to explain where the trailers are found (they *trail* the message). But it is also useful here, where we explain how trailers are added to existing messages, how trailer blocks are found (beyond the simple case in the introduction), and how the end of the message is found. † 1: in commit “explain the format after the intro” Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 26 ++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 759cdb6e18ec79..9f4c84abfd9e98 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -74,19 +74,21 @@ key: value This means that the trimmed __ and __ will be separated by "`:`{nbsp}" (one colon followed by one space). -By default the new trailer will appear at the end of all the existing -trailers. If there is no existing trailer, the new trailer will appear -at the end of the input. A blank line will be added before the new -trailer if there isn't one already. - -Existing trailers are extracted from the input by looking for -a group of one or more lines that (i) is all trailers, or (ii) contains at -least one Git-generated or user-configured trailer and consists of at +By default the new trailer will appear at the end of the trailer block. +A trailer block will be created with only that trailer if a trailer +block does not already exist. Recall that a trailer block needs to be +preceded by a blank line, so a blank line (specifically an empty line) +will be inserted before the new trailer block in that case. + +Existing trailers are extracted from the input by looking for the +trailer block. Concretely, that is a group of one or more lines that (i) +is all trailers, or (ii) contains at least one Git-generated or +user-configured trailer and consists of at least 25% trailers. -The group must be preceded by one or more empty (or whitespace-only) lines. -The group must either be at the end of the input or be the last -non-whitespace lines before a line that starts with `---` (followed by a -space or the end of the line). +The trailer block is by definition at the end the the message. The end +of the message in turn is either (i) at the end of the input, or (ii) +the last non-whitespace lines before a line that starts with `---` +(followed by a space or the end of the line). For convenience, a __ can be configured to make using `--trailer` shorter to type on the command line. This can be configured using the From 06447e2cbd8d4fe80801dae0bc0e216a5e376d65 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Wed, 10 Jun 2026 23:21:29 +0200 Subject: [PATCH 016/159] doc: interpret-trailers: document comment line treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment lines have always been ignored but this is not documented. This is mostly for completeness since this is unlikely to catch anyone by surprise. But we really ought to be reasonably complete here since it’s the only documentation page that documents trailers. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-interpret-trailers.adoc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Documentation/git-interpret-trailers.adoc b/Documentation/git-interpret-trailers.adoc index 9f4c84abfd9e98..5cf389595228da 100644 --- a/Documentation/git-interpret-trailers.adoc +++ b/Documentation/git-interpret-trailers.adoc @@ -115,6 +115,16 @@ key: This is a very long value, with spaces and newlines in it. ------------------------------------------------ +OTHER RULES +----------- + +What was covered in the previous section are the rules that are relevant +for regular use. The following points are included for completeness. + +This command ignores comment lines (see `core.commentString` in +linkgit:git-config[1]). This is for use with the `prepare-commit-msg` +and `commit-msg` hooks. + OPTIONS ------- `--in-place`:: From acfdb54eb359c9912fb74ec260f00d73e0b2d785 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 12 Jun 2026 16:07:08 -0400 Subject: [PATCH 017/159] t5334: expose shared `nth_line()` helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since commit 0cd2255e64b (midx: support custom `--base` for incremental MIDX writes, 2026-05-19), t5334 has referred to a non-existent helper function 'nth_line', which is defined in t5335, but not here. Move the helper to lib-midx.sh so that both tests can use the same implementation. Ensure likewise that `nth_line()` remains visible from within t5335 by sourcing lib-midx.sh there appropriately. Curiously, t5334 passes both before and after this change. Before this change, the failed command substitution leaves '--base' with an empty value, and after this change, the custom base value is still ignored by the normal incremental write path. The following commits will explain and address that behavior. Noticed-by: SZEDER Gábor Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- t/lib-midx.sh | 6 ++++++ t/t5335-compact-multi-pack-index.sh | 7 +------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/t/lib-midx.sh b/t/lib-midx.sh index e38c609604c301..b522dbdb0f431a 100644 --- a/t/lib-midx.sh +++ b/t/lib-midx.sh @@ -34,3 +34,9 @@ compare_results_with_midx () { midx_git_two_modes "cat-file --batch-all-objects --batch-check --unordered" sorted ' } + +nth_line() { + local n="$1" + shift + awk "NR==$n" "$@" +} diff --git a/t/t5335-compact-multi-pack-index.sh b/t/t5335-compact-multi-pack-index.sh index ec1dafe89fcfce..6a4b799b9c9f49 100755 --- a/t/t5335-compact-multi-pack-index.sh +++ b/t/t5335-compact-multi-pack-index.sh @@ -3,6 +3,7 @@ test_description='multi-pack-index compaction' . ./test-lib.sh +. "$TEST_DIRECTORY"/lib-midx.sh GIT_TEST_MULTI_PACK_INDEX=0 GIT_TEST_MULTI_PACK_INDEX_WRITE_BITMAP=0 @@ -13,12 +14,6 @@ packdir=$objdir/pack midxdir=$packdir/multi-pack-index.d midx_chain=$midxdir/multi-pack-index-chain -nth_line() { - local n="$1" - shift - awk "NR==$n" "$@" -} - write_packs () { for c in "$@" do From 8e519b87565a8689d509693a049bc6dad632dbb3 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 12 Jun 2026 16:07:11 -0400 Subject: [PATCH 018/159] midx: pass custom '--base' through incremental writes The 'multi-pack-index' builtin parses '--base' for incremental writes, but the normal write path does not pass that value through to `write_midx_file()`. As a result, something like: $ git multi-pack-index write --incremental --base= behaves as if no custom base had been given (unless the caller used the '--stdin-packs' path). Thread the parsed base through `write_midx_file()`, and update the repack caller to pass NULL for the new argument where no custom base selection is needed. This exposes a pre-existing problem in incremental writes with custom bases: the writer skips packs from the full existing MIDX chain, even when the caller selected an older base or no base at all. The affected t5334 cases fail while trying to write MIDX bitmaps. The detached layer omits packs above the selected base, and thus the resulting MIDX does not have a reachability closure, making it impossible to generate reachability bitmaps. Mark those tests as expected failures accordingly. The following commit will fix the broken behavior and restore these tests. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/multi-pack-index.c | 3 ++- builtin/repack.c | 2 +- midx-write.c | 2 ++ midx.h | 2 +- t/t5334-incremental-multi-pack-index.sh | 24 +++++++++++++++++++----- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/builtin/multi-pack-index.c b/builtin/multi-pack-index.c index 00ffb36394d08c..949bfa796b28a5 100644 --- a/builtin/multi-pack-index.c +++ b/builtin/multi-pack-index.c @@ -224,7 +224,8 @@ static int cmd_multi_pack_index_write(int argc, const char **argv, } ret = write_midx_file(source, opts.preferred_pack, - opts.refs_snapshot, opts.flags); + opts.refs_snapshot, opts.incremental_base, + opts.flags); free(opts.refs_snapshot); return ret; diff --git a/builtin/repack.c b/builtin/repack.c index 1524a9c13ad5b8..0092a72a996cae 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -629,7 +629,7 @@ int cmd_repack(int argc, unsigned flags = 0; if (git_env_bool(GIT_TEST_MULTI_PACK_INDEX_WRITE_INCREMENTAL, 0)) flags |= MIDX_WRITE_INCREMENTAL; - write_midx_file(existing.source, NULL, NULL, flags); + write_midx_file(existing.source, NULL, NULL, NULL, flags); } cleanup: diff --git a/midx-write.c b/midx-write.c index 561e9eedc0e6ef..aa438775ebd23f 100644 --- a/midx-write.c +++ b/midx-write.c @@ -1850,12 +1850,14 @@ static int write_midx_internal(struct write_midx_opts *opts) int write_midx_file(struct odb_source *source, const char *preferred_pack_name, const char *refs_snapshot, + const char *incremental_base, unsigned flags) { struct write_midx_opts opts = { .source = source, .preferred_pack_name = preferred_pack_name, .refs_snapshot = refs_snapshot, + .incremental_base = incremental_base, .flags = flags, }; diff --git a/midx.h b/midx.h index 63853a03a47fd1..92ed29d913d9a3 100644 --- a/midx.h +++ b/midx.h @@ -131,7 +131,7 @@ int prepare_multi_pack_index_one(struct odb_source *source); */ int write_midx_file(struct odb_source *source, const char *preferred_pack_name, const char *refs_snapshot, - unsigned flags); + const char *incremental_base, unsigned flags); int write_midx_file_only(struct odb_source *source, struct string_list *packs_to_include, const char *preferred_pack_name, diff --git a/t/t5334-incremental-multi-pack-index.sh b/t/t5334-incremental-multi-pack-index.sh index 68a103d13d23c3..69e96bf8d93713 100755 --- a/t/t5334-incremental-multi-pack-index.sh +++ b/t/t5334-incremental-multi-pack-index.sh @@ -119,7 +119,7 @@ test_expect_success 'write MIDX layer with --base without --no-write-chain-file' test_grep "cannot use --base without --no-write-chain-file" err ' -test_expect_success 'write MIDX layer with --base=none and --no-write-chain-file' ' +test_expect_failure 'write MIDX layer with --base=none and --no-write-chain-file' ' test_commit base-none && git repack -d && @@ -128,19 +128,33 @@ test_expect_success 'write MIDX layer with --base=none and --no-write-chain-file --no-write-chain-file --base=none)" && test_cmp "$midx_chain.bak" "$midx_chain" && - test_path_is_file "$midxdir/multi-pack-index-$layer.midx" + test_path_is_file "$midxdir/multi-pack-index-$layer.midx" && + + echo "$layer" >"$midx_chain" && + test-tool read-midx --show-objects "$objdir" "$layer" >midx.objects && + test_grep "^$(git rev-parse 2.2) " midx.objects && + cp "$midx_chain.bak" "$midx_chain" ' -test_expect_success 'write MIDX layer with --base= and --no-write-chain-file' ' +test_expect_failure 'write MIDX layer with --base= and --no-write-chain-file' ' test_commit base-hash && git repack -d && cp "$midx_chain" "$midx_chain.bak" && + base="$(nth_line 1 "$midx_chain")" && layer="$(git multi-pack-index write --bitmap --incremental \ - --no-write-chain-file --base="$(nth_line 1 "$midx_chain")")" && + --no-write-chain-file --base="$base")" && test_cmp "$midx_chain.bak" "$midx_chain" && - test_path_is_file "$midxdir/multi-pack-index-$layer.midx" + test_path_is_file "$midxdir/multi-pack-index-$layer.midx" && + + { + echo "$base" && + echo "$layer" + } >"$midx_chain" && + test-tool read-midx --show-objects "$objdir" "$layer" >midx.objects && + test_grep "^$(git rev-parse 2.2) " midx.objects && + cp "$midx_chain.bak" "$midx_chain" ' for reuse in false single multi From 6afc679fa62362ddaab955778d973c85a6cbf004 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 12 Jun 2026 16:07:14 -0400 Subject: [PATCH 019/159] midx-write: include packs above custom incremental base The previous commit made '--base' take effect on the normal incremental write path, which exposed an existing assumption in our helper function `should_include_pack()`, which is that any pack already present in `ctx->m` was skipped. That is only correct for non-incremental writes. For incremental writes, `ctx->base_midx` is the boundary that should be excluded from the new layer. If the caller selects an older base, or no base at all, then packs from layers above that base have to be included in the detached layer so that its bitmap has reachability closure. Teach `should_include_pack()` to choose the MIDX used for pack exclusion based on whether or not we are performing an incremental write. When doing so, use `ctx->base_midx`, and use `ctx->m` otherwise. The t5334 cases from the previous commit can now be marked as successful. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- midx-write.c | 16 +++++++++++----- t/t5334-incremental-multi-pack-index.sh | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/midx-write.c b/midx-write.c index aa438775ebd23f..c50fdb5c6d17c3 100644 --- a/midx-write.c +++ b/midx-write.c @@ -133,8 +133,17 @@ static uint32_t midx_pack_perm(struct write_midx_context *ctx, static int should_include_pack(const struct write_midx_context *ctx, const char *file_name) { + struct multi_pack_index *m = ctx->m; /* - * Note that at most one of ctx->m and ctx->to_include are set, + * When writing incrementally, ctx->m may contain layers above + * the selected base MIDX, which must be included in the new + * layer. + */ + if (ctx->incremental) + m = ctx->base_midx; + + /* + * Note that at most one of m and ctx->to_include are set, * so we are testing midx_contains_pack() and * string_list_has_string() independently (guarded by the * appropriate NULL checks). @@ -148,10 +157,7 @@ static int should_include_pack(const struct write_midx_context *ctx, * should be performed independently (likely checking * to_include before the existing MIDX). */ - if (ctx->m && midx_contains_pack(ctx->m, file_name)) - return 0; - else if (ctx->base_midx && midx_contains_pack(ctx->base_midx, - file_name)) + if (m && midx_contains_pack(m, file_name)) return 0; else if (ctx->to_include && !string_list_has_string(ctx->to_include, file_name)) diff --git a/t/t5334-incremental-multi-pack-index.sh b/t/t5334-incremental-multi-pack-index.sh index 69e96bf8d93713..84ff612097888f 100755 --- a/t/t5334-incremental-multi-pack-index.sh +++ b/t/t5334-incremental-multi-pack-index.sh @@ -119,7 +119,7 @@ test_expect_success 'write MIDX layer with --base without --no-write-chain-file' test_grep "cannot use --base without --no-write-chain-file" err ' -test_expect_failure 'write MIDX layer with --base=none and --no-write-chain-file' ' +test_expect_success 'write MIDX layer with --base=none and --no-write-chain-file' ' test_commit base-none && git repack -d && @@ -136,7 +136,7 @@ test_expect_failure 'write MIDX layer with --base=none and --no-write-chain-file cp "$midx_chain.bak" "$midx_chain" ' -test_expect_failure 'write MIDX layer with --base= and --no-write-chain-file' ' +test_expect_success 'write MIDX layer with --base= and --no-write-chain-file' ' test_commit base-hash && git repack -d && From 7f5550445099f501dd16299c49f59a0dd949d237 Mon Sep 17 00:00:00 2001 From: Zakariyah Ali Date: Sat, 20 Jun 2026 17:55:55 +0000 Subject: [PATCH 020/159] completion: hide dotfiles for selected path completion The completion helper for index paths uses git ls-files rather than shell filename completion. As a result, leading-dot paths such as a tracked .gitignore were offered even when the user had not started the path with ".". Hide leading-dot path components for git rm, git mv, and git ls-files when completing an empty path component. Explicit dot completion is still preserved, so git rm . can still complete .gitignore. This matches standard shell filename completion behavior, where dotfiles are hidden by default unless the user starts their input with a dot. This also resolves four TODO comments in t/9902-completion.sh which have been present since 2013 (commit ddf07bddef9a, "completion: add file completion tests", 2013-04-27), expecting that .gitignore would not be shown when completing on an empty path component. Signed-off-by: Zakariyah Ali Signed-off-by: Junio C Hamano --- contrib/completion/git-completion.bash | 36 +++++++++++++++++--------- t/t9902-completion.sh | 10 ++----- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/contrib/completion/git-completion.bash b/contrib/completion/git-completion.bash index a8e7c6ddbfb2b1..e8f8fab125b42b 100644 --- a/contrib/completion/git-completion.bash +++ b/contrib/completion/git-completion.bash @@ -638,25 +638,33 @@ __git_ls_files_helper () } -# __git_index_files accepts 1 or 2 arguments: +# __git_index_files accepts 1 to 4 arguments: # 1: Options to pass to ls-files (required). # 2: A directory path (optional). # If provided, only files within the specified directory are listed. # Sub directories are never recursed. Path must have a trailing # slash. # 3: List only paths matching this path component (optional). +# 4: Hide paths whose first component starts with a dot if this is +# "hide-dotfiles" and the third argument is empty (optional). __git_index_files () { - local root="$2" match="$3" + local root="$2" match="$3" hide_dotfiles="${4-}" + local hide_dotfiles_awk=0 + if [ "$hide_dotfiles" = "hide-dotfiles" ] && [ -z "$match" ]; then + hide_dotfiles_awk=1 + fi __git_ls_files_helper "$root" "$1" "${match:-?}" | - awk -F / -v pfx="${2//\\/\\\\}" '{ + awk -F / -v pfx="${2//\\/\\\\}" -v hide_dotfiles="$hide_dotfiles_awk" '{ paths[$1] = 1 } END { for (p in paths) { if (substr(p, 1, 1) != "\"") { # No special characters, easy! + if (hide_dotfiles == 1 && substr(p, 1, 1) == ".") + continue print pfx p continue } @@ -675,8 +683,10 @@ __git_index_files () # We have seen the same directory unquoted, # skip it. continue - else - print pfx p + + if (hide_dotfiles == 1 && substr(p, 1, 1) == ".") + continue + print pfx p } } function dequote(p, bs_idx, out, esc, esc_idx, dec) { @@ -721,13 +731,15 @@ __git_index_files () }' } -# __git_complete_index_file requires 1 argument: +# __git_complete_index_file accepts 1 or 2 arguments: # 1: the options to pass to ls-file +# 2: Hide paths whose first component starts with a dot if this is +# "hide-dotfiles" and the current word is empty (optional). # # The exception is --committable, which finds the files appropriate commit. __git_complete_index_file () { - local dequoted_word pfx="" cur_ + local dequoted_word pfx="" cur_ hide_dotfiles="${2-}" __git_dequote "$cur" @@ -740,7 +752,7 @@ __git_complete_index_file () cur_="$dequoted_word" esac - __gitcomp_file_direct "$(__git_index_files "$1" "$pfx" "$cur_")" + __gitcomp_file_direct "$(__git_index_files "$1" "$pfx" "$cur_" "$hide_dotfiles")" } # Lists branches from the local repository. @@ -2164,7 +2176,7 @@ _git_ls_files () # XXX ignore options like --modified and always suggest all cached # files. - __git_complete_index_file "--cached" + __git_complete_index_file "--cached" hide-dotfiles } _git_ls_remote () @@ -2397,9 +2409,9 @@ _git_mv () if [ $(__git_count_arguments "mv") -gt 0 ]; then # We need to show both cached and untracked files (including # empty directories) since this may not be the last argument. - __git_complete_index_file "--cached --others --directory" + __git_complete_index_file "--cached --others --directory" hide-dotfiles else - __git_complete_index_file "--cached" + __git_complete_index_file "--cached" hide-dotfiles fi } @@ -3219,7 +3231,7 @@ _git_rm () ;; esac - __git_complete_index_file "--cached" + __git_complete_index_file "--cached" hide-dotfiles } _git_shortlog () diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh index 28f61f08fb4cec..02aaf71876ea0a 100755 --- a/t/t9902-completion.sh +++ b/t/t9902-completion.sh @@ -2811,17 +2811,15 @@ test_expect_success 'complete files' ' touch untracked && - : TODO .gitignore should not be here && test_completion "git rm " <<-\EOF && - .gitignore modified EOF + test_completion "git rm ." ".gitignore" && + test_completion "git clean " "untracked" && - : TODO .gitignore should not be here && test_completion "git mv " <<-\EOF && - .gitignore modified EOF @@ -2832,9 +2830,7 @@ test_expect_success 'complete files' ' mkdir untracked-dir && - : TODO .gitignore should not be here && test_completion "git mv modified " <<-\EOF && - .gitignore dir modified untracked @@ -2843,9 +2839,7 @@ test_expect_success 'complete files' ' test_completion "git commit " "modified" && - : TODO .gitignore should not be here && test_completion "git ls-files " <<-\EOF && - .gitignore dir modified EOF From 2cc01b9c55de08d03f30e7ec43236aa08a829dc0 Mon Sep 17 00:00:00 2001 From: Zakariyah Ali Date: Sat, 20 Jun 2026 17:55:56 +0000 Subject: [PATCH 021/159] completion: hide dotfiles by default for path completion The previous implementation required callers to explicitly pass a "hide-dotfiles" flag to __git_complete_index_file to avoid cluttering completions with hidden files. This led to inconsistent behavior across commands (e.g., `git add` and `git mv` behaved differently) and forced callers to maintain repetitive logic. As suggested by Junio C Hamano, this commit simplifies the logic: 1. __git_complete_index_file now unconditionally hides dotfiles when no match pattern is provided. 2. The awk loop in __git_index_files is refactored to check the dotfile condition in a single, obvious place after handling path dequoting, removing the previous duplication. 3. Callers no longer need to pass "hide-dotfiles". This provides a cleaner API and ensures a consistent, expected behavior where dotfiles are hidden unless explicitly requested by typing a dot. Signed-off-by: Zakariyah Ali Signed-off-by: Junio C Hamano --- contrib/completion/git-completion.bash | 65 ++++++++++++-------------- t/t9902-completion.sh | 9 +++- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/contrib/completion/git-completion.bash b/contrib/completion/git-completion.bash index e8f8fab125b42b..b0b1b3c27aa61b 100644 --- a/contrib/completion/git-completion.bash +++ b/contrib/completion/git-completion.bash @@ -638,20 +638,23 @@ __git_ls_files_helper () } -# __git_index_files accepts 1 to 4 arguments: +# __git_index_files accepts 1 to 3 arguments: # 1: Options to pass to ls-files (required). # 2: A directory path (optional). # If provided, only files within the specified directory are listed. # Sub directories are never recursed. Path must have a trailing # slash. # 3: List only paths matching this path component (optional). -# 4: Hide paths whose first component starts with a dot if this is -# "hide-dotfiles" and the third argument is empty (optional). +# +# If the third argument is empty, paths that begin with a dot (dotfiles) +# are hidden. This matches user expectations where dotfiles are considered +# hidden configuration files/directories and shouldn't clutter default +# completions unless explicitly requested by typing a dot. __git_index_files () { - local root="$2" match="$3" hide_dotfiles="${4-}" + local root="$2" match="$3" local hide_dotfiles_awk=0 - if [ "$hide_dotfiles" = "hide-dotfiles" ] && [ -z "$match" ]; then + if [ -z "$match" ]; then hide_dotfiles_awk=1 fi @@ -661,28 +664,22 @@ __git_index_files () } END { for (p in paths) { - if (substr(p, 1, 1) != "\"") { - # No special characters, easy! - if (hide_dotfiles == 1 && substr(p, 1, 1) == ".") + if (substr(p, 1, 1) == "\"") { + # The path is quoted. + p = dequote(p) + if (p == "") continue - print pfx p - continue - } - - # The path is quoted. - p = dequote(p) - if (p == "") - continue - # Even when a directory name itself does not contain - # any special characters, it will still be quoted if - # any of its (stripped) trailing path components do. - # Because of this we may have seen the same directory - # both quoted and unquoted. - if (p in paths) - # We have seen the same directory unquoted, - # skip it. - continue + # Even when a directory name itself does not contain + # any special characters, it will still be quoted if + # any of its (stripped) trailing path components do. + # Because of this we may have seen the same directory + # both quoted and unquoted. + if (p in paths) + # We have seen the same directory unquoted, + # skip it. + continue + } if (hide_dotfiles == 1 && substr(p, 1, 1) == ".") continue @@ -731,15 +728,13 @@ __git_index_files () }' } -# __git_complete_index_file accepts 1 or 2 arguments: -# 1: the options to pass to ls-file -# 2: Hide paths whose first component starts with a dot if this is -# "hide-dotfiles" and the current word is empty (optional). +# __git_complete_index_file accepts 1 argument: +# 1: the options to pass to ls-files # # The exception is --committable, which finds the files appropriate commit. __git_complete_index_file () { - local dequoted_word pfx="" cur_ hide_dotfiles="${2-}" + local dequoted_word pfx="" cur_ __git_dequote "$cur" @@ -752,7 +747,7 @@ __git_complete_index_file () cur_="$dequoted_word" esac - __gitcomp_file_direct "$(__git_index_files "$1" "$pfx" "$cur_" "$hide_dotfiles")" + __gitcomp_file_direct "$(__git_index_files "$1" "$pfx" "$cur_")" } # Lists branches from the local repository. @@ -2176,7 +2171,7 @@ _git_ls_files () # XXX ignore options like --modified and always suggest all cached # files. - __git_complete_index_file "--cached" hide-dotfiles + __git_complete_index_file "--cached" } _git_ls_remote () @@ -2409,9 +2404,9 @@ _git_mv () if [ $(__git_count_arguments "mv") -gt 0 ]; then # We need to show both cached and untracked files (including # empty directories) since this may not be the last argument. - __git_complete_index_file "--cached --others --directory" hide-dotfiles + __git_complete_index_file "--cached --others --directory" else - __git_complete_index_file "--cached" hide-dotfiles + __git_complete_index_file "--cached" fi } @@ -3231,7 +3226,7 @@ _git_rm () ;; esac - __git_complete_index_file "--cached" hide-dotfiles + __git_complete_index_file "--cached" } _git_shortlog () diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh index 02aaf71876ea0a..7a7594455ca02e 100755 --- a/t/t9902-completion.sh +++ b/t/t9902-completion.sh @@ -2360,6 +2360,7 @@ test_expect_success 'setup for path completion tests' ' "spaces in dir" \ árvíztűrő && touch simple-dir/simple-file \ + simple-dir/.dotfile-in-dir \ "spaces in dir/spaces in file" \ "árvíztűrő/Сайн яваарай" && if test_have_prereq !MINGW && @@ -2380,6 +2381,11 @@ test_expect_success '__git_complete_index_file - simple' ' test_path_completion simple-dir/simple simple-dir/simple-file ' +test_expect_success '__git_complete_index_file - dotfiles' ' + test_path_completion "simple-dir/" "simple-dir/simple-file" && + test_path_completion "simple-dir/." "simple-dir/.dotfile-in-dir" +' + test_expect_success \ '__git_complete_index_file - escaped characters on cmdline' ' test_path_completion spac "spaces in dir" && # Bash will turn this @@ -2789,7 +2795,8 @@ test_expect_success 'complete files' ' echo "out_sorted" >> .gitignore && git add .gitignore && - test_completion "git commit " ".gitignore" && + test_completion "git commit " "" && + test_completion "git commit ." ".gitignore" && git commit -m ignore && From f4914a86e11be463819175ce769c3b689bbb9994 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:54:57 +0000 Subject: [PATCH 022/159] branch: expose helpers for finding the remote owning a tracking ref The remote-lookup that setup_tracking() does is useful outside branch.c too; for example, deciding which remote to "git fetch" from given a remote-tracking ref. Move 'struct tracking' to branch.h and add two helpers backed by the existing for_each_remote walk: find_tracking_remote_for_ref() and advise_ambiguous_fetch_refspec(). setup_tracking() uses both. No behavior change. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- branch.c | 96 ++++++++++++++++++++++++++++++-------------------------- branch.h | 16 ++++++++++ 2 files changed, 68 insertions(+), 44 deletions(-) diff --git a/branch.c b/branch.c index 243db7d0fc0226..46ae7f00354c9d 100644 --- a/branch.c +++ b/branch.c @@ -20,16 +20,9 @@ #include "run-command.h" #include "strmap.h" -struct tracking { - struct refspec_item spec; - struct string_list *srcs; - const char *remote; - int matches; -}; - struct find_tracked_branch_cb { struct tracking *tracking; - struct string_list ambiguous_remotes; + struct string_list *ambiguous_remotes; }; static int find_tracked_branch(struct remote *remote, void *priv) @@ -45,10 +38,10 @@ static int find_tracked_branch(struct remote *remote, void *priv) break; case 2: /* there are at least two remotes; backfill the first one */ - string_list_append(&ftb->ambiguous_remotes, tracking->remote); + string_list_append(ftb->ambiguous_remotes, tracking->remote); /* fall through */ default: - string_list_append(&ftb->ambiguous_remotes, remote->name); + string_list_append(ftb->ambiguous_remotes, remote->name); free(tracking->spec.src); string_list_clear(tracking->srcs, 0); break; @@ -59,6 +52,51 @@ static int find_tracked_branch(struct remote *remote, void *priv) return 0; } +void find_tracking_remote_for_ref(struct tracking *tracking, + struct string_list *ambiguous_remotes) +{ + struct find_tracked_branch_cb ftb_cb = { + .tracking = tracking, + .ambiguous_remotes = ambiguous_remotes, + }; + + for_each_remote(find_tracked_branch, &ftb_cb); +} + +void advise_ambiguous_fetch_refspec(const char *dst, + const struct string_list *ambiguous_remotes) +{ + struct strbuf remotes_advice = STRBUF_INIT; + struct string_list_item *item; + + if (!advice_enabled(ADVICE_AMBIGUOUS_FETCH_REFSPEC)) + return; + + for_each_string_list_item(item, ambiguous_remotes) + /* + * TRANSLATORS: This is a line listing a remote with duplicate + * refspecs in the advice message below. For RTL languages you'll + * probably want to swap the "%s" and leading " " space around. + */ + strbuf_addf(&remotes_advice, _(" %s\n"), item->string); + + /* + * TRANSLATORS: The second argument is a \n-delimited list of + * duplicate refspecs, composed above. + */ + advise(_("There are multiple remotes whose fetch refspecs map to the remote\n" + "tracking ref '%s':\n" + "%s" + "\n" + "This is typically a configuration error.\n" + "\n" + "To support setting up tracking branches, ensure that\n" + "different remotes' fetch refspecs map into different\n" + "tracking namespaces."), dst, + remotes_advice.buf); + strbuf_release(&remotes_advice); +} + static int should_setup_rebase(const char *origin) { switch (autorebase) { @@ -254,11 +292,8 @@ static void setup_tracking(const char *new_ref, const char *orig_ref, { struct tracking tracking; struct string_list tracking_srcs = STRING_LIST_INIT_DUP; + struct string_list ambiguous_remotes = STRING_LIST_INIT_DUP; int config_flags = quiet ? 0 : BRANCH_CONFIG_VERBOSE; - struct find_tracked_branch_cb ftb_cb = { - .tracking = &tracking, - .ambiguous_remotes = STRING_LIST_INIT_DUP, - }; if (!track) BUG("asked to set up tracking, but tracking is disallowed"); @@ -267,7 +302,7 @@ static void setup_tracking(const char *new_ref, const char *orig_ref, tracking.spec.dst = (char *)orig_ref; tracking.srcs = &tracking_srcs; if (track != BRANCH_TRACK_INHERIT) - for_each_remote(find_tracked_branch, &ftb_cb); + find_tracking_remote_for_ref(&tracking, &ambiguous_remotes); else if (inherit_tracking(&tracking, orig_ref)) goto cleanup; @@ -293,34 +328,7 @@ static void setup_tracking(const char *new_ref, const char *orig_ref, if (tracking.matches > 1) { int status = die_message(_("not tracking: ambiguous information for ref '%s'"), orig_ref); - if (advice_enabled(ADVICE_AMBIGUOUS_FETCH_REFSPEC)) { - struct strbuf remotes_advice = STRBUF_INIT; - struct string_list_item *item; - - for_each_string_list_item(item, &ftb_cb.ambiguous_remotes) - /* - * TRANSLATORS: This is a line listing a remote with duplicate - * refspecs in the advice message below. For RTL languages you'll - * probably want to swap the "%s" and leading " " space around. - */ - strbuf_addf(&remotes_advice, _(" %s\n"), item->string); - - /* - * TRANSLATORS: The second argument is a \n-delimited list of - * duplicate refspecs, composed above. - */ - advise(_("There are multiple remotes whose fetch refspecs map to the remote\n" - "tracking ref '%s':\n" - "%s" - "\n" - "This is typically a configuration error.\n" - "\n" - "To support setting up tracking branches, ensure that\n" - "different remotes' fetch refspecs map into different\n" - "tracking namespaces."), orig_ref, - remotes_advice.buf); - strbuf_release(&remotes_advice); - } + advise_ambiguous_fetch_refspec(orig_ref, &ambiguous_remotes); exit(status); } @@ -347,7 +355,7 @@ static void setup_tracking(const char *new_ref, const char *orig_ref, cleanup: string_list_clear(&tracking_srcs, 0); - string_list_clear(&ftb_cb.ambiguous_remotes, 0); + string_list_clear(&ambiguous_remotes, 0); } int read_branch_desc(struct strbuf *buf, const char *branch_name) diff --git a/branch.h b/branch.h index 3dc6e2a0ffe635..c2e6725491352f 100644 --- a/branch.h +++ b/branch.h @@ -1,9 +1,25 @@ #ifndef BRANCH_H #define BRANCH_H +#include "refspec.h" + +struct string_list; struct repository; struct strbuf; +struct tracking { + struct refspec_item spec; + struct string_list *srcs; + const char *remote; + int matches; +}; + +void find_tracking_remote_for_ref(struct tracking *tracking, + struct string_list *ambiguous_remotes); + +void advise_ambiguous_fetch_refspec(const char *dst, + const struct string_list *ambiguous_remotes); + enum branch_track { BRANCH_TRACK_UNSPECIFIED = -1, BRANCH_TRACK_NEVER = 0, From 8b13e2d194d5e1c2280d33de6b64c778a9cfa2ff Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Wed, 24 Jun 2026 21:54:58 +0000 Subject: [PATCH 023/159] checkout: extend --track with a "fetch" mode to refresh start-point Forking from an existing remote branch without refreshing first often has consequences: you start work that has already been done, or you build on an old version of the code which causes big conflicts later when you pull. The workaround is two commands ("git fetch && git checkout -b /"), and when the fetch is skipped the checkout silently starts from a stale tip. Users may already expect "/" to refer to the latest tip on the remote. While this blurs the line between fetch and checkout, git already does this in places where it pays off: "git clone" fetches and checks out, and "git pull" fetches and merges. Add a "fetch" mode to "--track" that refreshes before checking it out: git checkout -b new_branch --track=fetch origin/some-branch Only the requested branch is fetched so other remote-tracking branches are left untouched. When is a bare (e.g. "origin"), follow refs/remotes//HEAD to learn which branch to refresh. If "git fetch" fails but the remote-tracking ref already exists locally, warn and proceed from the existing tip, otherwise abort. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-checkout.adoc | 17 ++- Documentation/git-switch.adoc | 5 +- builtin/checkout.c | 138 +++++++++++++++++++- t/t7201-co.sh | 222 ++++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 7 deletions(-) diff --git a/Documentation/git-checkout.adoc b/Documentation/git-checkout.adoc index 43ccf47cf6de28..e5813562b8871e 100644 --- a/Documentation/git-checkout.adoc +++ b/Documentation/git-checkout.adoc @@ -158,11 +158,26 @@ of it"). resets __ to the start point instead of failing. `-t`:: -`--track[=(direct|inherit)]`:: +`--track[=(direct|inherit|fetch)[,...]]`:: When creating a new branch, set up "upstream" configuration. See `--track` in linkgit:git-branch[1] for details. As a convenience, --track without -b implies branch creation. + +The argument is a comma-separated list. `direct` (the default) and +`inherit` select the tracking mode and are mutually exclusive. Adding +`fetch` requests that the remote be fetched before __ is +resolved, so the new branch starts from a fresh tip: when +__ is in _/_ form, only that branch is +updated; when __ is a bare __ (e.g. `origin`), the +branch named by _/HEAD_ is updated, and the checkout fails +with a hint to configure that symref if it is not set. The checkout +also fails if no configured remote's fetch refspec maps to +__, or if more than one does (in which case the `fetch` +cannot be unambiguously routed). If the fetch itself fails and the +corresponding remote-tracking ref already exists, a warning is printed +and the checkout proceeds from the existing tip; otherwise the checkout +is aborted. ++ If no `-b` option is given, the name of the new branch will be derived from the remote-tracking branch, by looking at the local part of the refspec configured for the corresponding remote, and then stripping diff --git a/Documentation/git-switch.adoc b/Documentation/git-switch.adoc index 87707e92652064..937ad5a6676709 100644 --- a/Documentation/git-switch.adoc +++ b/Documentation/git-switch.adoc @@ -154,10 +154,11 @@ should result in deletion of the path). attached to a terminal, regardless of `--quiet`. `-t`:: -`--track[ (direct|inherit)]`:: +`--track[=(direct|inherit|fetch)[,...]]`:: When creating a new branch, set up "upstream" configuration. `-c` is implied. See `--track` in linkgit:git-branch[1] for - details. + details, and `--track` in linkgit:git-checkout[1] for the + `fetch` mode. + If no `-c` option is given, the name of the new branch will be derived from the remote-tracking branch, by looking at the local part of the diff --git a/builtin/checkout.c b/builtin/checkout.c index e031e6188613a6..805df07707dc61 100644 --- a/builtin/checkout.c +++ b/builtin/checkout.c @@ -26,10 +26,12 @@ #include "preload-index.h" #include "read-cache.h" #include "refs.h" +#include "refspec.h" #include "remote.h" #include "repo-settings.h" #include "resolve-undo.h" #include "revision.h" +#include "run-command.h" #include "setup.h" #include "submodule.h" #include "symlinks.h" @@ -61,6 +63,7 @@ struct checkout_opts { int count_checkout_paths; int overlay_mode; int dwim_new_local_branch; + int fetch; int discard_changes; int accept_ref; int accept_pathspec; @@ -112,6 +115,128 @@ struct branch_info { char *checkout; }; +static void fetch_remote_for_start_point(const char *arg, int quiet) +{ + struct strbuf dst = STRBUF_INIT; + struct tracking tracking = { 0 }; + struct string_list tracking_srcs = STRING_LIST_INIT_DUP; + struct string_list ambiguous_remotes = STRING_LIST_INIT_DUP; + struct child_process cmd = CHILD_PROCESS_INIT; + struct remote *named_remote; + int bare_ns; + + strbuf_addf(&dst, "refs/remotes/%s", arg); + if (check_refname_format(dst.buf, 0)) + die(_("cannot fetch start-point '%s': not a valid " + "remote-tracking name"), arg); + + named_remote = remote_get(arg); + bare_ns = !strchr(arg, '/') || + (named_remote && remote_is_configured(named_remote, 1)); + if (bare_ns) { + char *head_path = xstrfmt("refs/remotes/%s/HEAD", arg); + const char *head_target = + refs_resolve_ref_unsafe(get_main_ref_store(the_repository), + head_path, + RESOLVE_REF_READING, + NULL, NULL); + if (head_target && + starts_with(head_target, dst.buf) && + head_target[dst.len] == '/') { + strbuf_reset(&dst); + strbuf_addstr(&dst, head_target); + bare_ns = 0; + } + free(head_path); + } + + tracking.spec.dst = dst.buf; + tracking.srcs = &tracking_srcs; + find_tracking_remote_for_ref(&tracking, &ambiguous_remotes); + + if (tracking.matches > 1) { + int status = die_message(_("cannot fetch start-point '%s': " + "fetch refspecs of multiple remotes " + "map to '%s'"), arg, dst.buf); + advise_ambiguous_fetch_refspec(dst.buf, &ambiguous_remotes); + exit(status); + } + + if (!tracking.matches) { + if (bare_ns && named_remote && + remote_is_configured(named_remote, 1)) { + int status = die_message(_("cannot fetch start-point '%s' " + "because 'refs/remotes/%s/HEAD' " + "does not exist."), arg, arg); + advise(_("To create it run\n" + "\n" + " git remote set-head %s --auto\n"), arg); + exit(status); + } + die(_("cannot fetch start-point '%s': no configured remote's " + "fetch refspec matches it"), arg); + } + + strvec_push(&cmd.args, "fetch"); + if (quiet) + strvec_push(&cmd.args, "--quiet"); + strvec_pushl(&cmd.args, tracking.remote, + tracking_srcs.items[0].string, NULL); + cmd.git_cmd = 1; + if (run_command(&cmd)) { + if (refs_ref_exists(get_main_ref_store(the_repository), dst.buf)) + warning(_("failed to fetch start-point '%s'; " + "using existing '%s'"), arg, dst.buf); + else + die(_("failed to fetch start-point '%s'"), arg); + } + + string_list_clear(&tracking_srcs, 0); + string_list_clear(&ambiguous_remotes, 0); + strbuf_release(&dst); +} + +static int parse_opt_checkout_track(const struct option *opt, + const char *arg, int unset) +{ + struct checkout_opts *opts = opt->value; + struct string_list tokens = STRING_LIST_INIT_DUP; + struct string_list_item *item; + int saw_direct = 0; + int ret = 0; + + opts->fetch = 0; + if (unset) { + opts->track = BRANCH_TRACK_NEVER; + return 0; + } + opts->track = BRANCH_TRACK_EXPLICIT; + if (!arg) + return 0; + + string_list_split(&tokens, arg, ",", -1); + for_each_string_list_item(item, &tokens) { + if (!strcmp(item->string, "fetch")) + opts->fetch = 1; + else if (!strcmp(item->string, "direct")) + saw_direct = 1; + else if (!strcmp(item->string, "inherit")) + opts->track = BRANCH_TRACK_INHERIT; + else { + ret = error(_("option `%s' expects \"%s\", \"%s\", " + "or \"%s\""), + "--track", "direct", "inherit", "fetch"); + goto out; + } + } + if (saw_direct && opts->track == BRANCH_TRACK_INHERIT) + ret = error(_("option `%s' cannot combine \"%s\" and \"%s\""), + "--track", "direct", "inherit"); +out: + string_list_clear(&tokens, 0); + return ret; +} + static void branch_info_release(struct branch_info *info) { free(info->name); @@ -1734,10 +1859,10 @@ static struct option *add_common_switch_branch_options( { struct option options[] = { OPT_BOOL('d', "detach", &opts->force_detach, N_("detach HEAD at named commit")), - OPT_CALLBACK_F('t', "track", &opts->track, "(direct|inherit)", + OPT_CALLBACK_F('t', "track", opts, "(direct|inherit|fetch)[,...]", N_("set branch tracking configuration"), PARSE_OPT_OPTARG, - parse_opt_tracking_mode), + parse_opt_checkout_track), OPT__FORCE(&opts->force, N_("force checkout (throw away local modifications)"), PARSE_OPT_NOCOMPLETE), OPT_STRING(0, "orphan", &opts->new_orphan_branch, N_("new-branch"), N_("new unborn branch")), @@ -1942,8 +2067,13 @@ static int checkout_main(int argc, const char **argv, const char *prefix, opts->dwim_new_local_branch && opts->track == BRANCH_TRACK_UNSPECIFIED && !opts->new_branch; - int n = parse_branchname_arg(argc, argv, dwim_ok, which_command, - &new_branch_info, opts, &rev); + int n; + + if (opts->fetch) + fetch_remote_for_start_point(argv[0], opts->quiet); + + n = parse_branchname_arg(argc, argv, dwim_ok, which_command, + &new_branch_info, opts, &rev); argv += n; argc -= n; } else if (!opts->accept_ref && opts->from_treeish) { diff --git a/t/t7201-co.sh b/t/t7201-co.sh index 9bcf7c0b40461f..b3ed8e4ed5d22b 100755 --- a/t/t7201-co.sh +++ b/t/t7201-co.sh @@ -801,4 +801,226 @@ test_expect_success 'tracking info copied with autoSetupMerge=inherit' ' test_cmp_config "" --default "" branch.main2.merge ' +test_expect_success 'setup upstream for --track=fetch tests' ' + git checkout main && + git init fetch_upstream && + test_commit -C fetch_upstream u_main && + git remote add fetch_upstream fetch_upstream && + git fetch fetch_upstream && + git -C fetch_upstream checkout -b fetch_new && + test_commit -C fetch_upstream u_new +' + +test_expect_success 'checkout --track=fetch -b picks up branch created upstream after clone' ' + git checkout main && + test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_new && + git checkout --track=fetch -b local_new fetch_upstream/fetch_new && + test_cmp_rev refs/remotes/fetch_upstream/fetch_new HEAD && + test_cmp_config fetch_upstream branch.local_new.remote && + test_cmp_config refs/heads/fetch_new branch.local_new.merge +' + +test_expect_success 'checkout --track=fetch / leaves other tracking branches untouched' ' + git checkout main && + git -C fetch_upstream checkout -b fetch_target && + test_commit -C fetch_upstream u_target_pre && + git -C fetch_upstream checkout -b fetch_other && + test_commit -C fetch_upstream u_other_pre && + git fetch fetch_upstream && + git update-ref refs/heads/snapshot_other refs/remotes/fetch_upstream/fetch_other && + git -C fetch_upstream checkout fetch_target && + test_commit -C fetch_upstream u_target_post && + git -C fetch_upstream checkout fetch_other && + test_commit -C fetch_upstream u_other_post && + git checkout --track=fetch -b local_target fetch_upstream/fetch_target && + test_cmp_rev refs/remotes/fetch_upstream/fetch_target HEAD && + test_cmp_rev refs/remotes/fetch_upstream/fetch_other snapshot_other +' + +test_expect_success 'checkout --track=fetch with bare remote name fetches only /HEAD target' ' + git checkout main && + git -C fetch_upstream checkout main && + git remote set-head fetch_upstream main && + git -C fetch_upstream checkout -b fetch_unrelated && + test_commit -C fetch_upstream u_unrelated_pre && + git fetch fetch_upstream fetch_unrelated && + git update-ref refs/heads/snapshot_unrelated \ + refs/remotes/fetch_upstream/fetch_unrelated && + git -C fetch_upstream checkout main && + test_commit -C fetch_upstream u_main_post && + git -C fetch_upstream checkout fetch_unrelated && + test_commit -C fetch_upstream u_unrelated_post && + git checkout --track=fetch -b local_from_remote fetch_upstream && + test_cmp_rev refs/remotes/fetch_upstream/main HEAD && + test_cmp_rev refs/remotes/fetch_upstream/fetch_unrelated snapshot_unrelated +' + +test_expect_success 'checkout --track=fetch aborts and does not create branch when no existing ref' ' + git checkout main && + test_might_fail git branch -D bogus && + test_must_fail git checkout --track=fetch -b bogus fetch_upstream/does_not_exist && + test_must_fail git rev-parse --verify refs/heads/bogus +' + +test_expect_success 'checkout --track=fetch warns and proceeds when fetch fails but ref exists' ' + git checkout main && + git -C fetch_upstream checkout -b fetch_offline && + test_commit -C fetch_upstream u_offline && + git fetch fetch_upstream fetch_offline && + saved_url=$(git config remote.fetch_upstream.url) && + test_when_finished "git config remote.fetch_upstream.url \"$saved_url\"" && + git config remote.fetch_upstream.url ./does-not-exist && + git checkout --track=fetch -b local_offline fetch_upstream/fetch_offline 2>err && + test_grep "failed to fetch" err && + test_cmp_rev refs/remotes/fetch_upstream/fetch_offline HEAD +' + +test_expect_success 'checkout --track=fetch resolves through configured fetch refspec' ' + git checkout main && + git remote add fetch_custom ./fetch_upstream && + test_when_finished "git remote remove fetch_custom" && + git config --replace-all remote.fetch_custom.fetch \ + "+refs/heads/*:refs/remotes/custom-ns/*" && + git -C fetch_upstream checkout -b fetch_refspec && + test_commit -C fetch_upstream u_refspec && + test_must_fail git rev-parse --verify refs/remotes/custom-ns/fetch_refspec && + git checkout --track=fetch -b local_refspec custom-ns/fetch_refspec && + test_cmp_rev refs/remotes/custom-ns/fetch_refspec HEAD +' + +test_expect_success 'checkout --track=fetch on bare remote-tracking prefix follows /HEAD' ' + git checkout main && + git remote add fetch_ns ./fetch_upstream && + test_when_finished "git remote remove fetch_ns" && + test_when_finished "git update-ref -d refs/remotes/ns_alias/HEAD" && + git config --replace-all remote.fetch_ns.fetch \ + "+refs/heads/*:refs/remotes/ns_alias/*" && + git fetch fetch_ns && + git symbolic-ref refs/remotes/ns_alias/HEAD refs/remotes/ns_alias/main && + git -C fetch_upstream checkout main && + test_commit -C fetch_upstream u_ns_post && + git checkout --track=fetch -b local_ns ns_alias && + test_cmp_rev refs/remotes/ns_alias/main HEAD && + test_cmp_config fetch_ns branch.local_ns.remote && + test_cmp_config refs/heads/main branch.local_ns.merge +' + +test_expect_success 'checkout --track=fetch dies on bare remote name with no /HEAD' ' + git checkout main && + git remote add fetch_nohead ./fetch_upstream && + test_when_finished "git remote remove fetch_nohead" && + test_might_fail git symbolic-ref -d refs/remotes/fetch_nohead/HEAD && + test_must_fail git checkout --track=fetch -b local_nohead fetch_nohead 2>err && + test_grep "refs/remotes/fetch_nohead/HEAD" err && + test_grep "git remote set-head fetch_nohead --auto" err && + test_must_fail git rev-parse --verify refs/heads/local_nohead +' + +test_expect_success 'checkout --track=fetch on bare unknown name does not suggest set-head' ' + git checkout main && + test_must_fail git rev-parse --verify refs/remotes/no_such_ns/HEAD && + test_must_fail git config --get remote.no_such_ns.url && + test_must_fail git checkout --track=fetch -b local_unknown no_such_ns 2>err && + test_grep "no configured remote" err && + test_grep ! "set-head" err && + test_must_fail git rev-parse --verify refs/heads/local_unknown +' + +test_expect_success 'checkout --track=fetch rejects /HEAD pointing outside the tracking prefix' ' + git checkout main && + git remote add fetch_crossns ./fetch_upstream && + test_when_finished "git remote remove fetch_crossns" && + test_when_finished "git update-ref -d refs/remotes/fetch_crossns/HEAD" && + git fetch fetch_crossns && + git symbolic-ref refs/remotes/fetch_crossns/HEAD \ + refs/remotes/fetch_upstream/u_main && + test_must_fail git checkout --track=fetch -b local_crossns fetch_crossns 2>err && + test_grep "refs/remotes/fetch_crossns/HEAD" err && + test_must_fail git rev-parse --verify refs/heads/local_crossns +' + +test_expect_success 'checkout --track=fetch dies on ambiguous fetch refspec match' ' + git checkout main && + git remote add fetch_ambig_a ./fetch_upstream && + git remote add fetch_ambig_b ./fetch_upstream && + test_when_finished "git remote remove fetch_ambig_a" && + test_when_finished "git remote remove fetch_ambig_b" && + git config --replace-all remote.fetch_ambig_a.fetch \ + "+refs/heads/*:refs/remotes/ambig_ns/*" && + git config --replace-all remote.fetch_ambig_b.fetch \ + "+refs/heads/*:refs/remotes/ambig_ns/*" && + git -C fetch_upstream checkout -b fetch_ambig && + test_commit -C fetch_upstream u_ambig && + test_must_fail git checkout --track=fetch -b local_ambig ambig_ns/fetch_ambig 2>err && + test_grep "fetch_ambig_a" err && + test_grep "fetch_ambig_b" err && + test_grep "tracking namespaces" err && + test_must_fail git rev-parse --verify refs/heads/local_ambig +' + +test_expect_success 'checkout --track=fetch rejects invalid refname components' ' + git checkout main && + test_must_fail git checkout --track=fetch -b local_invalid "foo..bar" 2>err && + test_grep "valid" err && + test_must_fail git rev-parse --verify refs/heads/local_invalid +' + +test_expect_success 'checkout --track=inherit,direct is rejected' ' + test_must_fail git checkout --track=inherit,direct -b bad fetch_upstream/fetch_new 2>err && + test_grep "cannot combine" err +' + +test_expect_success 'checkout --track=fetch then --track=direct drops fetch (last-one-wins)' ' + git checkout main && + git -C fetch_upstream checkout -b fetch_lastwin && + test_commit -C fetch_upstream u_lastwin && + test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_lastwin && + test_must_fail git checkout --track=fetch --track=direct \ + -b local_lastwin fetch_upstream/fetch_lastwin && + test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_lastwin +' + +test_expect_success 'checkout --track=fetch,inherit fetches remote-tracking start-point' ' + git checkout main && + git -C fetch_upstream checkout -b fetch_inherit && + test_commit -C fetch_upstream u_inherit && + test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_inherit && + git checkout --track=fetch,inherit -b local_inherit \ + fetch_upstream/fetch_inherit && + test_cmp_rev refs/remotes/fetch_upstream/fetch_inherit HEAD +' + +test_expect_success 'checkout --track=fetch on local start-point errors' ' + git checkout main && + test_must_fail git checkout --track=fetch -b bad main 2>err && + test_grep "no configured remote" err && + test_must_fail git rev-parse --verify refs/heads/bad +' + +test_expect_success 'checkout --track=bogus reports an error' ' + git checkout main && + test_must_fail git checkout --track=bogus -b bogus_branch fetch_upstream/fetch_new 2>err && + test_grep "expects" err +' + +test_expect_success 'checkout -q --track=fetch silences the fetch output' ' + git checkout main && + git -C fetch_upstream checkout -b fetch_quiet && + test_commit -C fetch_upstream u_quiet && + test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_quiet && + git checkout -q --track=fetch -b local_quiet \ + fetch_upstream/fetch_quiet 2>err && + test_grep ! "-> fetch_upstream/fetch_quiet" err && + test_cmp_rev refs/remotes/fetch_upstream/fetch_quiet HEAD +' + +test_expect_success 'switch --track=fetch -c picks up branch created upstream after clone' ' + git checkout main && + git -C fetch_upstream checkout -b fetch_switch && + test_commit -C fetch_upstream u_switch && + test_must_fail git rev-parse --verify refs/remotes/fetch_upstream/fetch_switch && + git switch --track=fetch -c local_switch fetch_upstream/fetch_switch && + test_cmp_rev refs/remotes/fetch_upstream/fetch_switch HEAD +' + test_done From 1a9c49e4bb07bd26d2f591c746ab9c9c82423533 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:13 -0400 Subject: [PATCH 024/159] repack: unconditionally exclude non-kept packs In `write_cruft_pack()`, we handle excluding objects found in non-kept packs from being included in the cruft pack via two code paths: * When using '--combine-cruft-below-size' (provided that we are not expiring cruft objects), we use the aptly-named `combine_small_cruft_packs()` function. * In all other cases, we handle it directly in the 'else' branch of the same conditional. Simplify this by moving the non-kept pack exclusion out of the conditional entirely, so that non-kept packs are always excluded regardless of whether we are combining small cruft packs or not. This is a preparatory refactor for a subsequent change that will use the pack_geometry struct when available to determine which non-kept packs to exclude. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- repack-cruft.c | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/repack-cruft.c b/repack-cruft.c index 0653e88792332e..6a040e980173a1 100644 --- a/repack-cruft.c +++ b/repack-cruft.c @@ -9,7 +9,6 @@ static void combine_small_cruft_packs(FILE *in, off_t combine_cruft_below_size, { struct packed_git *p; struct strbuf buf = STRBUF_INIT; - size_t i; repo_for_each_pack(existing->repo, p) { if (!(p->is_cruft && p->pack_local)) @@ -30,10 +29,6 @@ static void combine_small_cruft_packs(FILE *in, off_t combine_cruft_below_size, } } - for (i = 0; i < existing->non_kept_packs.nr; i++) - fprintf(in, "-%s.pack\n", - existing->non_kept_packs.items[i].string); - strbuf_release(&buf); } @@ -80,15 +75,14 @@ int write_cruft_pack(const struct write_pack_opts *opts, in = xfdopen(cmd.in, "w"); for_each_string_list_item(item, names) fprintf(in, "%s-%s.pack\n", pack_prefix, item->string); - if (combine_cruft_below_size && !cruft_expiration) { + if (combine_cruft_below_size && !cruft_expiration) combine_small_cruft_packs(in, combine_cruft_below_size, existing); - } else { - for_each_string_list_item(item, &existing->non_kept_packs) - fprintf(in, "-%s.pack\n", item->string); + else for_each_string_list_item(item, &existing->cruft_packs) fprintf(in, "-%s.pack\n", item->string); - } + for_each_string_list_item(item, &existing->non_kept_packs) + fprintf(in, "-%s.pack\n", item->string); for_each_string_list_item(item, &existing->kept_packs) fprintf(in, "%s.pack\n", item->string); fclose(in); From d65798abb799bdf6915501ca274fc85cbc35d69f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:17 -0400 Subject: [PATCH 025/159] repack: extract `locate_existing_pack()` helper Factor out the lookup from `existing_packs_retain_cruft()` that converts a pack basename to a `string_list_item` into a reusable static helper function, `locate_existing_pack()`. A subsequent commit will introduce a new function which will need to perform this same lookup against a different `string_list`. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- repack.c | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/repack.c b/repack.c index 571dabb665ee9b..986c74ac7e8c0d 100644 --- a/repack.c +++ b/repack.c @@ -226,21 +226,32 @@ static void existing_packs_mark_for_deletion_1(const struct git_hash_algo *algop } } -void existing_packs_retain_cruft(struct existing_packs *existing, - struct packed_git *cruft) +static struct string_list_item *locate_existing_pack(struct string_list *list, + struct packed_git *p) { struct strbuf buf = STRBUF_INIT; struct string_list_item *item; - strbuf_addstr(&buf, pack_basename(cruft)); + strbuf_addstr(&buf, pack_basename(p)); strbuf_strip_suffix(&buf, ".pack"); - item = string_list_lookup(&existing->cruft_packs, buf.buf); + item = string_list_lookup(list, buf.buf); + + strbuf_release(&buf); + + return item; +} + +void existing_packs_retain_cruft(struct existing_packs *existing, + struct packed_git *cruft) +{ + struct string_list_item *item; + + item = locate_existing_pack(&existing->cruft_packs, cruft); if (!item) BUG("could not find cruft pack '%s'", pack_basename(cruft)); existing_packs_mark_retained(item); - strbuf_release(&buf); } void existing_packs_mark_for_deletion(struct existing_packs *existing, From 3e38d04bd41791c8f50e008adb5cb09f876d4edf Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:20 -0400 Subject: [PATCH 026/159] repack: mark geometric progression of packs as retained In non-geometric repacks, any packs which repack wishes to delete are handled via the `existing_packs` struct, which has a mechanism to retain would-be-deleted packs (e.g., if we happened to write a new pack identical to one otherwise marked for deletion). In geometric repacks, repack removes any rewritten packs (alternatively, any packs which were combined in order to restore a geometric progression) by enumerating them via `pack_geometry_remove_redundant()`. Prepare to use the `existing_packs` deletion machinery for geometric repacks by marking any non-kept packs above the geometric split line as retained. Do the same for promisor packs, which have their own split point. This commit only records which packs the later deletion pass must keep; it does not change which packs are written or removed. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/repack.c | 2 ++ repack.c | 27 +++++++++++++++++++++++++++ repack.h | 3 +++ 3 files changed, 32 insertions(+) diff --git a/builtin/repack.c b/builtin/repack.c index 1524a9c13ad5b8..ce979d86d964c3 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -325,6 +325,8 @@ int cmd_repack(int argc, } pack_geometry_init(&geometry, &existing, &po_args); pack_geometry_split(&geometry); + + existing_packs_retain_from_geometry(&existing, &geometry); } prepare_pack_objects(&cmd, &po_args, packtmp); diff --git a/repack.c b/repack.c index 986c74ac7e8c0d..9b3cb4254310a9 100644 --- a/repack.c +++ b/repack.c @@ -254,6 +254,33 @@ void existing_packs_retain_cruft(struct existing_packs *existing, existing_packs_mark_retained(item); } +static void existing_packs_retain_non_kept(struct existing_packs *existing, + struct packed_git *p) +{ + struct string_list_item *item; + + if (!p->pack_local) + return; + + item = locate_existing_pack(&existing->non_kept_packs, p); + if (!item) + BUG("could not find non-kept pack '%s'", pack_basename(p)); + + existing_packs_mark_retained(item); +} + +void existing_packs_retain_from_geometry(struct existing_packs *existing, + const struct pack_geometry *geometry) +{ + uint32_t i; + + for (i = geometry->split; i < geometry->pack_nr; i++) + existing_packs_retain_non_kept(existing, geometry->pack[i]); + for (i = geometry->promisor_split; i < geometry->promisor_pack_nr; i++) + existing_packs_retain_non_kept(existing, + geometry->promisor_pack[i]); +} + void existing_packs_mark_for_deletion(struct existing_packs *existing, struct string_list *names) diff --git a/repack.h b/repack.h index f9fbc895f02940..bb4c944d0cbd90 100644 --- a/repack.h +++ b/repack.h @@ -54,6 +54,7 @@ int finish_pack_objects_cmd(const struct git_hash_algo *algop, struct repository; struct packed_git; +struct pack_geometry; struct existing_packs { struct repository *repo; @@ -82,6 +83,8 @@ int existing_packs_has_non_kept(const struct existing_packs *existing); int existing_pack_is_marked_for_deletion(struct string_list_item *item); void existing_packs_retain_cruft(struct existing_packs *existing, struct packed_git *cruft); +void existing_packs_retain_from_geometry(struct existing_packs *existing, + const struct pack_geometry *geometry); void existing_packs_mark_for_deletion(struct existing_packs *existing, struct string_list *names); void existing_packs_retain_midx_packs(struct existing_packs *existing); From 5ff781e9fa3cfea4ac008c7e0e06a5f720b78060 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:23 -0400 Subject: [PATCH 027/159] repack: teach MIDX retention about geometric rollups When writing an incremental MIDX, existing_packs_retain_midx_packs() marks packs in the existing MIDX chain as retained. This keeps them from being deleted by the later existing_packs deletion pass, since retained MIDX layers may still refer to those packs. Geometric repacks need a narrower rule. Packs below the split are rolled up into the newly-written pack, and should remain eligible for deletion even if the old MIDX chain mentions them. Packs above the split were marked as retained by the previous commit. Teach existing_packs_retain_midx_packs() to skip packs which are part of the geometric rollup. This does not change the current caller's behavior, since geometric repacks do not yet use the existing_packs deletion path. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/repack.c | 2 +- repack.c | 43 +++++++++++++++++++++++++++++++++++++++++-- repack.h | 3 ++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/builtin/repack.c b/builtin/repack.c index ce979d86d964c3..66b46b86896d7e 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -576,7 +576,7 @@ int cmd_repack(int argc, if (delete_redundant && pack_everything & ALL_INTO_ONE) { if (write_midx == REPACK_WRITE_MIDX_INCREMENTAL) - existing_packs_retain_midx_packs(&existing); + existing_packs_retain_midx_packs(&existing, &geometry); existing_packs_mark_for_deletion(&existing, &names); } diff --git a/repack.c b/repack.c index 9b3cb4254310a9..c7b79a3c1137a2 100644 --- a/repack.c +++ b/repack.c @@ -292,6 +292,39 @@ void existing_packs_mark_for_deletion(struct existing_packs *existing, &existing->cruft_packs); } +static int pack_geometry_contains_pack(struct packed_git **packs, + uint32_t packs_nr, + const char *base) +{ + struct strbuf buf = STRBUF_INIT; + uint32_t i; + + for (i = 0; i < packs_nr; i++) { + strbuf_reset(&buf); + strbuf_addstr(&buf, pack_basename(packs[i])); + strbuf_strip_suffix(&buf, ".pack"); + + if (!strcmp(buf.buf, base)) { + strbuf_release(&buf); + return 1; + } + } + + strbuf_release(&buf); + return 0; +} + +static int pack_geometry_contains_rollup(const struct pack_geometry *geometry, + const char *base) +{ + if (!geometry || !geometry->split_factor) + return 0; + + return pack_geometry_contains_pack(geometry->pack, geometry->split, base) || + pack_geometry_contains_pack(geometry->promisor_pack, + geometry->promisor_split, base); +} + /* * Mark every pack that is referenced by the existing MIDX chain as * retained, so that a subsequent call to @@ -300,9 +333,12 @@ void existing_packs_mark_for_deletion(struct existing_packs *existing, * This is used when writing an incremental MIDX layer on top of an * existing chain: retained layers continue to reference the same * packs on disk, so those packs must not be unlinked even if the - * freshly-written pack supersedes them. + * freshly-written pack supersedes them. When doing a geometric repack, + * packs below the split are rewritten into the new MIDX tip and should + * remain eligible for deletion. */ -void existing_packs_retain_midx_packs(struct existing_packs *existing) +void existing_packs_retain_midx_packs(struct existing_packs *existing, + const struct pack_geometry *geometry) { struct string_list_item *item; struct strbuf buf = STRBUF_INIT; @@ -315,6 +351,9 @@ void existing_packs_retain_midx_packs(struct existing_packs *existing) strbuf_strip_suffix(&buf, ".pack"); strbuf_strip_suffix(&buf, ".idx"); + if (pack_geometry_contains_rollup(geometry, buf.buf)) + continue; + found = string_list_lookup(&existing->non_kept_packs, buf.buf); if (found) existing_packs_mark_retained(found); diff --git a/repack.h b/repack.h index bb4c944d0cbd90..f0d082df9e8acc 100644 --- a/repack.h +++ b/repack.h @@ -87,7 +87,8 @@ void existing_packs_retain_from_geometry(struct existing_packs *existing, const struct pack_geometry *geometry); void existing_packs_mark_for_deletion(struct existing_packs *existing, struct string_list *names); -void existing_packs_retain_midx_packs(struct existing_packs *existing); +void existing_packs_retain_midx_packs(struct existing_packs *existing, + const struct pack_geometry *geometry); void existing_packs_remove_redundant(struct existing_packs *existing, const char *packdir, bool wrote_incremental_midx); From 4adadb015781bdfec02120f9b1fa5a606d8c7cfd Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:27 -0400 Subject: [PATCH 028/159] repack: delete geometric packs via existing_packs Now that packs above the geometric split are marked as retained, teach geometric repacks to use the existing_packs deletion machinery instead of calling pack_geometry_remove_redundant(). This lets geometric repacks share the same mark-then-remove path as all-into-one repacks: packs below the split are marked for deletion, and packs above the split are ignored because they were retained earlier. When doing a geometric repack without --combine-cruft-below-size, retain all cruft packs before marking anything for deletion. Geometric repacks do not rewrite cruft packs in that mode, so the common deletion path must not remove them. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/repack.c | 11 +++++------ repack.c | 8 ++++++++ repack.h | 1 + 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/builtin/repack.c b/builtin/repack.c index 66b46b86896d7e..dfb6fed231d3c1 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -574,10 +574,13 @@ int cmd_repack(int argc, packtmp); /* End of pack replacement. */ - if (delete_redundant && pack_everything & ALL_INTO_ONE) { + if (delete_redundant) { if (write_midx == REPACK_WRITE_MIDX_INCREMENTAL) existing_packs_retain_midx_packs(&existing, &geometry); - existing_packs_mark_for_deletion(&existing, &names); + if (geometry.split_factor && !combine_cruft_below_size) + existing_packs_retain_all_cruft(&existing); + if (pack_everything & ALL_INTO_ONE || geometry.split_factor) + existing_packs_mark_for_deletion(&existing, &names); } if (write_midx != REPACK_WRITE_MIDX_NONE) { @@ -609,10 +612,6 @@ int cmd_repack(int argc, existing_packs_remove_redundant(&existing, packdir, wrote_incremental_midx); - if (geometry.split_factor) - pack_geometry_remove_redundant(&geometry, &names, - &existing, packdir, - wrote_incremental_midx); if (show_progress) opts |= PRUNE_PACKED_VERBOSE; prune_packed_objects(opts); diff --git a/repack.c b/repack.c index c7b79a3c1137a2..90797561954ae6 100644 --- a/repack.c +++ b/repack.c @@ -242,6 +242,14 @@ static struct string_list_item *locate_existing_pack(struct string_list *list, return item; } +void existing_packs_retain_all_cruft(struct existing_packs *existing) +{ + struct string_list_item *item; + + for_each_string_list_item(item, &existing->cruft_packs) + existing_packs_mark_retained(item); +} + void existing_packs_retain_cruft(struct existing_packs *existing, struct packed_git *cruft) { diff --git a/repack.h b/repack.h index f0d082df9e8acc..90c89630ef808a 100644 --- a/repack.h +++ b/repack.h @@ -81,6 +81,7 @@ void existing_packs_collect(struct existing_packs *existing, const struct string_list *extra_keep); int existing_packs_has_non_kept(const struct existing_packs *existing); int existing_pack_is_marked_for_deletion(struct string_list_item *item); +void existing_packs_retain_all_cruft(struct existing_packs *existing); void existing_packs_retain_cruft(struct existing_packs *existing, struct packed_git *cruft); void existing_packs_retain_from_geometry(struct existing_packs *existing, From 9dc0bf0e006ef8cf6f49ffafe40295e9803018db Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:30 -0400 Subject: [PATCH 029/159] repack-geometry: drop unused redundant-pack removal The previous commit stopped using pack_geometry_remove_redundant() when deleting packs after a geometric repack. The existing_packs machinery now handles the same removal after geometric packs are marked for deletion. Remove the unused geometry-specific helper and its declaration. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- repack-geometry.c | 44 -------------------------------------------- repack.h | 5 ----- 2 files changed, 49 deletions(-) diff --git a/repack-geometry.c b/repack-geometry.c index 2064683dcfe1e1..c75fa50861292f 100644 --- a/repack-geometry.c +++ b/repack-geometry.c @@ -245,50 +245,6 @@ struct packed_git *pack_geometry_preferred_pack(struct pack_geometry *geometry) return NULL; } -static void remove_redundant_packs(struct packed_git **pack, - uint32_t pack_nr, - struct string_list *names, - struct existing_packs *existing, - const char *packdir, - bool wrote_incremental_midx) -{ - const struct git_hash_algo *algop = existing->repo->hash_algo; - struct strbuf buf = STRBUF_INIT; - uint32_t i; - - for (i = 0; i < pack_nr; i++) { - struct packed_git *p = pack[i]; - if (string_list_has_string(names, hash_to_hex_algop(p->hash, - algop))) - continue; - - strbuf_reset(&buf); - strbuf_addstr(&buf, pack_basename(p)); - strbuf_strip_suffix(&buf, ".pack"); - - if ((p->pack_keep) || - (string_list_has_string(&existing->kept_packs, buf.buf))) - continue; - - repack_remove_redundant_pack(existing->repo, packdir, buf.buf, - wrote_incremental_midx); - } - - strbuf_release(&buf); -} - -void pack_geometry_remove_redundant(struct pack_geometry *geometry, - struct string_list *names, - struct existing_packs *existing, - const char *packdir, - bool wrote_incremental_midx) -{ - remove_redundant_packs(geometry->pack, geometry->split, - names, existing, packdir, wrote_incremental_midx); - remove_redundant_packs(geometry->promisor_pack, geometry->promisor_split, - names, existing, packdir, wrote_incremental_midx); -} - void pack_geometry_release(struct pack_geometry *geometry) { if (!geometry) diff --git a/repack.h b/repack.h index 90c89630ef808a..4295829cea0a61 100644 --- a/repack.h +++ b/repack.h @@ -134,11 +134,6 @@ void pack_geometry_init(struct pack_geometry *geometry, const struct pack_objects_args *args); void pack_geometry_split(struct pack_geometry *geometry); struct packed_git *pack_geometry_preferred_pack(struct pack_geometry *geometry); -void pack_geometry_remove_redundant(struct pack_geometry *geometry, - struct string_list *names, - struct existing_packs *existing, - const char *packdir, - bool wrote_incremental_midx); void pack_geometry_release(struct pack_geometry *geometry); struct tempfile; From 806cd59ba235c0b19261019c039a22392cc6fc04 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:33 -0400 Subject: [PATCH 030/159] pack-objects: extract `stdin_packs_add_all_pack_entries()` Extract the pack enumeration loop from stdin_packs_add_pack_entries() into a separate stdin_packs_add_all_pack_entries() helper, and have the caller dispatch to it based on the stdin_packs_mode. This prepares for a subsequent commit which will introduce an alternate code path for '--stdin-packs=follow-reachable' that determines the set of objects to include via a reachability walk rather than eagerly adding all objects from included packs. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/pack-objects.c | 49 ++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index 27048bbb4dd3c9..29e43abb51e9d4 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -3933,30 +3933,12 @@ static int stdin_packs_include_check(struct commit *commit, void *data) return stdin_packs_include_check_obj((struct object *)commit, data); } -static void stdin_packs_add_pack_entries(struct strmap *packs, - struct rev_info *revs) +static void stdin_packs_add_all_pack_entries(struct string_list *keys, + struct rev_info *revs) { - struct string_list keys = STRING_LIST_INIT_NODUP; struct string_list_item *item; - struct hashmap_iter iter; - struct strmap_entry *entry; - - strmap_for_each_entry(packs, &iter, entry) { - struct stdin_pack_info *info = entry->value; - if (!info->p) - die(_("could not find pack '%s'"), entry->key); - - string_list_append(&keys, entry->key)->util = info; - } - /* - * Order packs by ascending mtime; use QSORT directly to access the - * string_list_item's ->util pointer, which string_list_sort() does not - * provide. - */ - QSORT(keys.items, keys.nr, pack_mtime_cmp); - - for_each_string_list_item(item, &keys) { + for_each_string_list_item(item, keys) { struct stdin_pack_info *info = item->util; if (info->kind & STDIN_PACK_EXCLUDE_OPEN) { @@ -3977,6 +3959,31 @@ static void stdin_packs_add_pack_entries(struct strmap *packs, revs, ODB_FOR_EACH_OBJECT_PACK_ORDER); } +} + +static void stdin_packs_add_pack_entries(struct strmap *packs, + struct rev_info *revs) +{ + struct string_list keys = STRING_LIST_INIT_NODUP; + struct hashmap_iter iter; + struct strmap_entry *entry; + + strmap_for_each_entry(packs, &iter, entry) { + struct stdin_pack_info *info = entry->value; + if (!info->p) + die(_("could not find pack '%s'"), entry->key); + + string_list_append(&keys, entry->key)->util = info; + } + + /* + * Order packs by ascending mtime; use QSORT directly to access the + * string_list_item's ->util pointer, which string_list_sort() does not + * provide. + */ + QSORT(keys.items, keys.nr, pack_mtime_cmp); + + stdin_packs_add_all_pack_entries(&keys, revs); string_list_clear(&keys, 0); } From ec0165a0cd79f6391dbb546a065db1695db0aa91 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:37 -0400 Subject: [PATCH 031/159] pack-objects: introduce '--stdin-packs=follow-reachable' Introduce a new '--stdin-packs=follow-reachable' mode. Like '--stdin-packs=follow', this mode recognizes the '!' (excluded-open) pack prefix and halts at '^' (excluded-closed) packs. Unlike 'follow', which eagerly includes all objects from listed packs and then walks reachability to rescue additional objects, the new 'follow-reachable' mode uses reference tips as its traversal starting points and only includes objects that are both reachable AND belong to an included pack (or are reachable from a commit or tag in one): - Objects in included packs: added to the output if reachable. - Objects reachable from included-pack commits but in unknown packs: added to the output (rescued). - Objects in excluded-open ('!') packs: not included, but the traversal continues through them. - Objects in excluded-closed ('^') packs: not included, and the traversal halts. The implementation uses a two-phase approach: 1. In the first phase, commits and tags in included packs (and loose, when --unpacked is given) are marked with a flag bit (IN_INCLUDED_PACK). A commit-only walk from ref tips then identifies which marked objects are reachable, halting at excluded-closed packs. 2. In the second phase, every reachable marked object (from the previous step) becomes a tip for a full object traversal whose `show_object_pack_hint()` and `show_commit_pack_hint()` callbacks add discovered objects (obeying the usual constraints imposed by `want_object_in_pack()`). When '--unpacked' is given, reachable loose objects are included in the output while unreachable loose objects are left alone. This is achieved by marking loose commits and tags with IN_INCLUDED_PACK during the first phase, so the pre-walk discovers them naturally. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- Documentation/git-pack-objects.adoc | 17 +++ builtin/pack-objects.c | 185 +++++++++++++++++++++++-- t/t5331-pack-objects-stdin.sh | 201 ++++++++++++++++++++++++++++ 3 files changed, 393 insertions(+), 10 deletions(-) diff --git a/Documentation/git-pack-objects.adoc b/Documentation/git-pack-objects.adoc index 8a27aa19fd3f1f..d7b2e39e76c5e3 100644 --- a/Documentation/git-pack-objects.adoc +++ b/Documentation/git-pack-objects.adoc @@ -113,6 +113,23 @@ This mode is useful, for example, to resurrect once-unreachable objects found in cruft packs to generate packs which are closed under reachability up to the boundary set by the excluded packs. + +When `mode` is "follow-reachable", the same pack prefixes are recognized +as in "follow" (`!` for excluded-open, `^` for excluded-closed). However, +instead of including all objects from included packs, only objects that +are reachable from reference tips AND belong to an included pack (or are +reachable from a commit in one) are included. Objects in excluded-open +packs are traversed but not included; objects in excluded-closed packs +halt the traversal. ++ +This mode is designed for geometric repacking with cruft packs, where +the output pack should contain only reachable objects so that unreachable +ones can be collected separately. ++ +When `--unpacked` is given alongside `--stdin-packs=follow-reachable`, +reachable loose objects are also included in the output pack, while +unreachable loose objects are left alone. This includes both loose +commits and annotated tag objects. ++ Incompatible with `--revs`, or options that imply `--revs` (such as `--all`), with the exception of `--unpacked`, which is compatible. diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index 29e43abb51e9d4..5d96757b645323 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -290,6 +290,7 @@ enum stdin_packs_mode { STDIN_PACKS_MODE_NONE, STDIN_PACKS_MODE_STANDARD, STDIN_PACKS_MODE_FOLLOW, + STDIN_PACKS_MODE_FOLLOW_REACHABLE, }; /** @@ -3835,7 +3836,8 @@ static void show_object_pack_hint(struct object *object, const char *name, void *data) { enum stdin_packs_mode mode = *(enum stdin_packs_mode *)data; - if (mode == STDIN_PACKS_MODE_FOLLOW) { + if (mode == STDIN_PACKS_MODE_FOLLOW || + mode == STDIN_PACKS_MODE_FOLLOW_REACHABLE) { if (object->type == OBJ_BLOB && !odb_has_object(the_repository->objects, &object->oid, 0)) return; @@ -3866,7 +3868,8 @@ static void show_commit_pack_hint(struct commit *commit, void *data) { enum stdin_packs_mode mode = *(enum stdin_packs_mode *)data; - if (mode == STDIN_PACKS_MODE_FOLLOW) { + if (mode == STDIN_PACKS_MODE_FOLLOW || + mode == STDIN_PACKS_MODE_FOLLOW_REACHABLE) { show_object_pack_hint((struct object *)commit, "", data); return; } @@ -3933,6 +3936,156 @@ static int stdin_packs_include_check(struct commit *commit, void *data) return stdin_packs_include_check_obj((struct object *)commit, data); } +/* + * Flag bit set on commits that belong to an included pack during + * '--stdin-packs=follow-reachable'. Used by the pre-walk to + * identify which reachable commits should be tips for the main + * object traversal. + */ +#define IN_INCLUDED_PACK (1u<<11) + +static int mark_included_pack_tip(const struct object_id *oid, + struct packed_git *p, + uint32_t pos, + void *data) +{ + struct rev_info *main_revs = data; + off_t ofs = nth_packed_object_offset(p, pos); + enum object_type type; + struct object_info oi = OBJECT_INFO_INIT; + struct object *obj; + + oi.typep = &type; + if (packed_object_info(p, ofs, &oi) < 0) + return 0; + if (type != OBJ_COMMIT && type != OBJ_TAG) + return 0; + + obj = parse_object(the_repository, oid); + if (!obj) + return 0; + + obj->flags |= IN_INCLUDED_PACK; + + if (type == OBJ_TAG && main_revs) + add_pending_object(main_revs, obj, ""); + return 0; +} + +static int mark_loose_object_tip(const struct object_id *oid, + struct object_info *oi UNUSED, + void *data) +{ + struct rev_info *main_revs = data; + struct object *obj; + enum object_type type; + + type = odb_read_object_info(the_repository->objects, oid, NULL); + if (type != OBJ_COMMIT && type != OBJ_TAG) + return 0; + + obj = parse_object(the_repository, oid); + if (!obj) + return 0; + + obj->flags |= IN_INCLUDED_PACK; + + if (type == OBJ_TAG && main_revs) + add_pending_object(main_revs, obj, ""); + + return 0; +} + +static int add_ref_to_pending(const struct reference *ref, void *cb_data) +{ + struct rev_info *revs = cb_data; + struct object *object; + + object = parse_object(the_repository, ref->oid); + if (!object) + return 0; + + add_pending_object(revs, object, ""); + return 0; +} + +static void stdin_packs_add_reachable_pack_entries(struct string_list *keys, + struct rev_info *revs, + int rev_list_unpacked) +{ + struct rev_info pre_walk; + struct commit *commit; + struct string_list_item *item; + + /* + * Phase 1: mark commits in included packs, then walk from + * ref tips to discover which of them are reachable. The walk + * halts at excluded-closed packs (via no_kept_objects) and + * continues through excluded-open ones. + * + * Also set include_check on the outer revs so that phase 2 + * (the main object traversal) halts at closed packs. + */ + revs->include_check = stdin_packs_include_check; + revs->include_check_obj = stdin_packs_include_check_obj; + + for_each_string_list_item(item, keys) { + struct stdin_pack_info *info = item->util; + if (info->kind & STDIN_PACK_INCLUDE) + for_each_object_in_pack(info->p, + mark_included_pack_tip, + revs, + ODB_FOR_EACH_OBJECT_PACK_ORDER); + } + + if (rev_list_unpacked) { + /* + * With '--stdin-packs=follow-reachable', specifying + * '--unpacked' instructs pack-objects to pack any loose + * objects which are reachable. + * + * Pretend as if all loose objects are in an included + * pack in order to make them eligible for packing. + */ + struct odb_source *source = revs->repo->objects->sources; + for (; source; source = source->next) { + struct odb_source_files *files = odb_source_files_downcast(source); + struct odb_for_each_object_options opts = { 0 }; + if (local) + opts.flags |= ODB_FOR_EACH_OBJECT_LOCAL_ONLY; + + odb_source_for_each_object(&files->loose->base, NULL, + mark_loose_object_tip, + revs, &opts); + } + } + + repo_init_revisions(the_repository, &pre_walk, NULL); + pre_walk.no_kept_objects = 1; + pre_walk.keep_pack_cache_flags |= KEPT_PACK_IN_CORE; + pre_walk.ignore_missing_links = 1; + + refs_for_each_ref(get_main_ref_store(the_repository), + add_ref_to_pending, &pre_walk); + + if (prepare_revision_walk(&pre_walk)) + die(_("revision walk setup failed")); + + /* + * Phase 2 tips: every reachable commit that is in an + * included pack becomes a starting point for the main + * object traversal. + */ + while ((commit = get_revision(&pre_walk)) != NULL) { + if (commit->object.flags & IN_INCLUDED_PACK) + add_pending_oid(revs, NULL, + &commit->object.oid, 0); + } + + reset_revision_walk(); + release_revisions(&pre_walk); +} + static void stdin_packs_add_all_pack_entries(struct string_list *keys, struct rev_info *revs) { @@ -3962,7 +4115,9 @@ static void stdin_packs_add_all_pack_entries(struct string_list *keys, } static void stdin_packs_add_pack_entries(struct strmap *packs, - struct rev_info *revs) + struct rev_info *revs, + enum stdin_packs_mode mode, + int rev_list_unpacked) { struct string_list keys = STRING_LIST_INIT_NODUP; struct hashmap_iter iter; @@ -3983,13 +4138,18 @@ static void stdin_packs_add_pack_entries(struct strmap *packs, */ QSORT(keys.items, keys.nr, pack_mtime_cmp); - stdin_packs_add_all_pack_entries(&keys, revs); + if (mode == STDIN_PACKS_MODE_FOLLOW_REACHABLE) + stdin_packs_add_reachable_pack_entries(&keys, revs, + rev_list_unpacked); + else + stdin_packs_add_all_pack_entries(&keys, revs); string_list_clear(&keys, 0); } static void stdin_packs_read_input(struct rev_info *revs, - enum stdin_packs_mode mode) + enum stdin_packs_mode mode, + int rev_list_unpacked) { struct strbuf buf = STRBUF_INIT; struct strmap packs = STRMAP_INIT; @@ -4004,7 +4164,9 @@ static void stdin_packs_read_input(struct rev_info *revs, continue; else if (*key == '^') kind = STDIN_PACK_EXCLUDE_CLOSED; - else if (*key == '!' && mode == STDIN_PACKS_MODE_FOLLOW) + else if (*key == '!' && + (mode == STDIN_PACKS_MODE_FOLLOW || + mode == STDIN_PACKS_MODE_FOLLOW_REACHABLE)) kind = STDIN_PACK_EXCLUDE_OPEN; if (kind != STDIN_PACK_INCLUDE) @@ -4069,7 +4231,7 @@ static void stdin_packs_read_input(struct rev_info *revs, info->p = p; } - stdin_packs_add_pack_entries(&packs, revs); + stdin_packs_add_pack_entries(&packs, revs, mode, rev_list_unpacked); strbuf_release(&buf); strmap_clear(&packs, 1); @@ -4109,7 +4271,8 @@ static void read_stdin_packs(enum stdin_packs_mode mode, int rev_list_unpacked) /* avoids adding objects in excluded packs */ ignore_packed_keep_in_core = 1; - if (mode == STDIN_PACKS_MODE_FOLLOW) { + if (mode == STDIN_PACKS_MODE_FOLLOW || + mode == STDIN_PACKS_MODE_FOLLOW_REACHABLE) { /* * In '--stdin-packs=follow' mode, additionally ignore * objects in excluded-open packs to prevent them from @@ -4117,8 +4280,8 @@ static void read_stdin_packs(enum stdin_packs_mode mode, int rev_list_unpacked) */ ignore_packed_keep_in_core_open = 1; } - stdin_packs_read_input(&revs, mode); - if (rev_list_unpacked) + stdin_packs_read_input(&revs, mode, rev_list_unpacked); + if (rev_list_unpacked && mode != STDIN_PACKS_MODE_FOLLOW_REACHABLE) add_unreachable_loose_objects(&revs); if (prepare_revision_walk(&revs)) @@ -5027,6 +5190,8 @@ static int parse_stdin_packs_mode(const struct option *opt, const char *arg, *mode = STDIN_PACKS_MODE_STANDARD; else if (!strcmp(arg, "follow")) *mode = STDIN_PACKS_MODE_FOLLOW; + else if (!strcmp(arg, "follow-reachable")) + *mode = STDIN_PACKS_MODE_FOLLOW_REACHABLE; else die(_("invalid value for '%s': '%s'"), opt->long_name, arg); diff --git a/t/t5331-pack-objects-stdin.sh b/t/t5331-pack-objects-stdin.sh index c74b5861af322f..443d855291aca1 100755 --- a/t/t5331-pack-objects-stdin.sh +++ b/t/t5331-pack-objects-stdin.sh @@ -520,4 +520,205 @@ test_expect_success '--stdin-packs with !-delimited pack without follow' ' ) ' +test_expect_success '--stdin-packs=follow-reachable excludes unreachable objects' ' + test_when_finished "rm -fr repo" && + + git init repo && + ( + cd repo && + git config set maintenance.auto false && + + git branch -M main && + + # Create the following commit structure: + # + # A <-- B <-- C (main) + # ^ + # \ + # U (unreachable, no ref) + test_commit A && + test_commit B && + test_commit U && + U_TIP="$(git rev-parse HEAD)" && + git reset --hard HEAD^ && + git tag -d U && + git reflog expire --all --expire=all && + + test_commit C && + + A="$(echo A | git pack-objects --revs $packdir/pack)" && + B="$(echo A..B | git pack-objects --revs $packdir/pack)" && + C="$(echo B..C | git pack-objects --revs $packdir/pack)" && + U="$(echo "$U_TIP" | git pack-objects $packdir/pack)" && + + git prune-packed && + + # Include packs A and C, exclude B as open (since B + # may not have closure), leave U as unknown. + # + # With follow-reachable: + # - objects from A and C are included (reachable from + # main, through excluded-open B, and in included + # packs) + # - objects from B are excluded (excluded-open) + # - objects from U are NOT included (not reachable + # from any ref, even though the pack exists) + P=$(git pack-objects --stdin-packs=follow-reachable \ + $packdir/pack <<-EOF + pack-$A.pack + !pack-$B.pack + pack-$C.pack + EOF + ) && + + objects_in_packs $A $C >expect && + objects_in_packs $P >actual && + test_cmp expect actual + ) +' + +test_expect_success '--stdin-packs=follow-reachable with open-excluded packs' ' + test_when_finished "rm -fr repo" && + + git init repo && + ( + cd repo && + git config set maintenance.auto false && + + git branch -M main && + + # Create the following commit structure: + # + # A <-- B <-- C <-- D (main) + # + # Pack each commit separately, then use follow-reachable + # with B excluded-open and A excluded-closed. Since B is + # open, the traversal continues through it, but since A + # is closed, it halts there. + test_commit A && + test_commit B && + test_commit C && + test_commit D && + + A="$(echo A | git pack-objects --revs $packdir/pack)" && + B="$(echo A..B | git pack-objects --revs $packdir/pack)" && + C="$(echo B..C | git pack-objects --revs $packdir/pack)" && + D="$(echo C..D | git pack-objects --revs $packdir/pack)" && + + git prune-packed && + + # Include C and D, B excluded-open, A excluded-closed. + # + # The traversal starts at main (D), walks: + # D (included) -> C (included) -> B (open, continue + # but do not include) -> A (closed, halt). + # + # Objects from C and D are in the output (reachable, + # included). B.t is also rescued (reachable via + # C^{tree} or similar). A and its objects are NOT + # (behind the closed boundary). + P=$(git pack-objects --stdin-packs=follow-reachable \ + $packdir/pack <<-EOF + pack-$C.pack + pack-$D.pack + !pack-$B.pack + ^pack-$A.pack + EOF + ) && + + objects_in_packs $C $D >expect && + objects_in_packs $P >actual && + test_cmp expect actual + ) +' + +test_expect_success '--stdin-packs=follow-reachable with --unpacked and loose objects' ' + test_when_finished "rm -fr repo" && + + git init repo && + ( + cd repo && + git config set maintenance.auto false && + + git branch -M main && + + test_commit A && + test_commit B && + + A="$(echo A | git pack-objects --revs $packdir/pack)" && + B="$(echo A..B | git pack-objects --revs $packdir/pack)" && + + git prune-packed && + + # Create a reachable loose commit on top of B. + test_commit C && + + # Create an unreachable loose object. + unreachable="$(echo "unreachable" | git hash-object -w --stdin)" && + + # Include A and B, no excluded packs. With --unpacked, + # the reachable loose objects from C should be included + # in the output but the unreachable blob should not. + P=$(git pack-objects --stdin-packs=follow-reachable \ + --unpacked $packdir/pack <<-EOF + pack-$A.pack + pack-$B.pack + EOF + ) && + + # The output should contain objects from A, B, and C. + { + objects_in_packs $A $B && + git rev-list --objects --no-object-names B..C + } >expect.raw && + sort expect.raw >expect && + + objects_in_packs $P >actual && + + # The unreachable blob should NOT be in the output. + ! grep $unreachable actual && + + test_cmp expect actual + ) +' + +test_expect_success '--stdin-packs=follow-reachable with --unpacked and loose annotated tag' ' + test_when_finished "rm -fr repo" && + + git init repo && + ( + cd repo && + git config set maintenance.auto false && + + git branch -M main && + + test_commit A && + + A="$(echo A | git pack-objects --revs $packdir/pack)" && + + git prune-packed && + + # Create a loose annotated tag pointing at A. + git tag -a -m "annotated" annotated-tag A && + tag_oid="$(git rev-parse annotated-tag)" && + + P=$(git pack-objects --stdin-packs=follow-reachable \ + --unpacked $packdir/pack <<-EOF + pack-$A.pack + EOF + ) && + + # The output should contain objects from A plus the + # loose annotated tag object. + { + objects_in_packs $A && + echo $tag_oid + } >expect.raw && + sort expect.raw >expect && + + objects_in_packs $P >actual && + test_cmp expect actual + ) +' + test_done From e6a6b7e9fce005463ddbda3d21173fab0114a200 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:40 -0400 Subject: [PATCH 032/159] pack-objects: support '--refs-snapshot' with 'follow-reachable' The '--stdin-packs=follow-reachable' mode walks from reference tips to determine which objects in included packs are reachable. Without a snapshot, pack-objects discovers refs by iterating live references, which may change between the time the repack writes the geometric pack and the time it writes the MIDX bitmap. If a reference is updated during that window, the set of reachable objects seen by pack-objects may differ from the set seen by the MIDX bitmap writer. This can cause reachable objects to end up in the cruft pack (because pack-objects did not see the reference that makes them reachable) rather than the geometric pack. While this does not cause data loss, it has two undesirable consequences: - Reachable objects in the cruft pack cannot receive bitmap coverage (since the cruft pack may be excluded from the MIDX when 'repack.midxMustContainCruft' is false). - Serving fetches that need those objects requires loading the cruft pack, which may contain many unrelated unreachable objects. To avoid this, teach pack-objects to accept '--refs-snapshot=' when used with '--stdin-packs=follow-reachable'. The snapshot file uses the same format as the MIDX bitmap writer: one hex OID per line, with an optional '+' prefix for preferred bitmap commits. 'pack-objects' happily ignores the '+' prefix for indicating preferred bitmap commits as a convenience, so that the ref-snapshot can be shared between the MIDX generation machinery and 'pack-objects'. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- Documentation/git-pack-objects.adoc | 8 +++++ builtin/pack-objects.c | 46 +++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Documentation/git-pack-objects.adoc b/Documentation/git-pack-objects.adoc index d7b2e39e76c5e3..4ebe407cfafeac 100644 --- a/Documentation/git-pack-objects.adoc +++ b/Documentation/git-pack-objects.adoc @@ -133,6 +133,14 @@ commits and annotated tag objects. Incompatible with `--revs`, or options that imply `--revs` (such as `--all`), with the exception of `--unpacked`, which is compatible. +--refs-snapshot=:: + When used with `--stdin-packs=follow-reachable`, read reference + tips from `` instead of iterating live references. The file + format is one hex object ID per line, with an optional `+` prefix + (for preferred bitmap commits). This ensures a consistent view of + references when the same snapshot is shared with other tools (e.g., + the MIDX bitmap writer). + --cruft:: Packs unreachable objects into a separate "cruft" pack, denoted by the existence of a `.mtimes` file. Typically used by `git diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index 5d96757b645323..082ff760abc6ca 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -219,6 +219,7 @@ static int incremental; static int ignore_packed_keep_on_disk; static int ignore_packed_keep_in_core; static int ignore_packed_keep_in_core_open; +static const char *stdin_packs_refs_snapshot; static int ignore_packed_keep_in_core_has_cruft; static int allow_ofs_delta; static struct pack_idx_option pack_idx_opts; @@ -4009,6 +4010,38 @@ static int add_ref_to_pending(const struct reference *ref, void *cb_data) return 0; } +static void read_refs_snapshot(const char *refs_snapshot, + struct rev_info *revs) +{ + struct strbuf buf = STRBUF_INIT; + struct object_id oid; + FILE *f = xfopen(refs_snapshot, "r"); + + while (strbuf_getline(&buf, f) != EOF) { + struct object *object; + const char *hex = buf.buf; + const char *end = NULL; + + if (*hex == '+') + hex++; + + if (parse_oid_hex_algop(hex, &oid, &end, + the_repository->hash_algo) < 0) + die(_("could not parse line: %s"), buf.buf); + if (*end) + die(_("malformed line: %s"), buf.buf); + + object = parse_object(the_repository, &oid); + if (!object) + continue; + + add_pending_object(revs, object, ""); + } + + fclose(f); + strbuf_release(&buf); +} + static void stdin_packs_add_reachable_pack_entries(struct string_list *keys, struct rev_info *revs, int rev_list_unpacked) @@ -4065,8 +4098,11 @@ static void stdin_packs_add_reachable_pack_entries(struct string_list *keys, pre_walk.keep_pack_cache_flags |= KEPT_PACK_IN_CORE; pre_walk.ignore_missing_links = 1; - refs_for_each_ref(get_main_ref_store(the_repository), - add_ref_to_pending, &pre_walk); + if (stdin_packs_refs_snapshot) + read_refs_snapshot(stdin_packs_refs_snapshot, &pre_walk); + else + refs_for_each_ref(get_main_ref_store(the_repository), + add_ref_to_pending, &pre_walk); if (prepare_revision_walk(&pre_walk)) die(_("revision walk setup failed")); @@ -5267,6 +5303,8 @@ int cmd_pack_objects(int argc, OPT_CALLBACK_F(0, "stdin-packs", &stdin_packs, N_("mode"), N_("read packs from stdin"), PARSE_OPT_OPTARG, parse_stdin_packs_mode), + OPT_FILENAME(0, "refs-snapshot", &stdin_packs_refs_snapshot, + N_("refs snapshot for follow-reachable traversal")), OPT_BOOL(0, "stdout", &pack_to_stdout, N_("output pack to stdout")), OPT_BOOL(0, "include-tag", &include_tag, @@ -5484,6 +5522,10 @@ int cmd_pack_objects(int argc, if (stdin_packs && use_internal_rev_list) die(_("cannot use internal rev list with --stdin-packs")); + if (stdin_packs_refs_snapshot && + stdin_packs != STDIN_PACKS_MODE_FOLLOW_REACHABLE) + die(_("--refs-snapshot can only be used with --stdin-packs=follow-reachable")); + if (cruft) { if (use_internal_rev_list) die(_("cannot use internal rev list with --cruft")); From a53c3b6193d783f1dd2bd283782b1c3033ef0b3e Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Fri, 26 Jun 2026 15:02:43 -0400 Subject: [PATCH 033/159] repack: support combining '--geometric' with '--cruft' Teach 'git repack' to accept '--geometric' and '--cruft' together. When both are given, the geometric repack rolls up non-cruft packs as usual, and a separate cruft pack is written to collect unreachable objects. Previously, '--cruft' implied `ALL_INTO_ONE`, which is fundamentally incompatible with geometric repacking. Relax this so that '--cruft' only implies `ALL_INTO_ONE` when '--geometric' is not also given. When combining the two modes: - Use the new '--stdin-packs=follow-reachable' mode so that only reachable objects from the rolled-up packs (and any reachable loose objects) appear in the geometric pack. Unreachable objects are left for the cruft writer to collect. - Plumb our `pack_geometry` into `write_cruft_pack()`, so that the latter can tell 'pack-objects' which non-kept packs are below the split (excluded, so their unreachable objects are candidates for the cruft pack) versus above the split (included, so they are treated as reachable). - Handle promisor packs in the cruft writer's geometry path, since promisor packs have their own split point. - Use the refs snapshot (when available) so that pack-objects and the MIDX bitmap writer see the same set of reference tips. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- Documentation/git-repack.adoc | 11 ++ builtin/repack.c | 23 +++- repack-cruft.c | 23 +++- repack.h | 3 +- t/t7704-repack-cruft.sh | 251 ++++++++++++++++++++++++++++++++++ 5 files changed, 300 insertions(+), 11 deletions(-) diff --git a/Documentation/git-repack.adoc b/Documentation/git-repack.adoc index 72c42015e23f94..e9df7713278abc 100644 --- a/Documentation/git-repack.adoc +++ b/Documentation/git-repack.adoc @@ -70,6 +70,11 @@ to the new separate pack will be written. are packed into a separate cruft pack. Unreachable objects can be pruned using the normal expiry rules with the next `git gc` invocation (see linkgit:git-gc[1]). Incompatible with `-k`. ++ +When combined with `--geometric`, `--cruft` does not imply `-a`. Instead, +the geometric repack rolls up packs as usual, and a separate cruft pack is +written to collect unreachable objects. Only reachable objects from the +rolled-up packs are included in the resulting geometric pack. --cruft-expiration=:: Expire unreachable objects older than `` @@ -245,6 +250,12 @@ progression. Loose objects are implicitly included in this "roll-up", without respect to their reachability. This is subject to change in the future. + +When combined with `--cruft`, only reachable objects from rolled-up packs +are included in the geometric pack, along with any reachable loose objects. +Unreachable objects (both from rolled-up packs and loose) are collected +into a separate cruft pack. Existing cruft packs are retained. See +`--cruft` above for details. ++ When writing a multi-pack bitmap, `git repack` selects the largest resulting pack as the preferred pack for object selection by the MIDX (see linkgit:git-multi-pack-index[1]). diff --git a/builtin/repack.c b/builtin/repack.c index dfb6fed231d3c1..165cfff75cde56 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -260,7 +260,7 @@ int cmd_repack(int argc, keep_unreachable, "-k/--keep-unreachable", pack_everything & PACK_CRUFT, "--cruft"); - if (pack_everything & PACK_CRUFT) + if (pack_everything & PACK_CRUFT && !geometry.split_factor) pack_everything |= ALL_INTO_ONE; if (write_bitmaps < 0) { @@ -296,7 +296,8 @@ int cmd_repack(int argc, die(_("invalid value for %s: %d"), "--midx-new-layer-threshold", config_ctx.midx_new_layer_threshold); - if (write_midx != REPACK_WRITE_MIDX_NONE && write_bitmaps) { + if ((write_midx != REPACK_WRITE_MIDX_NONE && write_bitmaps) || + (geometry.split_factor && (pack_everything & PACK_CRUFT))) { struct strbuf path = STRBUF_INIT; strbuf_addf(&path, "%s/%s_XXXXXX", @@ -317,7 +318,7 @@ int cmd_repack(int argc, existing_packs_collect(&existing, &keep_pack_list); if (geometry.split_factor) { - if (pack_everything) + if (pack_everything & ~PACK_CRUFT) die(_("options '%s' and '%s' cannot be used together"), "--geometric", "-A/-a"); if (write_midx == REPACK_WRITE_MIDX_INCREMENTAL) { geometry.midx_layer_threshold = config_ctx.midx_new_layer_threshold; @@ -393,10 +394,16 @@ int cmd_repack(int argc, pack_geometry_repack_promisors(repo, &po_args, &geometry, &names, packtmp); - if (midx_must_contain_cruft) + if (pack_everything & PACK_CRUFT) { + strvec_push(&cmd.args, "--stdin-packs=follow-reachable"); + if (refs_snapshot) + strvec_pushf(&cmd.args, "--refs-snapshot=%s", + get_tempfile_path(refs_snapshot)); + } else if (midx_must_contain_cruft) strvec_push(&cmd.args, "--stdin-packs"); else strvec_push(&cmd.args, "--stdin-packs=follow"); + strvec_push(&cmd.args, "--unpacked"); } else { strvec_push(&cmd.args, "--unpacked"); @@ -431,7 +438,8 @@ int cmd_repack(int argc, const char *basename = pack_basename(geometry.pack[i]); char marker = '^'; - if (!midx_must_contain_cruft && + if ((pack_everything & PACK_CRUFT || + !midx_must_contain_cruft) && !string_list_has_string(&existing.midx_packs, basename)) { /* @@ -505,7 +513,8 @@ int cmd_repack(int argc, ret = write_cruft_pack(&opts, cruft_expiration, combine_cruft_below_size, &names, - &existing); + &existing, + geometry.split_factor ? &geometry : NULL); if (ret) goto cleanup; @@ -540,7 +549,7 @@ int cmd_repack(int argc, */ opts.destination = expire_to; ret = write_cruft_pack(&opts, NULL, 0ul, &names, - &existing); + &existing, NULL); if (ret) goto cleanup; } diff --git a/repack-cruft.c b/repack-cruft.c index 6a040e980173a1..6c553bbb0b517d 100644 --- a/repack-cruft.c +++ b/repack-cruft.c @@ -36,7 +36,8 @@ int write_cruft_pack(const struct write_pack_opts *opts, const char *cruft_expiration, unsigned long combine_cruft_below_size, struct string_list *names, - struct existing_packs *existing) + struct existing_packs *existing, + struct pack_geometry *geometry) { struct child_process cmd = CHILD_PROCESS_INIT; struct string_list_item *item; @@ -81,8 +82,24 @@ int write_cruft_pack(const struct write_pack_opts *opts, else for_each_string_list_item(item, &existing->cruft_packs) fprintf(in, "-%s.pack\n", item->string); - for_each_string_list_item(item, &existing->non_kept_packs) - fprintf(in, "-%s.pack\n", item->string); + if (geometry) { + uint32_t j; + for (j = 0; j < geometry->split; j++) + fprintf(in, "-%s\n", + pack_basename(geometry->pack[j])); + for (; j < geometry->pack_nr; j++) + fprintf(in, "%s\n", + pack_basename(geometry->pack[j])); + for (j = 0; j < geometry->promisor_split; j++) + fprintf(in, "-%s\n", + pack_basename(geometry->promisor_pack[j])); + for (; j < geometry->promisor_pack_nr; j++) + fprintf(in, "%s\n", + pack_basename(geometry->promisor_pack[j])); + } else { + for_each_string_list_item(item, &existing->non_kept_packs) + fprintf(in, "-%s.pack\n", item->string); + } for_each_string_list_item(item, &existing->kept_packs) fprintf(in, "%s.pack\n", item->string); fclose(in); diff --git a/repack.h b/repack.h index 4295829cea0a61..872a503fbd1489 100644 --- a/repack.h +++ b/repack.h @@ -169,6 +169,7 @@ int write_cruft_pack(const struct write_pack_opts *opts, const char *cruft_expiration, unsigned long combine_cruft_below_size, struct string_list *names, - struct existing_packs *existing); + struct existing_packs *existing, + struct pack_geometry *geometry); #endif /* REPACK_H */ diff --git a/t/t7704-repack-cruft.sh b/t/t7704-repack-cruft.sh index 9e03b04315db76..5e2b776e7bac4f 100755 --- a/t/t7704-repack-cruft.sh +++ b/t/t7704-repack-cruft.sh @@ -891,4 +891,255 @@ test_expect_success 'repack rescues once-cruft objects above geometric split' ' git repack --geometric=2 -d --write-midx --write-bitmap-index ' +test_expect_success 'repack --geometric --cruft combines packs and writes cruft' ' + git init geometric-cruft-basic && + ( + cd geometric-cruft-basic && + + test_commit A && + test_commit B && + + B="$(git rev-parse B)" && + + git reset --hard $B^ && + git tag -d B && + git reflog expire --all --expire=all && + + # Initial state: one non-cruft pack, one cruft pack. + git repack -d --cruft && + + ls $packdir/pack-*.mtimes >cruft.before && + test_line_count = 1 cruft.before && + + test_commit C && + git repack && + + # At this point we have three packs: + # - the non-cruft pack from A + # - the cruft pack from B + # - a new non-cruft pack from C + # + # The two non-cruft packs are not in a geometric + # progression, so they should be rolled up. + git repack -d --geometric=2 --cruft && + + # The old cruft pack for B is retained, since the + # geometric repack does not touch cruft packs. + ls $packdir/pack-*.mtimes >cruft.after && + test_line_count = 1 cruft.after && + + # Ensure that all reachable objects are present. + git fsck + ) +' + +test_expect_success 'repack --geometric --cruft writes new cruft for loose unreachable' ' + git init geometric-cruft-new-cruft && + ( + cd geometric-cruft-new-cruft && + + git config set maintenance.auto false && + + test_commit A && + git repack && + + test_commit B && + git repack && + + # Create an unreachable commit whose objects are + # still loose (never packed). + test_commit C && + C="$(git rev-parse C)" && + git reset --hard $C^ && + git tag -d C && + git reflog expire --all --expire=all && + + # At this point we have two non-cruft packs of + # similar size that are not in geometric progression, + # and loose unreachable objects from commit C. + ls $packdir/pack-*.idx >packs.before && + test_line_count = 2 packs.before && + + # Geometric+cruft repack should roll up the two + # non-cruft packs and write a new cruft pack for C + # (whose objects are loose and unreachable). + git repack -d --geometric=2 --cruft && + + ls $packdir/pack-*.mtimes >cruft.after && + test_line_count = 1 cruft.after && + + git fsck + ) +' + +test_expect_success 'repack --geometric --cruft -d deletes rolled-up packs' ' + git init geometric-cruft-delete && + ( + cd geometric-cruft-delete && + + test_commit A && + git repack -d && + + test_commit B && + git repack -d && + + ls $packdir/pack-*.idx >before && + + git repack -d --geometric=2 --cruft && + + # Two packs should have been rolled into one. No cruft + # pack is written because there are no unreachable objects. + ls $packdir/pack-*.idx >after && + test_line_count = 1 after && + + # The rolled-up packs should be gone. + ! test_cmp before after + ) +' + +test_expect_success 'repack --geometric --cruft collects loose unreachable objects' ' + git init geometric-cruft-loose && + ( + cd geometric-cruft-loose && + + test_commit A && + git repack -d && + + test_commit B && + git repack && + + # Create a loose unreachable object by making it + # orphaned (not in any pack). + loose="$(echo "cruft object" | git hash-object -w --stdin)" && + + # We have two non-cruft packs and a loose unreachable + # object. The geometric+cruft repack should roll up + # the packs AND write a cruft pack for the loose + # unreachable object. + git repack -d --geometric=2 --cruft && + + ls $packdir/pack-*.mtimes >cruft.packs && + test_line_count = 1 cruft.packs && + + git fsck + ) +' + +test_expect_success 'repack --geometric --cruft accumulates cruft packs' ' + git init geometric-cruft-accumulate && + ( + cd geometric-cruft-accumulate && + + git config set maintenance.auto false && + + test_commit A && + git repack && + + # First round: create unreachable objects and do a + # geometric+cruft repack. + unreachable_1="$(echo "cruft 1" | git hash-object -w --stdin)" && + git repack -d --geometric=2 --cruft && + + ls $packdir/pack-*.mtimes >cruft.1 && + test_line_count = 1 cruft.1 && + + test_commit B && + git repack && + + # Second round: create more unreachable objects and + # repack again. The old cruft pack should be retained + # and a new one written. + unreachable_2="$(echo "cruft 2" | git hash-object -w --stdin)" && + git repack -d --geometric=2 --cruft && + + ls $packdir/pack-*.mtimes >cruft.2 && + test_line_count = 2 cruft.2 && + + git fsck + ) +' + +test_expect_success 'repack --geometric --cruft --combine-cruft-below-size' ' + git init geometric-cruft-combine && + ( + cd geometric-cruft-combine && + + git config set maintenance.auto false && + + test_commit A && + git repack && + + # Create a small cruft pack. + unreachable_1="$(echo "cruft 1" | git hash-object -w --stdin)" && + git repack -d --geometric=2 --cruft && + + ls $packdir/pack-*.mtimes >cruft.before && + test_line_count = 1 cruft.before && + + test_commit B && + git repack && + + # Create another small cruft pack. + unreachable_2="$(echo "cruft 2" | git hash-object -w --stdin)" && + git repack -d --geometric=2 --cruft && + + ls $packdir/pack-*.mtimes >cruft.mid && + test_line_count = 2 cruft.mid && + + test_commit C && + git repack && + + # With --combine-cruft-below-size, the two small cruft + # packs should be combined into one. + unreachable_3="$(echo "cruft 3" | git hash-object -w --stdin)" && + git repack -d --geometric=2 --cruft \ + --combine-cruft-below-size=10M && + + ls $packdir/pack-*.mtimes >cruft.after && + test_line_count = 1 cruft.after && + + git fsck + ) +' + +test_expect_success 'repack --geometric --cruft --expire-to' ' + git init geometric-cruft-expire-to && + ( + cd geometric-cruft-expire-to && + + git config set maintenance.auto false && + + test_commit A && + git repack && + + test_commit B && + git repack && + + # Create unreachable objects and record them. + test_commit C && + C="$(git rev-parse C)" && + git rev-list --objects --no-object-names B..C >unreachable.raw && + sort unreachable.raw >unreachable.want && + + git reset --hard $C^ && + git tag -d C && + git reflog expire --all --expire=all && + + git init --bare expired.git && + git repack -d --geometric=2 --cruft \ + --cruft-expiration=now \ + --expire-to="expired.git/objects/pack/pack" && + + # The expired objects should appear in the + # expire-to location. + expired="$(ls expired.git/objects/pack/pack-*.idx)" && + test_path_is_file "${expired%.idx}.mtimes" && + git show-index <"$expired" >expired.raw && + cut -d" " -f2 expired.raw | sort >expired.objects && + test_cmp unreachable.want expired.objects && + + git fsck + ) +' + test_done From 6fa5fbaf2fc88582d070adc2b0e765682206a984 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Sat, 27 Jun 2026 17:28:55 +0000 Subject: [PATCH 034/159] diff: rename and group the line-range filter for clarity The line-range filter that mm/line-log-cleanup added uses names that obscure its model. The cursors lno_post/lno_pre and the index lno_0 share an lno_ prefix but conflate the pre/post-image axis with the 0-based/1-based axis, the hunk state is a flat set of rhunk_* fields, and the filter-state pointer is just s. The filter bridges two layers of diff.c, and its fields already used each layer's vocabulary, but in cryptic abbreviations. Spell them out to the form the rest of the file uses, so that the patches that follow can simplify and fix it with those clearer names in place: - lno_post/lno_pre -> lno_in_postimage/lno_in_preimage, the line-number cursors, matching the counters in struct emit_callback - lno_0 -> idx_in_postimage, the 0-based range index - the hunk-header geometry stays old/new (old_begin, new_begin, and counts) to match the xdiff_emit_hunk_fn callback and the "@@ - + @@" header it feeds, but moves from flat rhunk_* fields into a "hunk" sub-struct, so accesses read filter->hunk.old_begin - flush_rhunk -> flush_range_hunk - the filter-state pointer in each callback: s -> filter Also rename the struct line_range_callback to line_range_filter: it is a filter over xdiff output, not merely a callback. No behavior change. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 192 +++++++++++++++++++++++++++++---------------------------- 1 file changed, 97 insertions(+), 95 deletions(-) diff --git a/diff.c b/diff.c index 5a584fa1d569e7..1e043c959ff2b4 100644 --- a/diff.c +++ b/diff.c @@ -623,15 +623,15 @@ struct emit_callback { * reveals whether they precede an in-range line (flush into range hunk) or * an out-of-range line (discard). */ -struct line_range_callback { +struct line_range_filter { xdiff_emit_line_fn orig_line_fn; void *orig_cb_data; const struct range_set *ranges; /* 0-based [start, end) */ unsigned int cur_range; /* index into the range_set */ /* Post/pre-image line counters (1-based, set from hunk headers) */ - long lno_post; - long lno_pre; + long lno_in_postimage; + long lno_in_preimage; /* * Function name from most recent xdiff hunk header; @@ -640,12 +640,14 @@ struct line_range_callback { char func[80]; long funclen; - /* Range hunk being accumulated for the current range */ - struct strbuf rhunk; - long rhunk_old_begin, rhunk_old_count; - long rhunk_new_begin, rhunk_new_count; - int rhunk_active; - int rhunk_has_changes; /* any '+' or '-' lines? */ + /* The range hunk being accumulated for the current range. */ + struct { + struct strbuf lines; /* buffered in-range diff lines */ + long old_begin, old_count; + long new_begin, new_count; + int active; + int has_changes; /* any '+' or '-' line? */ + } hunk; /* Removal lines not yet known to be in-range */ struct strbuf pending_rm; @@ -2540,26 +2542,26 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED return 1; } -static void discard_pending_rm(struct line_range_callback *s) +static void discard_pending_rm(struct line_range_filter *filter) { - strbuf_reset(&s->pending_rm); - s->pending_rm_count = 0; + strbuf_reset(&filter->pending_rm); + filter->pending_rm_count = 0; } -static void flush_rhunk(struct line_range_callback *s) +static void flush_range_hunk(struct line_range_filter *filter) { struct strbuf hdr = STRBUF_INIT; const char *p, *end; - if (!s->rhunk_active || s->ret) + if (!filter->hunk.active || filter->ret) return; /* Drain any pending removal lines into the range hunk */ - if (s->pending_rm_count) { - strbuf_addbuf(&s->rhunk, &s->pending_rm); - s->rhunk_old_count += s->pending_rm_count; - s->rhunk_has_changes = 1; - discard_pending_rm(s); + if (filter->pending_rm_count) { + strbuf_addbuf(&filter->hunk.lines, &filter->pending_rm); + filter->hunk.old_count += filter->pending_rm_count; + filter->hunk.has_changes = 1; + discard_pending_rm(filter); } /* @@ -2568,22 +2570,22 @@ static void flush_rhunk(struct line_range_callback *s) * ctxlen causes xdiff to emit context covering a range that * has no changes in this commit. */ - if (!s->rhunk_has_changes) { - s->rhunk_active = 0; - strbuf_reset(&s->rhunk); + if (!filter->hunk.has_changes) { + filter->hunk.active = 0; + strbuf_reset(&filter->hunk.lines); return; } strbuf_addf(&hdr, "@@ -%ld,%ld +%ld,%ld @@", - s->rhunk_old_begin, s->rhunk_old_count, - s->rhunk_new_begin, s->rhunk_new_count); - if (s->funclen > 0) { + filter->hunk.old_begin, filter->hunk.old_count, + filter->hunk.new_begin, filter->hunk.new_count); + if (filter->funclen > 0) { strbuf_addch(&hdr, ' '); - strbuf_add(&hdr, s->func, s->funclen); + strbuf_add(&hdr, filter->func, filter->funclen); } strbuf_addch(&hdr, '\n'); - s->ret = s->orig_line_fn(s->orig_cb_data, hdr.buf, hdr.len); + filter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len); strbuf_release(&hdr); /* @@ -2591,18 +2593,18 @@ static void flush_rhunk(struct line_range_callback *s) * The cast discards const because xdiff_emit_line_fn takes * char *, though fn_out_consume does not modify the buffer. */ - p = s->rhunk.buf; - end = p + s->rhunk.len; - while (!s->ret && p < end) { + p = filter->hunk.lines.buf; + end = p + filter->hunk.lines.len; + while (!filter->ret && p < end) { const char *eol = memchr(p, '\n', end - p); unsigned long line_len = eol ? (unsigned long)(eol - p + 1) : (unsigned long)(end - p); - s->ret = s->orig_line_fn(s->orig_cb_data, (char *)p, line_len); + filter->ret = filter->orig_line_fn(filter->orig_cb_data, (char *)p, line_len); p += line_len; } - s->rhunk_active = 0; - strbuf_reset(&s->rhunk); + filter->hunk.active = 0; + strbuf_reset(&filter->hunk.lines); } static void line_range_hunk_fn(void *data, @@ -2610,7 +2612,7 @@ static void line_range_hunk_fn(void *data, long new_begin, long new_nr UNUSED, const char *func, long funclen) { - struct line_range_callback *s = data; + struct line_range_filter *filter = data; /* * When count > 0, begin is 1-based. When count == 0, begin is @@ -2622,104 +2624,104 @@ static void line_range_hunk_fn(void *data, * flush or discard them when the next content line reveals * whether the removals precede in-range content. */ - s->lno_post = new_begin; - s->lno_pre = old_begin; + filter->lno_in_postimage = new_begin; + filter->lno_in_preimage = old_begin; if (funclen > 0) { - if (funclen > (long)sizeof(s->func)) - funclen = sizeof(s->func); - memcpy(s->func, func, funclen); + if (funclen > (long)sizeof(filter->func)) + funclen = sizeof(filter->func); + memcpy(filter->func, func, funclen); } - s->funclen = funclen; + filter->funclen = funclen; } static int line_range_line_fn(void *priv, char *line, unsigned long len) { - struct line_range_callback *s = priv; + struct line_range_filter *filter = priv; const struct range *cur; - long lno_0, cur_pre; + long idx_in_postimage, cur_pre; - if (s->ret) - return s->ret; + if (filter->ret) + return filter->ret; if (line[0] == '-') { - if (!s->pending_rm_count) - s->pending_rm_pre_begin = s->lno_pre; - s->lno_pre++; - strbuf_add(&s->pending_rm, line, len); - s->pending_rm_count++; - return s->ret; + if (!filter->pending_rm_count) + filter->pending_rm_pre_begin = filter->lno_in_preimage; + filter->lno_in_preimage++; + strbuf_add(&filter->pending_rm, line, len); + filter->pending_rm_count++; + return filter->ret; } if (line[0] == '\\') { - if (s->pending_rm_count) - strbuf_add(&s->pending_rm, line, len); - else if (s->rhunk_active) - strbuf_add(&s->rhunk, line, len); + if (filter->pending_rm_count) + strbuf_add(&filter->pending_rm, line, len); + else if (filter->hunk.active) + strbuf_add(&filter->hunk.lines, line, len); /* otherwise outside tracked range; drop silently */ - return s->ret; + return filter->ret; } if (line[0] != '+' && line[0] != ' ') BUG("unexpected diff line type '%c'", line[0]); - lno_0 = s->lno_post - 1; - cur_pre = s->lno_pre; /* save before advancing for context lines */ - s->lno_post++; + idx_in_postimage = filter->lno_in_postimage - 1; + cur_pre = filter->lno_in_preimage; /* save before advancing for context lines */ + filter->lno_in_postimage++; if (line[0] == ' ') - s->lno_pre++; + filter->lno_in_preimage++; /* Advance past ranges we've passed */ - while (s->cur_range < s->ranges->nr && - lno_0 >= s->ranges->ranges[s->cur_range].end) { - if (s->rhunk_active) - flush_rhunk(s); - discard_pending_rm(s); - s->cur_range++; + while (filter->cur_range < filter->ranges->nr && + idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) { + if (filter->hunk.active) + flush_range_hunk(filter); + discard_pending_rm(filter); + filter->cur_range++; } /* Past all ranges */ - if (s->cur_range >= s->ranges->nr) { - discard_pending_rm(s); - return s->ret; + if (filter->cur_range >= filter->ranges->nr) { + discard_pending_rm(filter); + return filter->ret; } - cur = &s->ranges->ranges[s->cur_range]; + cur = &filter->ranges->ranges[filter->cur_range]; /* Before current range */ - if (lno_0 < cur->start) { - discard_pending_rm(s); - return s->ret; + if (idx_in_postimage < cur->start) { + discard_pending_rm(filter); + return filter->ret; } /* In range so start a new range hunk if needed */ - if (!s->rhunk_active) { - s->rhunk_active = 1; - s->rhunk_has_changes = 0; - s->rhunk_new_begin = lno_0 + 1; - s->rhunk_old_begin = s->pending_rm_count - ? s->pending_rm_pre_begin : cur_pre; - s->rhunk_old_count = 0; - s->rhunk_new_count = 0; - strbuf_reset(&s->rhunk); + if (!filter->hunk.active) { + filter->hunk.active = 1; + filter->hunk.has_changes = 0; + filter->hunk.new_begin = idx_in_postimage + 1; + filter->hunk.old_begin = filter->pending_rm_count + ? filter->pending_rm_pre_begin : cur_pre; + filter->hunk.old_count = 0; + filter->hunk.new_count = 0; + strbuf_reset(&filter->hunk.lines); } /* Flush pending removals into range hunk */ - if (s->pending_rm_count) { - strbuf_addbuf(&s->rhunk, &s->pending_rm); - s->rhunk_old_count += s->pending_rm_count; - s->rhunk_has_changes = 1; - discard_pending_rm(s); + if (filter->pending_rm_count) { + strbuf_addbuf(&filter->hunk.lines, &filter->pending_rm); + filter->hunk.old_count += filter->pending_rm_count; + filter->hunk.has_changes = 1; + discard_pending_rm(filter); } - strbuf_add(&s->rhunk, line, len); - s->rhunk_new_count++; + strbuf_add(&filter->hunk.lines, line, len); + filter->hunk.new_count++; if (line[0] == '+') - s->rhunk_has_changes = 1; + filter->hunk.has_changes = 1; else - s->rhunk_old_count++; + filter->hunk.old_count++; - return s->ret; + return filter->ret; } static void pprint_rename(struct strbuf *name, const char *a, const char *b) @@ -4086,7 +4088,7 @@ static void builtin_diff(const char *name_a, xdi_diff_outf(&mf1, &mf2, NULL, quick_consume, &ecbdata, &xpp, &xecfg); } else if (line_ranges) { - struct line_range_callback lr_state; + struct line_range_filter lr_state; unsigned int i; long max_span = 0; @@ -4094,7 +4096,7 @@ static void builtin_diff(const char *name_a, lr_state.orig_line_fn = fn_out_consume; lr_state.orig_cb_data = &ecbdata; lr_state.ranges = line_ranges; - strbuf_init(&lr_state.rhunk, 0); + strbuf_init(&lr_state.hunk.lines, 0); strbuf_init(&lr_state.pending_rm, 0); /* @@ -4125,11 +4127,11 @@ static void builtin_diff(const char *name_a, die("unable to generate diff for %s", one->path); - flush_rhunk(&lr_state); + flush_range_hunk(&lr_state); if (lr_state.ret) die("unable to generate diff for %s", one->path); - strbuf_release(&lr_state.rhunk); + strbuf_release(&lr_state.hunk.lines); strbuf_release(&lr_state.pending_rm); } else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume, &ecbdata, &xpp, &xecfg)) From 5a508c13ac159a6c8e938ba6ce544a33f234e58b Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Sat, 27 Jun 2026 17:28:56 +0000 Subject: [PATCH 035/159] diff: simplify the line-range filter by classifying removals immediately The filter buffered '-' lines in a pending_rm strbuf, deferring their classification until a '+' or ' ' line revealed the post-image position. That buffering is unnecessary: a removal occupies no post-image line, so it does not advance lno_in_postimage, and xdiff emits removals before additions within a change. A '-' therefore arrives while lno_in_postimage already holds the index the following '+'/' ' will occupy, and can be classified against the ranges as it arrives. The buffering also hid a bug: flush_range_hunk() drained pending_rm into the range hunk whenever the hunk was active, even after lno_in_postimage had advanced past the tracked range, so a deletion just after the tracked function leaked into the patch. Classifying each line as it arrives removes the pending_rm buffer, the discard_pending_rm() helper, three struct fields, and makes that bug impossible by construction. With every line classified on arrival, the buffered lines are the hunk's single source of truth, so the old/new counts need not be kept alongside them: flush_range_hunk() derives the counts (and whether the hunk holds any change) from the buffer when it builds the header. Drop the per-line counting and the old_count, new_count, and has_changes fields; there is no longer a second tally that could fall out of sync with the buffer. Add begin_range_hunk() to open the accumulator at the first in-range line, seeding both begins from the live image cursors, as the counterpart to flush_range_hunk(). With the counting gone too, line_range_line_fn() now only appends an in-range line. Document the coordinate model: a block comment on struct line_range_filter states it (the pre/post-image cursors, the 0-based idx_in_postimage, removals classified by the following line) with a worked example. Add tests for the leaked trailing deletion this fixes, the symmetric leading-deletion case, and the filter's range boundaries (a change at the first and last line of a range, and a pure in-range deletion). Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 215 ++++++++++++++++++++++++-------------------- t/t4211-line-log.sh | 125 ++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 97 deletions(-) diff --git a/diff.c b/diff.c index 1e043c959ff2b4..ee765d7ac2183d 100644 --- a/diff.c +++ b/diff.c @@ -610,18 +610,58 @@ struct emit_callback { }; /* - * State for the line-range callback wrappers that sit between - * xdi_diff_outf() and fn_out_consume(). xdiff produces a normal, - * unfiltered diff; the wrappers intercept each hunk header and line, - * track post-image position, and forward only lines that fall within - * the requested ranges. Contiguous in-range lines are collected into - * range hunks and flushed with a synthetic @@ header so that - * fn_out_consume() sees well-formed unified-diff fragments. + * Line-range filter: scopes "git log -L" output to the tracked ranges. * - * Removal lines ('-') cannot be classified by post-image position, so - * they are buffered in pending_rm until the next '+' or ' ' line - * reveals whether they precede an in-range line (flush into range hunk) or - * an out-of-range line (discard). + * It sits between xdi_diff_outf() and an output callback (fn_out_consume, + * diffstat_consume, checkdiff_consume). xdiff produces a normal diff; the + * filter forwards only the lines inside the requested ranges, collecting + * contiguous in-range lines into a "range hunk" emitted with a synthetic + * @@ header so the callback sees well-formed unified-diff fragments. + * + * A diff describes the change from a pre-image to a post-image. Each + * line is context (' ', in both), a removal ('-', pre-image only), or + * an addition ('+', post-image only). -L tracks ranges in the + * post-image, so a line is in range by its post-image position. + * + * Two 1-based cursors track the next line in each image, named as in + * struct emit_callback and seeded from the xdiff hunk header: + * + * lno_in_postimage advances on '+' and ' ' (lines in the post-image) + * lno_in_preimage advances on '-' and ' ' (lines in the pre-image) + * + * Ranges are 0-based half-open [start, end), so a line is tested at the + * 0-based index idx_in_postimage = lno_in_postimage - 1. + * + * A '-' is not present in the post-image, so it has no post-image line + * number of its own. Since it does not advance lno_in_postimage, it is + * classified at the idx_in_postimage that the following '+'/' ' will + * occupy. xdiff emits a change's removals before its additions, so that + * index is already known when the '-' arrives. + * + * The synthetic "@@ - + @@" header has two sides, old (the + * pre-image) and new (the post-image), matching the xdiff_emit_hunk_fn + * callback; the hunk.old_begin / hunk.new_begin fields below hold those + * begins, and flush_range_hunk() derives the counts from the buffered + * lines. + * + * Example, tracking post-image line 2 (range [1, 2)) of: + * + * pre-image post-image + * 1 a 1 a + * 2 b 2 X (b -> X) + * 3 c 3 c + * + * classify each line by idx_in_postimage. The pre and post columns + * are each cursor's value while that line is classified, i.e. before + * the line advances them (pre = lno_in_preimage, + * post = lno_in_postimage, idx = idx_in_postimage): + * ' a' pre 1 post 1 idx 0 -> before start, skip + * '-b' pre 2 post 2 idx 1 -> keep (removal) + * '+X' pre 3 post 2 idx 1 -> keep (addition) + * ' c' pre 3 post 3 idx 2 -> past end, flush + * + * -b and +X share idx = 1 because -b did not advance lno_in_postimage; + * both land in the range hunk, flushed when ' c' crosses the range end. */ struct line_range_filter { xdiff_emit_line_fn orig_line_fn; @@ -640,20 +680,18 @@ struct line_range_filter { char func[80]; long funclen; - /* The range hunk being accumulated for the current range. */ + /* + * The range hunk being accumulated. At most one is live at a time: + * it is flushed and reset as the cursor leaves each range (and once + * more at end of diff), then reused for the next range. + */ struct { struct strbuf lines; /* buffered in-range diff lines */ - long old_begin, old_count; - long new_begin, new_count; + long old_begin; + long new_begin; int active; - int has_changes; /* any '+' or '-' line? */ } hunk; - /* Removal lines not yet known to be in-range */ - struct strbuf pending_rm; - int pending_rm_count; - long pending_rm_pre_begin; /* pre-image line of first pending */ - int ret; /* latched error from orig_line_fn */ }; @@ -2542,26 +2580,48 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED return 1; } -static void discard_pending_rm(struct line_range_filter *filter) +/* + * Begin a range hunk at the first in-range line. Its position fixes the + * hunk's begins, taken from the two image cursors before they advance: + * new_begin from the post-image, old_begin from the pre-image. The line + * counts are not tracked here; flush_range_hunk() derives them from the + * buffered lines. + */ +static void begin_range_hunk(struct line_range_filter *filter) { - strbuf_reset(&filter->pending_rm); - filter->pending_rm_count = 0; + filter->hunk.active = 1; + filter->hunk.new_begin = filter->lno_in_postimage; + filter->hunk.old_begin = filter->lno_in_preimage; + strbuf_reset(&filter->hunk.lines); } static void flush_range_hunk(struct line_range_filter *filter) { struct strbuf hdr = STRBUF_INIT; const char *p, *end; + long old_count = 0, new_count = 0; + int has_changes = 0; if (!filter->hunk.active || filter->ret) return; - /* Drain any pending removal lines into the range hunk */ - if (filter->pending_rm_count) { - strbuf_addbuf(&filter->hunk.lines, &filter->pending_rm); - filter->hunk.old_count += filter->pending_rm_count; - filter->hunk.has_changes = 1; - discard_pending_rm(filter); + /* + * Derive the hunk's geometry from the buffered lines: a ' ' + * counts on both sides, a '-' on the old side, a '+' on the new. + * A '-' or '+' marks a real change; the "\ No newline at end of + * file" marker (line[0] == '\\') counts on neither side. + */ + p = filter->hunk.lines.buf; + end = p + filter->hunk.lines.len; + while (p < end) { + const char *eol = memchr(p, '\n', end - p); + if (*p == ' ' || *p == '-') + old_count++; + if (*p == ' ' || *p == '+') + new_count++; + if (*p == '-' || *p == '+') + has_changes = 1; + p = eol ? eol + 1 : end; } /* @@ -2570,15 +2630,15 @@ static void flush_range_hunk(struct line_range_filter *filter) * ctxlen causes xdiff to emit context covering a range that * has no changes in this commit. */ - if (!filter->hunk.has_changes) { + if (!has_changes) { filter->hunk.active = 0; strbuf_reset(&filter->hunk.lines); return; } strbuf_addf(&hdr, "@@ -%ld,%ld +%ld,%ld @@", - filter->hunk.old_begin, filter->hunk.old_count, - filter->hunk.new_begin, filter->hunk.new_count); + filter->hunk.old_begin, old_count, + filter->hunk.new_begin, new_count); if (filter->funclen > 0) { strbuf_addch(&hdr, ' '); strbuf_add(&hdr, filter->func, filter->funclen); @@ -2618,11 +2678,6 @@ static void line_range_hunk_fn(void *data, * When count > 0, begin is 1-based. When count == 0, begin is * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of * that type will arrive, so the value is unused. - * - * Any pending removal lines from the previous xdiff hunk are - * intentionally left in pending_rm: the line callback will - * flush or discard them when the next content line reveals - * whether the removals precede in-range content. */ filter->lno_in_postimage = new_begin; filter->lno_in_preimage = old_begin; @@ -2638,88 +2693,56 @@ static void line_range_hunk_fn(void *data, static int line_range_line_fn(void *priv, char *line, unsigned long len) { struct line_range_filter *filter = priv; - const struct range *cur; - long idx_in_postimage, cur_pre; + long idx_in_postimage; + int in_range; if (filter->ret) return filter->ret; - if (line[0] == '-') { - if (!filter->pending_rm_count) - filter->pending_rm_pre_begin = filter->lno_in_preimage; - filter->lno_in_preimage++; - strbuf_add(&filter->pending_rm, line, len); - filter->pending_rm_count++; - return filter->ret; - } - if (line[0] == '\\') { - if (filter->pending_rm_count) - strbuf_add(&filter->pending_rm, line, len); - else if (filter->hunk.active) + if (filter->hunk.active) strbuf_add(&filter->hunk.lines, line, len); - /* otherwise outside tracked range; drop silently */ return filter->ret; } - if (line[0] != '+' && line[0] != ' ') + if (line[0] != '+' && line[0] != ' ' && line[0] != '-') BUG("unexpected diff line type '%c'", line[0]); + /* + * idx_in_postimage is this line's 0-based post-image index (see the model on + * struct line_range_filter). The cursors are advanced only after + * the line is classified, so a '-' is tested at the same idx_in_postimage as + * the '+'/' ' that follows it. + */ idx_in_postimage = filter->lno_in_postimage - 1; - cur_pre = filter->lno_in_preimage; /* save before advancing for context lines */ - filter->lno_in_postimage++; - if (line[0] == ' ') - filter->lno_in_preimage++; - /* Advance past ranges we've passed */ + /* Retire ranges we have passed, flushing the one we leave. */ while (filter->cur_range < filter->ranges->nr && idx_in_postimage >= filter->ranges->ranges[filter->cur_range].end) { if (filter->hunk.active) flush_range_hunk(filter); - discard_pending_rm(filter); filter->cur_range++; } - /* Past all ranges */ - if (filter->cur_range >= filter->ranges->nr) { - discard_pending_rm(filter); - return filter->ret; - } + in_range = filter->cur_range < filter->ranges->nr && + idx_in_postimage >= filter->ranges->ranges[filter->cur_range].start && + idx_in_postimage < filter->ranges->ranges[filter->cur_range].end; - cur = &filter->ranges->ranges[filter->cur_range]; + if (in_range) { + if (!filter->hunk.active) + begin_range_hunk(filter); - /* Before current range */ - if (idx_in_postimage < cur->start) { - discard_pending_rm(filter); - return filter->ret; + strbuf_add(&filter->hunk.lines, line, len); } - /* In range so start a new range hunk if needed */ - if (!filter->hunk.active) { - filter->hunk.active = 1; - filter->hunk.has_changes = 0; - filter->hunk.new_begin = idx_in_postimage + 1; - filter->hunk.old_begin = filter->pending_rm_count - ? filter->pending_rm_pre_begin : cur_pre; - filter->hunk.old_count = 0; - filter->hunk.new_count = 0; - strbuf_reset(&filter->hunk.lines); - } - - /* Flush pending removals into range hunk */ - if (filter->pending_rm_count) { - strbuf_addbuf(&filter->hunk.lines, &filter->pending_rm); - filter->hunk.old_count += filter->pending_rm_count; - filter->hunk.has_changes = 1; - discard_pending_rm(filter); - } - - strbuf_add(&filter->hunk.lines, line, len); - filter->hunk.new_count++; - if (line[0] == '+') - filter->hunk.has_changes = 1; - else - filter->hunk.old_count++; + /* + * Advance each image's cursor: a line present in that image (see + * the model) consumes one of its line numbers. + */ + if (line[0] != '-') + filter->lno_in_postimage++; + if (line[0] != '+') + filter->lno_in_preimage++; return filter->ret; } @@ -4097,7 +4120,6 @@ static void builtin_diff(const char *name_a, lr_state.orig_cb_data = &ecbdata; lr_state.ranges = line_ranges; strbuf_init(&lr_state.hunk.lines, 0); - strbuf_init(&lr_state.pending_rm, 0); /* * Inflate ctxlen so that all changes within @@ -4132,7 +4154,6 @@ static void builtin_diff(const char *name_a, die("unable to generate diff for %s", one->path); strbuf_release(&lr_state.hunk.lines); - strbuf_release(&lr_state.pending_rm); } else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume, &ecbdata, &xpp, &xecfg)) die("unable to generate diff for %s", one->path); diff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh index ca4eb7bbc713ef..e9691066deea74 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -738,6 +738,131 @@ test_expect_success '-L with -G filters to diff-text matches' ' grep "F2 + 2" actual ' +test_expect_success 'setup for trailing deletion test' ' + git checkout --orphan trailing-del && + git reset --hard && + cat >file.c <<-\EOF && + void tracked() + { + return 1; + } + // trailing comment + EOF + git add file.c && + test_tick && + git commit -m "add file with trailing comment" && + # Modify tracked() AND delete the trailing comment in + # one commit, so the commit touches the tracked range + # and is not filtered out by the revision walker. + cat >file.c <<-\EOF && + void tracked() + { + return 2; + } + EOF + git commit -a -m "modify tracked and delete trailing comment" +' + +test_expect_success '-L does not include deletions past end of tracked range' ' + git log -L:tracked:file.c --format= -1 -p >actual && + # The trailing comment deletion is outside the tracked + # range and should not appear in the patch output. + test_grep "return 2" actual && + test_grep ! "trailing comment" actual +' + +test_expect_success '-L includes leading deletions resolved by in-range line' ' + git checkout --orphan leading-del && + git reset --hard && + cat >file.c <<-\EOF && + // leading comment + void tracked() + { + return 1; + } + EOF + git add file.c && + test_tick && + git commit -m "add file with leading comment" && + cat >file.c <<-\EOF && + void tracked() + { + return 2; + } + EOF + git commit -a -m "modify tracked and delete leading comment" && + git log -L:tracked:file.c --format= -1 -p >actual && + # The leading comment deletion is resolved by the next + # non-removal line (void tracked), which is in range: a + # removal is classified by the position of the following + # line, so it joins the range that line falls in. + test_grep "return 2" actual && + test_grep "leading comment" actual +' + +test_expect_success 'setup for line-range filter edge cases' ' + git checkout --orphan filter-edge && + git reset --hard && + cat >file.c <<-\EOF && + void before() + { + return 0; + } + + void tracked() + { + int a = 1; + int b = 2; + int c = 3; + return a + b + c; + } + + void after() + { + return 9; + } + EOF + git add file.c && + test_tick && + git commit -m "initial" +' + +test_expect_success '-L change at exact first line of range' ' + git checkout filter-edge && + # Change the function signature (first line of range) + sed "s/void tracked/int tracked/" file.c >tmp && + mv tmp file.c && + git commit -a -m "change first line" && + git log -L:tracked:file.c -p --format=%s -1 >actual && + test_grep "change first line" actual && + test_grep "+int tracked" actual && + test_grep "\\-void tracked" actual +' + +test_expect_success '-L change at exact last line of range' ' + git checkout filter-edge && + git reset --hard HEAD~1 && + # Change the closing brace line (last line of range) + sed "s/^}$/} \/\/ end tracked/" file.c >tmp && + mv tmp file.c && + git commit -a -m "change last line" && + git log -L:tracked:file.c -p --format=%s -1 >actual && + test_grep "change last line" actual && + test_grep "end tracked" actual +' + +test_expect_success '-L pure deletion in range (no additions)' ' + git checkout filter-edge && + git reset --hard HEAD~1 && + # Delete a line inside tracked() without adding anything + sed "/int c/d" file.c >tmp && + mv tmp file.c && + git commit -a -m "pure deletion" && + git log -L:tracked:file.c -p --format=%s -1 >actual && + test_grep "pure deletion" actual && + test_grep "\\-.*int c" actual +' + test_expect_success '-L with --diff-filter=M excludes root commit' ' git checkout parent-oids && git log -L:func2:file.c --diff-filter=M --format=%s --no-patch >actual && From 7555510bf32835fae50363b86a8a667c1b681803 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Sat, 27 Jun 2026 17:28:57 +0000 Subject: [PATCH 036/159] diff: emit -L hunk headers via xdiff's formatter The line-range filter builds its own "@@ - + @@" header for each range hunk. For a side with no lines (count 0, such as the old side of a pure insertion), the begin should be the number of the line before the change, per the convention git diff and xdl_emit_hunk_hdr() follow. The hand-rolled code's begin was one too high; in t4211 this produced @@ -25,0 +18,9 @@ an old begin of 25 in a 24-line file, where git diff would give 24. Stop hand-rolling the header. flush_range_hunk() now formats it through xdiff's own emitter: a new xdiff_emit_hunk_header() helper wraps xdl_emit_hunk_hdr(), the function that produces every other diff's hunk headers. The count-0 begin is then correct by construction, and as a side effect -L headers match git diff exactly, including its omission of a count of 1 ("@@ -22 +22 @@" rather than "@@ -22,1 +22,1 @@"). xdiff's hunk callback already hands line_range_hunk_fn() a count-0 begin decremented, so undo that when seeding the cursors and let the formatter re-apply the convention once, at emit time. The off-by-one predates this series, and the two regenerated fixtures reach it from different origins: no-assertion-error has carried it since its test was added in ab60c693a2 (line-log: fix assertion error, 2025-08-18), while vanishes-early acquired it when 86e986f166 (line-log: route -L output through the standard diff pipeline) reshaped its tracked line into a pure insertion. vanishes-early also drops its count-1 counts. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 27 +++++++++++------------- t/t4211/sha1/expect.no-assertion-error | 2 +- t/t4211/sha1/expect.vanishes-early | 6 +++--- t/t4211/sha256/expect.no-assertion-error | 2 +- t/t4211/sha256/expect.vanishes-early | 6 +++--- xdiff-interface.c | 19 +++++++++++++++++ xdiff-interface.h | 15 +++++++++++++ 7 files changed, 54 insertions(+), 23 deletions(-) diff --git a/diff.c b/diff.c index ee765d7ac2183d..9751bb679874b9 100644 --- a/diff.c +++ b/diff.c @@ -2636,14 +2636,9 @@ static void flush_range_hunk(struct line_range_filter *filter) return; } - strbuf_addf(&hdr, "@@ -%ld,%ld +%ld,%ld @@", - filter->hunk.old_begin, old_count, - filter->hunk.new_begin, new_count); - if (filter->funclen > 0) { - strbuf_addch(&hdr, ' '); - strbuf_add(&hdr, filter->func, filter->funclen); - } - strbuf_addch(&hdr, '\n'); + xdiff_emit_hunk_header(&hdr, filter->hunk.old_begin, old_count, + filter->hunk.new_begin, new_count, + filter->func, filter->funclen); filter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len); strbuf_release(&hdr); @@ -2668,19 +2663,21 @@ static void flush_range_hunk(struct line_range_filter *filter) } static void line_range_hunk_fn(void *data, - long old_begin, long old_nr UNUSED, - long new_begin, long new_nr UNUSED, + long old_begin, long old_nr, + long new_begin, long new_nr, const char *func, long funclen) { struct line_range_filter *filter = data; /* - * When count > 0, begin is 1-based. When count == 0, begin is - * adjusted down by 1 by xdl_emit_hunk_hdr(), but no lines of - * that type will arrive, so the value is unused. + * Seed the per-image line cursors from the hunk header's begins. For + * a side with no lines (count 0), xdiff's callback has already moved + * its begin to the line before the change, so add one back to recover + * the true 1-based start. xdiff_emit_hunk_header() reapplies that -1 + * when the clipped hunk is emitted. */ - filter->lno_in_postimage = new_begin; - filter->lno_in_preimage = old_begin; + filter->lno_in_postimage = new_nr ? new_begin : new_begin + 1; + filter->lno_in_preimage = old_nr ? old_begin : old_begin + 1; if (funclen > 0) { if (funclen > (long)sizeof(filter->func)) diff --git a/t/t4211/sha1/expect.no-assertion-error b/t/t4211/sha1/expect.no-assertion-error index 54c568f273a6d2..95faf51a7b46d9 100644 --- a/t/t4211/sha1/expect.no-assertion-error +++ b/t/t4211/sha1/expect.no-assertion-error @@ -8,7 +8,7 @@ diff --git a/b.c b/b.c index bf79c2f..27c829c 100644 --- a/b.c +++ b/b.c -@@ -25,0 +18,9 @@ +@@ -24,0 +18,9 @@ +long f(long x) +{ + int s = 0; diff --git a/t/t4211/sha1/expect.vanishes-early b/t/t4211/sha1/expect.vanishes-early index a413ad36598ddf..e4b1a201d5d254 100644 --- a/t/t4211/sha1/expect.vanishes-early +++ b/t/t4211/sha1/expect.vanishes-early @@ -8,7 +8,7 @@ diff --git a/a.c b/a.c index 0b9cae5..5de3ea4 100644 --- a/a.c +++ b/a.c -@@ -23,0 +24,1 @@ int main () +@@ -22,0 +24 @@ int main () +/* incomplete lines are bad! */ commit 100b61a6f2f720f812620a9d10afb3a960ccb73c @@ -21,7 +21,7 @@ diff --git a/a.c b/a.c index 5e709a1..0b9cae5 100644 --- a/a.c +++ b/a.c -@@ -22,1 +22,1 @@ int main () +@@ -22 +22 @@ int main () -} +} \ No newline at end of file @@ -37,5 +37,5 @@ new file mode 100644 index 0000000..444e415 --- /dev/null +++ b/a.c -@@ -0,0 +20,1 @@ +@@ -0,0 +20 @@ +} diff --git a/t/t4211/sha256/expect.no-assertion-error b/t/t4211/sha256/expect.no-assertion-error index c25f2ce19c05d9..815d27f7f17b7e 100644 --- a/t/t4211/sha256/expect.no-assertion-error +++ b/t/t4211/sha256/expect.no-assertion-error @@ -8,7 +8,7 @@ diff --git a/b.c b/b.c index 69cb69c..a0d566e 100644 --- a/b.c +++ b/b.c -@@ -25,0 +18,9 @@ +@@ -24,0 +18,9 @@ +long f(long x) +{ + int s = 0; diff --git a/t/t4211/sha256/expect.vanishes-early b/t/t4211/sha256/expect.vanishes-early index bc33b963dc8570..263fc9eaace442 100644 --- a/t/t4211/sha256/expect.vanishes-early +++ b/t/t4211/sha256/expect.vanishes-early @@ -8,7 +8,7 @@ diff --git a/a.c b/a.c index e4fa1d8..62c1fc2 100644 --- a/a.c +++ b/a.c -@@ -23,0 +24,1 @@ int main () +@@ -22,0 +24 @@ int main () +/* incomplete lines are bad! */ commit 29f32ac3141c48b22803e5c4127b719917b67d0f8ca8c5248bebfa2a19f7da10 @@ -21,7 +21,7 @@ diff --git a/a.c b/a.c index d325124..e4fa1d8 100644 --- a/a.c +++ b/a.c -@@ -22,1 +22,1 @@ int main () +@@ -22 +22 @@ int main () -} +} \ No newline at end of file @@ -37,5 +37,5 @@ new file mode 100644 index 0000000..9f550c3 --- /dev/null +++ b/a.c -@@ -0,0 +20,1 @@ +@@ -0,0 +20 @@ +} diff --git a/xdiff-interface.c b/xdiff-interface.c index 5ee2b96d0a756f..32e04630ee2ee8 100644 --- a/xdiff-interface.c +++ b/xdiff-interface.c @@ -91,6 +91,25 @@ static int xdiff_outf(void *priv_, mmbuffer_t *mb, int nbuf) return 0; } +static int strbuf_out_line(void *priv, mmbuffer_t *mb, int nbuf) +{ + struct strbuf *out = priv; + int i; + for (i = 0; i < nbuf; i++) + strbuf_add(out, mb[i].ptr, mb[i].size); + return 0; +} + +void xdiff_emit_hunk_header(struct strbuf *out, + long old_begin, long old_count, + long new_begin, long new_count, + const char *func, long funclen) +{ + xdemitcb_t ecb = { .priv = out, .out_line = strbuf_out_line }; + xdl_emit_hunk_hdr(old_begin, old_count, new_begin, new_count, + func, funclen, &ecb); +} + /* * Trim down common substring at the end of the buffers, * but end on a complete line. diff --git a/xdiff-interface.h b/xdiff-interface.h index ce54e1c0e002f8..51c88296ed5e68 100644 --- a/xdiff-interface.h +++ b/xdiff-interface.h @@ -76,4 +76,19 @@ int xdiff_compare_lines(const char *l1, long s1, */ unsigned long xdiff_hash_string(const char *s, size_t len, long flags); +struct strbuf; + +/* + * Append a unified-diff hunk header to `out`, e.g. + * "@@ - + @@ func\n". The header comes from wrapping xdiff's + * own hunk-header emitter, so it matches what a normal diff would + * produce for these begins and counts. For a side with no lines + * (count 0) the begin is the line before the change, and a count of 1 + * is omitted. + */ +void xdiff_emit_hunk_header(struct strbuf *out, + long old_begin, long old_count, + long new_begin, long new_count, + const char *func, long funclen); + #endif From 56cf30f68c6ab06e34c295c62b081da8904b1c41 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Sat, 27 Jun 2026 17:28:58 +0000 Subject: [PATCH 037/159] diff: extract a line-range diff helper for reuse builtin_diff() open-codes the line-range filter setup and teardown around its xdi_diff_outf() call: zero the struct, point it at the output callback, inflate ctxlen to the largest range span so each range yields a single xdiff hunk, run the diff, flush the trailing range hunk, and release the buffer. The upcoming -L stat and check formats need the same sequence. Extract line_range_filter_init() for the setup and a line_range_filter_diff() helper that prepares the xdiff config the filter needs, runs an initialized filter through xdi_diff_outf(), flushes the final range hunk, and releases it, returning the latched error. The helper inflates ctxlen to the largest range span so each range yields a single xdiff hunk, and clears XDL_EMIT_NO_HUNK_HDR so the hunk headers the filter seeds its position from are always emitted. Folding both into the helper keeps these invariants, which the filter's position tracking relies on, in a single place for every consumer. builtin_diff() now does init + line_range_filter_diff(); the next two patches reuse them in builtin_diffstat() and builtin_checkdiff() instead of repeating the boilerplate. No behavior change: builtin_diff() leaves XDL_EMIT_NO_HUNK_HDR unset, so clearing it is a no-op until the suppressing consumers arrive. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 100 +++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/diff.c b/diff.c index 9751bb679874b9..6233a96bf02252 100644 --- a/diff.c +++ b/diff.c @@ -2580,6 +2580,18 @@ static int quick_consume(void *priv, char *line UNUSED, unsigned long len UNUSED return 1; } +static void line_range_filter_init(struct line_range_filter *filter, + const struct range_set *ranges, + xdiff_emit_line_fn line_fn, + void *cb_data) +{ + memset(filter, 0, sizeof(*filter)); + filter->orig_line_fn = line_fn; + filter->orig_cb_data = cb_data; + filter->ranges = ranges; + strbuf_init(&filter->hunk.lines, 0); +} + /* * Begin a range hunk at the first in-range line. Its position fixes the * hunk's begins, taken from the two image cursors before they advance: @@ -2744,6 +2756,50 @@ static int line_range_line_fn(void *priv, char *line, unsigned long len) return filter->ret; } +/* + * Run an xdiff pass through an initialized line-range filter, flush the + * final range hunk, and release the filter. Inflates ctxlen to the largest + * range span first, so that every change within a single range lands in one + * xdiff hunk and the inter-change context is emitted; the filter then clips + * back to range boundaries. The optimal ctxlen depends on where changes fall + * within the range, which is only known after xdiff runs, so the max span is + * the upper bound that guarantees correctness in a single pass. Every + * consumer (patch, diffstat, check) relies on one xdiff hunk per range, so + * this lives here rather than at each call site. Also clears + * XDL_EMIT_NO_HUNK_HDR: the filter seeds its per-image position from the hunk + * headers, so a consumer that otherwise suppresses them (diffstat) still gets + * them here. Returns non-zero if xdiff or any forwarded callback failed. + */ +static int line_range_filter_diff(struct line_range_filter *filter, + mmfile_t *mf1, mmfile_t *mf2, + xpparam_t *xpp, xdemitconf_t *xecfg) +{ + const struct range_set *ranges = filter->ranges; + long max_span = 0; + unsigned int i; + int ret; + + for (i = 0; i < ranges->nr; i++) { + long span = ranges->ranges[i].end - ranges->ranges[i].start; + if (span > max_span) + max_span = span; + } + if (max_span > xecfg->ctxlen) + xecfg->ctxlen = max_span; + + /* the filter seeds its per-image position from hunk headers */ + xecfg->flags &= ~XDL_EMIT_NO_HUNK_HDR; + + ret = xdi_diff_outf(mf1, mf2, line_range_hunk_fn, + line_range_line_fn, filter, xpp, xecfg); + if (!ret) { + flush_range_hunk(filter); + ret = filter->ret; + } + strbuf_release(&filter->hunk.lines); + return ret; +} + static void pprint_rename(struct strbuf *name, const char *a, const char *b) { const char *old_name = a; @@ -4108,49 +4164,15 @@ static void builtin_diff(const char *name_a, xdi_diff_outf(&mf1, &mf2, NULL, quick_consume, &ecbdata, &xpp, &xecfg); } else if (line_ranges) { - struct line_range_filter lr_state; - unsigned int i; - long max_span = 0; + struct line_range_filter lr_filter; - memset(&lr_state, 0, sizeof(lr_state)); - lr_state.orig_line_fn = fn_out_consume; - lr_state.orig_cb_data = &ecbdata; - lr_state.ranges = line_ranges; - strbuf_init(&lr_state.hunk.lines, 0); - - /* - * Inflate ctxlen so that all changes within - * any single range are merged into one xdiff - * hunk and the inter-change context is emitted. - * The callback clips back to range boundaries. - * - * The optimal ctxlen depends on where changes - * fall within the range, which is only known - * after xdiff runs; the max range span is the - * upper bound that guarantees correctness in a - * single pass. - */ - for (i = 0; i < line_ranges->nr; i++) { - long span = line_ranges->ranges[i].end - - line_ranges->ranges[i].start; - if (span > max_span) - max_span = span; - } - if (max_span > xecfg.ctxlen) - xecfg.ctxlen = max_span; - - if (xdi_diff_outf(&mf1, &mf2, - line_range_hunk_fn, - line_range_line_fn, - &lr_state, &xpp, &xecfg)) - die("unable to generate diff for %s", - one->path); + line_range_filter_init(&lr_filter, line_ranges, + fn_out_consume, &ecbdata); - flush_range_hunk(&lr_state); - if (lr_state.ret) + if (line_range_filter_diff(&lr_filter, &mf1, &mf2, + &xpp, &xecfg)) die("unable to generate diff for %s", one->path); - strbuf_release(&lr_state.hunk.lines); } else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume, &ecbdata, &xpp, &xecfg)) die("unable to generate diff for %s", one->path); From 660aae7323bca80e4441479ccd3b83e60dc16477 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Sat, 27 Jun 2026 17:28:59 +0000 Subject: [PATCH 038/159] line-log: support diff stat formats with -L Reuse the line_range_filter in builtin_diffstat() so the stat formats count only the lines within the tracked range. When a filepair carries line_ranges, the filter wraps diffstat_consume() as its output callback, forwarding only the lines inside the range for counting. flush_range_hunk() replays buffered content through diffstat_consume(), which ignores synthetic @@ headers since it only counts '+' and '-' lines. Expand the output format allowlist in setup_revisions() to accept --stat, --numstat, and --shortstat with -L. Leave --dirstat out of the allowlist so it is rejected like any other unsupported format. Its default mode counts each file's whole-file byte damage via diffcore_count_changes(), outside the line-based pipeline that the -L filter scopes, so bare --dirstat cannot honor the tracked range. The --dirstat=lines mode could: it aggregates the same per-file line counts as --numstat, which -L already scopes. But accepting only that sub-mode while bare --dirstat keeps erroring is a confusing split, so the whole format is deferred to a follow-up; --numstat already reports the exact per-file counts within the tracked range. Also drop "yet" from the generic -L rejection message ("does not yet support the requested diff format"). Some rejected formats do not fit a line range at all, so "yet" wrongly implied they are all just awaiting support. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- Documentation/line-range-options.adoc | 12 ++- diff.c | 13 ++- revision.c | 6 +- t/t4211-line-log.sh | 150 ++++++++++++++++++++++---- 4 files changed, 155 insertions(+), 26 deletions(-) diff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc index 72f639b5e79ea4..a111a492b49881 100644 --- a/Documentation/line-range-options.adoc +++ b/Documentation/line-range-options.adoc @@ -9,10 +9,14 @@ __ and __ (or __) must exist in the starting revision. You can specify this option more than once. Implies `--patch`. Patch output can be suppressed using `--no-patch`. - Non-patch diff formats `--raw`, `--name-only`, `--name-status`, - and `--summary` are supported. Diff stat formats - (`--stat`, `--numstat`, `--shortstat`, `--dirstat`) are not - currently implemented. + The following non-patch diff formats are supported: `--raw`, + `--name-only`, `--name-status`, `--summary`, + `--stat`, `--numstat`, and `--shortstat`. + The stat formats count only lines within the tracked range. + `--dirstat` is not supported + with `-L`: it summarizes change as each directory's share of + the total churn, not as counts for the tracked lines. Use + `--numstat` for exact per-file counts within the range. + Patch formatting options such as `--word-diff`, `--color-moved`, `--no-prefix`, and whitespace options (`-w`, `-b`) are supported, diff --git a/diff.c b/diff.c index 6233a96bf02252..026fafeb90b6fc 100644 --- a/diff.c +++ b/diff.c @@ -4289,7 +4289,18 @@ static void builtin_diffstat(const char *name_a, const char *name_b, xecfg.ctxlen = o->context; xecfg.interhunkctxlen = o->interhunkcontext; xecfg.flags = XDL_EMIT_NO_HUNK_HDR; - if (xdi_diff_outf(&mf1, &mf2, NULL, + + if (p->line_ranges) { + struct line_range_filter lr_filter; + + line_range_filter_init(&lr_filter, p->line_ranges, + diffstat_consume, diffstat); + + if (line_range_filter_diff(&lr_filter, &mf1, &mf2, + &xpp, &xecfg)) + die("unable to generate diffstat for %s", + one->path); + } else if (xdi_diff_outf(&mf1, &mf2, NULL, diffstat_consume, diffstat, &xpp, &xecfg)) die("unable to generate diffstat for %s", one->path); diff --git a/revision.c b/revision.c index 6a8101e8b7ef5f..2c76e15778de32 100644 --- a/revision.c +++ b/revision.c @@ -3193,8 +3193,10 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s (revs->diffopt.output_format & ~(DIFF_FORMAT_PATCH | DIFF_FORMAT_NO_OUTPUT | DIFF_FORMAT_RAW | DIFF_FORMAT_NAME | - DIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY)))) - die(_("-L does not yet support the requested diff format")); + DIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY | + DIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT | + DIFF_FORMAT_SHORTSTAT)))) + die(_("-L does not support the requested diff format")); if (revs->expand_tabs_in_log < 0) revs->expand_tabs_in_log = revs->expand_tabs_in_log_default; diff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh index e9691066deea74..b9ca336dbc4f99 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -176,24 +176,15 @@ test_expect_success '--name-status shows status and path' ' test_grep ! "^@@" actual ' -test_expect_success '--stat is not yet supported with -L' ' - test_must_fail git log -L1,24:b.c --stat 2>err && - test_grep "does not yet support" err -' - -test_expect_success '--numstat is not yet supported with -L' ' - test_must_fail git log -L1,24:b.c --numstat 2>err && - test_grep "does not yet support" err -' - -test_expect_success '--shortstat is not yet supported with -L' ' - test_must_fail git log -L1,24:b.c --shortstat 2>err && - test_grep "does not yet support" err -' - -test_expect_success '--dirstat is not yet supported with -L' ' +test_expect_success '--dirstat is not supported with -L' ' + # --dirstat is not supported with -L: its default mode measures + # whole-file change, not the tracked lines, and the + # --dirstat=lines variant is deferred too, so both forms are + # rejected like any other unsupported format. test_must_fail git log -L1,24:b.c --dirstat 2>err && - test_grep "does not yet support" err + test_grep "does not support" err && + test_must_fail git log -L1,24:b.c --dirstat=lines 2>err && + test_grep "does not support" err ' test_expect_success 'setup for checking fancy rename following' ' @@ -887,9 +878,9 @@ test_expect_success '-L with -S suppresses non-matching commits' ' test_cmp expect actual ' -test_expect_success '--full-diff is not yet supported with -L' ' +test_expect_success '--full-diff is not supported with -L' ' test_must_fail git log -L1,24:b.c --full-diff 2>err && - test_grep "does not yet support" err + test_grep "does not support" err ' test_expect_success '-L --oneline has no extra blank line before diff' ' @@ -900,6 +891,127 @@ test_expect_success '-L --oneline has no extra blank line before diff' ' test_grep "^diff --git" line2 ' +test_expect_success 'setup for stat range-scoping tests' ' + git checkout --orphan stat-scoping && + git reset --hard && + cat >file.c <<-\EOF && + int func1() + { + return F1; + } + + int func2() + { + return F2; + } + EOF + git add file.c && + test_tick && + git commit -m "Add func1() and func2()" && + + # Modify both functions in a single commit so that + # whole-file stats differ from the counts for the tracked range. + sed -e "s/F1/F1 + 1/" -e "s/F2/F2 + 2/" file.c >tmp && + mv tmp file.c && + git commit -a -m "Modify both functions" +' + +test_expect_success '--numstat counts only lines in tracked range' ' + # "Modify both functions" changes one line in func1 and one in + # func2. Whole-file numstat would show 2 added, 2 deleted. + # numstat for func2 within the tracked range should show only 1 and 1. + git log -L:func2:file.c --numstat --format=%s -1 >actual && + test_grep "Modify both functions" actual && + test_grep "^1 1 file.c$" actual && + test_grep ! "^diff --git" actual +' + +test_expect_success '--numstat counts only additions for root commit' ' + # Root commit creates both func1 (4 lines) and func2 (4 lines). + # Whole-file numstat would show 9 lines added. numstat for func2 + # within the tracked range should show only 4. + git log -L:func2:file.c --numstat --format=%s >actual && + test_grep "Add func1() and func2()" actual && + test_grep "^4 0 file.c$" actual && + test_grep ! "^diff --git" actual +' + +test_expect_success '--stat counts only lines in tracked range' ' + git log -L:func2:file.c --stat --format=%s -1 >actual && + test_grep "Modify both functions" actual && + test_grep "file.c |" actual && + test_grep "1 insertion" actual && + test_grep "1 deletion" actual && + test_grep ! "^diff --git" actual +' + +test_expect_success '--shortstat counts only lines in tracked range' ' + # --shortstat prints only the summary line: no per-file "file.c |" + # line. Counts cover only the tracked range, as for --numstat above. + git log -L:func2:file.c --shortstat --format=%s -1 >actual && + test_grep "Modify both functions" actual && + test_grep "1 insertion" actual && + test_grep "1 deletion" actual && + test_grep ! "file.c |" actual && + test_grep ! "^diff --git" actual +' + +test_expect_success '--numstat across renames and multiple commits' ' + # parallel-change carries the tracked function f across an a.c -> b.c + # rename and a merge of two parallel histories. With -M, --numstat + # follows the rename and reports added/removed counts for f within + # the tracked range (not whole-file) per commit; the file column flips from + # b.c to a.c at the rename as the walk goes back in time. Commits + # that do not change the range of f emit no row (the merge and the + # pure file-move produce nothing), so there are fewer rows than + # commits. + git checkout parallel-change && + git log -M -L ":f:b.c" --format= --numstat >actual && + cat >expect <<-\EOF && + 1 1 b.c + 1 1 a.c + 1 1 a.c + 1 1 a.c + 1 0 a.c + 13 0 a.c + EOF + test_cmp expect actual +' + +test_expect_success '-L multiple ranges with --numstat excludes untracked change' ' + git checkout --orphan multi-range && + git reset --hard && + cat >m.c <<-\EOF && + int func1() + { + return F1; + } + + int func2() + { + return F2; + } + + int func3() + { + return F3; + } + EOF + git add m.c && + test_tick && + git commit -m "add m.c" && + # Change all three functions but track only func1 and func2. + # Whole-file numstat would be 3 3; a 2 2 result proves the + # untracked func3 change is excluded and the two ranges just sum. + sed -e "s/F1/F1 + 1/" -e "s/F2/F2 + 2/" -e "s/F3/F3 + 3/" m.c >tmp && + mv tmp m.c && + git commit -a -m "Modify all three functions" && + git log -L:func1:m.c -L:func2:m.c --numstat --format=%s -1 >actual && + test_grep "Modify all three functions" actual && + test_grep "^2 2 m.c$" actual && + test_grep ! "^3 3 m.c$" actual +' + test_expect_success '--summary shows new file on root commit' ' git checkout parent-oids && git log -L:func2:file.c --summary --format= >actual && From 54438a56a4edb4c70459180f37af3e43e5d7e6a3 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Sat, 27 Jun 2026 17:29:00 +0000 Subject: [PATCH 039/159] diff: support --check with -L line ranges builtin_checkdiff() runs its own xdiff pass to detect whitespace errors in newly added lines. When -L is active, the check should be scoped to the tracked line ranges rather than the whole file. Reuse the line_range_filter to wrap checkdiff_consume(), the same pattern already used for patch output and diffstat. The filter forwards only in-range lines for whitespace checking. checkdiff reports the file line number of each error, which it normally learns from the hunk header via checkdiff_consume_hunk(). The filter synthesizes its own hunk headers, so give it an optional hunk callback and route checkdiff_consume_hunk() through it; this sets the post-image position before the in-range lines are replayed. Without it the reported line numbers would count from the start of the range hunk rather than the start of the file. The trailing blank-at-eof check is a second pass that scans the whole file via check_blank_at_eof(), so gate its report on the tracked ranges as well; otherwise a blank line added at end of file is reported even when it lies outside the range. Add DIFF_FORMAT_CHECKDIFF to the -L output format allowlist in setup_revisions() so that -L --check is accepted, and list --check among the supported formats in the documentation. Add tests covering that whitespace errors are reported, scoped to the tracked range, and labeled with the correct file line number, including when two errors in one range are separated by a gap that would otherwise split into multiple xdiff hunks. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- Documentation/line-range-options.adoc | 2 +- diff.c | 65 ++++++++++++++++++- revision.c | 2 +- t/t4211-line-log.sh | 92 +++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 5 deletions(-) diff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc index a111a492b49881..33b4e948815aac 100644 --- a/Documentation/line-range-options.adoc +++ b/Documentation/line-range-options.adoc @@ -10,7 +10,7 @@ You can specify this option more than once. Implies `--patch`. Patch output can be suppressed using `--no-patch`. The following non-patch diff formats are supported: `--raw`, - `--name-only`, `--name-status`, `--summary`, + `--name-only`, `--name-status`, `--summary`, `--check`, `--stat`, `--numstat`, and `--shortstat`. The stat formats count only lines within the tracked range. `--dirstat` is not supported diff --git a/diff.c b/diff.c index 026fafeb90b6fc..519c5133566032 100644 --- a/diff.c +++ b/diff.c @@ -665,6 +665,12 @@ struct emit_callback { */ struct line_range_filter { xdiff_emit_line_fn orig_line_fn; + /* + * Optional; consumers that report file line numbers (e.g. + * checkdiff) need the synthetic hunk header to set their + * post-image position before in-range lines are replayed. + */ + xdiff_emit_hunk_fn orig_hunk_fn; void *orig_cb_data; const struct range_set *ranges; /* 0-based [start, end) */ unsigned int cur_range; /* index into the range_set */ @@ -2652,6 +2658,17 @@ static void flush_range_hunk(struct line_range_filter *filter) filter->hunk.new_begin, new_count, filter->func, filter->funclen); + /* + * Inform a line-numbering consumer of the post-image position + * before replaying lines, mirroring the hunk callback xdiff + * would have issued for a non-scoped diff. + */ + if (filter->orig_hunk_fn) + filter->orig_hunk_fn(filter->orig_cb_data, + filter->hunk.old_begin, old_count, + filter->hunk.new_begin, new_count, + filter->func, filter->funclen); + filter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len); strbuf_release(&hdr); @@ -4330,11 +4347,29 @@ static void builtin_diffstat(const char *name_a, const char *name_b, diff_free_filespec_data(two); } +/* + * Is the 0-based line index within any of the tracked ranges? + * (range_set ranges are 0-based, half-open [start, end).) This is a + * one-shot query for a single line and scans; the streaming filter + * (line_range_line_fn) uses a forward cursor instead. + */ +static int idx_in_ranges(const struct range_set *ranges, long idx) +{ + unsigned int i; + + for (i = 0; i < ranges->nr; i++) + if (idx >= ranges->ranges[i].start && + idx < ranges->ranges[i].end) + return 1; + return 0; +} + static void builtin_checkdiff(const char *name_a, const char *name_b, const char *attr_path, struct diff_filespec *one, struct diff_filespec *two, - struct diff_options *o) + struct diff_options *o, + const struct range_set *line_ranges) { mmfile_t mf1, mf2; struct checkdiff_t data; @@ -4374,7 +4409,19 @@ static void builtin_checkdiff(const char *name_a, const char *name_b, memset(&xecfg, 0, sizeof(xecfg)); xecfg.ctxlen = 1; /* at least one context line */ xpp.flags = 0; - if (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk, + + if (line_ranges) { + struct line_range_filter lr_filter; + + line_range_filter_init(&lr_filter, line_ranges, + checkdiff_consume, &data); + lr_filter.orig_hunk_fn = checkdiff_consume_hunk; + + if (line_range_filter_diff(&lr_filter, &mf1, &mf2, + &xpp, &xecfg)) + die("unable to generate checkdiff for %s", + one->path); + } else if (xdi_diff_outf(&mf1, &mf2, checkdiff_consume_hunk, checkdiff_consume, &data, &xpp, &xecfg)) die("unable to generate checkdiff for %s", one->path); @@ -4387,6 +4434,17 @@ static void builtin_checkdiff(const char *name_a, const char *name_b, check_blank_at_eof(&mf1, &mf2, &ecbdata); blank_at_eof = ecbdata.blank_at_eof_in_postimage; + /* + * check_blank_at_eof() scans the whole file; with -L, + * keep the report only when its line is in a tracked + * range. The error's location is the first trailing + * blank line (blank_at_eof, 1-based; ranges 0-based), so + * we scope by that line. + */ + if (blank_at_eof && line_ranges && + !idx_in_ranges(line_ranges, blank_at_eof - 1)) + blank_at_eof = 0; + if (blank_at_eof) { static char *err; if (!err) @@ -5179,7 +5237,8 @@ static void run_checkdiff(struct diff_filepair *p, struct diff_options *o) diff_fill_oid_info(p->one, o->repo->index); diff_fill_oid_info(p->two, o->repo->index); - builtin_checkdiff(name, other, attr_path, p->one, p->two, o); + builtin_checkdiff(name, other, attr_path, p->one, p->two, o, + p->line_ranges); } void repo_diff_setup(struct repository *r, struct diff_options *options) diff --git a/revision.c b/revision.c index 2c76e15778de32..7abb287451ba14 100644 --- a/revision.c +++ b/revision.c @@ -3195,7 +3195,7 @@ int setup_revisions(int argc, const char **argv, struct rev_info *revs, struct s DIFF_FORMAT_RAW | DIFF_FORMAT_NAME | DIFF_FORMAT_NAME_STATUS | DIFF_FORMAT_SUMMARY | DIFF_FORMAT_NUMSTAT | DIFF_FORMAT_DIFFSTAT | - DIFF_FORMAT_SHORTSTAT)))) + DIFF_FORMAT_SHORTSTAT | DIFF_FORMAT_CHECKDIFF)))) die(_("-L does not support the requested diff format")); if (revs->expand_tabs_in_log < 0) diff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh index b9ca336dbc4f99..68576418f41895 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -1018,4 +1018,96 @@ test_expect_success '--summary shows new file on root commit' ' test_grep "create mode 100644 file.c" actual ' +test_expect_success 'setup for --check test' ' + git checkout --orphan check-test && + git reset --hard && + cat >check.c <<-\EOF && + void tracked() + { + return; + } + + void other() + { + return; + } + EOF + git add check.c && + test_tick && + git commit -m "add check.c" && + # Introduce trailing whitespace errors in both functions + sed "s/return;/return; /" check.c >check.c.tmp && + mv check.c.tmp check.c && + git commit -a -m "introduce trailing whitespace" +' + +test_expect_success '--check scoped to tracked range with correct file line' ' + # tracked() trailing whitespace is at check.c:3; report it with the + # real file line number, not a count from the start of the range + # hunk. other() at check.c:8 is outside the range and is excluded. + test_must_fail git log -L:tracked:check.c --check --format= >actual && + test_grep "check.c:3: trailing whitespace" actual && + test_grep ! "check.c:8:" actual +' + +test_expect_success '--check reports each of several tracked ranges' ' + # Track both functions as separate ranges. Each range is flushed + # as its own hunk, so the second error must report its real file + # line (check.c:8), not continue the numbering from the first + # range (check.c:3). + test_must_fail git log -L:tracked:check.c -L:other:check.c \ + --check --format= >actual && + test_grep "check.c:3: trailing whitespace" actual && + test_grep "check.c:8: trailing whitespace" actual +' + +test_expect_success '--check line numbers stay correct across a gap in one range' ' + git checkout --orphan check-gap && + git reset --hard && + cat >gap.c <<-\EOF && + void tracked() + { + int a = 1; + int b = 2; + int c = 3; + int d = 4; + int e = 5; + int g = 7; + return; + } + EOF + git add gap.c && + test_tick && + git commit -m "add gap.c" && + # Two trailing-whitespace errors within one tracked range, + # separated by clean lines. ctxlen is inflated to the range span, + # so they land in a single xdiff hunk with the gap as context; + # both must report their real file line number, with the context + # lines between them counted. + sed -e "s/int a = 1;/int a = 1; /" -e "s/int g = 7;/int g = 7; /" gap.c >tmp && + mv tmp gap.c && + git commit -a -m "ws errors with a gap" && + test_must_fail git log -L:tracked:gap.c --check --format= >actual && + test_grep "gap.c:3: trailing whitespace" actual && + test_grep "gap.c:8: trailing whitespace" actual +' + +test_expect_success '--check does not report blank-at-eof outside the range' ' + git checkout --orphan check-eof && + git reset --hard && + printf "void tracked()\n{\n return;\n}\n\nint tail = 1;\n" >eof.c && + git add eof.c && + test_tick && + git commit -m "add eof.c" && + # One commit introduces a trailing-whitespace error inside tracked() + # (line 3) and a blank line at end of file (line 7, outside the + # range). The blank-at-eof check scans the whole file, so it must be + # scoped: report the in-range error, not the out-of-range EOF blank. + printf "void tracked()\n{\n return; \n}\n\nint tail = 1;\n\n" >eof.c && + git commit -a -m "ws in range, blank at eof out of range" && + test_must_fail git log -L:tracked:eof.c --check --format= >actual && + test_grep "eof.c:3: trailing whitespace" actual && + test_grep ! "blank line at EOF" actual +' + test_done From f67c51df064d2b64b257bd8c17d757cc0ce1b7fc Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Sat, 27 Jun 2026 17:29:01 +0000 Subject: [PATCH 040/159] diffcore-pickaxe: scope -G to the -L tracked range git log -L scopes its diff output to the tracked range, but pickaxe (-S, -G) still runs in diffcore over the whole-file change, so -L -G selects a commit whenever the pattern appears in any added or removed line of the file, even outside the tracked range. Teach -G to honor the range. diff_grep() already runs an xdiff pass and greps the +/- lines; route that pass through the line-range filter so only the tracked range's lines are grepped. Expose the filter as diff_emit_line_ranges(), an xdi_diff_outf() that emits only the tracked range's lines, thread the filepair's line_ranges through the pickaxe callback, and pass it from pickaxe_match(). Skip scoping under textconv, whose output is not in the original file's line coordinates. -G needs only a hit/no-hit answer, so the line-number concerns the filter handles for patch and check output do not apply here. -S is left matching the whole file: it counts needle occurrences per blob rather than grepping the diff, so scoping it needs a different approach, left to a follow-up. has_changes() takes the range parameter but ignores it for now. Document the resulting -L pickaxe scoping: -G is scoped to the tracked range, while -S still matches the whole file. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- Documentation/line-range-options.adoc | 5 +- diff.c | 15 ++++++ diffcore-pickaxe.c | 37 +++++++++++--- t/t4211-line-log.sh | 72 +++++++++++++++++++++++++-- xdiff-interface.h | 13 +++++ 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc index 33b4e948815aac..d619ffa6336513 100644 --- a/Documentation/line-range-options.adoc +++ b/Documentation/line-range-options.adoc @@ -20,6 +20,9 @@ + Patch formatting options such as `--word-diff`, `--color-moved`, `--no-prefix`, and whitespace options (`-w`, `-b`) are supported, -as are pickaxe options (`-S`, `-G`) and `--diff-filter`. +as are pickaxe options (`-S`, `-G`) and `--diff-filter`. `-G` is +scoped to the tracked range; `-S` is still evaluated over the whole +file, so an `-S` query may select a commit for a change outside the +range. + include::line-range-format.adoc[] diff --git a/diff.c b/diff.c index 519c5133566032..a8f346621b9fb5 100644 --- a/diff.c +++ b/diff.c @@ -2817,6 +2817,21 @@ static int line_range_filter_diff(struct line_range_filter *filter, return ret; } +/* + * Expose the in-file line-range filter to callers outside diff.c (e.g. + * pickaxe -G); see xdiff-interface.h for the contract. + */ +int diff_emit_line_ranges(mmfile_t *one, mmfile_t *two, + const struct range_set *ranges, + xdiff_emit_line_fn line_fn, void *cb_data, + xpparam_t *xpp, xdemitconf_t *xecfg) +{ + struct line_range_filter filter; + + line_range_filter_init(&filter, ranges, line_fn, cb_data); + return line_range_filter_diff(&filter, one, two, xpp, xecfg); +} + static void pprint_rename(struct strbuf *name, const char *a, const char *b) { const char *old_name = a; diff --git a/diffcore-pickaxe.c b/diffcore-pickaxe.c index a52d569911c48e..047b2bf7ac65d5 100644 --- a/diffcore-pickaxe.c +++ b/diffcore-pickaxe.c @@ -16,7 +16,8 @@ typedef int (*pickaxe_fn)(mmfile_t *one, mmfile_t *two, struct diff_options *o, - regex_t *regexp, kwset_t kws); + regex_t *regexp, kwset_t kws, + const struct range_set *ranges); struct diffgrep_cb { regex_t *regexp; @@ -42,7 +43,8 @@ static int diffgrep_consume(void *priv, char *line, unsigned long len) static int diff_grep(mmfile_t *one, mmfile_t *two, struct diff_options *o, - regex_t *regexp, kwset_t kws UNUSED) + regex_t *regexp, kwset_t kws UNUSED, + const struct range_set *ranges) { struct diffgrep_cb ecbdata; xpparam_t xpp; @@ -50,8 +52,11 @@ static int diff_grep(mmfile_t *one, mmfile_t *two, int ret; /* - * We have both sides; need to run textual diff and see if - * the pattern appears on added/deleted lines. + * We have both sides; need to run textual diff and see if the + * pattern appears on added/deleted lines. Under -L (ranges set), + * forward only the tracked range's lines so the match is scoped. + * -G needs only a hit/no-hit answer, so the line-number bookkeeping + * the filter does for -L patch and check output is irrelevant here. */ memset(&xpp, 0, sizeof(xpp)); memset(&xecfg, 0, sizeof(xecfg)); @@ -65,8 +70,12 @@ static int diff_grep(mmfile_t *one, mmfile_t *two, * An xdiff error might be our "data->hit" from above. See the * comment for xdiff_emit_line_fn in xdiff-interface.h */ - ret = xdi_diff_outf(one, two, NULL, diffgrep_consume, - &ecbdata, &xpp, &xecfg); + if (ranges) + ret = diff_emit_line_ranges(one, two, ranges, diffgrep_consume, + &ecbdata, &xpp, &xecfg); + else + ret = xdi_diff_outf(one, two, NULL, diffgrep_consume, + &ecbdata, &xpp, &xecfg); if (ecbdata.hit) return 1; if (ret) @@ -119,8 +128,13 @@ static unsigned int contains(mmfile_t *mf, regex_t *regexp, kwset_t kws, static int has_changes(mmfile_t *one, mmfile_t *two, struct diff_options *o UNUSED, - regex_t *regexp, kwset_t kws) + regex_t *regexp, kwset_t kws, + const struct range_set *ranges UNUSED) { + /* + * -S counts needle occurrences in each whole blob. Scoping this to + * a -L range is left to a follow-up; for now -S ignores the range. + */ unsigned int c1 = one ? contains(one, regexp, kws, 0) : 0; unsigned int c2 = two ? contains(two, regexp, kws, c1 + 1) : 0; return c1 != c2; @@ -132,6 +146,7 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o, struct userdiff_driver *textconv_one = NULL; struct userdiff_driver *textconv_two = NULL; mmfile_t mf1, mf2; + const struct range_set *ranges; int ret; /* ignore unmerged */ @@ -169,7 +184,13 @@ static int pickaxe_match(struct diff_filepair *p, struct diff_options *o, mf1.size = fill_textconv(o->repo, textconv_one, p->one, &mf1.ptr); mf2.size = fill_textconv(o->repo, textconv_two, p->two, &mf2.ptr); - ret = fn(&mf1, &mf2, o, regexp, kws); + /* + * -L scopes the search to the tracked range, but the range is in + * original-file line coordinates that do not map onto textconv + * output, so search the whole file when textconv is in play. + */ + ranges = (textconv_one || textconv_two) ? NULL : p->line_ranges; + ret = fn(&mf1, &mf2, o, regexp, kws, ranges); if (textconv_one) free(mf1.ptr); diff --git a/t/t4211-line-log.sh b/t/t4211-line-log.sh index 68576418f41895..bac74d44794778 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -722,9 +722,9 @@ test_expect_success '-L with -S filters to string-count changes' ' test_expect_success '-L with -G filters to diff-text matches' ' git checkout parent-oids && git log -L:func2:file.c -G "F2 [+] 2" --format= >actual && - # -G greps the whole-file diff text, not just the tracked range; - # combined with -L, this selects commits that both touch func2 - # and have "F2 + 2" in their diff. + # -G greps the diff text, and under -L only the lines in the + # tracked range (unlike -S above, which searches the whole file); + # this selects commits whose change to func2 contains "F2 + 2". test $(grep -c "^diff --git" actual) = 1 && grep "F2 + 2" actual ' @@ -1110,4 +1110,70 @@ test_expect_success '--check does not report blank-at-eof outside the range' ' test_grep ! "blank line at EOF" actual ' +test_expect_success '-L -G is scoped to the tracked range' ' + git checkout --orphan grep-scope && + git reset --hard && + cat >gp.c <<-\EOF && + int func1() + { + return ALPHA; + } + + int func2() + { + return BETA; + } + EOF + git add gp.c && + test_tick && + git commit -m "add gp.c" && + sed -e "s/ALPHA/ALPHA2/" -e "s/BETA/BETA2/" gp.c >tmp && + mv tmp gp.c && + git commit -a -m "touch both functions" && + # The commit changes ALPHA (func1) and BETA (func2). Tracking func2, + # -G BETA matches its in-range change; -G ALPHA must not, since ALPHA + # changes only outside the tracked range. + git log -L:func2:gp.c -G BETA --format=%s >actual && + test_grep "touch both functions" actual && + git log -L:func2:gp.c -G ALPHA --format=%s >actual && + test_grep ! "touch both functions" actual +' + +test_expect_success '-L -G searches the whole file under textconv' ' + git checkout --orphan grep-textconv && + git reset --hard && + cat >tc.c <<-\EOF && + int func1() + { + return F1; + } + + int func2() + { + return F2; + } + EOF + git add tc.c && + test_tick && + git commit -m "add tc.c" && + # One commit changes func1 and func2; MAGIC lands only in the + # func2 change, outside func1. + sed -e "s/F1/F1 + 1/" -e "s/return F2/return MAGIC/" tc.c >tmp && + mv tmp tc.c && + git commit -a -m "change both funcs" && + echo "tc.c diff=tc" >.gitattributes && + + # Without a textconv driver, -G is scoped to func1, so MAGIC (only + # in the func2 change) does not select the commit. + git log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual && + test_must_be_empty actual && + + # A textconv driver makes the range (original-file line numbers) + # meaningless against the driver output, so -G falls back to the + # whole file and MAGIC now selects the commit. + git config diff.tc.textconv cat && + git log -L:func1:tc.c -G MAGIC --format=%s --no-patch >actual && + test_grep "change both funcs" actual +' + test_done diff --git a/xdiff-interface.h b/xdiff-interface.h index 51c88296ed5e68..71e5dffefb8ded 100644 --- a/xdiff-interface.h +++ b/xdiff-interface.h @@ -46,6 +46,19 @@ int xdi_diff_outf(mmfile_t *mf1, mmfile_t *mf2, xdiff_emit_line_fn line_fn, void *consume_callback_data, xpparam_t const *xpp, xdemitconf_t const *xecfg); + +struct range_set; +/* + * Like xdi_diff_outf(), but forwards only the lines within the given + * (post-image) line ranges to line_fn, as "git log -L" scopes its output. + * Returns line_fn's latched return value (so a consumer can signal a hit + * with a non-zero return), or non-zero on xdiff failure. Defined in + * diff.c (it reuses the line-range filter there). + */ +int diff_emit_line_ranges(mmfile_t *mf1, mmfile_t *mf2, + const struct range_set *ranges, + xdiff_emit_line_fn line_fn, void *cb_data, + xpparam_t *xpp, xdemitconf_t *xecfg); int read_mmfile(mmfile_t *ptr, const char *filename); void read_mmblob(mmfile_t *ptr, struct object_database *odb, const struct object_id *oid); From 636e098435dd72e23eeaa3b57f2972c2f8f66a86 Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Sun, 28 Jun 2026 09:34:36 -0700 Subject: [PATCH 041/159] SQUASH??? bare grep !??? --- t/t5331-pack-objects-stdin.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/t5331-pack-objects-stdin.sh b/t/t5331-pack-objects-stdin.sh index 443d855291aca1..0fa2f12455f01b 100755 --- a/t/t5331-pack-objects-stdin.sh +++ b/t/t5331-pack-objects-stdin.sh @@ -676,7 +676,7 @@ test_expect_success '--stdin-packs=follow-reachable with --unpacked and loose ob objects_in_packs $P >actual && # The unreachable blob should NOT be in the output. - ! grep $unreachable actual && + test_grep ! $unreachable actual && test_cmp expect actual ) From 0a700eae48a70d8e5a5177c73b7da64e00e57a45 Mon Sep 17 00:00:00 2001 From: "Bryan B. Lima" Date: Mon, 29 Jun 2026 23:02:20 -0300 Subject: [PATCH 042/159] submodule absorbgitdirs tests: use test_* helper functions Use modern helper functions from test-lib-functions.sh to provide nice error messages. Signed-off-by: Bryan B. Lima Co-authored-by: Gustavo S. Correa Signed-off-by: Gustavo S. Correa Signed-off-by: Junio C Hamano --- t/t7412-submodule-absorbgitdirs.sh | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/t/t7412-submodule-absorbgitdirs.sh b/t/t7412-submodule-absorbgitdirs.sh index 0490499573fd39..bd1c6844807085 100755 --- a/t/t7412-submodule-absorbgitdirs.sh +++ b/t/t7412-submodule-absorbgitdirs.sh @@ -34,8 +34,8 @@ test_expect_success 'absorb the git dir' ' git submodule absorbgitdirs 2>actual && test_cmp expect actual && git fsck && - test -f sub1/.git && - test -d .git/modules/sub1 && + test_path_is_file sub1/.git && + test_path_is_dir .git/modules/sub1 && git status >actual.1 && git -C sub1 rev-parse HEAD >actual.2 && test_cmp expect.1 actual.1 && @@ -47,9 +47,9 @@ test_expect_success 'absorbing does not fail for deinitialized submodules' ' git submodule deinit --all && git submodule absorbgitdirs 2>err && test_must_be_empty err && - test -d .git/modules/sub1 && - test -d sub1 && - ! test -e sub1/.git + test_path_is_dir .git/modules/sub1 && + test_path_is_dir sub1 && + test_path_is_missing sub1/.git ' test_expect_success 'setup nested submodule' ' @@ -72,8 +72,8 @@ test_expect_success 'absorb the git dir in a nested submodule' ' EOF git submodule absorbgitdirs 2>actual && test_cmp expect actual && - test -f sub1/nested/.git && - test -d .git/modules/sub1/modules/nested && + test_path_is_file sub1/nested/.git && + test_path_is_dir .git/modules/sub1/modules/nested && git status >actual.1 && git -C sub1/nested rev-parse HEAD >actual.2 && test_cmp expect.1 actual.1 && @@ -109,9 +109,9 @@ test_expect_success 'absorb the git dir in a nested submodule' ' EOF git submodule absorbgitdirs 2>actual && test_cmp expect actual && - test -f sub1/.git && - test -f sub1/nested/.git && - test -d .git/modules/sub1/modules/nested && + test_path_is_file sub1/.git && + test_path_is_file sub1/nested/.git && + test_path_is_dir .git/modules/sub1/modules/nested && git status >actual.1 && git -C sub1/nested rev-parse HEAD >actual.2 && test_cmp expect.1 actual.1 && @@ -155,7 +155,7 @@ test_expect_success 'absorbing the git dir fails for incomplete submodules' ' test_must_fail git submodule absorbgitdirs 2>actual && test_cmp expect actual && git -C sub2 fsck && - test -d sub2/.git && + test_path_is_dir sub2/.git && git status >actual && git -C sub2 rev-parse HEAD >actual.2 && test_cmp expect.1 actual.1 && From ffe5c33a4423b78e893e340db7387aec8befada7 Mon Sep 17 00:00:00 2001 From: Zephyr Yao Date: Thu, 2 Jul 2026 00:17:59 -0400 Subject: [PATCH 043/159] apply: avoid leaking abandoned git-header state When find_header() sees a "diff --git" line, it calls parse_git_diff_header() to parse the git-style extended header. That parser updates the caller's struct patch as it goes, filling in the default name, old/new names, and new/delete state. But not every "diff --git" line found while scanning is ultimately accepted as the patch header. If parse_git_diff_header() returns a length that covers only the "diff --git" line, find_header() continues scanning for another header. In that case the partially parsed git-header state must not interfere with the later traditional "---" / "+++" header. Leaving that state behind can combine incompatible metadata from the abandoned git header and the later traditional header. For example, after: diff --git a/foo b/foo --- /dev/null +++ b/foo @@ -0,0 +1 @@ +x the abandoned git header can leave an old name in the patch, while the traditional header marks the patch as creating a new file. That impossible state later trips the check_preimage() assertion that a creation patch should not have a preimage. Parse a candidate git header into a temporary patch and line number. Commit that temporary state to the real patch only when the git header is actually accepted; otherwise release it and keep scanning with the original patch state unchanged. Also reject an empty parsed default name from the "diff --git" line. An empty patch->def_name is not a valid pathname, and should not be used later as a fallback when old_name and new_name are missing. Add regression tests for both the empty default-name case and the non-empty abandoned-header case above. Co-authored-by: Mahya SamDaliri Signed-off-by: Mahya SamDaliri Co-authored-by: Haotian Zhang Signed-off-by: Haotian Zhang Co-authored-by: Martin Kellogg Signed-off-by: Martin Kellogg Signed-off-by: Zephyr Yao Signed-off-by: Junio C Hamano --- apply.c | 29 ++++++++++++++++++++++------- t/t4100-apply-stat.sh | 25 +++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apply.c b/apply.c index 4aa1694cfaa2f0..e01850255808fd 100644 --- a/apply.c +++ b/apply.c @@ -1362,6 +1362,9 @@ int parse_git_diff_header(struct strbuf *root, * the default name from the header. */ patch->def_name = git_header_name(p_value, line, len); + if (patch->def_name && !*patch->def_name) + FREE_AND_NULL(patch->def_name); + if (patch->def_name && root->len) { char *s = xstrfmt("%s%s", root->buf, patch->def_name); free(patch->def_name); @@ -1632,15 +1635,27 @@ static int find_header(struct apply_state *state, * or mode change, so we handle that specially */ if (!memcmp("diff --git ", line, 11)) { - int git_hdr_len = parse_git_diff_header(&state->root, - state->patch_input_file, - &state->linenr, - state->p_value, line, len, - size, patch); - if (git_hdr_len < 0) + struct patch git_patch = { 0 }; + int git_linenr = state->linenr; + int git_hdr_len; + + git_patch.inaccurate_eof = patch->inaccurate_eof; + git_patch.recount = patch->recount; + git_hdr_len = parse_git_diff_header(&state->root, + state->patch_input_file, + &git_linenr, + state->p_value, line, len, + size, &git_patch); + if (git_hdr_len < 0) { + release_patch(&git_patch); return -128; - if (git_hdr_len <= len) + } + if (git_hdr_len <= len) { + release_patch(&git_patch); continue; + } + *patch = git_patch; + state->linenr = git_linenr; *hdrsize = git_hdr_len; return offset; } diff --git a/t/t4100-apply-stat.sh b/t/t4100-apply-stat.sh index 8393076469e083..d3406edaddbe11 100755 --- a/t/t4100-apply-stat.sh +++ b/t/t4100-apply-stat.sh @@ -113,6 +113,31 @@ test_expect_success 'applying a patch with a missing filename reports the input' test_cmp expect err ' +test_expect_success 'empty default filename reports the input' ' + cat >empty-name.patch <<-\EOF && + diff --git "a/""b/" + + --- /dev/null + +++ " + @@ -0,0 +1 @@ + + + EOF + test_must_fail git apply empty-name.patch 2>err && + test_grep "git diff header lacks filename information" err +' + +test_expect_success 'abandoned git header does not reuse names' ' + cat >abandoned-git-header.patch <<-\EOF && + diff --git a/foo b/foo + + --- /dev/null + +++ b/foo + @@ -0,0 +1 @@ + +x + EOF + git apply --check abandoned-git-header.patch +' + test_expect_success 'applying a patch with an invalid mode reports the input' ' cat >mode.patch <<-\EOF && diff --git a/f b/f From cd6ad979ab5a81800c40e32e7e92bdc6f0ec0090 Mon Sep 17 00:00:00 2001 From: Ian Jackson Date: Mon, 6 Jul 2026 12:58:15 +0100 Subject: [PATCH 044/159] git-subtree: Bail out if we find output from Rust rewrite This is going to be forward compatible, but not backward compatible: projects are expected to adopt the new tool, but not go back to this old one. CC: Colin Stagner CC: Johannes Schindelin Signed-off-by: Ian Jackson Signed-off-by: Junio C Hamano --- contrib/subtree/git-subtree.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/contrib/subtree/git-subtree.sh b/contrib/subtree/git-subtree.sh index 791fd8260c4703..e9c7ca7cf513b1 100755 --- a/contrib/subtree/git-subtree.sh +++ b/contrib/subtree/git-subtree.sh @@ -278,6 +278,20 @@ main () { "cmd_$arg_command" "$@" } +# Usage: reject_if_v2_config REV +# +# Bails if we find .git-subtree/config. This file is used by the RIIR +# git-subtree, which can read data from this script, but which generates +# data that this script cannot cope with. So if we find that the user's +# project has already been processed with the new tool, we stop, to +# avoid generating broken output. +reject_if_v2_config () { + local config=.git-subtree/config + if git rev-parse --verify -q "$rev:$config"; then + die "fatal: tree contains $config: has been processed with new standalone (Rust) git-subtree; use that tool instead of this one. See https://codeberg.org/diziet/git-subtree https://crates.io/crates/git-subtree" + fi +} + # Usage: cache_setup cache_setup () { assert test $# = 0 @@ -846,6 +860,7 @@ process_split_commit () { # Or: cmd_add REPOSITORY REF cmd_add () { + reject_if_v2_config HEAD ensure_clean if test $# -eq 1 @@ -934,6 +949,8 @@ cmd_split () { die "fatal: you must provide exactly one revision, and optionally a repository. Got: '$*'" fi + reject_if_v2_config "$rev" + # Now validate prefix against the commit, not the working tree if ! git cat-file -e "$rev:$dir" 2>/dev/null then @@ -1034,6 +1051,7 @@ cmd_merge () { then repository="$2" fi + reject_if_v2_config HEAD ensure_clean if test -n "$arg_addmerge_squash" From 20b117c1eb19c0e08ede3c7285c991eac4c9345f Mon Sep 17 00:00:00 2001 From: Ian Jackson Date: Mon, 6 Jul 2026 12:58:16 +0100 Subject: [PATCH 045/159] git-subtree: Bail out if we find output from Rust rewrite (test) Signed-off-by: Ian Jackson Signed-off-by: Junio C Hamano --- contrib/subtree/t/t7900-subtree.sh | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/contrib/subtree/t/t7900-subtree.sh b/contrib/subtree/t/t7900-subtree.sh index 4194687cfbb9b5..e8fa6401668991 100755 --- a/contrib/subtree/t/t7900-subtree.sh +++ b/contrib/subtree/t/t7900-subtree.sh @@ -439,6 +439,24 @@ test_expect_success 'split sub dir/ with --rejoin' ' ) ' +test_expect_success 'split fail on RIIR git subtree data' ' + subtree_test_create_repo "$test_count" && + subtree_test_create_repo "$test_count/sub proj" && + test_create_commit "$test_count" main1 && + test_create_commit "$test_count/sub proj" sub1 && + ( + cd "$test_count" && + git fetch ./"sub proj" HEAD && + git subtree add --prefix="sub dir" FETCH_HEAD && + # simulate RIIR git-subtree generated data + mkdir .git-subtree && + echo "# sabotage" >.git-subtree/config && + git add .git-subtree/config && + git commit -m sabotage && + test_must_fail git subtree split -P "sub dir" HEAD + ) +' + # Tests that commits from other subtrees are not processed as # part of a split. # From 4ae2ac8153e98568cfd6efed0e94a239db113ca6 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Tue, 7 Jul 2026 21:07:25 +0200 Subject: [PATCH 046/159] replay: add helper to put entry into replayed_commits The function replay_revisions() in replay.c is rather lengthy. Extract the logic to put a commit entry into a `struct mapped_commits` into a helper function put_mapped_commit(). While at it, rename mapped_commit() to get_mapped_commit() to pair with this new function. Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- replay.c | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/replay.c b/replay.c index da531d5bc68add..b9f8fc47ceb626 100644 --- a/replay.c +++ b/replay.c @@ -250,9 +250,9 @@ static void set_up_replay_mode(struct repository *repo, strset_clear(&rinfo.positive_refs); } -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, - struct commit *commit, - struct commit *fallback) +static struct commit *get_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *fallback) { khint_t pos; if (!commit) @@ -263,6 +263,21 @@ static struct commit *mapped_commit(kh_oid_map_t *replayed_commits, return kh_value(replayed_commits, pos); } +static void put_mapped_commit(kh_oid_map_t *replayed_commits, + struct commit *commit, + struct commit *new_commit) +{ + khint_t pos; + int ret; + + pos = kh_put_oid_map(replayed_commits, commit->object.oid, &ret); + if (ret == 0) + BUG("Duplicate rewritten commit: %s", + oid_to_hex(&commit->object.oid)); + + kh_value(replayed_commits, pos) = new_commit; +} + static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, kh_oid_map_t *replayed_commits, @@ -283,7 +298,7 @@ static struct commit *pick_regular_commit(struct repository *repo, base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); } - replayed_base = mapped_commit(replayed_commits, base, onto); + replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -423,8 +438,6 @@ int replay_revisions(struct rev_info *revs, replayed_commits = kh_init_oid_map(); while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - khint_t pos; - int hr; if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); @@ -436,11 +449,7 @@ int replay_revisions(struct rev_info *revs, break; /* Record commit -> last_commit mapping */ - pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr); - if (hr == 0) - BUG("Duplicate rewritten commit: %s\n", - oid_to_hex(&commit->object.oid)); - kh_value(replayed_commits, pos) = last_commit; + put_mapped_commit(replayed_commits, commit, last_commit); /* Update any necessary branches */ if (ref) From 8a7fba28ffae126149699129c73b92fe875ee7f9 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Tue, 7 Jul 2026 21:07:26 +0200 Subject: [PATCH 047/159] replay: resolve the replay base outside pick_regular_commit() Depending on what gets passed into the function pick_regular_commit(), it decides the new base for the replayed commit. It first tries to find the replayed results of `pickme`'s parent in the `replayed_commits` map. If not found, it falls back to `onto`. When using git-replay(1) with --onto, the fallback is the revision passed in with this option, but when using --revert, the fallback is `last_commit`. It's rather confusing the base is decided partly inside pick_regular_commit() and partly by its caller. Move the base selection completely into the caller: replay_revisions(). This bundles all the logic of deciding on the base together. Also, this reduces the number of parameters of pick_regular_commit(), making its interface cleaner. This refactoring doesn't bring any behavior changes. Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- replay.c | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/replay.c b/replay.c index b9f8fc47ceb626..5aee0eafbcd507 100644 --- a/replay.c +++ b/replay.c @@ -280,25 +280,19 @@ static void put_mapped_commit(kh_oid_map_t *replayed_commits, static struct commit *pick_regular_commit(struct repository *repo, struct commit *pickme, - kh_oid_map_t *replayed_commits, - struct commit *onto, + struct commit *replayed_base, struct merge_options *merge_opt, struct merge_result *result, enum replay_mode mode, enum replay_empty_commit_action empty) { - struct commit *base, *replayed_base; struct tree *pickme_tree, *base_tree, *replayed_base_tree; - if (pickme->parents) { - base = pickme->parents->item; - base_tree = repo_get_commit_tree(repo, base); - } else { - base = NULL; + if (pickme->parents) + base_tree = repo_get_commit_tree(repo, pickme->parents->item); + else base_tree = lookup_tree(repo, repo->hash_algo->empty_tree); - } - replayed_base = get_mapped_commit(replayed_commits, base, onto); replayed_base_tree = repo_get_commit_tree(repo, replayed_base); pickme_tree = repo_get_commit_tree(repo, pickme); @@ -439,12 +433,26 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; + /* + * Decide where to replay this commit on. + * If the parent commit was replayed already, the replayed result + * can be found in `replayed_commits`. Otherwise fall back to `onto`. + * When reverting, commits are replayed in reverse order and thus + * its parent isn't replayed yet. Therefore revert commits are + * always replayed onto `last_commit`. + */ + struct commit *parent = commit->parents ? commit->parents->item : NULL; + struct commit *base = get_mapped_commit(replayed_commits, parent, onto); + + if (mode == REPLAY_MODE_REVERT) + base = last_commit; + if (commit->parents && commit->parents->next) die(_("replaying merge commits is not supported yet!")); - last_commit = pick_regular_commit(revs->repo, commit, replayed_commits, - mode == REPLAY_MODE_REVERT ? last_commit : onto, - &merge_opt, &result, mode, opts->empty); + last_commit = pick_regular_commit(revs->repo, commit, base, + &merge_opt, &result, + mode, opts->empty); if (!last_commit) break; From 5bf15ad0c0fdeac41510818d214429d3888a7397 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Tue, 7 Jul 2026 21:07:27 +0200 Subject: [PATCH 048/159] replay: offer an option to linearize the commit topology One of the stated goals of git-replay(1) is to allow implementing the git-rebase(1) functionality on the server side. The default mode of git-rebase(1) is to act as if `--no-rebase-merges` was given. This mode drops merge commits instead of replaying them, and linearizes the history into a sequence of regular (single-parent) commits. Add option `--linearize` to git-replay(1) to do the same. Each replayed commit is stacked on top of the previously replayed one. When a merge is encountered, the commits reachable from all of its sides are replayed into the single line and the merge itself is dropped. If a ref was pointing to a merge commit, that ref is updated to the merge's last replayed ancestor. git-replay(1) accepts multiple revision ranges, for example: $ git replay --onto main topic1 topic2 Without `--linearize` this replays 'topic1' and 'topic2' onto 'main' independently and updates both refs. With `--linearize` the whole set is flattened into one line: the ranges are stacked on top of each other rather than replayed side by side, so both refs end up pointing at different points along that single history. Replaying all revision ranges into one single linear history is intentional and it's the only way to ensure predictable results. A user who wants to linearize ranges independently is advised to use separate git-replay(1) invocations. Linearizing is a distinct operation, and flattening merge commits is just one aspect of that. Recreating merges would be a separate mode, so rather than mirror git-rebase(1)'s `--rebase-merges[=]` interface, git-replay(1) uses its own `--linearize` option. Based-on-patches-by: Johannes Schindelin Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- Documentation/git-replay.adoc | 19 ++++- builtin/replay.c | 4 +- replay.c | 54 ++++++++----- replay.h | 5 ++ t/t3650-replay-basics.sh | 140 +++++++++++++++++++++++++++++++++- 5 files changed, 199 insertions(+), 23 deletions(-) diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc index a32f72aead3750..98e20c1c6eeca0 100644 --- a/Documentation/git-replay.adoc +++ b/Documentation/git-replay.adoc @@ -10,7 +10,7 @@ SYNOPSIS -------- [verse] (EXPERIMENTAL!) 'git replay' ([--contained] --onto= | --advance= | --revert=) - [--ref=] [--ref-action=] + [--ref=] [--ref-action=] [--linearize] DESCRIPTION ----------- @@ -88,6 +88,23 @@ incompatible with `--contained` (which is a modifier for `--onto` only). + The default mode can be configured via the `replay.refAction` configuration variable. +--linearize:: + In this mode, each replayed commit is stacked on top of the + previously replayed one, so all replayed commits are flattened into + a single linear history. ++ +When a merge commit is encountered, the behavior of git-rebase(1)'s +option `--no-rebase-merges` is imitated. All commits in the range +reachable from the merge commit are replayed into a linear history, and +the merge commit itself is dropped. A ref that pointed to a merge commit +is updated to the merge's last replayed ancestor. ++ +This flattens the `` as a whole. When multiple revision +ranges are given they are stacked on top of each other into one linear +history. Each of their refs is updated to point to its position in that +history. To linearize ranges separately, replay them in separate `git +replay` invocations. + :: Range of commits to replay; see "Specifying Ranges" in linkgit:git-rev-parse[1]. In `--advance=` or diff --git a/builtin/replay.c b/builtin/replay.c index 39e3a86f6c10ab..5e6ff4191ae486 100644 --- a/builtin/replay.c +++ b/builtin/replay.c @@ -85,7 +85,7 @@ int cmd_replay(int argc, const char *const replay_usage[] = { N_("(EXPERIMENTAL!) git replay " "([--contained] --onto= | --advance= | --revert=)\n" - "[--ref=] [--ref-action=] "), + "[--ref=] [--ref-action=] [--linearize] "), NULL }; struct option replay_options[] = { @@ -111,6 +111,8 @@ int cmd_replay(int argc, N_("mode"), N_("control ref update behavior (update|print)"), PARSE_OPT_NONEG), + OPT_BOOL(0, "linearize", &opts.linearize, + N_("drop merge commits, replaying only non-merge commits")), OPT_END() }; diff --git a/replay.c b/replay.c index 5aee0eafbcd507..bd1f3bb8985184 100644 --- a/replay.c +++ b/replay.c @@ -433,26 +433,40 @@ int replay_revisions(struct rev_info *revs, while ((commit = get_revision(revs))) { const struct name_decoration *decoration; - /* - * Decide where to replay this commit on. - * If the parent commit was replayed already, the replayed result - * can be found in `replayed_commits`. Otherwise fall back to `onto`. - * When reverting, commits are replayed in reverse order and thus - * its parent isn't replayed yet. Therefore revert commits are - * always replayed onto `last_commit`. - */ - struct commit *parent = commit->parents ? commit->parents->item : NULL; - struct commit *base = get_mapped_commit(replayed_commits, parent, onto); - - if (mode == REPLAY_MODE_REVERT) - base = last_commit; - - if (commit->parents && commit->parents->next) - die(_("replaying merge commits is not supported yet!")); - - last_commit = pick_regular_commit(revs->repo, commit, base, - &merge_opt, &result, - mode, opts->empty); + if (commit->parents && commit->parents->next) { + if (!opts->linearize) + die(_("replaying merge commits is not supported yet!")); + /* + * Drop the merge commit: do not pick it, leave + * `last_commit` unchanged, and fall through to the + * rest of the loop. As a result: + * - refs pointing to the merge commit will be updated + * to `last_commit`. + * - the next replayed commit uses `last_commit` as its + * `base`. + */ + } else { + /* + * Decide where to replay this commit onto. + * If the parent commit was replayed already, the replayed result + * can be found in `replayed_commits`. Otherwise fall back to `onto`. + * When reverting, commits are replayed in reverse order and thus + * its parent isn't replayed yet. Therefore revert commits are + * always replayed onto `last_commit`. + * Also when opts->linearize is true, set the base to + * `last_commit` to create a single linear history. + */ + struct commit *parent = commit->parents ? commit->parents->item : NULL; + struct commit *base = get_mapped_commit(replayed_commits, parent, onto); + + if (opts->linearize || mode == REPLAY_MODE_REVERT) + base = last_commit; + + last_commit = pick_regular_commit(revs->repo, commit, base, + &merge_opt, &result, + mode, opts->empty); + } + if (!last_commit) break; diff --git a/replay.h b/replay.h index 1851a07705ab03..07e6fdcca39693 100644 --- a/replay.h +++ b/replay.h @@ -62,6 +62,11 @@ struct replay_revisions_options { * Defaults to REPLAY_EMPTY_COMMIT_DROP. */ enum replay_empty_commit_action empty; + + /* + * Whether to linearize the commits (i.e. drop merge commits). + */ + int linearize; }; /* This struct is used as an out-parameter by `replay_revisions()`. */ diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh index 3353bc4a4dc6ed..4d3d442e8aad5e 100755 --- a/t/t3650-replay-basics.sh +++ b/t/t3650-replay-basics.sh @@ -52,8 +52,19 @@ test_expect_success 'setup' ' test_merge P O --no-ff && git switch main && + git switch --orphan unrelated && + test_commit unrelated-root && + git switch -c conflict B && - test_commit C.conflict C.t conflict + test_commit C.conflict C.t conflict && + git branch -D unrelated && + + git switch -c divergent-x main && + test_commit X && + git switch -c divergent-y main && + test_commit Y && + git switch divergent-x && + test_merge Z divergent-y --no-ff ' test_expect_success 'setup bare' ' @@ -565,4 +576,131 @@ test_expect_success '--onto with --ref rejects multiple revision ranges' ' test_grep "cannot be used with multiple revision ranges" err ' +test_expect_success 'replay to rebase merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual +' + +test_expect_success 'replay to rebase merge commit with --linearize down to the root commit' ' + git replay --ref-action=print --linearize \ + --onto unrelated-root topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J I B A unrelated-root >expect && + test_cmp expect actual +' + +test_expect_success 'replay to cherry-pick merge commit with --linearize' ' + git replay --ref-action=print --linearize \ + --advance main I..topic-with-merge >result && + + test_line_count = 1 result && + + git log --format=%s $(cut -f 3 -d " " result) >actual && + test_write_lines O N J M L B A >expect && + test_cmp expect actual && + + printf "update refs/heads/main " >expect && + printf "%s " $(cut -f 3 -d " " result) >>expect && + git rev-parse main >>expect && + test_cmp expect result +' + +test_expect_success 'replay --linearize produces the same patches' ' + git replay --ref-action=print --linearize \ + --onto main I..topic-with-merge >result && + + test_line_count = 1 result && + tip=$(cut -f 3 -d " " result) && + + # range-diff does not care about the dropped merge, + # so the original commits (I..topic-with-merge) + # and the replayed chain (main..tip) must produce identical patches. + git range-diff I..topic-with-merge main..$tip >out && + test_file_not_empty out && + test_grep ! -v "=" out && + + git log --oneline main..$tip >out && + test_line_count = 3 out +' + +test_expect_success 'replay with --linearize rebase multiple divergent branches into a single line' ' + git replay --ref-action=print --linearize \ + --onto main ^B topic2 topic3 topic4 >result && + + test_line_count = 3 result && + cut -f 3 -d " " result >new-branch-tips && + + >expect && + for i in 2 3 4 + do + printf "update refs/heads/topic$i " >>expect && + printf "%s " $(grep topic$i result | cut -f 3 -d " ") >>expect && + git rev-parse topic$i >>expect || return 1 + done && + + test_cmp expect result && + + test_write_lines E D C M L B A >expect2 && + test_write_lines H G F E D C M L B A >expect3 && + test_write_lines J I H G F E D C M L B A >expect4 && + + for i in 2 3 4 + do + git log --format=%s $(grep topic$i result | cut -f 3 -d " ") >actual && + test_cmp expect$i actual || return 1 + done +' + +test_expect_success 'replay with --linearize of a divergent merge keeps both sides' ' + git replay --ref-action=print --linearize \ + --onto main main..divergent-x >result && + test_line_count = 1 result && + tip=$(cut -f 3 -d " " result) && + + # The merge Z is dropped, but both X and Y are linearized onto main; + # neither side is lost. + git log --format=%s main..$tip >actual && + test_write_lines Y X >expect && + test_cmp expect actual +' + +test_expect_success '--linearize with --contained updates contained refs' ' + git replay --ref-action=print --linearize --contained \ + --onto main ^B topic-with-merge >result && + + test_line_count = 2 result && + + git log --format=%s $(head -n 1 result | cut -f 3 -d " ") >actual && + test_write_lines J I M L B A >expect && + test_cmp expect actual && + + git log --format=%s $(tail -n 1 result | cut -f 3 -d " ") >actual && + test_write_lines O N J I M L B A >expect && + test_cmp expect actual +' + +test_expect_success 'replay --revert with --linearize reverts a range containing a merge' ' + git replay --ref-action=print --revert=divergent-x --linearize \ + main..divergent-x >result && + test_line_count = 1 result && + tip=$(cut -f 3 -d " " result) && + + git log --format=%s $tip >actual && + test_write_lines \ + "Revert \"X\"" "Revert \"Y\"" Z Y X M L B A >expect && + test_cmp expect actual && + + test_must_fail git cat-file -e $tip:X.t && + test_must_fail git cat-file -e $tip:Y.t +' + test_done From 21f13207864a79c1f6aa416ad3ccbac19f2a6dc4 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:28 +0000 Subject: [PATCH 049/159] diff-delta: widen `struct delta_index`' size fields to `size_t` Preparation for widening the delta-encoding API to `size_t` in subsequent commits, which is what lets pack-objects drop the `cast_size_t_to_ulong()` shims that 606c192380 (odb, packfile: use size_t for streaming object sizes, 2026-05-08) had to leave behind in `get_delta()` and `try_delta()` because their downstream consumers were still narrow. The struct is private to diff-delta.c, so widening its fields in isolation is a no-op at runtime: the values stored continue to fit in 32 bits on Windows because the public API around it still truncates. Splitting it out keeps the API-change commit focused on caller updates. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- diff-delta.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/diff-delta.c b/diff-delta.c index 43c339f01061ca..b6b65d76078030 100644 --- a/diff-delta.c +++ b/diff-delta.c @@ -125,9 +125,9 @@ struct unpacked_index_entry { }; struct delta_index { - unsigned long memsize; + size_t memsize; const void *src_buf; - unsigned long src_size; + size_t src_size; unsigned int hash_mask; struct index_entry *hash[FLEX_ARRAY]; }; @@ -140,7 +140,7 @@ struct delta_index * create_delta_index(const void *buf, unsigned long bufsize) struct unpacked_index_entry *entry, **hash; struct index_entry *packed_entry, **packed_hash; void *mem; - unsigned long memsize; + size_t memsize; if (!buf || !bufsize) return NULL; From 9b65a9103b79657f2f779a1197372791ca884204 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:29 +0000 Subject: [PATCH 050/159] delta: widen `create_delta_index()` parameter to `size_t` The sole caller (`try_delta()` in builtin/pack-objects.c) passes an `unsigned long`, which promotes safely, so no caller fixups are needed. Splitting it out keeps the `diff_delta()`/`create_delta()` widening, which does ripple to several callers, in its own commit. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- delta.h | 2 +- diff-delta.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/delta.h b/delta.h index eb5c6d2fdb9c51..a19586d789d89b 100644 --- a/delta.h +++ b/delta.h @@ -14,7 +14,7 @@ struct delta_index; * using free_delta_index(). */ struct delta_index * -create_delta_index(const void *buf, unsigned long bufsize); +create_delta_index(const void *buf, size_t bufsize); /* * free_delta_index: free the index created by create_delta_index() diff --git a/diff-delta.c b/diff-delta.c index b6b65d76078030..c93ac425940eae 100644 --- a/diff-delta.c +++ b/diff-delta.c @@ -132,7 +132,7 @@ struct delta_index { struct index_entry *hash[FLEX_ARRAY]; }; -struct delta_index * create_delta_index(const void *buf, unsigned long bufsize) +struct delta_index * create_delta_index(const void *buf, size_t bufsize) { unsigned int i, hsize, hmask, entries, prev_val, *hash_count; const unsigned char *data, *buffer = buf; From eb1d699586f4288780c6748e21e12b45369376ef Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:30 +0000 Subject: [PATCH 051/159] pack-objects: widen delta-cache accounting to `size_t` These three are a single accounting tuple (the globals tracking cumulative cached-delta bytes, plus the helper that compares them against an incoming delta size) and are latently 32-bit on Windows where `unsigned long` != `size_t`: a pack with many large cached deltas could wrap silently. The widening is internally consistent on its own: the additions and subtractions against delta_cache_size already come from `size_t` sources (`DELTA_SIZE()` returns `size_t`), and `delta_cacheable()`'s sole caller in `try_delta()` still passes `unsigned long`, which promotes. Prerequisite for dropping `try_delta()`'s `cast_size_t_to_ulong()` shims, which becomes possible once 1create_delta()` and `diff_delta()` are widened in a later commit. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- builtin/pack-objects.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index 27048bbb4dd3c9..2c525cc1b2948c 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -260,8 +260,8 @@ static int exclude_promisor_objects_best_effort; static int use_delta_islands; -static unsigned long delta_cache_size = 0; -static unsigned long max_delta_cache_size = DEFAULT_DELTA_CACHE_SIZE; +static size_t delta_cache_size = 0; +static size_t max_delta_cache_size = DEFAULT_DELTA_CACHE_SIZE; static unsigned long cache_max_small_delta_size = 1000; static unsigned long window_memory_limit = 0; @@ -2687,8 +2687,8 @@ struct unpacked { unsigned depth; }; -static int delta_cacheable(unsigned long src_size, unsigned long trg_size, - unsigned long delta_size) +static int delta_cacheable(size_t src_size, size_t trg_size, + size_t delta_size) { if (max_delta_cache_size && delta_cache_size + delta_size > max_delta_cache_size) return 0; From 445aa3cd593d579c03ff45cd336e19e3e2638f0a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:31 +0000 Subject: [PATCH 052/159] pack-objects: widen `free_unpacked()` return to `size_t` `free_unpacked()` sums two byte counts: `sizeof_delta_index()` and `SIZE(n->entry)`. The latter has been `size_t` since the prior topic "More work supporting objects larger than 4GB on Windows" widened `SIZE()`/`oe_size()` to `size_t`, so accumulating it into an `unsigned long` return was a silent Windows-only truncation on a packing run with many large objects. The sole caller, `find_deltas()`, still holds its own `mem_usage` in an `unsigned long` for now, and therefore still truncates silently. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- builtin/pack-objects.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index 2c525cc1b2948c..a44e61ab0f8be2 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -2955,9 +2955,9 @@ static unsigned int check_delta_limit(struct object_entry *me, unsigned int n) return m; } -static unsigned long free_unpacked(struct unpacked *n) +static size_t free_unpacked(struct unpacked *n) { - unsigned long freed_mem = sizeof_delta_index(n->index); + size_t freed_mem = sizeof_delta_index(n->index); free_delta_index(n->index); n->index = NULL; if (n->data) { From 161889b4c8d04cd91dd8524ffe8c79c3575d930b Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:32 +0000 Subject: [PATCH 053/159] pack-objects: widen `mem_usage` and `try_delta()`'s out-param to `size_t` The pair must move together because `find_deltas()` passes `&mem_usage` to `try_delta()`: widening either alone breaks the type match. `mem_usage` accumulates per-object byte counts already computed in `size_t` (`SIZE()` and `sizeof_delta_index()` reach here through `free_unpacked()`, now `size_t`), and was the last 32-bit-on-Windows narrowing point in the delta-window memory accounting chain. With this commit, that chain uses `size_t` consistently except for `sizeof_delta_index()`'s still-narrow return, whose value is bounded by `create_delta_index()`'s entries cap. `window_memory_limit` (config-driven via `git_config_ulong()`) stays `unsigned long`: it is only compared against `mem_usage` and promotes. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- builtin/pack-objects.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index a44e61ab0f8be2..d0ccf8a62d4e5f 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -2787,7 +2787,7 @@ size_t oe_get_size_slow(struct packing_data *pack, } static int try_delta(struct unpacked *trg, struct unpacked *src, - unsigned max_depth, unsigned long *mem_usage) + unsigned max_depth, size_t *mem_usage) { struct object_entry *trg_entry = trg->entry; struct object_entry *src_entry = src->entry; @@ -2974,7 +2974,7 @@ static void find_deltas(struct object_entry **list, unsigned *list_size, { uint32_t i, idx = 0, count = 0; struct unpacked *array; - unsigned long mem_usage = 0; + size_t mem_usage = 0; CALLOC_ARRAY(array, window); From 6f8241eb18d810f51f1d388ae207208b17a9e79c Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:33 +0000 Subject: [PATCH 054/159] delta: widen `create_delta()` and `diff_delta()` to `size_t` Last stop in the delta-encoding API widening for >4 GiB blobs on Windows: with `create_delta_index()` done in the prior commit and `create_delta()`/`diff_delta()` finished here, every byte count that crosses delta.h is now `size_t`. The struct fields they store into have been `size_t` since the diff-delta struct widening. The API change must move with all callers in the same commit (the build only passes when every `&delta_size` matches the new `size_t*`). Caller updates are kept minimal: * builtin/pack-objects.c `get_delta()` and `try_delta()`: widen only the local `delta_size` variable; the surrounding unsigned-long locals and their `cast_size_t_to_ulong()` shims are out of scope here and will be cleaned up in their own commits. * builtin/fast-import.c, diff.c, t/helper/test-pack-deltas.c: keep the local unsigned-long delta size (each feeds a still- unsigned-long downstream consumer: zlib's `avail_in`, `deflate_it()`, the test helper's own `do_compress()`), and bridge via a temporary `size_t` plus `cast_size_t_to_ulong()`. The new casts are paid back in later topics that widen those consumers. * t/helper/test-delta.c: widen the local outright (no downstream consumer beyond the test's own `out_size`, which is already `size_t`). Note that GCC struggles a bit to figure out that `deltalen` is always initialized before it is used; To help it along, we initialize it to 0. This work-around will go away in a later patch series when `deltalen` can be widened to `size_t`. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- builtin/fast-import.c | 6 ++++-- builtin/pack-objects.c | 6 ++++-- delta.h | 10 +++++----- diff-delta.c | 4 ++-- diff.c | 4 +++- t/helper/test-delta.c | 2 +- t/helper/test-pack-deltas.c | 5 +++-- 7 files changed, 22 insertions(+), 15 deletions(-) diff --git a/builtin/fast-import.c b/builtin/fast-import.c index aa656c5195d366..1c6e5366c2ce06 100644 --- a/builtin/fast-import.c +++ b/builtin/fast-import.c @@ -962,7 +962,7 @@ static int store_object( struct object_entry *e; unsigned char hdr[96]; struct object_id oid; - unsigned long hdrlen, deltalen; + unsigned long hdrlen, deltalen = 0; struct git_hash_ctx c; git_zstream s; struct repo_config_values *cfg = repo_config_values(the_repository); @@ -998,11 +998,13 @@ static int store_object( if (last && last->data.len && last->data.buf && last->depth < max_depth && dat->len > the_hash_algo->rawsz) { + size_t deltalen_st; delta_count_attempts_by_type[type]++; delta = diff_delta(last->data.buf, last->data.len, dat->buf, dat->len, - &deltalen, dat->len - the_hash_algo->rawsz); + &deltalen_st, dat->len - the_hash_algo->rawsz); + deltalen = cast_size_t_to_ulong(deltalen_st); } else delta = NULL; diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index d0ccf8a62d4e5f..f739fee7532715 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -353,7 +353,8 @@ static void index_commit_for_bitmap(struct commit *commit) static void *get_delta(struct object_entry *entry) { - unsigned long size, base_size, delta_size; + unsigned long size, base_size; + size_t delta_size; void *buf, *base_buf, *delta_buf; enum object_type type; size_t size_st = 0, base_size_st = 0; @@ -2791,7 +2792,8 @@ static int try_delta(struct unpacked *trg, struct unpacked *src, { struct object_entry *trg_entry = trg->entry; struct object_entry *src_entry = src->entry; - unsigned long trg_size, src_size, delta_size, sizediff, max_size, sz; + unsigned long trg_size, src_size, sizediff, max_size, sz; + size_t delta_size; unsigned ref_depth; enum object_type type; void *delta_buf; diff --git a/delta.h b/delta.h index a19586d789d89b..59ccaaa0e0f625 100644 --- a/delta.h +++ b/delta.h @@ -42,8 +42,8 @@ unsigned long sizeof_delta_index(struct delta_index *index); */ void * create_delta(const struct delta_index *index, - const void *buf, unsigned long bufsize, - unsigned long *delta_size, unsigned long max_delta_size); + const void *buf, size_t bufsize, + size_t *delta_size, size_t max_delta_size); /* * diff_delta: create a delta from source buffer to target buffer @@ -54,9 +54,9 @@ create_delta(const struct delta_index *index, * updated with its size. The returned buffer must be freed by the caller. */ static inline void * -diff_delta(const void *src_buf, unsigned long src_bufsize, - const void *trg_buf, unsigned long trg_bufsize, - unsigned long *delta_size, unsigned long max_delta_size) +diff_delta(const void *src_buf, size_t src_bufsize, + const void *trg_buf, size_t trg_bufsize, + size_t *delta_size, size_t max_delta_size) { struct delta_index *index = create_delta_index(src_buf, src_bufsize); if (index) { diff --git a/diff-delta.c b/diff-delta.c index c93ac425940eae..15210e8381e1c7 100644 --- a/diff-delta.c +++ b/diff-delta.c @@ -318,8 +318,8 @@ unsigned long sizeof_delta_index(struct delta_index *index) void * create_delta(const struct delta_index *index, - const void *trg_buf, unsigned long trg_size, - unsigned long *delta_size, unsigned long max_size) + const void *trg_buf, size_t trg_size, + size_t *delta_size, size_t max_size) { unsigned int i, val; off_t outpos, moff; diff --git a/diff.c b/diff.c index 2a9d0d86871139..69eb2f76a4e51c 100644 --- a/diff.c +++ b/diff.c @@ -3647,9 +3647,11 @@ static void emit_binary_diff_body(struct diff_options *o, delta = NULL; deflated = deflate_it(two->ptr, two->size, &deflate_size); if (one->size && two->size) { + size_t delta_size_st = 0; delta = diff_delta(one->ptr, one->size, two->ptr, two->size, - &delta_size, deflate_size); + &delta_size_st, deflate_size); + delta_size = cast_size_t_to_ulong(delta_size_st); if (delta) { void *to_free = delta; orig_size = delta_size; diff --git a/t/helper/test-delta.c b/t/helper/test-delta.c index 8223a60229229e..d807afef751b48 100644 --- a/t/helper/test-delta.c +++ b/t/helper/test-delta.c @@ -32,7 +32,7 @@ int cmd__delta(int argc, const char **argv) die_errno("unable to read '%s'", argv[3]); if (argv[1][1] == 'd') { - unsigned long delta_size; + size_t delta_size; out_buf = diff_delta(from.buf, from.len, data.buf, data.len, &delta_size, 0); diff --git a/t/helper/test-pack-deltas.c b/t/helper/test-pack-deltas.c index 840797cf0dbabb..5e0f7268427003 100644 --- a/t/helper/test-pack-deltas.c +++ b/t/helper/test-pack-deltas.c @@ -49,7 +49,7 @@ static void write_ref_delta(struct hashfile *f, { unsigned char header[MAX_PACK_OBJECT_HEADER]; unsigned long delta_size, compressed_size, hdrlen; - size_t size, base_size; + size_t size, base_size, delta_size_st = 0; enum object_type type; void *base_buf, *delta_buf; void *buf = odb_read_object(the_repository->objects, @@ -65,7 +65,8 @@ static void write_ref_delta(struct hashfile *f, die("unable to read %s", oid_to_hex(base)); delta_buf = diff_delta(base_buf, base_size, - buf, size, &delta_size, 0); + buf, size, &delta_size_st, 0); + delta_size = cast_size_t_to_ulong(delta_size_st); compressed_size = do_compress(&delta_buf, delta_size); From 9647dcedd7749e675d585d32d7ea34f2b8c90a38 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:34 +0000 Subject: [PATCH 055/159] packfile, git-zlib: widen `use_pack()` and zstream avail fields to `size_t` Bundling the two widenings: four call sites pass `&stream.avail_in` directly to `use_pack()`, and widening either type fencepost alone would force a bridge variable at each. Doing both together is the simpler end state and is the prerequisite for the `do_compress()` widening in the next commit, which is what lets `write_no_reuse_object()` lose its last `cast_size_t_to_ulong()` shim. The unsigned-long locals widened at the other `use_pack()` callers (avail / remaining / left) hold pack-window sizes bounded by `core.packedGitWindowSize`, so the change is type consistency rather than a new >4GB capability. `git_zstream.avail_in`/`avail_out` likewise reach zlib's `uInt` fields only after `zlib_buf_cap()`'s 1 GiB cap, so the wrapper already accepted `size_t`-shaped inputs in practice. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- builtin/pack-objects.c | 8 ++++---- git-zlib.h | 4 ++-- pack-check.c | 4 ++-- packfile.c | 4 ++-- packfile.h | 3 ++- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index f739fee7532715..bef1305ce47d00 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -488,7 +488,7 @@ static void copy_pack_data(struct hashfile *f, off_t len) { unsigned char *in; - unsigned long avail; + size_t avail; while (len) { in = use_pack(p, w_curs, offset, &avail); @@ -2260,7 +2260,7 @@ static void check_object(struct object_entry *entry, uint32_t object_index) struct object_id base_ref; struct object_entry *base_entry; unsigned long used, used_0; - unsigned long avail; + size_t avail; off_t ofs; unsigned char *buf, c; enum object_type type; @@ -2756,8 +2756,8 @@ size_t oe_get_size_slow(struct packing_data *pack, struct pack_window *w_curs; unsigned char *buf; enum object_type type; - unsigned long used, avail; - size_t size; + unsigned long used; + size_t avail, size; if (e->type_ != OBJ_OFS_DELTA && e->type_ != OBJ_REF_DELTA) { size_t sz; diff --git a/git-zlib.h b/git-zlib.h index 44380e8ad38305..0b24b15bd05f7f 100644 --- a/git-zlib.h +++ b/git-zlib.h @@ -5,8 +5,8 @@ typedef struct git_zstream { struct z_stream_s z; - unsigned long avail_in; - unsigned long avail_out; + size_t avail_in; + size_t avail_out; size_t total_in; size_t total_out; unsigned char *next_in; diff --git a/pack-check.c b/pack-check.c index 5adfb3f2726fb3..befb860472f418 100644 --- a/pack-check.c +++ b/pack-check.c @@ -34,7 +34,7 @@ int check_pack_crc(struct packed_git *p, struct pack_window **w_curs, uint32_t data_crc = crc32(0, NULL, 0); do { - unsigned long avail; + size_t avail; void *data = use_pack(p, w_curs, offset, &avail); if (avail > len) avail = len; @@ -71,7 +71,7 @@ static int verify_packfile(struct repository *r, r->hash_algo->init_fn(&ctx); do { - unsigned long remaining; + size_t remaining; unsigned char *in = use_pack(p, w_curs, offset, &remaining); offset += remaining; if (!pack_sig_ofs) diff --git a/packfile.c b/packfile.c index 78c389e6f35e22..7fbe47ca18f86e 100644 --- a/packfile.c +++ b/packfile.c @@ -704,7 +704,7 @@ static int in_window(struct repository *r, struct pack_window *win, unsigned char *use_pack(struct packed_git *p, struct pack_window **w_cursor, off_t offset, - unsigned long *left) + size_t *left) { struct pack_window *win = *w_cursor; @@ -1228,7 +1228,7 @@ int unpack_object_header(struct packed_git *p, size_t *sizep) { unsigned char *base; - unsigned long left; + size_t left; unsigned long used; enum object_type type; diff --git a/packfile.h b/packfile.h index defb6f442cca09..820d247d054645 100644 --- a/packfile.h +++ b/packfile.h @@ -402,7 +402,8 @@ uint32_t get_pack_fanout(struct packed_git *p, uint32_t value); struct object_database; -unsigned char *use_pack(struct packed_git *, struct pack_window **, off_t, unsigned long *); +unsigned char *use_pack(struct packed_git *, struct pack_window **, off_t, + size_t *); void close_pack_windows(struct packed_git *); void close_pack(struct packed_git *); void unuse_pack(struct pack_window **); From 075b33feee5236696cb8407eff4fe2d2e8012a4a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:35 +0000 Subject: [PATCH 056/159] archive-zip: widen `zlib_deflate_raw()`'s maxsize local to `size_t` Prep for the upcoming `git_deflate_bound()` widening to `size_t`: the local that catches its return needs to be `size_t` too, otherwise the widening would introduce a silent Windows narrowing here. No semantic effect with the current unsigned-long-returning `git_deflate_bound()` (`size_t == unsigned long` on this caller's platforms today). Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- archive-zip.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/archive-zip.c b/archive-zip.c index 97ea8d60d6187b..a487d4c0413355 100644 --- a/archive-zip.c +++ b/archive-zip.c @@ -206,7 +206,7 @@ static void *zlib_deflate_raw(void *data, unsigned long size, unsigned long *compressed_size) { git_zstream stream; - unsigned long maxsize; + size_t maxsize; void *buffer; int result; From a741ef9611e4ed9111a2e18f8f10d9a3ffa19f90 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:36 +0000 Subject: [PATCH 057/159] diff: widen `deflate_it()`'s bound local from int to `size_t` Fixes a pre-existing silent narrowing from `git_deflate_bound()`'s `unsigned long` return into an `int` local: anything past 2 GiB has always wrapped negative here and then been re-extended to `size_t` inside `xmalloc()`. Also prep for the upcoming `git_deflate_bound()` widening to `size_t`, which would extend the narrowing further if `bound` stayed `int`. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- diff.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/diff.c b/diff.c index 69eb2f76a4e51c..c14f69719bd852 100644 --- a/diff.c +++ b/diff.c @@ -3609,7 +3609,7 @@ static unsigned char *deflate_it(char *data, unsigned long size, unsigned long *result_size) { - int bound; + size_t bound; unsigned char *deflated; git_zstream stream; struct repo_config_values *cfg = repo_config_values(the_repository); From a37b058932cdf0dc78d7c7d0d320386566069208 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:37 +0000 Subject: [PATCH 058/159] http-push: widen `start_put()`'s size local from `ssize_t` to `size_t` The local is initialised from `git_deflate_bound()` (an unsigned upper bound on the deflated output, never negative) and used in exactly three places: the initialising assignment, `strbuf_grow(buf, size)` whose parameter is already `size_t`, and `stream.avail_out` which became `size_t` in the prior commit. There is no comparison against zero or a negative value, no subtraction, no arithmetic that depends on signedness, and no path that would assign a signed quantity to it. The original `ssize_t` was the wrong type to begin with: a `git_deflate_bound()` result above `SSIZE_MAX` would have wrapped negative on assignment and then implicitly re-extended to a huge `size_t` at `strbuf_grow()`/`stream.avail_out`, requesting an absurd allocation. That is not a real-world concern for the object sizes http-push pushes today, but it is also the reason the type needs to move to `size_t` before `git_deflate_bound()` itself is widened. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- http-push.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/http-push.c b/http-push.c index 3c23cbba27a9ec..2a07d1425961fd 100644 --- a/http-push.c +++ b/http-push.c @@ -367,7 +367,7 @@ static void start_put(struct transfer_request *request) void *unpacked; size_t len; int hdrlen; - ssize_t size; + size_t size; git_zstream stream; struct repo_config_values *cfg = repo_config_values(the_repository); From 620e7dc2a5511f486b49957e211c6c569270225a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:38 +0000 Subject: [PATCH 059/159] t/helper/test-pack-deltas: widen `do_compress()`'s maxsize local to `size_t` Prep for the upcoming `git_deflate_bound()` widening to `size_t`. The local is only ever the return value of `git_deflate_bound()` and the `xmalloc()`/`stream.avail_out` sizes derived from it; widening it has no semantic effect today. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- t/helper/test-pack-deltas.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/helper/test-pack-deltas.c b/t/helper/test-pack-deltas.c index 5e0f7268427003..959705fecaf144 100644 --- a/t/helper/test-pack-deltas.c +++ b/t/helper/test-pack-deltas.c @@ -22,7 +22,7 @@ static unsigned long do_compress(void **pptr, unsigned long size) { git_zstream stream; void *in, *out; - unsigned long maxsize; + size_t maxsize; git_deflate_init(&stream, 1); maxsize = git_deflate_bound(&stream, size); From 21f64467a7132cf25b965b9ee58afd7c0ffc31ea Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Thu, 9 Jul 2026 16:49:39 +0000 Subject: [PATCH 060/159] git-zlib: widen `git_deflate_bound()` to `size_t` All four `unsigned long`/`int`/`ssize_t` receivers across archive-zip, diff, http-push and t/helper/test-pack-deltas were widened to `size_t` in the prior commits, and remote-curl and fast-import were already there. With every caller prepared, both the parameter and the return type can now move without introducing any silent narrowing. For inputs above zlib's `uLong` range (i.e. >4 GiB on platforms where `uLong` is 32-bit, notably 64-bit Windows), defer to zlib's stored-block formula (the same fallback it would itself use for an unknown stream state) plus the worst-case wrapper overhead. The existing path through `deflateBound()` is unchanged for inputs that fit. Assisted-by: Opus 4.7 Signed-off-by: Johannes Schindelin Signed-off-by: Junio C Hamano --- git-zlib.c | 16 ++++++++++++++-- git-zlib.h | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/git-zlib.c b/git-zlib.c index d21adb3bf5b15e..ebbbcc6d1a5077 100644 --- a/git-zlib.c +++ b/git-zlib.c @@ -167,9 +167,21 @@ int git_inflate(git_zstream *strm, int flush) return status; } -unsigned long git_deflate_bound(git_zstream *strm, unsigned long size) +size_t git_deflate_bound(git_zstream *strm, size_t size) { - return deflateBound(&strm->z, size); +#if SIZE_MAX > ULONG_MAX + if (size > maximum_unsigned_value_of_type(uLong)) + /* + * deflateBound() takes uLong, which is 32-bit on + * Windows. For inputs above that range, return zlib's + * stored-block formula (the conservative path it would + * itself use for an unknown stream state) plus the + * worst-case wrapper overhead. + */ + return size + (size >> 5) + (size >> 7) + (size >> 11) + + 7 + 18; +#endif + return deflateBound(&strm->z, (uLong)size); } void git_deflate_init(git_zstream *strm, int level) diff --git a/git-zlib.h b/git-zlib.h index 0b24b15bd05f7f..9248d11ca9622c 100644 --- a/git-zlib.h +++ b/git-zlib.h @@ -25,6 +25,6 @@ void git_deflate_end(git_zstream *); int git_deflate_abort(git_zstream *); int git_deflate_end_gently(git_zstream *); int git_deflate(git_zstream *, int flush); -unsigned long git_deflate_bound(git_zstream *, unsigned long); +size_t git_deflate_bound(git_zstream *, size_t); #endif /* GIT_ZLIB_H */ From dcade13aa712cafd36ab39a9935fa6aadd59b232 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Fri, 10 Jul 2026 17:30:55 +0000 Subject: [PATCH 061/159] t/lib-httpd: fix apply-one-time-script race under concurrent requests apply-one-time-script.sh checks for the "one-time-script" marker, runs it, captures the git-http-backend response in the fixed-name files "out" and "out_modified", and removes the marker only after it has finished serving the modified response. Because the client receives the response body before that removal, it can start its next request while the marker still exists. Apache can then run this CGI for two requests at once: a partial fetch that receives a REF_DELTA against a missing promisor object lazily fetches that base while the first response is still in flight. The second request passes the marker check, the first request then removes the marker, and the second fails to exec the now-missing marker, emits no output, and the server answers HTTP 500: fatal: ... The requested URL returned error: 500 fatal: could not fetch from promisor remote This has been seen as a flaky failure of t5616.47 on the macOS CI runners. Claim the marker atomically with a rename, and only once the one-time script has succeeded and actually changed the response; give the scratch files per-request names. A request that loses the rename, or whose script fails or leaves the response unchanged, serves the unmodified body and keeps the marker for a later request. No path emits an empty body, so the HTTP 500 no longer occurs. Running the one-time script more than once is fine; the only thing to avoid is serving a second, racing request's modified output. Two requests can both find the marker and run the script before either renames it away, but the rename is atomic, so exactly one of them wins: it serves its modified body and consumes the marker. The loser's rename fails because the marker is already gone, so it discards the modified output it produced and serves the unmodified body instead. The rename, not running the script, is what is serialized. Add t5567 to lock this down. The overlap depends on timing, so a live httpd test such as t5616.47 (the real code path) passes almost every time even against the buggy helper; t5567 instead drives the helper directly with a fake git-http-backend and forces the overlap with FIFOs. Against the pre-fix helper it fails with the same shell error seen in the field: ./one-time-script: No such file or directory Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- t/lib-httpd/apply-one-time-script.sh | 44 +++++++++---- t/meson.build | 1 + t/t5567-one-time-script.sh | 96 ++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 14 deletions(-) create mode 100755 t/t5567-one-time-script.sh diff --git a/t/lib-httpd/apply-one-time-script.sh b/t/lib-httpd/apply-one-time-script.sh index b1682944e280e2..adb9cec5284f1e 100644 --- a/t/lib-httpd/apply-one-time-script.sh +++ b/t/lib-httpd/apply-one-time-script.sh @@ -6,21 +6,37 @@ # # This can be used to simulate the effects of the repository changing in # between HTTP request-response pairs. -if test -f one-time-script -then - LC_ALL=C - export LC_ALL +# +# Apache can run this CGI for concurrent requests (for example a partial fetch +# that lazily fetches a missing object while the first response is still in +# flight), so the helper claims the marker atomically with a rename, and only +# once it has decided to modify the response. A request that loses the race +# finds the marker already gone and serves its response unchanged; no request +# is left emitting an empty body, which the server would report as HTTP 500. +# Scratch files are per-request ($$) so concurrent requests do not clobber each +# other. +# +# The script may run more than once: the marker is consumed when the response +# actually changes (the rename after "cmp"), not when the script runs, so a +# request whose response is not the targeted one runs the script, sees no +# change, and leaves the marker for a later request. That is safe because the +# scripts are stateless filters over the captured response. - "$GIT_EXEC_PATH/git-http-backend" >out - ./one-time-script out >out_modified +test -f one-time-script || exec "$GIT_EXEC_PATH/git-http-backend" - if cmp -s out out_modified - then - cat out - else - cat out_modified - rm one-time-script - fi +LC_ALL=C +export LC_ALL + +out=out.$$ +modified=out-modified.$$ +"$GIT_EXEC_PATH/git-http-backend" >"$out" + +if ./one-time-script "$out" 2>/dev/null >"$modified" && + ! cmp -s "$out" "$modified" && + mv one-time-script one-time-script.$$ 2>/dev/null +then + cat "$modified" else - "$GIT_EXEC_PATH/git-http-backend" + cat "$out" fi +rm -f "$out" "$modified" one-time-script.$$ diff --git a/t/meson.build b/t/meson.build index 3219264fe7d497..a118a4d7196b17 100644 --- a/t/meson.build +++ b/t/meson.build @@ -707,6 +707,7 @@ integration_tests = [ 't5564-http-proxy.sh', 't5565-push-multiple.sh', 't5566-push-group.sh', + 't5567-one-time-script.sh', 't5570-git-daemon.sh', 't5571-pre-push-hook.sh', 't5572-pull-submodule.sh', diff --git a/t/t5567-one-time-script.sh b/t/t5567-one-time-script.sh new file mode 100755 index 00000000000000..cd8e6560056160 --- /dev/null +++ b/t/t5567-one-time-script.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +test_description='apply-one-time-script CGI helper is safe under concurrent requests' + +. ./test-lib.sh + +HELPER="$TEST_DIRECTORY/lib-httpd/apply-one-time-script.sh" + +test_expect_success PIPE 'concurrent requests: one rewritten, one passed through, neither empty' ' + mkdir workdir fakebin && + ENTERED="$PWD/entered" && + GATE="$PWD/gate" && + export ENTERED GATE && + mkfifo "$ENTERED" "$GATE" && + + # Stand in for git-http-backend. The modify role returns a response + # containing "packfile", which the one-time script rewrites. The + # passthrough role returns a response that is left untouched, but first + # announces that it has entered the helper and then blocks, so that it + # is still in flight when the modify role claims and removes the marker. + write_script fakebin/git-http-backend <<-\EOF && + printf "Status: 200 OK\r\n" + printf "Content-Type: application/x-git-result\r\n" + printf "\r\n" + if test "$ROLE" = modify + then + printf "packfile\n" + else + echo entered >"$ENTERED" + read -r released <"$GATE" + printf "refs\n" + fi + EOF + + # The transform that replace_packfile would install as one-time-script: + # rewrite responses that contain "packfile", leave the rest alone. + write_script workdir/one-time-script <<-\EOF && + if grep packfile "$1" >/dev/null + then + sed "/packfile/q" "$1" && + printf "REPLACED\n" + else + cat "$1" + fi + EOF + + GIT_EXEC_PATH="$PWD/fakebin" && + export GIT_EXEC_PATH && + + # Hold GATE open read-write on fd 9 for the duration, so releasing the + # passthrough request below cannot block even if that request has + # already exited (it keeps a reader on the FIFO). + exec 9<>"$GATE" && + + # Launch the passthrough request in the background. It enters the + # helper, signals us through ENTERED, then blocks on GATE inside the + # fake backend. The braces keep the && chain intact while backgrounding + # only the subshell, so "wait" can reap it by pid; kill it on any exit + # so a stray blocked child cannot hold the test output open and stall a + # reader such as prove. + { ( + cd workdir && + ROLE=passthrough sh "$HELPER" >../passthrough.out 2>../passthrough.err + ) & } && + passthrough_pid=$! && + test_when_finished "kill $passthrough_pid 2>/dev/null || :" && + + # Wait until the passthrough request is past the marker check. + read -r entered <"$ENTERED" && + + # Run the modifying request to completion while the passthrough request + # is still blocked. + ( + cd workdir && + ROLE=modify sh "$HELPER" >../modify.out 2>../modify.err + ) && + + # Release the passthrough request and let it finish. Ignore the helper + # exit status here so a broken helper is diagnosed by the assertions + # below rather than aborting the test. + echo released >&9 && + { wait "$passthrough_pid" || :; } && + + # Neither request may error out or produce an empty (HTTP 500) body, + # and each must have played its role: the modify request rewrote its + # response and the passthrough request came through untouched. + test_must_be_empty passthrough.err && + test_must_be_empty modify.err && + test_grep "Status: 200 OK" passthrough.out && + test_grep "Status: 200 OK" modify.out && + test_grep REPLACED modify.out && + test_grep ! REPLACED passthrough.out && + test_grep refs passthrough.out +' + +test_done From ffb323e5b76d50310b82d39f294de2aecd5681b8 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Fri, 10 Jul 2026 17:30:56 +0000 Subject: [PATCH 062/159] t/lib-httpd: make http-429 first-request check atomic http-429.sh records "already returned 429 once" with a "test -f" followed by a "touch" of a shared state file. That check-then-act is not atomic: Apache can run this CGI for several requests at once, and two of them can both pass the "test -f" before either "touch"es, so both treat themselves as the first request. The retry flow that drives this endpoint is mostly sequential, so this has not been seen to fail, but the race is latent. Decide whether this is the first request with a single atomic mkdir, which fails if the directory already exists, so exactly one of any concurrent requests is rate-limited and the rest are forwarded. Skipping state for "permanent" is required for correctness, not just an optimization. The marker tells a later or concurrent request that a 429 has already been served, so that it forwards to git-http-backend instead of rate-limiting. Since "permanent" must return 429 to every request, that marker must never become visible to another such request. The original did not achieve this by staying stateless: its "touch" of the marker ran unconditionally, and the "permanent" case removed it afterward with "rm -f". That create-then-remove leaves a window in which a concurrent "permanent" request sees the marker and is forwarded. It is the same class of check-then-act race this patch removes from the first-request check, latent for the same reason: the flow is mostly sequential. This version fuses the check and the mark into one atomic mkdir and, rather than recreate the pattern as mkdir-then-rmdir, skips the mkdir for "permanent" with a "!= permanent" guard. No marker is ever created, so there is no window and every "permanent" request rate-limits. There is no accompanying regression test. The check and the set are adjacent commands with no external step in between to synchronize on, so the overlap cannot be forced deterministically, only reproduced probabilistically; the fix is preventive. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- t/lib-httpd/http-429.sh | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/t/lib-httpd/http-429.sh b/t/lib-httpd/http-429.sh index c97b16145b7f92..9746ec67ae986b 100644 --- a/t/lib-httpd/http-429.sh +++ b/t/lib-httpd/http-429.sh @@ -26,14 +26,24 @@ repo_path="${remaining#*/}" # Get rest (repo path) # The repo name is the first component before any "/" repo_name="${repo_path%%/*}" -# Use current directory (HTTPD_ROOT_PATH) for state file -# Create a safe filename from test_context, retry_after and repo_name -# This ensures all requests for the same test context share the same state file +# Use current directory (HTTPD_ROOT_PATH) for state. +# Create a safe name from test_context, retry_after and repo_name so that all +# requests for the same test context share the same state. safe_name=$(echo "${test_context}-${retry_after}-${repo_name}" | tr '/' '_' | tr -cd 'a-zA-Z0-9_-') -state_file="http-429-state-${safe_name}" +state="http-429-state-${safe_name}" -# Check if this is the first call (no state file exists) -if test -f "$state_file" +# This endpoint returns 429 to the first request and forwards later ones to +# git-http-backend, so the retry succeeds. Apache can run this CGI for several +# requests at once, so a single atomic "mkdir" elects that first request: the +# one whose mkdir succeeds returns 429 and leaves the directory behind as the +# "already rate-limited" marker; every later request finds the directory (mkdir +# fails) and is forwarded. +# +# "permanent" is the exception: it must return 429 to every request and never +# succeed, so it skips the mkdir and records no state. A leftover directory +# would make its own later requests find the marker and be forwarded, which is +# exactly what "permanent" must not do. +if test "$retry_after" != permanent && ! mkdir "$state" 2>/dev/null then # Already returned 429 once, forward to git-http-backend # Set PATH_INFO to just the repo path (without retry-after value) @@ -52,9 +62,6 @@ then exec "$GIT_EXEC_PATH/git-http-backend" fi -# Mark that we've returned 429 -touch "$state_file" - # Output HTTP 429 response printf "Status: 429 Too Many Requests\r\n" @@ -67,8 +74,7 @@ case "$retry_after" in printf "Retry-After: invalid-format-123abc\r\n" ;; permanent) - # Always return 429, don't set state file for success - rm -f "$state_file" + # Always return 429 printf "Retry-After: 1\r\n" printf "Content-Type: text/plain\r\n" printf "\r\n" From 23e68ead57e50f8de3a368f7f1a07b236f2b344c Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Fri, 10 Jul 2026 17:30:57 +0000 Subject: [PATCH 063/159] t/README: document writing concurrency-safe helpers The apply-one-time-script.sh and http-429.sh fixes addressed the same underlying problem: a test helper assuming it has exclusive access to a file when the web server can run it for several requests at once. The atomic idioms that avoid this are not specific to CGI or to HTTP, so document them generally, alongside the other guidance for writing tests, and leave a pointer from the lib-httpd helper list rather than a local comment. The note covers the anti-pattern (a "test -f" then a separate act) and the two safe operations (mkdir to elect a winner, rename to consume a one-shot marker), citing Git's own lockfile machinery and make_symlink() as precedent. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- t/README | 32 ++++++++++++++++++++++++++++++++ t/lib-httpd.sh | 3 +++ 2 files changed, 35 insertions(+) diff --git a/t/README b/t/README index 085921be4b6c2a..a9d425f392115c 100644 --- a/t/README +++ b/t/README @@ -854,6 +854,38 @@ from the test harness library. At the end of the script, call 'test_done'. +Writing concurrency-safe helpers +-------------------------------- + +Some test code runs concurrently: a test may background work with '&', +and the helper scripts installed for the web server (in t/lib-httpd) are +run once per request, so the same script can execute for several +requests at once. Such code cannot assume it has exclusive access to a +file. + +When exactly one of several concurrent processes needs to "win" a +decision, a single atomic filesystem operation can make it, rather than +a check followed by a separate action. A "test -f X" then "touch X" +(or "rm X") races: two processes can both pass the check before either +acts. Two atomic operations avoid this: + + - "mkdir dir", which fails if the directory already exists, so that + exactly one caller wins, electing a first or only request (see + t/lib-httpd/http-429.sh). + + - "mv src dst" (rename), which fails if the source is gone, so that + exactly one caller consumes it, claiming a planted one-shot marker + (see t/lib-httpd/apply-one-time-script.sh). + +A "$$" suffix on per-request scratch files keeps concurrent invocations +from clobbering each other's fixed-name files. + +This is a standard shell locking idiom, and the same reasoning behind +Git's own lockfile machinery, which creates its lock with O_CREAT|O_EXCL, +and make_symlink() in t/test-lib.sh, which uses an mkdir lock: an atomic +operation whose failure indicates that another process got there first. + + Test harness library -------------------- diff --git a/t/lib-httpd.sh b/t/lib-httpd.sh index fc646447d5c038..d64f9c8c2d0045 100644 --- a/t/lib-httpd.sh +++ b/t/lib-httpd.sh @@ -159,6 +159,9 @@ prepare_httpd() { mkdir -p "$HTTPD_DOCUMENT_ROOT_PATH" cp "$TEST_PATH"/passwd "$HTTPD_ROOT_PATH" cp "$TEST_PATH"/proxy-passwd "$HTTPD_ROOT_PATH" + # The web server can run any of these CGI scripts for two requests at + # once; a helper that keeps state between requests must do so with an + # atomic operation. See "Writing concurrency-safe helpers" in t/README. install_script incomplete-length-upload-pack-v2-http.sh install_script incomplete-body-upload-pack-v2-http.sh install_script error-no-report.sh From 6001fcbed30f3b0ae2f9ab6df4e391fdb7b90959 Mon Sep 17 00:00:00 2001 From: Kristofer Karlsson Date: Sat, 11 Jul 2026 13:27:36 +0000 Subject: [PATCH 064/159] Documentation/technical: add paint-down-to-common doc Add a technical document describing the paint_down_to_common() algorithm used for merge-base computation, covering the paint walk, generation number regions, and termination conditions. Signed-off-by: Kristofer Karlsson Signed-off-by: Junio C Hamano --- Documentation/Makefile | 1 + Documentation/technical/meson.build | 1 + .../technical/paint-down-to-common.adoc | 175 ++++++++++++++++++ commit-reach.c | 6 +- 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 Documentation/technical/paint-down-to-common.adoc diff --git a/Documentation/Makefile b/Documentation/Makefile index 2699f0b24af192..f8dea4b3953250 100644 --- a/Documentation/Makefile +++ b/Documentation/Makefile @@ -129,6 +129,7 @@ TECH_DOCS += technical/long-running-process-protocol TECH_DOCS += technical/multi-pack-index TECH_DOCS += technical/packfile-uri TECH_DOCS += technical/pack-heuristics +TECH_DOCS += technical/paint-down-to-common TECH_DOCS += technical/parallel-checkout TECH_DOCS += technical/partial-clone TECH_DOCS += technical/platform-support diff --git a/Documentation/technical/meson.build b/Documentation/technical/meson.build index ec07088c57617f..9ce11d5e484d9c 100644 --- a/Documentation/technical/meson.build +++ b/Documentation/technical/meson.build @@ -18,6 +18,7 @@ articles = [ 'multi-pack-index.adoc', 'packfile-uri.adoc', 'pack-heuristics.adoc', + 'paint-down-to-common.adoc', 'parallel-checkout.adoc', 'partial-clone.adoc', 'platform-support.adoc', diff --git a/Documentation/technical/paint-down-to-common.adoc b/Documentation/technical/paint-down-to-common.adoc new file mode 100644 index 00000000000000..c203f14455645b --- /dev/null +++ b/Documentation/technical/paint-down-to-common.adoc @@ -0,0 +1,175 @@ +Merge-Base Computation and paint_down_to_common() +================================================== + +The function `paint_down_to_common()` in `commit-reach.c` computes merge +bases by walking the commit graph backwards from two sets of tips and +finding where their ancestry meets. + +Use cases +--------- + +Computing merge bases is used in two different ways: + + 1. *Finding all merge bases* (`merge-base --all`, `merge-tree`, + `merge`, `rebase`). A merge base is a common ancestor that is + not itself an ancestor of another common ancestor. + + 2. *Ancestry checks* (`in_merge_bases`, used by `merge-base + --is-ancestor`, `branch -d`, `fetch`). These ask: "is commit A + an ancestor of commit B?" If a common ancestor equals one of the + inputs, that input is necessarily the only merge base -- no other + common ancestor can be both as recent and not an ancestor of it. + +Both use cases share the same algorithm and implementation. + +Algorithm +--------- + +Given a commit `one` and a set of commits `twos[]`, the walk paints +commits with two colors: + + - PARENT1: reachable from `one` + - PARENT2: reachable from any commit in `twos[]` + +The walk uses a priority queue ordered by generation number +(highest first), breaking ties by commit date. Each step dequeues +the highest-priority commit (this is when we say a commit is +"visited") and propagates its paint flags to its parents, enqueuing +them if they gained new flags. When a commit receives both PARENT1 +and PARENT2, it is a merge-base candidate. A candidate gains the +STALE flag so its ancestors propagate staleness -- any deeper common +ancestor is necessarily redundant. + +[[generation-regions]] +INFINITY and finite generation regions +-------------------------------------- + +The properties in this section assume generation-number ordering (the +default comparator). They do NOT hold when the date-ordering fallback +is active -- see <>. + +The commit-graph stores a generation number for each commit. +Commits not in the commit-graph have generation +`GENERATION_NUMBER_INFINITY`. The graph is closed under +reachability: if a commit is in the graph, all its ancestors are +too. This partitions the commit graph into two regions: + +.... + +---------------------------------------+ + | INFINITY region | + | generation = INFINITY | + | queue order: heuristic (commit date) | + +---------------------------------------+ + | + v + +---------------------------------------+ + | Finite region | + | generation = finite | + | queue order: topological | + +---------------------------------------+ +.... + +When the commit-graph is enabled, the INFINITY region is typically +very small -- it only contains commits added since the last +commit-graph refresh. + +All reachable INFINITY-generation commits are visited before any +finite-generation commit, because INFINITY is larger than any finite +value. Once the walk crosses into the finite region, it stays there. + +In the finite region, generation ordering guarantees topological +traversal: children are always visited before their parents. This +means that paint on already-visited commits is final -- no future +traversal step can add paint to them. + +In the INFINITY region, commit-date ordering can violate this: a +parent with a later date can be visited before a child with an earlier +date. Paint flags are therefore NOT final at visit time, and a +commit visited with only one side's paint may later gain the other. + +Paint flags are only added, never removed. Since each flag can be set +at most once per commit, the number of times a commit can be +re-enqueued is bounded by the number of flag transitions. + +Termination +----------- + +The walk uses a `nonstale_queue` wrapper around `prio_queue` that +tracks `max_nonstale`: the lowest-priority non-stale commit enqueued +so far. Once that commit is dequeued, every remaining entry is known +to be STALE and the loop terminates. Specifically, the main loop +ends when one of the following conditions holds: + + 1. The queue is empty. + 2. `max_nonstale` has been dequeued, meaning the queue only contains + STALE entries. + 3. Generation cutoff: the dequeued commit's generation is below + a caller-supplied `min_generation` threshold. + 4. Single result: the caller only needs one merge base, one has + been found, and the walk has entered the finite-generation + region. + +Stale entry condition +~~~~~~~~~~~~~~~~~~~~~ +Once all queued entries are stale, no new merge-base candidates can +be discovered -- that requires at least one non-stale commit from +each side meeting. Continuing the walk could still invalidate +existing candidates by proving one is an ancestor of another, but +`remove_redundant()` handles that as a post-processing step, so it +is safe to exit early. + +Generation cutoff +~~~~~~~~~~~~~~~~~ +Some callers (notably `remove_redundant()`) supply a `min_generation` +threshold -- the minimum generation of the input commits. No merge +base can have a generation below this threshold, so the walk +terminates as soon as it dequeues such a commit. + +Single result +~~~~~~~~~~~~~ +When only one merge base is needed, the walk is in the +finite-generation region, and the queue uses generation ordering, +the first candidate found is necessarily the highest-generation +common ancestor. No remaining commit in the queue can be a +descendant of this candidate (generation ordering guarantees +children are visited first), so it cannot be redundant and the walk +can stop immediately. + +This optimization is NOT safe when the date-ordering fallback is +active, because commit-date order can visit a deeper ancestor +before a shallower one -- see <>. + +[[date-ordering-fallback]] +Date-ordering fallback +---------------------- + +When the commit-graph has generation numbers v1 and no +generation floor is specified, topological ordering +(via generation numbers) is disabled. Topological levels are +correct but unbalanced -- ordering by such generation numbers +can sometimes cause the walk to detour too far before finding +merge bases. Commit-date ordering typically reaches them in +fewer steps -- see this change for more details: + + 091f4cf3 (commit: don't use generation numbers if not needed, + 2018-08-30) + +With generation number v2 (corrected commit dates) we have the best +of both worlds and do not need this fallback. + +For v1, `paint_down_to_common()` falls back to pure commit-date +ordering via `compare_commits_by_commit_date`. Because commit +dates are not monotonic (clock skew, rebases, etc.), the queue +may visit commits out of topological order. + +This disables the optimization that depends on generation ordering: + + - *Single result*: the first merge-base candidate found may not + be the shallowest, because a deeper ancestor with a higher + commit date can be dequeued first. + +Related documentation +--------------------- + + - `Documentation/technical/commit-graph.adoc` -- generation numbers + and the reachability closure property. diff --git a/commit-reach.c b/commit-reach.c index 708798a39b2d8e..bbf8c3eff068f6 100644 --- a/commit-reach.c +++ b/commit-reach.c @@ -96,7 +96,11 @@ static struct commit *nonstale_queue_get_dedup(struct nonstale_queue *queue) return commit; } -/* all input commits in one and twos[] must have been parsed! */ +/* + * See Documentation/technical/paint-down-to-common.adoc + * + * All input commits in one and twos[] must have been parsed! + */ static int paint_down_to_common(struct repository *r, struct commit *one, int n, struct commit **twos, From e69450189128ae285871bd821f78c71e9f729599 Mon Sep 17 00:00:00 2001 From: Kristofer Karlsson Date: Sat, 11 Jul 2026 13:27:37 +0000 Subject: [PATCH 065/159] test-lib-functions: improve diagnostic output for trace2 data assertions test_trace2_data is a bare grep that silently exits on failure. Add a more informative variant that verifies the event appears exactly once and reports what went wrong: key not found, multiple entries, or value mismatch. Diagnostics go to FD 4 like test_grep. Before (value mismatch): $ test_trace2_data status count/changed 999 Signed-off-by: Junio C Hamano --- t/test-lib-functions.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh index 809c6621241944..3521efe5d732af 100644 --- a/t/test-lib-functions.sh +++ b/t/test-lib-functions.sh @@ -1996,6 +1996,42 @@ test_trace2_data () { grep -e '"category":"'"$1"'","key":"'"$2"'","value":"'"$3"'"' } +# Check that the given trace2 data event has the expected value and +# appears exactly once. Produces a diagnostic on failure. +# +# test_trace2_data_singular [