From d1e198ff9ebd2f3af5fb281f618f5e088489971e Mon Sep 17 00:00:00 2001 From: Paulius Zaleckas Date: Thu, 16 Jul 2026 17:09:53 +0300 Subject: [PATCH 001/316] submodule: fix premature failure in recursive submodule fetch When git fetch --recurse-submodules encounters a failure fetching a submodule's refs (phase 1), it immediately marks the overall operation as failed, even though a subsequent OID-based fetch (phase 2) is about to be attempted for any missing commits. If phase 2 succeeds, the overall result should be success, but the prematurely set failure flag makes it look like an error. Restructure fetch_finish() so that a phase-1 failure does not record an error immediately. Instead, the decision is deferred: - If missing commits trigger a phase-2 (OID-based) retry and that retry succeeds, no error is recorded. - If the phase-2 retry also fails, the error is recorded then. - If the submodule was fetched unconditionally (RECURSE_SUBMODULES_ON) and is not in the changed list, a phase-1 failure is recorded right away since there is no OID retry to fall back on. - If phase 1 fails but all required commits are already present locally, there is no retry to defer to; the failure is still recorded, since the fetch itself went wrong (e.g. a transport error) even though the wanted commits happen to be available. This resolves the NEEDSWORK comment added by bd5e567dc7 (submodule: explain first attempt failure clearly, 2019-03-13). Extract the common error-recording logic into a helper record_fetch_error() and use it in fetch_start_failure() and for the "Could not access submodule" error in get_fetch_task_from_index() as well; the latter now also lists the submodule in the final error summary. Add a test ensuring a failed submodule fetch is still reported when the gitlinked commits happen to be present locally. Helped-by: Ramsay Jones Signed-off-by: Paulius Zaleckas Signed-off-by: Junio C Hamano --- submodule.c | 52 +++++++++++++++++++-------- t/t5526-fetch-submodules.sh | 72 +++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/submodule.c b/submodule.c index fd91201a92d7b0..8bcef68a42480d 100644 --- a/submodule.c +++ b/submodule.c @@ -1562,6 +1562,13 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf return NULL; } +static void record_fetch_error(struct submodule_parallel_fetch *spf, + const char *name) +{ + spf->result = 1; + strbuf_addf(&spf->submodules_with_errors, "\t%s\n", name); +} + static struct fetch_task * get_fetch_task_from_index(struct submodule_parallel_fetch *spf, struct strbuf *err) @@ -1599,7 +1606,7 @@ get_fetch_task_from_index(struct submodule_parallel_fetch *spf, ce->name); if (S_ISGITLINK(ce->ce_mode) && !is_empty_dir(empty_submodule_path.buf)) { - spf->result = 1; + record_fetch_error(spf, ce->name); strbuf_addf(err, _("Could not access submodule '%s'\n"), ce->name); @@ -1753,7 +1760,7 @@ static int fetch_start_failure(struct strbuf *err UNUSED, struct submodule_parallel_fetch *spf = cb; struct fetch_task *task = task_cb; - spf->result = 1; + record_fetch_error(spf, task->sub->name); fetch_task_free(task); return 0; @@ -1779,18 +1786,12 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED, if (!task || !task->sub) BUG("callback cookie bogus"); - if (retvalue) { + if (retvalue && task->commits) { /* - * NEEDSWORK: This indicates that the overall fetch - * failed, even though there may be a subsequent fetch - * by commit hash that might work. It may be a good - * idea to not indicate failure in this case, and only - * indicate failure if the subsequent fetch fails. + * This is the second pass (OID-based fetch) and it failed. + * The commits are genuinely unavailable from the remote. */ - spf->result = 1; - - strbuf_addf(&spf->submodules_with_errors, "\t%s\n", - task->sub->name); + record_fetch_error(spf, task->sub->name); } /* Is this the second time we process this submodule? */ @@ -1798,9 +1799,17 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED, goto out; it = string_list_lookup(&spf->changed_submodule_names, task->sub->name); - if (!it) - /* Could be an unchanged submodule, not contained in the list */ + if (!it) { + /* + * This submodule is not in the changed list (e.g. it was + * fetched because RECURSE_SUBMODULES_ON fetches all populated + * submodules). A phase 1 failure here has no OID-based retry + * to fall back on, so it is a genuine error. + */ + if (retvalue) + record_fetch_error(spf, task->sub->name); goto out; + } cs_data = it->util; oid_array_filter(&cs_data->new_commits, @@ -1809,6 +1818,11 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED, /* Are there commits we want, but do not exist? */ if (cs_data->new_commits.nr) { + /* + * Schedule an OID-based phase 2 fetch to retrieve the missing + * commits directly. Defer any error from phase 1: if phase 2 + * succeeds, the overall operation should still succeed. + */ task->commits = &cs_data->new_commits; ALLOC_GROW(spf->oid_fetch_tasks, spf->oid_fetch_tasks_nr + 1, @@ -1818,6 +1832,16 @@ static int fetch_finish(int retvalue, struct strbuf *err UNUSED, return 0; } + /* + * All required commits are already present locally (they were either + * fetched by phase 1 or existed beforehand), so there is no phase 2 + * retry to defer to. If phase 1 failed, the fetch itself went wrong + * (e.g. a transport error) and must still be reported, even though + * the gitlinked commits are available. + */ + if (retvalue) + record_fetch_error(spf, task->sub->name); + out: fetch_task_free(task); return 0; diff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh index 1242ee918526ea..7ad274ce040afd 100755 --- a/t/t5526-fetch-submodules.sh +++ b/t/t5526-fetch-submodules.sh @@ -1262,4 +1262,76 @@ test_expect_success "fetch --all with --no-recurse-submodules only fetches super ! grep "Fetching submodule" fetch-log ' +# Create an isolated environment for submodule fetch error tests. +# +# Sets up sub_bare (the submodule upstream), super_bare (the superproject +# upstream), super_work (a working clone of super_bare with an initialized +# submodule), and clone (a clone of super_bare with an initialized submodule +# at a reachable commit). The caller can then create an unreachable commit +# and push the superproject to put the clone one commit behind a state it +# cannot fully fetch. +# +# Usage: create_err_env +create_err_env () { + local envdir="$1" && + mkdir "$envdir" && + + git init --bare "$envdir/sub_bare" && + git clone "$envdir/sub_bare" "$envdir/sub_work" && + test_commit -C "$envdir/sub_work" "${envdir}_base" && + git -C "$envdir/sub_work" push && + + git init --bare "$envdir/super_bare" && + git clone "$envdir/super_bare" "$envdir/super_work" && + git -C "$envdir/super_work" submodule add \ + "$pwd/$envdir/sub_bare" sub && + git -C "$envdir/super_work" commit -m "add submodule" && + git -C "$envdir/super_work" push && + + git clone "$envdir/super_bare" "$envdir/clone" && + git -C "$envdir/clone" submodule update --init +} + +# Push a commit to /super_bare that records a submodule SHA that is +# present locally in super_work/sub but NOT pushed to sub_bare, making the +# submodule commit unreachable from clone's sub remote. +push_unreachable_commit () { + local envdir="$1" && + git -C "$envdir/super_work/sub" commit --allow-empty -m "unreachable" && + git -C "$envdir/super_work" add sub && + git -C "$envdir/super_work" commit -m "point sub to unreachable commit" && + git -C "$envdir/super_work" push +} + +test_expect_success 'setup for submodule fetch error tests' ' + git config --global protocol.file.allow always +' + +test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' ' + # Create the same commit (unreferenced, via commit-tree with fixed + # dates) in both super_work/sub and clone/sub, point the gitlink at + # it, and break clone/sub'\''s remote. The commit exists in clone/sub + # but is unreachable, so the submodule stays in the changed list; the + # fetch failure must still be reported even though there is nothing + # left to fetch by commit hash. + test_when_finished "rm -fr env_phase1" && + create_err_env env_phase1 && + commit=$(GIT_AUTHOR_DATE="1234567890 +0000" \ + GIT_COMMITTER_DATE="1234567890 +0000" \ + git -C env_phase1/super_work/sub commit-tree \ + "HEAD^{tree}" -p HEAD -m present) && + present=$(GIT_AUTHOR_DATE="1234567890 +0000" \ + GIT_COMMITTER_DATE="1234567890 +0000" \ + git -C env_phase1/clone/sub commit-tree \ + "HEAD^{tree}" -p HEAD -m present) && + test "$commit" = "$present" && + git -C env_phase1/super_work/sub checkout "$commit" && + git -C env_phase1/super_work add sub && + git -C env_phase1/super_work commit -m "gitlink to locally-present commit" && + git -C env_phase1/super_work push && + git -C env_phase1/clone/sub remote set-url origin "$pwd/env_phase1/missing" && + test_must_fail git -C env_phase1/clone fetch --recurse-submodules 2>err && + test_grep "Errors during submodule fetch" err +' + test_done From 0b977622b8434e6753425fb66024e2fd93bba0c8 Mon Sep 17 00:00:00 2001 From: Paulius Zaleckas Date: Thu, 16 Jul 2026 17:09:54 +0300 Subject: [PATCH 002/316] fetch: add fetch.submoduleErrors to make submodule fetch errors non-fatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When fetching with --recurse-submodules, a submodule commit that is not yet reachable from any of the submodule's remote refs causes the entire fetch to fail. This is overly strict when the missing commit belongs to an upstream branch that is still being prepared (e.g. an in-progress merge topic): the local branch does not need that commit, so there is no reason to treat its absence as fatal. Add a new config key fetch.submoduleErrors (values: fail/warn) and a corresponding --submodule-errors=(fail|warn) command-line option that control this behaviour. The default remains fail (existing behaviour); setting the value to warn causes submodule fetch failures to be reported on stderr without affecting the overall exit status of git fetch / git pull. Forward the option to child fetches in add_options_to_argv() so that it also takes effect for `git fetch --all` / `--multiple` (where per-remote child processes handle the submodule recursion themselves) and for nested submodule recursion. The resolved value is forwarded whenever it was set explicitly, in either direction: the per-remote children re-read the repository configuration, so a command-line --submodule-errors=fail must be passed down to them to override fetch.submoduleErrors=warn from the configuration. When neither the configuration nor the command line sets a value, nothing is forwarded and the child processes fall back to their own configuration. Helped-by: Jean-Noël Avila Helped-by: Ramsay Jones Helped-by: Junio C Hamano Signed-off-by: Paulius Zaleckas Signed-off-by: Junio C Hamano --- Documentation/config/fetch.adoc | 14 +++++ Documentation/fetch-options.adoc | 8 +++ builtin/fetch.c | 70 ++++++++++++++++++++++++- submodule.c | 8 ++- submodule.h | 7 ++- t/t5526-fetch-submodules.sh | 89 ++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 4 deletions(-) diff --git a/Documentation/config/fetch.adoc b/Documentation/config/fetch.adoc index 04ac90912d3a58..5c9c942a704cf3 100644 --- a/Documentation/config/fetch.adoc +++ b/Documentation/config/fetch.adoc @@ -10,6 +10,20 @@ reference. Defaults to `on-demand`, or to the value of `submodule.recurse` if set. +`fetch.submoduleErrors`:: + Controls how errors from submodule fetches are handled when + `--recurse-submodules` is in effect. When set to `fail` (the default), + any submodule fetch error causes the overall `git fetch` or `git pull` + to exit with a non-zero status. When set to `warn`, submodule fetch + errors are reported to standard error but do not affect the exit + status of the command. This is useful when working in repositories + where some branches reference submodule commits that are not yet + available on the submodule remote, but those commits are not needed + for the currently checked-out branch. ++ +The value of this option can be overridden by the `--submodule-errors` +option of linkgit:git-fetch[1]. + `fetch.fsckObjects`:: If it is set to true, git-fetch-pack will check all fetched objects. See `transfer.fsckObjects` for what's diff --git a/Documentation/fetch-options.adoc b/Documentation/fetch-options.adoc index 035f780e583cee..78525f6848056f 100644 --- a/Documentation/fetch-options.adoc +++ b/Documentation/fetch-options.adoc @@ -294,6 +294,14 @@ ifndef::git-pull[] `--no-recurse-submodules`:: Disable recursive fetching of submodules (this has the same effect as using the `--recurse-submodules=no` option). + +`--submodule-errors=(fail|warn)`:: + Control how errors from submodule fetches are handled when + `--recurse-submodules` is in effect. When set to `fail` (the default), + any submodule fetch error causes the overall `git fetch` to exit with a + non-zero status. When set to `warn`, submodule fetch errors are reported + to standard error but do not affect the exit status of the command. Can + also be configured via `fetch.submoduleErrors`. See linkgit:git-config[1]. endif::git-pull[] `--set-upstream`:: diff --git a/builtin/fetch.c b/builtin/fetch.c index c1d7c672f4e0d8..2c583ed0cc8508 100644 --- a/builtin/fetch.c +++ b/builtin/fetch.c @@ -110,8 +110,30 @@ struct fetch_config { int recurse_submodules; int parallel; int submodule_fetch_jobs; + int submodule_errors; }; +/* really private - use accessors below to parse and format */ +static const char *submodule_error_name[] = { + [SUBMODULE_ERRORS_FAIL] = "fail", + [SUBMODULE_ERRORS_WARN] = "warn", +}; + +static const char *submodule_error(unsigned num) +{ + if (ARRAY_SIZE(submodule_error_name) <= num) + BUG("invalid submodule errors mode %u", num); + return submodule_error_name[num]; +} + +static int parse_submodule_error(const char *name) +{ + for (unsigned num = 0; num < ARRAY_SIZE(submodule_error_name); num++) + if (!strcmp(submodule_error_name[num], name)) + return num; + return -1; +} + static int git_fetch_config(const char *k, const char *v, const struct config_context *ctx, void *cb) { @@ -152,6 +174,19 @@ static int git_fetch_config(const char *k, const char *v, return 0; } + if (!strcmp(k, "fetch.submoduleerrors")) { + int mode; + + if (!v) + return config_error_nonbool(k); + mode = parse_submodule_error(v); + if (mode < 0) + die(_("invalid value for '%s': '%s'"), + "fetch.submoduleErrors", v); + fetch_config->submodule_errors = mode; + return 0; + } + if (!strcmp(k, "fetch.parallel")) { fetch_config->parallel = git_config_int(k, v, ctx->kvi); if (fetch_config->parallel < 0) @@ -2205,6 +2240,9 @@ static void add_options_to_argv(struct strvec *argv, strvec_push(argv, "--no-recurse-submodules"); else if (config->recurse_submodules == RECURSE_SUBMODULES_ON_DEMAND) strvec_push(argv, "--recurse-submodules=on-demand"); + if (config->submodule_errors != -1) + strvec_pushf(argv, "--submodule-errors=%s", + submodule_error(config->submodule_errors)); if (tags == TAGS_SET) strvec_push(argv, "--tags"); else if (tags == TAGS_UNSET) @@ -2464,6 +2502,23 @@ static int fetch_one(struct remote *remote, int argc, const char **argv, return exit_code; } +static int option_parse_submodule_errors(const struct option *opt, + const char *arg, int unset) +{ + int *v = opt->value; + int mode; + + if (unset) { + *v = SUBMODULE_ERRORS_FAIL; + return 0; + } + mode = parse_submodule_error(arg); + if (mode < 0) + die(_("invalid value for '%s': '%s'"), "--submodule-errors", arg); + *v = mode; + return 0; +} + int cmd_fetch(int argc, const char **argv, const char *prefix, @@ -2477,6 +2532,7 @@ int cmd_fetch(int argc, .recurse_submodules = RECURSE_SUBMODULES_DEFAULT, .parallel = 1, .submodule_fetch_jobs = -1, + .submodule_errors = -1, /* unset */ }; const char *submodule_prefix = ""; const char *bundle_uri; @@ -2491,6 +2547,7 @@ int cmd_fetch(int argc, int max_jobs = -1; int recurse_submodules_cli = RECURSE_SUBMODULES_DEFAULT; int recurse_submodules_default = RECURSE_SUBMODULES_ON_DEMAND; + int submodule_errors_cli = -1; /* -1: not set on command line */ int fetch_write_commit_graph = -1; int stdin_refspecs = 0; int negotiate_only = 0; @@ -2527,6 +2584,10 @@ int cmd_fetch(int argc, OPT_CALLBACK_F(0, "recurse-submodules", &recurse_submodules_cli, N_("on-demand"), N_("control recursive fetching of submodules"), PARSE_OPT_OPTARG, option_fetch_parse_recurse_submodules), + OPT_CALLBACK_F(0, "submodule-errors", &submodule_errors_cli, + N_("(fail|warn)"), + N_("control how submodule fetch errors are handled"), + 0, option_parse_submodule_errors), OPT_BOOL(0, "dry-run", &dry_run, N_("dry run")), OPT_BOOL(0, "porcelain", &porcelain, N_("machine-readable output")), @@ -2616,6 +2677,9 @@ int cmd_fetch(int argc, if (recurse_submodules_cli != RECURSE_SUBMODULES_DEFAULT) config.recurse_submodules = recurse_submodules_cli; + if (submodule_errors_cli != -1) + config.submodule_errors = submodule_errors_cli; + if (negotiate_only) { switch (recurse_submodules_cli) { case RECURSE_SUBMODULES_OFF: @@ -2819,11 +2883,14 @@ int cmd_fetch(int argc, if (!result && remote && (config.recurse_submodules != RECURSE_SUBMODULES_OFF)) { struct strvec options = STRVEC_INIT; int max_children = max_jobs; + int submodule_errors = config.submodule_errors; if (max_children < 0) max_children = config.submodule_fetch_jobs; if (max_children < 0) max_children = config.parallel; + if (submodule_errors < 0) + submodule_errors = SUBMODULE_ERRORS_FAIL; add_options_to_argv(&options, &config); trace2_region_enter_printf("fetch", "recurse-submodule", the_repository, "%s", submodule_prefix); @@ -2833,7 +2900,8 @@ int cmd_fetch(int argc, config.recurse_submodules, recurse_submodules_default, verbosity < 0, - max_children); + max_children, + submodule_errors); trace2_region_leave_printf("fetch", "recurse-submodule", the_repository, "%s", submodule_prefix); strvec_clear(&options); } diff --git a/submodule.c b/submodule.c index 8bcef68a42480d..da4ace751fc2f7 100644 --- a/submodule.c +++ b/submodule.c @@ -1409,6 +1409,7 @@ struct submodule_parallel_fetch { int oid_fetch_tasks_nr, oid_fetch_tasks_alloc; struct strbuf submodules_with_errors; + int submodule_errors; }; #define SPF_INIT { \ .args = STRVEC_INIT, \ @@ -1565,7 +1566,8 @@ static struct fetch_task *fetch_task_create(struct submodule_parallel_fetch *spf static void record_fetch_error(struct submodule_parallel_fetch *spf, const char *name) { - spf->result = 1; + if (spf->submodule_errors == SUBMODULE_ERRORS_FAIL) + spf->result = 1; strbuf_addf(&spf->submodules_with_errors, "\t%s\n", name); } @@ -1851,7 +1853,8 @@ int fetch_submodules(struct repository *r, const struct strvec *options, const char *prefix, int command_line_option, int default_option, - int quiet, int max_parallel_jobs) + int quiet, int max_parallel_jobs, + int submodule_errors) { struct submodule_parallel_fetch spf = SPF_INIT; const struct run_process_parallel_opts opts = { @@ -1871,6 +1874,7 @@ int fetch_submodules(struct repository *r, spf.default_option = default_option; spf.quiet = quiet; spf.prefix = prefix; + spf.submodule_errors = submodule_errors; if (!r->worktree) goto out; diff --git a/submodule.h b/submodule.h index b10e16e6c063d2..c80b687d2a7c67 100644 --- a/submodule.h +++ b/submodule.h @@ -90,12 +90,17 @@ int should_update_submodules(void); */ const struct submodule *submodule_from_ce(const struct cache_entry *ce); void check_for_new_submodule_commits(struct object_id *oid); +/* Values for the submodule_errors parameter of fetch_submodules(). */ +#define SUBMODULE_ERRORS_FAIL 0 /* submodule fetch errors are fatal (default) */ +#define SUBMODULE_ERRORS_WARN 1 /* submodule fetch errors are non-fatal warnings */ + int fetch_submodules(struct repository *r, const struct strvec *options, const char *prefix, int command_line_option, int default_option, - int quiet, int max_parallel_jobs); + int quiet, int max_parallel_jobs, + int submodule_errors); unsigned is_submodule_modified(const char *path, int ignore_untracked); int submodule_uses_gitfile(const char *path); diff --git a/t/t5526-fetch-submodules.sh b/t/t5526-fetch-submodules.sh index 7ad274ce040afd..19d17440cf2220 100755 --- a/t/t5526-fetch-submodules.sh +++ b/t/t5526-fetch-submodules.sh @@ -1307,6 +1307,57 @@ test_expect_success 'setup for submodule fetch error tests' ' git config --global protocol.file.allow always ' +test_expect_success 'fetch --recurse-submodules fails when submodule commit is unreachable (default)' ' + test_when_finished "rm -fr env_default" && + create_err_env env_default && + push_unreachable_commit env_default && + test_must_fail git -C env_default/clone fetch --recurse-submodules 2>err && + test_grep "Errors during submodule fetch" err +' + +test_expect_success 'fetch.submoduleErrors=warn: unreachable submodule commit is non-fatal' ' + test_when_finished "rm -fr env_warn_cfg" && + create_err_env env_warn_cfg && + push_unreachable_commit env_warn_cfg && + git -C env_warn_cfg/clone -c fetch.submoduleErrors=warn \ + fetch --recurse-submodules 2>err && + test_grep "Errors during submodule fetch" err +' + +test_expect_success '--submodule-errors=warn: unreachable submodule commit is non-fatal' ' + test_when_finished "rm -fr env_warn_cli" && + create_err_env env_warn_cli && + push_unreachable_commit env_warn_cli && + git -C env_warn_cli/clone fetch --recurse-submodules \ + --submodule-errors=warn 2>err && + test_grep "Errors during submodule fetch" err +' + +test_expect_success '--submodule-errors=fail: unreachable submodule commit is fatal' ' + test_when_finished "rm -fr env_fail_cli" && + create_err_env env_fail_cli && + push_unreachable_commit env_fail_cli && + test_must_fail git -C env_fail_cli/clone fetch --recurse-submodules \ + --submodule-errors=fail 2>err && + test_grep "Errors during submodule fetch" err +' + +test_expect_success 'fetch.submoduleErrors=warn does not suppress successful fetch' ' + # A new reachable submodule commit (pushed to sub_bare) should be + # fetched without any error summary. + test_when_finished "rm -fr env_ok" && + create_err_env env_ok && + test_commit -C env_ok/sub_work reachable_ok && + git -C env_ok/sub_work push && + git -C env_ok/super_work submodule update --remote && + git -C env_ok/super_work add sub && + git -C env_ok/super_work commit -m "point sub to reachable commit" && + git -C env_ok/super_work push && + git -C env_ok/clone -c fetch.submoduleErrors=warn \ + fetch --recurse-submodules 2>err && + test_grep ! "Errors during submodule fetch" err +' + test_expect_success 'failed submodule fetch is fatal even when its commits are present locally' ' # Create the same commit (unreferenced, via commit-tree with fixed # dates) in both super_work/sub and clone/sub, point the gitlink at @@ -1334,4 +1385,42 @@ test_expect_success 'failed submodule fetch is fatal even when its commits are p test_grep "Errors during submodule fetch" err ' +test_expect_success '--submodule-errors=warn is honored by fetch --all' ' + # A second remote forces fetch_multiple(), which hands the submodule + # recursion off to per-remote child processes; the option must be + # forwarded to them. + test_when_finished "rm -fr env_all" && + create_err_env env_all && + push_unreachable_commit env_all && + git -C env_all/clone remote add second "$pwd/env_all/super_bare" && + git -C env_all/clone fetch --all --recurse-submodules \ + --submodule-errors=warn 2>err && + test_grep "Errors during submodule fetch" err +' + +test_expect_success '--submodule-errors=fail overrides warn config for fetch --all' ' + # The per-remote child processes re-read the repository config, so + # the command-line override must be forwarded to them explicitly. + test_when_finished "rm -fr env_override" && + create_err_env env_override && + push_unreachable_commit env_override && + git -C env_override/clone remote add second "$pwd/env_override/super_bare" && + git -C env_override/clone config fetch.submoduleErrors warn && + test_must_fail git -C env_override/clone fetch --all --recurse-submodules \ + --submodule-errors=fail 2>err && + test_grep "Errors during submodule fetch" err +' + +test_expect_success 'fetch.submoduleErrors=warn: inaccessible submodule is non-fatal' ' + test_when_finished "rm -fr env_access" && + create_err_env env_access && + rm env_access/clone/sub/.git && + rm -r env_access/clone/.git/modules/sub && + git -C env_access/clone -c fetch.submoduleErrors=warn \ + fetch --recurse-submodules 2>err && + test_grep "Could not access submodule" err && + test_must_fail git -C env_access/clone fetch --recurse-submodules 2>err && + test_grep "Could not access submodule" err +' + test_done From 7dd020f88352c03997f8f19028041537e49352dc Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Tue, 18 Aug 2026 11:57:30 +0200 Subject: [PATCH 003/316] format-rev: use lower case for opts description The option descriptions use a mix of initial capital and lower case letters. Lower case is the correct style. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- builtin/name-rev.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/builtin/name-rev.c b/builtin/name-rev.c index 60cbbfb4b7d190..254c88199fd50d 100644 --- a/builtin/name-rev.c +++ b/builtin/name-rev.c @@ -833,12 +833,12 @@ int cmd_format_rev(int argc, OPT_STRING_LIST(0, "notes", ¬es, N_("notes"), N_("display notes for pretty format")), OPT_CALLBACK_F('z', "null", &nul_data, N_("z"), - N_("Use NUL for input and output termination"), + N_("use NUL for input and output termination"), PARSE_OPT_NOARG | PARSE_OPT_NONEG, format_nul_cb), OPT_BOOL(0, "null-input", &nul_data.nul_input, - N_("Use NUL for input termination")), + N_("use NUL for input termination")), OPT_BOOL(0, "null-output", &nul_data.nul_output, - N_("Use NUL for output termination")), + N_("use NUL for output termination")), OPT_END(), }; From 0ac2a41b18493a509ca366ac6e4268b70865b0cc Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Tue, 18 Aug 2026 11:57:31 +0200 Subject: [PATCH 004/316] format-rev: place BUG calls first in callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I added these parse-options `BUG` statements based on existing examples; one `BUG` check per flag. Now, of course the code as-is will not call this callback with `unset` set to `0`, or with an argument string. Rather, these preconditions defend against `opts[]` getting changed *without* changing this callback. And I copied the existing examples that I found down to the placement. And the placement doesn’t matter here; we just unconditionally set two variables. Failing on `BUG` before or after that makes no difference to the user. Still, it is better style to test function preconditions as early as possible. So let’s move them to the start. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- builtin/name-rev.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/builtin/name-rev.c b/builtin/name-rev.c index 254c88199fd50d..d6686bbdbb9009 100644 --- a/builtin/name-rev.c +++ b/builtin/name-rev.c @@ -782,10 +782,10 @@ static int format_nul_cb(const struct option *option, int unset) { struct format_nul_data *data = option->value; - data->nul_input = 1; - data->nul_output = 1; BUG_ON_OPT_NEG(unset); BUG_ON_OPT_ARG(arg); + data->nul_input = 1; + data->nul_output = 1; return 0; } From 71051871fa0541edf381009035e4e4119361987c Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Tue, 18 Aug 2026 11:57:32 +0200 Subject: [PATCH 005/316] format-rev: factor option variables into a struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will in two commits add three more options to this command. Let’s prepare for that by moving option variables into a struct so that we get less local variables. This allows us to inline `format_nul_data` into this new structure. Let’s also rename `stdin_mode_arg` to `stdin_mode`. (We couldn’t use `stdin_mode` before because of the enumeration with the same name.) Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- builtin/name-rev.c | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/builtin/name-rev.c b/builtin/name-rev.c index d6686bbdbb9009..c8cb2f2d520049 100644 --- a/builtin/name-rev.c +++ b/builtin/name-rev.c @@ -772,16 +772,19 @@ int cmd_name_rev(int argc, return 0; } -struct format_nul_data { +struct format_rev_data { + const char *format; + const char *stdin_mode; bool nul_input; bool nul_output; + struct string_list notes; }; static int format_nul_cb(const struct option *option, const char *arg, int unset) { - struct format_nul_data *data = option->value; + struct format_rev_data *data = option->value; BUG_ON_OPT_NEG(unset); BUG_ON_OPT_ARG(arg); data->nul_input = 1; @@ -813,31 +816,30 @@ int cmd_format_rev(int argc, const char *prefix, struct repository *repo UNUSED) { - const char *format = NULL; + struct format_rev_data data = { + .notes = STRING_LIST_INIT_NODUP, + }; enum stdin_mode stdin_mode; - const char *stdin_mode_arg = NULL; - struct format_nul_data nul_data = { 0, 0 }; char output_terminator; strbuf_getline_fn getline_fn; struct display_notes_opt format_notes_opt; struct rev_info format_rev = REV_INFO_INIT; struct pretty_format format_pp = { 0 }; - struct string_list notes = STRING_LIST_INIT_NODUP; struct strbuf scratch_buf = STRBUF_INIT; struct command cmd; struct option opts[] = { - OPT_STRING(0, "format", &format, N_("format"), + OPT_STRING(0, "format", &data.format, N_("format"), N_("pretty format to use")), - OPT_STRING(0, "stdin-mode", &stdin_mode_arg, N_("stdin-mode"), + OPT_STRING(0, "stdin-mode", &data.stdin_mode, N_("stdin-mode"), N_("how revs are processed")), - OPT_STRING_LIST(0, "notes", ¬es, N_("notes"), + OPT_STRING_LIST(0, "notes", &data.notes, N_("notes"), N_("display notes for pretty format")), - OPT_CALLBACK_F('z', "null", &nul_data, N_("z"), + OPT_CALLBACK_F('z', "null", &data, N_("z"), N_("use NUL for input and output termination"), PARSE_OPT_NOARG | PARSE_OPT_NONEG, format_nul_cb), - OPT_BOOL(0, "null-input", &nul_data.nul_input, + OPT_BOOL(0, "null-input", &data.nul_input, N_("use NUL for input termination")), - OPT_BOOL(0, "null-output", &nul_data.nul_output, + OPT_BOOL(0, "null-output", &data.nul_output, N_("use NUL for output termination")), OPT_END(), }; @@ -849,18 +851,18 @@ int cmd_format_rev(int argc, usage_with_options(format_rev_usage, opts); } - if (!format) + if (!data.format) die(_("'%s' is required"), "--format"); - if (!stdin_mode_arg) + if (!data.stdin_mode) die(_("'%s' is required"), "--stdin-mode"); - getline_fn = nul_data.nul_input ? strbuf_getline_nul : strbuf_getline_lf; - output_terminator = nul_data.nul_output ? '\0' : '\n'; + getline_fn = data.nul_input ? strbuf_getline_nul : strbuf_getline_lf; + output_terminator = data.nul_output ? '\0' : '\n'; init_display_notes(&format_notes_opt); - stdin_mode = parse_stdin_mode(stdin_mode_arg); + stdin_mode = parse_stdin_mode(data.stdin_mode); - get_commit_format(format, &format_rev); + get_commit_format(data.format, &format_rev); format_pp.ctx.rev = &format_rev; format_pp.ctx.fmt = format_rev.commit_format; format_pp.ctx.abbrev = format_rev.abbrev; @@ -868,13 +870,13 @@ int cmd_format_rev(int argc, format_pp.ctx.date_mode = format_rev.date_mode; format_pp.ctx.color = GIT_COLOR_AUTO; - userformat_find_requirements(format, + userformat_find_requirements(data.format, &format_pp.want); if (format_pp.want.notes) { int ignore_show_notes = 0; struct string_list_item *n; - for_each_string_list_item(n, ¬es) + for_each_string_list_item(n, &data.notes) enable_ref_display_notes(&format_notes_opt, &ignore_show_notes, n->string); @@ -934,7 +936,7 @@ int cmd_format_rev(int argc, } strbuf_release(&scratch_buf); - string_list_clear(¬es, 0); + string_list_clear(&data.notes, 0); release_display_notes(&format_notes_opt); return 0; } From e622d15903c421f41f4a08229d124943f5ab133c Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Tue, 18 Aug 2026 11:57:33 +0200 Subject: [PATCH 006/316] doc: rev-list-options.adoc: factor out --date alts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will introduce `--date` to git-format-rev(1) in the next commit and will need to add it to the documentation. Let’s factor out the option alternatives so that it can be included in git-format-rev(1). The initial paragraph of this option mentions things like git-log(1). We could make it fit in git-format-rev(1) while not changing it for git-rev-list(1) and related commands with some conditionals like `ifndef`, but writing a dedicated paragraph is simple enough. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- .../rev-list-option-date-alternatives.adoc | 55 ++++++++++++++++++ Documentation/rev-list-options.adoc | 56 +------------------ 2 files changed, 56 insertions(+), 55 deletions(-) create mode 100644 Documentation/rev-list-option-date-alternatives.adoc diff --git a/Documentation/rev-list-option-date-alternatives.adoc b/Documentation/rev-list-option-date-alternatives.adoc new file mode 100644 index 00000000000000..141570b10598f6 --- /dev/null +++ b/Documentation/rev-list-option-date-alternatives.adoc @@ -0,0 +1,55 @@ +-- +`--date=relative` shows dates relative to the current time, +e.g. ``2 hours ago''. The `-local` option has no effect for +`--date=relative`. + +`--date=local` is an alias for `--date=default-local`. + +`--date=iso` (or `--date=iso8601`) shows timestamps in a ISO 8601-like format. +The differences to the strict ISO 8601 format are: + + - a space instead of the `T` date/time delimiter + - a space between time and time zone + - no colon between hours and minutes of the time zone + +`--date=iso-strict` (or `--date=iso8601-strict`) shows timestamps in strict +ISO 8601 format. + +`--date=rfc` (or `--date=rfc2822`) shows timestamps in RFC 2822 +format, often found in email messages. + +`--date=short` shows only the date, but not the time, in `YYYY-MM-DD` format. + +`--date=raw` shows the date as seconds since the epoch (1970-01-01 +00:00:00 UTC), followed by a space, and then the timezone as an offset +from UTC (a `+` or `-` with four digits; the first two are hours, and +the second two are minutes). I.e., as if the timestamp were formatted +with `strftime("%s %z")`). +Note that the `-local` option does not affect the seconds-since-epoch +value (which is always measured in UTC), but does switch the accompanying +timezone value. + +`--date=human` shows the timezone if the timezone does not match the +current time-zone, and doesn't print the whole date if that matches +(ie skip printing year for dates that are "this year", but also skip +the whole date itself if it's in the last few days and we can just say +what weekday it was). For older dates the hour and minute is also +omitted. + +`--date=unix` shows the date as a Unix epoch timestamp (seconds since +1970). As with `--raw`, this is always in UTC and therefore `-local` +has no effect. + +`--date=format:` feeds the __ to your system `strftime`, +except for `%s`, `%z`, and `%Z`, which are handled internally. +Use `--date=format:%c` to show the date in your system locale's +preferred format. See the `strftime`(3) manual for a complete list of +format placeholders. When using `-local`, the correct syntax is +`--date=format-local:`. + +`--date=default` is the default format, and is based on ctime(3) +output. It shows a single line with three-letter day of the week, +three-letter month, day-of-month, hour-minute-seconds in "HH:MM:SS" +format, followed by 4-digit year, plus timezone information, unless +the local time zone is used, e.g. `Thu Jan 1 00:00:00 1970 +0000`. +-- diff --git a/Documentation/rev-list-options.adoc b/Documentation/rev-list-options.adoc index fd831f0ec64744..6e6093f474732a 100644 --- a/Documentation/rev-list-options.adoc +++ b/Documentation/rev-list-options.adoc @@ -1132,61 +1132,7 @@ include::pretty-options.adoc[] author's). If `-local` is appended to the format (e.g., `iso-local`), the user's local time zone is used instead. + --- -`--date=relative` shows dates relative to the current time, -e.g. ``2 hours ago''. The `-local` option has no effect for -`--date=relative`. - -`--date=local` is an alias for `--date=default-local`. - -`--date=iso` (or `--date=iso8601`) shows timestamps in a ISO 8601-like format. -The differences to the strict ISO 8601 format are: - - - a space instead of the `T` date/time delimiter - - a space between time and time zone - - no colon between hours and minutes of the time zone - -`--date=iso-strict` (or `--date=iso8601-strict`) shows timestamps in strict -ISO 8601 format. - -`--date=rfc` (or `--date=rfc2822`) shows timestamps in RFC 2822 -format, often found in email messages. - -`--date=short` shows only the date, but not the time, in `YYYY-MM-DD` format. - -`--date=raw` shows the date as seconds since the epoch (1970-01-01 -00:00:00 UTC), followed by a space, and then the timezone as an offset -from UTC (a `+` or `-` with four digits; the first two are hours, and -the second two are minutes). I.e., as if the timestamp were formatted -with `strftime("%s %z")`). -Note that the `-local` option does not affect the seconds-since-epoch -value (which is always measured in UTC), but does switch the accompanying -timezone value. - -`--date=human` shows the timezone if the timezone does not match the -current time-zone, and doesn't print the whole date if that matches -(ie skip printing year for dates that are "this year", but also skip -the whole date itself if it's in the last few days and we can just say -what weekday it was). For older dates the hour and minute is also -omitted. - -`--date=unix` shows the date as a Unix epoch timestamp (seconds since -1970). As with `--raw`, this is always in UTC and therefore `-local` -has no effect. - -`--date=format:` feeds the __ to your system `strftime`, -except for `%s`, `%z`, and `%Z`, which are handled internally. -Use `--date=format:%c` to show the date in your system locale's -preferred format. See the `strftime`(3) manual for a complete list of -format placeholders. When using `-local`, the correct syntax is -`--date=format-local:`. - -`--date=default` is the default format, and is based on ctime(3) -output. It shows a single line with three-letter day of the week, -three-letter month, day-of-month, hour-minute-seconds in "HH:MM:SS" -format, followed by 4-digit year, plus timezone information, unless -the local time zone is used, e.g. `Thu Jan 1 00:00:00 1970 +0000`. --- +include::rev-list-option-date-alternatives.adoc[] ifdef::git-rev-list[] `--header`:: From 35a86b341957a414f0ea5bc0be58d6aec3726cd6 Mon Sep 17 00:00:00 2001 From: Kristoffer Haugsbakk Date: Tue, 18 Aug 2026 11:57:34 +0200 Subject: [PATCH 007/316] format-rev: learn --abbrev, --color, and --date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three more options for controlling the formatting. This does not complete all the pretty formatting knobs for this command relative to e.g. git-log(1), but it does add the most important ones, in my opinion. We can see which are missing by taking a look at `Documentation/pretty-options.adoc`: • `--encoding=` • `--show-signature` • `--expand-tabs=` *** We could add these options to the command synopsis, but let’s instead simplify the synopsis to just mention the mandatory options and stuff the other ones into `[]`. I don’t think a long command synopsis line is useful. And this way the two mandatory options stand out more. Signed-off-by: Kristoffer Haugsbakk Signed-off-by: Junio C Hamano --- Documentation/git-format-rev.adoc | 44 +++++++++++++++++++++-- builtin/name-rev.c | 42 ++++++++++++++++------ t/t6120-describe.sh | 58 +++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/Documentation/git-format-rev.adoc b/Documentation/git-format-rev.adoc index 505a52feccd466..1a06ccbf9b806a 100644 --- a/Documentation/git-format-rev.adoc +++ b/Documentation/git-format-rev.adoc @@ -9,7 +9,7 @@ git-format-rev - EXPERIMENTAL: Pretty format revisions on demand SYNOPSIS -------- [synopsis] -(EXPERIMENTAL!) git format-rev --stdin-mode= --format= [--[no-]notes=] [-z] [--[no-]null-output] [--[no-]null-input] +(EXPERIMENTAL!) git format-rev [] --stdin-mode= --format= DESCRIPTION ----------- @@ -33,8 +33,8 @@ OPTIONS The argument `rev` is also accepted. `text`;; Formats all commit object names found in freeform text. These - must be full object names, i.e. abbreviated hexadecimal object - names will not be interpreted. + must be full object names, i.e. abbreviated hexadecimal (_hex_) + object names will not be interpreted. + Anything that is parsed as an object name but that is not found to be a commit object name is left alone (echoed). @@ -76,6 +76,44 @@ This is useful if the output could contain newlines, for example if the + This is useful if the input revision expressions could contain newlines. +`--color[=]`:: +`--no-color`:: + Respect color formatting. The default color behavior is + `auto`. Bare `--color` is the same as `--color=always`. ++ +Giving `--no-color` is the same as `--color=never`. ++ +__ must be one of: ++ +-- +`always`;; + Always use color, even if the output is something like a file. +`never`;; + Never use color. +`auto`;; + Use color when the output is a terminal but not when the output + is something like a file. +-- + +`--abbrev[=]`:: +`--no-abbrev`:: + Abbreviate the commit hex output. Without __ it will find the + minimum length which can describe the commit uniquely, with some + extra slack. Giving __ specifies the minimum length; a longer + length will be used if needed. ++ +Giving `--no-abbrev` will turn off abbreviation, showing the full commit +hex output. ++ +Note that some pretty formats use `--abbrev`. This behavior can be +controlled with these two options. + +`--date=`:: + Date format for pretty formats. Note that date atoms like `%aI` + are not affected. This option cannot be negated. ++ +include::rev-list-option-date-alternatives.adoc[] + [[io]] INPUT AND OUTPUT FORMAT ----------------------- diff --git a/builtin/name-rev.c b/builtin/name-rev.c index c8cb2f2d520049..fa20a2774beaae 100644 --- a/builtin/name-rev.c +++ b/builtin/name-rev.c @@ -21,6 +21,7 @@ #include "revision.h" #include "notes.h" #include "write-or-die.h" +#include "date.h" /* * One day. See the 'name a rev shortly after epoch' test in t6120 when @@ -778,6 +779,8 @@ struct format_rev_data { bool nul_input; bool nul_output; struct string_list notes; + struct rev_info rev; + int color; }; static int format_nul_cb(const struct option *option, @@ -792,6 +795,17 @@ static int format_nul_cb(const struct option *option, return 0; } +static int date_cb(const struct option *option, + const char *arg, + int unset) +{ + struct rev_info *data = option->value; + BUG_ON_OPT_NEG(unset); + parse_date_format(arg, &data->date_mode); + data->date_mode_explicit = 1; + return 0; +} + static enum stdin_mode parse_stdin_mode(const char *stdin_mode) { if (!strcmp(stdin_mode, "text")) @@ -805,9 +819,8 @@ static enum stdin_mode parse_stdin_mode(const char *stdin_mode) } static char const *const format_rev_usage[] = { - N_("(EXPERIMENTAL!) git format-rev --stdin-mode= " - "--format= [--[no-]notes=] " - "[-z] [--[no-]null-output] [--[no-]null-input]"), + N_("(EXPERIMENTAL!) git format-rev [] " + "--stdin-mode= --format="), NULL }; @@ -818,12 +831,13 @@ int cmd_format_rev(int argc, { struct format_rev_data data = { .notes = STRING_LIST_INIT_NODUP, + .rev = REV_INFO_INIT, + .color = GIT_COLOR_AUTO, }; enum stdin_mode stdin_mode; char output_terminator; strbuf_getline_fn getline_fn; struct display_notes_opt format_notes_opt; - struct rev_info format_rev = REV_INFO_INIT; struct pretty_format format_pp = { 0 }; struct strbuf scratch_buf = STRBUF_INIT; struct command cmd; @@ -834,6 +848,11 @@ int cmd_format_rev(int argc, N_("how revs are processed")), OPT_STRING_LIST(0, "notes", &data.notes, N_("notes"), N_("display notes for pretty format")), + OPT__ABBREV(&data.rev.abbrev), + OPT__COLOR(&data.color, N_("use colored output")), + OPT_CALLBACK_F(0, "date", &data.rev, N_("date"), + N_("date format"), + PARSE_OPT_NONEG, date_cb), OPT_CALLBACK_F('z', "null", &data, N_("z"), N_("use NUL for input and output termination"), PARSE_OPT_NOARG | PARSE_OPT_NONEG, format_nul_cb), @@ -862,13 +881,13 @@ int cmd_format_rev(int argc, init_display_notes(&format_notes_opt); stdin_mode = parse_stdin_mode(data.stdin_mode); - get_commit_format(data.format, &format_rev); - format_pp.ctx.rev = &format_rev; - format_pp.ctx.fmt = format_rev.commit_format; - format_pp.ctx.abbrev = format_rev.abbrev; - format_pp.ctx.date_mode_explicit = format_rev.date_mode_explicit; - format_pp.ctx.date_mode = format_rev.date_mode; - format_pp.ctx.color = GIT_COLOR_AUTO; + get_commit_format(data.format, &data.rev); + format_pp.ctx.rev = &data.rev; + format_pp.ctx.fmt = data.rev.commit_format; + format_pp.ctx.abbrev = data.rev.abbrev; + format_pp.ctx.date_mode_explicit = data.rev.date_mode_explicit; + format_pp.ctx.date_mode = data.rev.date_mode; + format_pp.ctx.color = data.color; userformat_find_requirements(data.format, &format_pp.want); @@ -935,6 +954,7 @@ int cmd_format_rev(int argc, BUG("uncovered case: %d", stdin_mode); } + date_mode_release(&data.rev.date_mode); strbuf_release(&scratch_buf); string_list_clear(&data.notes, 0); release_display_notes(&format_notes_opt); diff --git a/t/t6120-describe.sh b/t/t6120-describe.sh index 7a7c46658a3a81..a15da979abfc0c 100755 --- a/t/t6120-describe.sh +++ b/t/t6120-describe.sh @@ -1017,4 +1017,62 @@ do ' done input <<-\EOF && + third + second + first + EOF + git -C repo-format log --stdin --no-walk \ + --format="$format" "$opts" >expect actual expect && + test_must_fail git -C repo-format format-rev \ + --stdin-mode=revs --format="$format" "$opts" 2>actual && + test_cmp expect actual +} + +test_expect_success 'format-rev --color' ' + format_rev_cmp_log --color=always && + format_rev_cmp_log --color && + format_rev_cmp_log --no-color && + format_rev_err_cmp_log --color=not-valid +' + +test_expect_success 'format-rev --abbrev' ' + format_rev_cmp_log --abbrev && + format_rev_cmp_log --abbrev=31 && + format_rev_cmp_log --no-abbrev +' + +test_expect_success 'format-rev --date' ' + format_rev_cmp_log --date=relative && + format_rev_cmp_log --date=iso-strict && + # This also tests the only case where we need to release + # the data for the parsed format + format_rev_cmp_log --date="format:%c" && + format_rev_err_cmp_log --date=not-valid && + # Test --date (no arg) next + # We cannot compare the output to git-log(1) + # because that command uses a slightly different + # error message (different library) + cat >expect <<-EOF && + error: option \`date${SQ} requires a value + EOF + test_must_fail git -C repo-format format-rev \ + --stdin-mode=revs --format="$format" --date 2>actual && + test_cmp expect actual +' + test_done From 3de34b072adbaef8c5f4ff337215a4d91597cf7a Mon Sep 17 00:00:00 2001 From: Aaron Plattner Date: Wed, 19 Aug 2026 20:21:04 -0700 Subject: [PATCH 008/316] http: preserve wwwauth_headers across redirects When cURL follows a redirect, it calls the CURLOPT_HEADERFUNCTION for each header received including ones from a redirect. http_request() sets fwrite_wwwauth() as the header function, which will record the wwwauth[] entries for the last step in the redirection chain. However, when http_request_recoverable() sees that cURL followed a redirect, it attempts to update the credentials for the request from the new URL using credential_from_url(). The first thing that does is call credential_clear(), which clears everything including wwwauth_headers. If the new URL should use a credential helper rather than credentials embedded in the URL, this loses the list of authentication methods that the server provided in the redirect. The WWW-Authenticate challenge is not derived from the URL; it is populated from the server's response, and after a redirect it describes how to authenticate to the redirect target and it needs to survive the URL update so that credential helpers can know which authentication methods are allowed. Add a new credential_update_url() that wraps credential_from_url() and preserves wwwauth_headers specifically. Use SWAP() to avoid having to copy the whole strbuf. Signed-off-by: Aaron Plattner Signed-off-by: Junio C Hamano --- credential.c | 16 +++++++++++++ credential.h | 8 +++++++ http.c | 9 +++++++- t/lib-httpd/apache.conf | 1 + t/t5563-simple-http-auth.sh | 45 +++++++++++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/credential.c b/credential.c index 2594c0c4229ba0..035399d7ee5abd 100644 --- a/credential.c +++ b/credential.c @@ -708,3 +708,19 @@ void credential_from_url(struct credential *c, const char *url) if (credential_from_url_gently(c, url, 0) < 0) die(_("credential url cannot be parsed: %s"), url); } + +void credential_update_url(struct credential *c, const char *url) +{ + struct strvec wwwauth_headers = STRVEC_INIT; + + /* + * credential_from_url() clears the whole credential. Preserve the + * WWW-Authenticate list, which is derived from the server's original + * response rather than from the URL and is required to authenticate to + * the new URL. + */ + SWAP(wwwauth_headers, c->wwwauth_headers); + credential_from_url(c, url); + SWAP(c->wwwauth_headers, wwwauth_headers); + strvec_clear(&wwwauth_headers); +} diff --git a/credential.h b/credential.h index c78b72d110eaac..b90f666e33d4e2 100644 --- a/credential.h +++ b/credential.h @@ -305,6 +305,14 @@ void credential_write(const struct credential *, FILE *, void credential_from_url(struct credential *, const char *url); int credential_from_url_gently(struct credential *, const char *url, int quiet); +/* + * Update the URL-derived fields (protocol, host, path) of an existing + * credential to match a new URL. Unlike credential_from_url(), this function + * preserves state that was derived from a server's HTTP redirect response, + * such as the WWW-Authenticate headers. + */ +void credential_update_url(struct credential *c, const char *url); + int credential_match(const struct credential *want, const struct credential *have, int match_password); diff --git a/http.c b/http.c index a0d399b2745ae6..e8abb9f95ac7ab 100644 --- a/http.c +++ b/http.c @@ -2427,7 +2427,14 @@ static int http_request_recoverable(const char *url, if (options->effective_url && options->base_url) { if (update_url_from_redirect(options->base_url, url, options->effective_url)) { - credential_from_url(&http_auth, options->base_url->buf); + /* + * Use credential_update_url() rather than + * credential_from_url() so that the WWW-Authenticate + * challenge the server sent with the redirect target's + * response is preserved and handed to the credential + * helper. + */ + credential_update_url(&http_auth, options->base_url->buf); url = options->effective_url->buf; } } diff --git a/t/lib-httpd/apache.conf b/t/lib-httpd/apache.conf index 4149fc1078a877..0627ef143308a9 100644 --- a/t/lib-httpd/apache.conf +++ b/t/lib-httpd/apache.conf @@ -203,6 +203,7 @@ RewriteRule ^/dumb-redir/(.*)$ /dumb/$1 [R=301] RewriteRule ^/smart-redir-perm/(.*)$ /smart/$1 [R=301] RewriteRule ^/smart-redir-temp/(.*)$ /smart/$1 [R=302] RewriteRule ^/smart-redir-auth/(.*)$ /auth/smart/$1 [R=301] +RewriteRule ^/custom_auth_redir/(.*)$ /custom_auth/$1 [R=302] RewriteRule ^/smart-redir-limited/(.*)/info/refs$ /smart/$1/info/refs [R=301] RewriteRule ^/ftp-redir/(.*)$ ftp://localhost:1000/$1 [R=302] diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh index a7d475dd68dbd7..349ae4ab39cb0f 100755 --- a/t/t5563-simple-http-auth.sh +++ b/t/t5563-simple-http-auth.sh @@ -557,6 +557,51 @@ test_expect_success 'access using bearer auth' ' EOF ' +test_expect_success 'bearer auth after redirect preserves wwwauth headers' ' + test_when_finished "per_test_cleanup" && + + set_credential_reply get <<-EOF && + capability[]=authtype + authtype=Bearer + credential=YS1naXQtdG9rZW4= + EOF + + cat >"$HTTPD_ROOT_PATH/custom-auth.valid" <<-EOF && + id=1 creds=Bearer YS1naXQtdG9rZW4= + EOF + + cat >"$HTTPD_ROOT_PATH/custom-auth.challenge" <<-EOF && + id=1 status=200 + id=default response=WWW-Authenticate: FooBar param1="value1" param2="value2" + id=default response=WWW-Authenticate: Bearer authorize_uri="id.example.com" p=1 q=0 + id=default response=WWW-Authenticate: Basic realm="example.com" + EOF + + test_config_global credential.helper test-helper && + test_config_global credential.useHttpPath true && + git ls-remote "$HTTPD_URL/custom_auth_redir/repo.git" && + + expect_credential_query get <<-EOF && + capability[]=authtype + capability[]=state + protocol=http + host=$HTTPD_DEST + path=custom_auth/repo.git + wwwauth[]=FooBar param1="value1" param2="value2" + wwwauth[]=Bearer authorize_uri="id.example.com" p=1 q=0 + wwwauth[]=Basic realm="example.com" + EOF + + expect_credential_query store <<-EOF + capability[]=authtype + authtype=Bearer + credential=YS1naXQtdG9rZW4= + protocol=http + host=$HTTPD_DEST + path=custom_auth/repo.git + EOF +' + test_expect_success 'access using bearer auth with invalid credentials' ' test_when_finished "per_test_cleanup" && From ef0cac750952ae459f2bbe9944e14424105b0a95 Mon Sep 17 00:00:00 2001 From: Volodymyr Vriukalo <0@zitro.id> Date: Sat, 22 Aug 2026 01:01:41 +0200 Subject: [PATCH 009/316] branch: do not track a start point with no ref Forcing a branch to a commit that no ref points at aborts when both `submodule.recurse` and `submodule.propagateBranches` are set and the repository has a remote configured: BUG: refspec.c:442: refspec_find_match: need either src or dst Aborted (core dumped) `create_branches_recursively()` resolves the start point through `dwim_branch_start()`, which leaves `branch_point` NULL when the start point names no ref -- an object id, or a revision expression such as `HEAD~0`. That NULL becomes `tracking_name`, and the `setup_tracking()` call below it is guarded on `track` alone. `setup_tracking()` assigns it to `tracking.spec.dst` without checking, then hands the spec to `for_each_remote()`, so `refspec_find_match()` receives a query with neither src nor dst and trips its assertion. `for_each_remote()` never reaches that callback where no remote is configured, which is why the abort needs one. 961b130d20 (branch: add --recurse-submodules option for branch creation, 2022-01-28) added the call with no guard at all. 75388bf5b4 (branch: support more tracking modes when recursing, 2022-03-29) added the guard on `track`. Updating the branch happens before the abort, so the command does what was asked and then exits 134. Callers that check the exit status therefore see a failure that did not happen, and one that rolls back on failure would undo a successful update. `create_branch()` already declines this: it calls `setup_tracking()` under `if (real_ref && track)`, leaving tracking unset when the start point resolved to no ref. Make the recursive path agree. Checking for NULL inside `setup_tracking()` would also silence the abort, but it would put the decision in the callee for one caller that has the answer already, and leave the two creation paths disagreeing about when tracking is set up. Reproducing it needs all four of: - `submodule.recurse=true` - `submodule.propagateBranches=true` - a configured remote - a start point that is not a ref name Submodules take no part, so the new test builds a repository with neither a submodule nor a `.gitmodules`, where `propagateBranches` is set and has nothing to propagate to. Assisted-by: An LLM. Signed-off-by: Volodymyr Vriukalo <0@zitro.id> Signed-off-by: Junio C Hamano --- branch.c | 2 +- t/t3207-branch-submodule.sh | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/branch.c b/branch.c index 22f4f46b96ed3a..e26301197d200e 100644 --- a/branch.c +++ b/branch.c @@ -840,7 +840,7 @@ void create_branches_recursively(struct repository *r, const char *name, * tedious to determine whether or not tracking was set up in the * superproject. */ - if (track) + if (tracking_name && track) setup_tracking(name, tracking_name, track, quiet); for (i = 0; i < submodule_entry_list.entry_nr; i++) { diff --git a/t/t3207-branch-submodule.sh b/t/t3207-branch-submodule.sh index 4afb2d3198d79d..97d057959db0a5 100755 --- a/t/t3207-branch-submodule.sh +++ b/t/t3207-branch-submodule.sh @@ -98,6 +98,23 @@ test_expect_success 'should respect submodule.recurse when creating branches' ' ) ' +test_expect_success 'should move a branch to a start point that names no ref' ' + test_when_finished "rm -rf no-submodules" && + git init no-submodules && + ( + cd no-submodules && + test_commit one && + test_commit two && + git remote add origin . && + git config submodule.propagateBranches true && + git config submodule.recurse true && + git branch branch-a HEAD~1 && + oid=$(git rev-parse HEAD) && + git branch -f branch-a "$oid" && + test_cmp_rev HEAD branch-a + ) +' + test_expect_success 'should ignore submodule.recurse when not creating branches' ' test_when_finished "reset_test" && ( From 9d721b060686f309b3a719fdb9f261124ca954fd Mon Sep 17 00:00:00 2001 From: Volodymyr Vriukalo <0@zitro.id> Date: Sat, 22 Aug 2026 01:01:42 +0200 Subject: [PATCH 010/316] branch: allow recursion with no tracking name Creating a branch across submodules from a commit that no ref points at fails, with the helper's usage text reprinted as an error: submodule 'sub': usage: git submodule--helper create-branch [...] fatal: submodule 'sub': cannot create branch 'branch-a' `submodule_create_branch()` runs the helper in a child process because `install_branch_config_multiple_remotes()` cannot write config into a submodule, and passes the branch name, the start oid and the tracking name as three positionals. `dwim_branch_start()` leaves the tracking name NULL where the start point named no ref, and `strvec_pushl()` stops at the first NULL, so the child receives two positionals. `module_create_branch()` requires exactly three and prints its usage. Make the third positional optional, since a start point that named no ref has no tracking name to give and the recursion has nothing to track in the submodule either. Push it separately in the caller too: relying on `strvec_pushl()` to stop early leaves the argument dropped by accident rather than by intent, and a reader has to know where the terminator falls to see that it can go missing at all. 961b130d20 (branch: add --recurse-submodules option for branch creation, 2022-01-28) introduced both sides. This is the same NULL tracking name as the previous patch, reached one step earlier: the dry-run pass over the submodules runs before the superproject's own `setup_tracking()` call, so with a submodule present this failure hides the abort that patch removes. The new test therefore needs that patch under it. Assisted-by: An LLM. Signed-off-by: Volodymyr Vriukalo <0@zitro.id> Signed-off-by: Junio C Hamano --- branch.c | 10 +++++++++- builtin/submodule--helper.c | 7 ++++--- t/t3207-branch-submodule.sh | 13 +++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/branch.c b/branch.c index e26301197d200e..b5182f2bb70a6e 100644 --- a/branch.c +++ b/branch.c @@ -760,7 +760,15 @@ static int submodule_create_branch(struct repository *r, break; } - strvec_pushl(&child.args, name, start_oid, tracking_name, NULL); + /* + * The tracking name is absent when the start point named no ref. + * Push it separately: strvec_pushl() stops at the first NULL, so + * passing it inline would drop the argument by accident rather + * than by intent. + */ + strvec_pushl(&child.args, name, start_oid, NULL); + if (tracking_name) + strvec_push(&child.args, tracking_name); if ((ret = start_command(&child))) return ret; diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index e7cd3225fa84c4..1eb73de6742516 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -3336,7 +3336,7 @@ static int module_create_branch(int argc, const char **argv, const char *prefix, OPT_END() }; const char *const usage[] = { - N_("git submodule--helper create-branch [-f|--force] [--create-reflog] [-q|--quiet] [-t|--track] [-n|--dry-run] "), + N_("git submodule--helper create-branch [-f|--force] [--create-reflog] [-q|--quiet] [-t|--track] [-n|--dry-run] []"), NULL }; struct repo_config_values *cfg = repo_config_values(the_repository); @@ -3345,13 +3345,14 @@ static int module_create_branch(int argc, const char **argv, const char *prefix, track = cfg->branch_track; argc = parse_options(argc, argv, prefix, options, usage, 0); - if (argc != 3) + if (argc < 2 || argc > 3) usage_with_options(usage, options); if (!quiet && !dry_run) printf_ln(_("creating branch '%s'"), argv[0]); - create_branches_recursively(the_repository, argv[0], argv[1], argv[2], + create_branches_recursively(the_repository, argv[0], argv[1], + argc > 2 ? argv[2] : NULL, force, reflog, quiet, track, dry_run); return 0; } diff --git a/t/t3207-branch-submodule.sh b/t/t3207-branch-submodule.sh index 97d057959db0a5..4dd26c6c0f887b 100755 --- a/t/t3207-branch-submodule.sh +++ b/t/t3207-branch-submodule.sh @@ -115,6 +115,19 @@ test_expect_success 'should move a branch to a start point that names no ref' ' ) ' +test_expect_success 'should recurse into submodules from a start point that names no ref' ' + test_when_finished "reset_test" && + ( + cd super && + oid=$(git rev-parse HEAD) && + git branch --recurse-submodules branch-a "$oid" && + git rev-parse branch-a && + git -C sub rev-parse branch-a && + git -C sub/sub-sub rev-parse branch-a && + git -C second/sub rev-parse branch-a + ) +' + test_expect_success 'should ignore submodule.recurse when not creating branches' ' test_when_finished "reset_test" && ( From 209363b3eb4facb36ebbfb0c09d48caa5d390b8c Mon Sep 17 00:00:00 2001 From: Wesley Schwengle Date: Thu, 27 Aug 2026 19:43:44 -0400 Subject: [PATCH 011/316] git-svn: don't print v1-layout migration noise when there's nothing to migrate `migrate_from_v1()' unconditionally announced and created `.git/svn' when no legacy `refs/remotes/*' metadata existed to migrate. This happens for example right after a `git init'. Defer the logic until an actual candidate is found, matching the existing pattern of `migrate_from_v0' Signed-off-by: Wesley Schwengle Signed-off-by: Junio C Hamano --- perl/Git/SVN/Migration.pm | 16 ++++++++++------ t/t9107-git-svn-migrate.sh | 7 +++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/perl/Git/SVN/Migration.pm b/perl/Git/SVN/Migration.pm index ed96ac593e8098..65f6b393a0d89c 100644 --- a/perl/Git/SVN/Migration.pm +++ b/perl/Git/SVN/Migration.pm @@ -91,11 +91,6 @@ sub migrate_from_v1 { # just in case somebody used 'svn' as their $id at some point... return $migrated if -d $svn_dir && ! -f "$svn_dir/info/url"; - print STDERR "Migrating from a git-svn v1 layout...\n"; - mkpath([$svn_dir]); - print STDERR "Data from a previous version of git-svn exists, but\n\t", - "$svn_dir\n\t(required for this version ", - "($::VERSION) of git-svn) does not exist.\n"; my ($fh, $ctx) = command_output_pipe(qw/rev-parse --symbolic --all/); while (<$fh>) { my $x = $_; @@ -106,6 +101,15 @@ sub migrate_from_v1 { next unless -f $info_url; my $u = eval { ::file_to_s($info_url) }; next unless $u; + unless ($migrated) { + print STDERR "Migrating from a git-svn v1 layout...\n"; + mkpath([$svn_dir]); + print STDERR "Data from a previous version of ", + "git-svn exists, but\n\t", + "$svn_dir\n\t(required for this version ", + "($::VERSION) of git-svn) does not ", + "exist.\n"; + } my $dn = dirname("$svn_dir/$x"); mkpath([$dn]) unless -d $dn; if ($x eq 'svn') { # they used 'svn' as GIT_SVN_ID: @@ -127,7 +131,7 @@ sub migrate_from_v1 { $migrated++; } command_close_pipe($fh, $ctx); - print STDERR "Done migrating from a git-svn v1 layout\n"; + print STDERR "Done migrating from a git-svn v1 layout\n" if $migrated; $migrated; } diff --git a/t/t9107-git-svn-migrate.sh b/t/t9107-git-svn-migrate.sh index aa908bbc2f7dad..49e4f4774b1f97 100755 --- a/t/t9107-git-svn-migrate.sh +++ b/t/t9107-git-svn-migrate.sh @@ -3,6 +3,13 @@ test_description='git svn metadata migrations from previous versions' . ./lib-git-svn.sh +test_expect_success 'migrate is silent when there is nothing to migrate' ' + git svn migrate 2>err.log && + test_grep ! "Migrating from a git-svn v1 layout" err.log && + test_grep ! "Data from a previous version of git-svn exists" err.log && + ! test -d "$GIT_DIR"/svn + ' + test_expect_success 'setup old-looking metadata' ' cp "$GIT_DIR"/config "$GIT_DIR"/config-old-git-svn && mkdir import && From c618e8f8bd07dbefd44e717bff5864ff04e0e52d Mon Sep 17 00:00:00 2001 From: Wesley Schwengle Date: Thu, 27 Aug 2026 19:43:45 -0400 Subject: [PATCH 012/316] Makefile: add NO_GIT_SVN knob to skip building/installing git-svn This option also implies that NO_SVN_TESTS is enabled. Signed-off-by: Wesley Schwengle Signed-off-by: Junio C Hamano --- Makefile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Makefile b/Makefile index cedc234173e377..616d9044290ca3 100644 --- a/Makefile +++ b/Makefile @@ -88,6 +88,9 @@ include shared.mak # tests. These tests take up a significant amount of the total test time # but are not needed unless you plan to talk to SVN repos. # +# Define NO_GIT_SVN if you don't want git-svn built or installed at all. +# Implies NO_SVN_TESTS. +# # Define NO_FINK if you are building on Darwin/Mac OS X, have Fink # installed in /sw, but don't want GIT to link against any libraries # installed there. If defined you may specify your own (or Fink's) @@ -744,7 +747,9 @@ SCRIPT_PERL += git-cvsexportcommit.perl SCRIPT_PERL += git-cvsimport.perl SCRIPT_PERL += git-cvsserver.perl SCRIPT_PERL += git-send-email.perl +ifndef NO_GIT_SVN SCRIPT_PERL += git-svn.perl +endif SCRIPT_PYTHON += git-p4.py @@ -3382,6 +3387,9 @@ $(test_bindir_programs): bin-wrappers/%: bin-wrappers/wrap-for-bin.sh # However, the environment gets quite big, and some programs have problems # with that. +ifdef NO_GIT_SVN +NO_SVN_TESTS = YesPlease +endif export NO_SVN_TESTS export TEST_NO_MALLOC_CHECK From 83c9ba8970c7a6e2d0a6d6602dc62948f0074cb6 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Sat, 29 Aug 2026 18:37:04 +0000 Subject: [PATCH 013/316] doc: remote-helpers: option pushcert if-asked The documentation mentions that the value for the option "pushcert" is "true" or "false". This is incomplete, as can be seen by analyzing transport-helper.c:1009-1018 (added in commit 30261094b1f7fdcba3b7a1) which also implements setting the option to the value "if-asked". Add the third value to the documentation (added in commit b9299a2bb1). Signed-off-by: Lorenz Leutgeb Signed-off-by: Junio C Hamano --- Documentation/gitremote-helpers.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Documentation/gitremote-helpers.adoc b/Documentation/gitremote-helpers.adoc index 39cdece16e93a4..10921e91b9e251 100644 --- a/Documentation/gitremote-helpers.adoc +++ b/Documentation/gitremote-helpers.adoc @@ -523,7 +523,7 @@ set by Git if the remote helper has the 'option' capability. 'option update-shallow' {'true'|'false'}:: Allow to extend .git/shallow if the new refs require it. -'option pushcert' {'true'|'false'}:: +'option pushcert' {'true'|'false'|'if-asked'}:: GPG sign pushes. 'option push-option' :: From b7879201bf29a38132dfba5a61e1ad67cf6fc54b Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 3 Sep 2026 05:05:13 +0000 Subject: [PATCH 014/316] diff: rename line-range filter struct and clarify fields diff's line-range filtering logic uses the line_range_callback struct to represent filtering state. However, this name does not clearly reflect the role it plays. This is especially relevant as we expand diff's line-range filtering to work with more options, including --stat and -G. Also, line_range_callback's fields are terse, while the comment explaining line_range_callback is verbose and out of place compared to its surroundings. Rename line_range_callback to line_range_filter, and replace the verbose comment with a concise one, instead preferring descriptive field and variable names that are self-explanatory over comments. No logical behavior change. Some fields are grouped under a new struct in the newly renamed line_range_filter. Everything else is just a rename. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 270 ++++++++++++++++++++++++--------------------------------- 1 file changed, 114 insertions(+), 156 deletions(-) diff --git a/diff.c b/diff.c index 414532d09f5ba3..679a0e27d4d9c5 100644 --- a/diff.c +++ b/diff.c @@ -610,49 +610,31 @@ 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. - * - * 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). + * Filter the line ranges that are emitted by diff. */ -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; + const struct range_set *range_sets_to_filter_by; + unsigned int range_set_idx; + + struct { + char func_name[80]; + long func_name_len; + long old_begin, old_count; + long new_begin, new_count; + long lno_in_preimage; + long lno_in_postimage; + struct strbuf lines; + int active; + int has_changes; + } accumulating_hunk; - /* - * Function name from most recent xdiff hunk header; - * size matches struct func_line.buf in xdiff/xemit.c. - */ - 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? */ - - /* 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 */ + long pending_rm_pre_begin; - int ret; /* latched error from orig_line_fn */ + int ret; }; static int count_lines(const char *data, int size) @@ -2540,69 +2522,59 @@ 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; + const char *line_buf, *line_buf_end; - if (!s->rhunk_active || s->ret) + if (!filter->accumulating_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->accumulating_hunk.lines, &filter->pending_rm); + filter->accumulating_hunk.old_count += filter->pending_rm_count; + filter->accumulating_hunk.has_changes = 1; + discard_pending_rm(filter); } - /* - * Suppress context-only hunks: they contain no actual changes - * and would just be noise. This can happen when the inflated - * 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->accumulating_hunk.has_changes) { + filter->accumulating_hunk.active = 0; + strbuf_reset(&filter->accumulating_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->accumulating_hunk.old_begin, filter->accumulating_hunk.old_count, + filter->accumulating_hunk.new_begin, filter->accumulating_hunk.new_count); + if (filter->accumulating_hunk.func_name_len > 0) { strbuf_addch(&hdr, ' '); - strbuf_add(&hdr, s->func, s->funclen); + strbuf_add(&hdr, filter->accumulating_hunk.func_name, + filter->accumulating_hunk.func_name_len); } 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); - /* - * Replay buffered lines one at a time through fn_out_consume. - * 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) { - 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); - p += line_len; + line_buf = filter->accumulating_hunk.lines.buf; + line_buf_end = line_buf + filter->accumulating_hunk.lines.len; + while (!filter->ret && line_buf < line_buf_end) { + const char *eol = memchr(line_buf, '\n', line_buf_end - line_buf); + unsigned long line_len = eol ? (unsigned long)(eol - line_buf + 1) + : (unsigned long)(line_buf_end - line_buf); + filter->ret = filter->orig_line_fn(filter->orig_cb_data, + (char *)line_buf, line_len); + line_buf += line_len; } - s->rhunk_active = 0; - strbuf_reset(&s->rhunk); + filter->accumulating_hunk.active = 0; + strbuf_reset(&filter->accumulating_hunk.lines); } static void line_range_hunk_fn(void *data, @@ -2610,116 +2582,102 @@ 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 - * 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. - */ - s->lno_post = new_begin; - s->lno_pre = old_begin; + filter->accumulating_hunk.lno_in_postimage = new_begin; + filter->accumulating_hunk.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->accumulating_hunk.func_name)) + funclen = sizeof(filter->accumulating_hunk.func_name); + memcpy(filter->accumulating_hunk.func_name, func, funclen); } - s->funclen = funclen; + filter->accumulating_hunk.func_name_len = 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->accumulating_hunk.lno_in_preimage; + filter->accumulating_hunk.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); - /* otherwise outside tracked range; drop silently */ - return s->ret; + if (filter->pending_rm_count) + strbuf_add(&filter->pending_rm, line, len); + else if (filter->accumulating_hunk.active) + strbuf_add(&filter->accumulating_hunk.lines, line, len); + 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->accumulating_hunk.lno_in_postimage - 1; + cur_pre = filter->accumulating_hunk.lno_in_preimage; + filter->accumulating_hunk.lno_in_postimage++; if (line[0] == ' ') - s->lno_pre++; + filter->accumulating_hunk.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->range_set_idx < filter->range_sets_to_filter_by->nr && + idx_in_postimage >= + filter->range_sets_to_filter_by->ranges[filter->range_set_idx].end) { + if (filter->accumulating_hunk.active) + flush_range_hunk(filter); + discard_pending_rm(filter); + filter->range_set_idx++; } - /* Past all ranges */ - if (s->cur_range >= s->ranges->nr) { - discard_pending_rm(s); - return s->ret; + if (filter->range_set_idx >= filter->range_sets_to_filter_by->nr) { + discard_pending_rm(filter); + return filter->ret; } - cur = &s->ranges->ranges[s->cur_range]; + cur = &filter->range_sets_to_filter_by->ranges[filter->range_set_idx]; - /* 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->accumulating_hunk.active) { + filter->accumulating_hunk.active = 1; + filter->accumulating_hunk.has_changes = 0; + filter->accumulating_hunk.new_begin = idx_in_postimage + 1; + filter->accumulating_hunk.old_begin = filter->pending_rm_count + ? filter->pending_rm_pre_begin : cur_pre; + filter->accumulating_hunk.old_count = 0; + filter->accumulating_hunk.new_count = 0; + strbuf_reset(&filter->accumulating_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->accumulating_hunk.lines, &filter->pending_rm); + filter->accumulating_hunk.old_count += filter->pending_rm_count; + filter->accumulating_hunk.has_changes = 1; + discard_pending_rm(filter); } - strbuf_add(&s->rhunk, line, len); - s->rhunk_new_count++; + strbuf_add(&filter->accumulating_hunk.lines, line, len); + filter->accumulating_hunk.new_count++; if (line[0] == '+') - s->rhunk_has_changes = 1; + filter->accumulating_hunk.has_changes = 1; else - s->rhunk_old_count++; + filter->accumulating_hunk.old_count++; - return s->ret; + return filter->ret; } static void pprint_rename(struct strbuf *name, const char *a, const char *b) @@ -4066,15 +4024,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_callback lr_state; + struct line_range_filter lr_state; unsigned int i; long max_span = 0; 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.rhunk, 0); + lr_state.range_sets_to_filter_by = line_ranges; + strbuf_init(&lr_state.accumulating_hunk.lines, 0); strbuf_init(&lr_state.pending_rm, 0); /* @@ -4105,11 +4063,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.accumulating_hunk.lines); strbuf_release(&lr_state.pending_rm); } else if (xdi_diff_outf(&mf1, &mf2, NULL, fn_out_consume, &ecbdata, &xpp, &xecfg)) From 631b40f4f16b2c14840583d7b7d70a18c754042b Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 3 Sep 2026 05:05:14 +0000 Subject: [PATCH 015/316] diff: simplify the line-range filter by classifying removals immediately Currently, the diff line-range filter buffers preimage removal lines until a postimage line arrives. That line's number confirms whether the preimage line falls in a relevant range. However, storing preimage lines in a separate buffer is unnecessary. Worse, the logic has a bug: a preimage line outside the target range is included when it immediately follows an in-range postimage line. Preimage lines will always precede their postimage counterpart both in content line number and emission order from xdiff's line callback function. So preimage lines can share the postimage buffer. The filter flushes them based on whether the postimage lines fall within the target range. Remove logic related to storing preimage lines in a separate "removal" buffer and prepending them to the accumulating_hunk's line buffer. Instead, store those lines in the accumulating_hunk's line_buffer immediately and flush everything as appropriate based on postimage line numbers that arrive. This resolves the bug by construction. Also, calculate the old and new line counts for the diff hunk header when flushing rather than storing counters in line_range_filter to simplify state management further. Add a test to t/t4211-line-log.sh that verifies the preimage line emission bug is fixed. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 121 +++++++++++++++++--------------------------- t/t4211-line-log.sh | 31 ++++++++++++ 2 files changed, 78 insertions(+), 74 deletions(-) diff --git a/diff.c b/diff.c index 679a0e27d4d9c5..c94ddbebe51be5 100644 --- a/diff.c +++ b/diff.c @@ -621,19 +621,14 @@ struct line_range_filter { struct { char func_name[80]; long func_name_len; - long old_begin, old_count; - long new_begin, new_count; + long old_begin; + long new_begin; long lno_in_preimage; long lno_in_postimage; struct strbuf lines; int active; - int has_changes; } accumulating_hunk; - struct strbuf pending_rm; - int pending_rm_count; - long pending_rm_pre_begin; - int ret; }; @@ -2522,40 +2517,56 @@ 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) +static void begin_range_hunk(struct line_range_filter *filter) { - strbuf_reset(&filter->pending_rm); - filter->pending_rm_count = 0; + filter->accumulating_hunk.active = 1; + filter->accumulating_hunk.new_begin = filter->accumulating_hunk.lno_in_postimage; + filter->accumulating_hunk.old_begin = filter->accumulating_hunk.lno_in_preimage; + strbuf_reset(&filter->accumulating_hunk.lines); } static void flush_range_hunk(struct line_range_filter *filter) { struct strbuf hdr = STRBUF_INIT; const char *line_buf, *line_buf_end; + long old_count = 0, new_count = 0; + int has_changes = 0; if (!filter->accumulating_hunk.active || filter->ret) return; - if (filter->pending_rm_count) { - strbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm); - filter->accumulating_hunk.old_count += filter->pending_rm_count; - filter->accumulating_hunk.has_changes = 1; - discard_pending_rm(filter); + line_buf = filter->accumulating_hunk.lines.buf; + line_buf_end = line_buf + filter->accumulating_hunk.lines.len; + while (line_buf < line_buf_end) { + const char *eol = memchr(line_buf, '\n', line_buf_end - line_buf); + if (*line_buf == ' ') { + old_count++; + new_count++; + } + else if (*line_buf == '-') { + old_count++; + has_changes = 1; + } + else if (*line_buf == '+') { + new_count++; + has_changes = 1; + } + line_buf = eol ? eol + 1 : line_buf_end; } - if (!filter->accumulating_hunk.has_changes) { + if (!has_changes) { filter->accumulating_hunk.active = 0; strbuf_reset(&filter->accumulating_hunk.lines); return; } strbuf_addf(&hdr, "@@ -%ld,%ld +%ld,%ld @@", - filter->accumulating_hunk.old_begin, filter->accumulating_hunk.old_count, - filter->accumulating_hunk.new_begin, filter->accumulating_hunk.new_count); + filter->accumulating_hunk.old_begin, old_count, + filter->accumulating_hunk.new_begin, new_count); if (filter->accumulating_hunk.func_name_len > 0) { strbuf_addch(&hdr, ' '); strbuf_add(&hdr, filter->accumulating_hunk.func_name, - filter->accumulating_hunk.func_name_len); + filter->accumulating_hunk.func_name_len); } strbuf_addch(&hdr, '\n'); @@ -2598,84 +2609,48 @@ 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->accumulating_hunk.lno_in_preimage; - filter->accumulating_hunk.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->accumulating_hunk.active) + if (filter->accumulating_hunk.active) strbuf_add(&filter->accumulating_hunk.lines, line, len); 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 = filter->accumulating_hunk.lno_in_postimage - 1; - cur_pre = filter->accumulating_hunk.lno_in_preimage; - filter->accumulating_hunk.lno_in_postimage++; - if (line[0] == ' ') - filter->accumulating_hunk.lno_in_preimage++; while (filter->range_set_idx < filter->range_sets_to_filter_by->nr && idx_in_postimage >= filter->range_sets_to_filter_by->ranges[filter->range_set_idx].end) { if (filter->accumulating_hunk.active) flush_range_hunk(filter); - discard_pending_rm(filter); filter->range_set_idx++; } - if (filter->range_set_idx >= filter->range_sets_to_filter_by->nr) { - discard_pending_rm(filter); - return filter->ret; - } - - cur = &filter->range_sets_to_filter_by->ranges[filter->range_set_idx]; - - if (idx_in_postimage < cur->start) { - discard_pending_rm(filter); - return filter->ret; - } + in_range = filter->range_set_idx < filter->range_sets_to_filter_by->nr && + idx_in_postimage >= + filter->range_sets_to_filter_by->ranges[filter->range_set_idx].start && + idx_in_postimage < + filter->range_sets_to_filter_by->ranges[filter->range_set_idx].end; - if (!filter->accumulating_hunk.active) { - filter->accumulating_hunk.active = 1; - filter->accumulating_hunk.has_changes = 0; - filter->accumulating_hunk.new_begin = idx_in_postimage + 1; - filter->accumulating_hunk.old_begin = filter->pending_rm_count - ? filter->pending_rm_pre_begin : cur_pre; - filter->accumulating_hunk.old_count = 0; - filter->accumulating_hunk.new_count = 0; - strbuf_reset(&filter->accumulating_hunk.lines); - } + if (in_range) { + if (!filter->accumulating_hunk.active) + begin_range_hunk(filter); - if (filter->pending_rm_count) { - strbuf_addbuf(&filter->accumulating_hunk.lines, &filter->pending_rm); - filter->accumulating_hunk.old_count += filter->pending_rm_count; - filter->accumulating_hunk.has_changes = 1; - discard_pending_rm(filter); + strbuf_add(&filter->accumulating_hunk.lines, line, len); } - strbuf_add(&filter->accumulating_hunk.lines, line, len); - filter->accumulating_hunk.new_count++; - if (line[0] == '+') - filter->accumulating_hunk.has_changes = 1; - else - filter->accumulating_hunk.old_count++; + if (line[0] == ' ' || line[0] == '+') + filter->accumulating_hunk.lno_in_postimage++; + if (line[0] == ' ' || line[0] == '-') + filter->accumulating_hunk.lno_in_preimage++; return filter->ret; } @@ -4033,7 +4008,6 @@ static void builtin_diff(const char *name_a, lr_state.orig_cb_data = &ecbdata; lr_state.range_sets_to_filter_by = line_ranges; strbuf_init(&lr_state.accumulating_hunk.lines, 0); - strbuf_init(&lr_state.pending_rm, 0); /* * Inflate ctxlen so that all changes within @@ -4068,7 +4042,6 @@ static void builtin_diff(const char *name_a, die("unable to generate diff for %s", one->path); strbuf_release(&lr_state.accumulating_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 d0a834ed8f5bba..233dc232e3150b 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -738,6 +738,37 @@ test_expect_success '-L with -G filters to diff-text matches' ' test_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 outside tracked range + EOF + git add file.c && + test_tick && + git commit -m "add file with trailing comment" && + # Remove the trailing comment AND modify tracked() so there + # is a modification to the line range we track and a + # modification to the following line, which we do not track. + 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 && + test_grep "return 2" actual && + test_grep ! "trailing comment" 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 bacaa4529605790a9a83ba7c5742763a8a10258b Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 3 Sep 2026 05:05:15 +0000 Subject: [PATCH 016/316] diff: emit -L hunk headers via xdiff's formatter Currently, diff's line-range filter implements its own method for emitting diff hunk headers. This mostly matches what xdiff itself outputs, but there is a discrepancy for postimage or preimage sides with 0 line changes. For a side with no lines (count 0), the begin is the line before the change. The header omits the line count of 1. Rather than fix this case in the line-range implementation, expose the function xdiff uses to emit its headers. Reusing it keeps the header format consistent with and without -L. Update test scenarios and fixtures to reflect the now consistent header format. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 21 ++++++++------------- 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 | 13 +++++++++++++ 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/diff.c b/diff.c index c94ddbebe51be5..cb1a85c6247350 100644 --- a/diff.c +++ b/diff.c @@ -2560,15 +2560,10 @@ static void flush_range_hunk(struct line_range_filter *filter) return; } - strbuf_addf(&hdr, "@@ -%ld,%ld +%ld,%ld @@", - filter->accumulating_hunk.old_begin, old_count, - filter->accumulating_hunk.new_begin, new_count); - if (filter->accumulating_hunk.func_name_len > 0) { - strbuf_addch(&hdr, ' '); - strbuf_add(&hdr, filter->accumulating_hunk.func_name, - filter->accumulating_hunk.func_name_len); - } - strbuf_addch(&hdr, '\n'); + xdiff_emit_hunk_header(&hdr, filter->accumulating_hunk.old_begin, old_count, + filter->accumulating_hunk.new_begin, new_count, + filter->accumulating_hunk.func_name, + filter->accumulating_hunk.func_name_len); filter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len); strbuf_release(&hdr); @@ -2589,14 +2584,14 @@ 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; - filter->accumulating_hunk.lno_in_postimage = new_begin; - filter->accumulating_hunk.lno_in_preimage = old_begin; + filter->accumulating_hunk.lno_in_postimage = new_nr ? new_begin : new_begin + 1; + filter->accumulating_hunk.lno_in_preimage = old_nr ? old_begin : old_begin + 1; if (funclen > 0) { if (funclen > (long)sizeof(filter->accumulating_hunk.func_name)) 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 db6938689f0a9e..686db8bb8b6710 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..24284566292c3d 100644 --- a/xdiff-interface.h +++ b/xdiff-interface.h @@ -76,4 +76,17 @@ 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 the given line number begins and line counts. + */ +void xdiff_emit_hunk_header(struct strbuf *out, + long old_begin, long old_nr, + long new_begin, long new_nr, + const char *func, long funclen); + #endif From d90f8109d491fdfcd367ca8d400095bc28858792 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 3 Sep 2026 05:05:16 +0000 Subject: [PATCH 017/316] diff: extract a line-range diff helper for reuse Extract logic for initializing the line-range filter and running a diff for a specific line range. This logic is needed for any diff that targets a line range independent of the current patch display path. The subsequent commits use this logic to enable additional line range targeted diff modes. No logical behavior change. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- diff.c | 87 ++++++++++++++++++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/diff.c b/diff.c index cb1a85c6247350..a7604a773a38be 100644 --- a/diff.c +++ b/diff.c @@ -2517,6 +2517,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->range_sets_to_filter_by = ranges; + strbuf_init(&filter->accumulating_hunk.lines, 0); +} + static void begin_range_hunk(struct line_range_filter *filter) { filter->accumulating_hunk.active = 1; @@ -2650,6 +2662,37 @@ static int line_range_line_fn(void *priv, char *line, unsigned long len) return filter->ret; } + +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->range_sets_to_filter_by; + 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->accumulating_hunk.lines); + return ret; +} + static void pprint_rename(struct strbuf *name, const char *a, const char *b) { const char *old_name = a; @@ -3994,49 +4037,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.range_sets_to_filter_by = line_ranges; - strbuf_init(&lr_state.accumulating_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.accumulating_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 57d22fe63b2c89c2b176ea2a56ceeacb666d5adb Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 3 Sep 2026 05:05:17 +0000 Subject: [PATCH 018/316] diff: support stat formats with -L Reuse the line_range_filter in builtin_diffstat() so -L supports the stat formats and add tests verifying the new behavior. Ungate the newly enabled options and drop "yet" from the generic -L rejection message ("does not yet support the requested diff format"). Some rejected formats do not fit -L at all, so "yet" wrongly implies they are all awaiting support. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- Documentation/line-range-options.adoc | 10 +- diff.c | 13 ++- revision.c | 6 +- t/t4211-line-log.sh | 130 ++++++++++++++++++++++---- 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc index 72f639b5e79ea4..b3e8b5c62ce30a 100644 --- a/Documentation/line-range-options.adoc +++ b/Documentation/line-range-options.adoc @@ -9,10 +9,12 @@ __ 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 a7604a773a38be..4a30d7b6310fdb 100644 --- a/diff.c +++ b/diff.c @@ -4162,7 +4162,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 35afe52208e710..4639c0df8e8501 100644 --- a/revision.c +++ b/revision.c @@ -3229,8 +3229,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 233dc232e3150b..4e8f71c28987be 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -176,24 +176,9 @@ 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' ' 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_expect_success 'setup for checking fancy rename following' ' @@ -793,9 +778,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' ' @@ -806,6 +791,113 @@ test_expect_success '-L --oneline has no extra blank line before diff' ' test_grep "^diff --git" line2 ' +test_expect_success 'setup for -L stat tests' ' + git checkout --orphan stat-range && + git reset --hard && + cat >file.c <<-\EOF && + int func1() + { + return F1; + } + + int tracked_fn() + { + return F2; + } + EOF + git add file.c && + test_tick && + git commit -m "Add func1() and tracked_fn()" && + + # Modify both functions so whole-file stats (2 added, 2 deleted) + # differ from the tracked range of tracked_fn (1 and 1). + 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 '-L --numstat limits counts to the tracked range' ' + git log -L:tracked_fn:file.c --numstat --format=%s >actual && + cat >expect <<-\EOF && + Modify both functions + + 1 1 file.c + Add func1() and tracked_fn() + + 4 0 file.c + EOF + test_cmp expect actual +' + +test_expect_success '-L --stat and --shortstat limit counts to the tracked range' ' + git log -L:tracked_fn:file.c --stat --format=%s -1 >actual && + cat >expect <<-\EOF && + Modify both functions + + file.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + EOF + test_cmp expect actual && + + git log -L:tracked_fn:file.c --shortstat --format=%s -1 >actual && + cat >expect <<-\EOF && + Modify both functions + + 1 file changed, 1 insertion(+), 1 deletion(-) + EOF + test_cmp expect 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. + 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 tracked_func1() + { + return F1; + } + + int tracked_func2() + { + return F2; + } + + int func3() + { + return F3; + } + EOF + git add m.c && + test_tick && + git commit -m "add m.c" && + 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:tracked_func1:m.c -L:tracked_func2:m.c --numstat --format=%s -1 >actual && + cat >expect <<-\EOF && + Modify all three functions + + 2 2 m.c + EOF + test_cmp expect 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 b30c7098b450e824578c909b8faea26eac7a386d Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 3 Sep 2026 05:05:18 +0000 Subject: [PATCH 019/316] diff: support --check with -L line ranges Reuse the line_range_filter in builtin_checkdiff() so -L supports the --check option. Add orig_hunk_fn field similar to orig_line_fn that forwards xdiff_emit_hunk_fn calls when we flush filtered hunks. This is necessary because --check relies on receiving calls to its checkdiff_consume_hunk function for managing state. Document and ungate the newly enabled option, and add tests verifying the new behavior. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- Documentation/line-range-options.adoc | 11 ++-- diff.c | 43 +++++++++++++- revision.c | 2 +- t/t4211-line-log.sh | 83 +++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 9 deletions(-) diff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc index b3e8b5c62ce30a..4a7ab97d750068 100644 --- a/Documentation/line-range-options.adoc +++ b/Documentation/line-range-options.adoc @@ -10,11 +10,12 @@ 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`, `--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. + `--name-only`, `--name-status`, `--summary`, `--check`, `--stat`, + `--numstat`, and `--shortstat`. The stat formats count only lines + within the tracked range. `--dirstat` is not supported with `-L`: it + reports how change is distributed across directories over whole files, + which is not meaningful for line ranges within a file. 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 4a30d7b6310fdb..49b6732c817be6 100644 --- a/diff.c +++ b/diff.c @@ -614,6 +614,7 @@ struct emit_callback { */ struct line_range_filter { xdiff_emit_line_fn orig_line_fn; + xdiff_emit_hunk_fn orig_hunk_fn; void *orig_cb_data; const struct range_set *range_sets_to_filter_by; unsigned int range_set_idx; @@ -2577,6 +2578,13 @@ static void flush_range_hunk(struct line_range_filter *filter) filter->accumulating_hunk.func_name, filter->accumulating_hunk.func_name_len); + if (filter->orig_hunk_fn) + filter->orig_hunk_fn(filter->orig_cb_data, + filter->accumulating_hunk.old_begin, old_count, + filter->accumulating_hunk.new_begin, new_count, + filter->accumulating_hunk.func_name, + filter->accumulating_hunk.func_name_len); + filter->ret = filter->orig_line_fn(filter->orig_cb_data, hdr.buf, hdr.len); strbuf_release(&hdr); @@ -4203,11 +4211,23 @@ static void builtin_diffstat(const char *name_a, const char *name_b, diff_free_filespec_data(two); } +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; @@ -4247,7 +4267,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); @@ -4260,6 +4292,10 @@ 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; + 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) @@ -5055,7 +5091,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 4639c0df8e8501..4cc0d032bcb05f 100644 --- a/revision.c +++ b/revision.c @@ -3231,7 +3231,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 4e8f71c28987be..2a542aa64363ad 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -924,4 +924,87 @@ test_expect_success 'get_commit_action() does not mutate a not-yet-walked commit ) ' +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" && + 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 is limited to tracked ranges and reports real file line numbers' ' + test_must_fail git log -L:tracked:check.c --check --format= >raw && + grep -E ":[0-9]+:" raw >actual && + echo "check.c:3: trailing whitespace." >expect && + test_cmp expect actual && + + test_must_fail git log -L:tracked:check.c -L:other:check.c \ + --check --format= >raw && + grep -E ":[0-9]+:" raw >actual && + cat >expect <<-\EOF && + check.c:3: trailing whitespace. + check.c:8: trailing whitespace. + EOF + test_cmp expect actual +' + +test_expect_success '--check reports each error at its real line 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" && + 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= >raw && + grep -E ":[0-9]+:" raw >actual && + cat >expect <<-\EOF && + gap.c:3: trailing whitespace. + gap.c:8: trailing whitespace. + EOF + test_cmp expect 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" && + 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= >raw && + grep -E ":[0-9]+:" raw >actual && + echo "eof.c:3: trailing whitespace." >expect && + test_cmp expect actual +' + test_done From cbf01d2f2ee97736a70f9bab6cc321e81c636859 Mon Sep 17 00:00:00 2001 From: Michael Montalbo Date: Thu, 3 Sep 2026 05:05:19 +0000 Subject: [PATCH 020/316] diffcore-pickaxe: limit -G to the -L tracked range Teach -G to only search the line ranges specified by -L. Teaching -S is left as future work, so it still matches the entire file even if -L is specified. Rather than being part of diff.c's builtin implementations, the diffcore-pickaxe functionality interacts with xdiff-interface as a separate component. Add a sibling to xdi_diff_outf(), called diff_emit_line_ranges(), that limits emitted lines to the given line ranges. Use diff_emit_line_ranges() when searching text if line ranges have been specified. If textconv is enabled, use normal diffing instead of diff_emit_line_ranges() since line range tracking relies on the line coordinates of the original, pre-textconv file. Update documentation and add tests accordingly. Signed-off-by: Michael Montalbo Signed-off-by: Junio C Hamano --- Documentation/line-range-options.adoc | 4 +- diff.c | 11 ++++ diffcore-pickaxe.c | 30 ++++++++-- t/t4211-line-log.sh | 81 ++++++++++++++++++++++----- xdiff-interface.h | 10 ++++ 5 files changed, 115 insertions(+), 21 deletions(-) diff --git a/Documentation/line-range-options.adoc b/Documentation/line-range-options.adoc index 4a7ab97d750068..52e1262fd71fa1 100644 --- a/Documentation/line-range-options.adoc +++ b/Documentation/line-range-options.adoc @@ -19,6 +19,8 @@ + 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 +limited to the tracked range. In contrast, `-S` is evaluated over the whole +file and may select a commit with a change outside the tracked range. + include::line-range-format.adoc[] diff --git a/diff.c b/diff.c index 49b6732c817be6..1a3571d229b5eb 100644 --- a/diff.c +++ b/diff.c @@ -2701,6 +2701,17 @@ static int line_range_filter_diff(struct line_range_filter *filter, return ret; } +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 b0915be86fc475..2425fe8101181f 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; @@ -65,8 +67,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 +125,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. Limiting this to + * an -L range is left as 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 +143,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 +181,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 limits the search to the tracked range, but the range is in + * pre-textconv line coordinates that do not map onto textconv + * output, so search the whole file when textconv is enabled. + */ + 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 2a542aa64363ad..2354400d1cc16f 100755 --- a/t/t4211-line-log.sh +++ b/t/t4211-line-log.sh @@ -703,24 +703,18 @@ test_expect_success '-L suppresses deletions outside tracked range' ' test $(grep -c "^diff --git" actual) = 1 ' -test_expect_success '-L with -S filters to string-count changes' ' +test_expect_success '-L with -S selects only the matching commit' ' git checkout parent-oids && - git log -L:func2:file.c -S "F2 + 2" --format= >actual && - # -S searches the whole file, not just the tracked range; - # combined with the -L range walk, this selects commits that - # both touch func2 and change the count of "F2 + 2" in the file. - test $(grep -c "^diff --git" actual) = 1 && - test_grep "F2 + 2" actual + git log -L:func2:file.c -S "F2 + 2" --format=%s --no-patch >actual && + echo "Modify func2() in file.c" >expect && + test_cmp expect actual ' -test_expect_success '-L with -G filters to diff-text matches' ' +test_expect_success '-L with -G selects only the matching commit' ' 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. - test $(grep -c "^diff --git" actual) = 1 && - test_grep "F2 + 2" actual + git log -L:func2:file.c -G "F2 [+] 2" --format=%s --no-patch >actual && + echo "Modify func2() in file.c" >expect && + test_cmp expect actual ' test_expect_success 'setup for trailing deletion test' ' @@ -1007,4 +1001,63 @@ test_expect_success '--check does not report blank-at-eof outside the range' ' test_cmp expect actual ' +test_expect_success '-L -G is limited to the tracked range' ' + git checkout --orphan grep-range && + 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" && + git log -L:func2:gp.c -G BETA --format=%s --no-patch >actual && + cat >expect <<-\EOF && + touch both functions + add gp.c + EOF + test_cmp expect actual && + git log -L:func2:gp.c -G ALPHA --format=%s --no-patch >actual && + test_must_be_empty 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" && + sed -e "s/F1/F1 + 1/" -e "s/return F2/return FINDME/" tc.c >tmp && + mv tmp tc.c && + git commit -a -m "change both funcs" && + echo "tc.c diff=tc" >.gitattributes && + git log -L:func1:tc.c -G FINDME --format=%s --no-patch >actual && + test_must_be_empty actual && + git config diff.tc.textconv cat && + git log -L:func1:tc.c -G FINDME --format=%s --no-patch >actual && + echo "change both funcs" >expect && + test_cmp expect actual +' + test_done diff --git a/xdiff-interface.h b/xdiff-interface.h index 24284566292c3d..4151bc2097f95a 100644 --- a/xdiff-interface.h +++ b/xdiff-interface.h @@ -46,6 +46,16 @@ 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 + * postimage line ranges to line_fn. + */ +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 1c306b70771b1f442de361a2f896b3f6b02be1bb Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 7 Sep 2026 19:59:34 +0000 Subject: [PATCH 021/316] hex: add functionality for lowercase-only hex We currently allow both upper and lower case for all hex values in Git. However, in a future commit, we'll want to change that to allow only lowercase values in some cases. To prepare for that case, provide a table to convert hex values using lowercase only and an enum to let us choose which we want, wiring it up to the hexval function. For now, keep things completely the same by specifying only the variant that accepts both lowercase and uppercase to avoid changing behavior. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- color.c | 2 +- hex-ll.c | 37 ++++++++++++++++++++++++++++++++++++- hex-ll.h | 14 ++++++++++---- pkt-line.c | 8 ++++---- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/color.c b/color.c index 00b53f97acbcc7..9015d0faf18259 100644 --- a/color.c +++ b/color.c @@ -72,7 +72,7 @@ static int get_hex_color(const char **inp, int width, unsigned char *out) unsigned int val; assert(width == 1 || width == 2); - val = (hexval(in[0]) << 4) | hexval(in[width - 1]); + val = (hexval(in[0], HEX_KIND_MIXED) << 4) | hexval(in[width - 1], HEX_KIND_MIXED); if (val & ~0xff) return -1; *inp += width; diff --git a/hex-ll.c b/hex-ll.c index 4d7ece1de5ed7c..8f5a4e46440b7f 100644 --- a/hex-ll.c +++ b/hex-ll.c @@ -36,10 +36,45 @@ const signed char hexval_table[256] = { -1, -1, -1, -1, -1, -1, -1, -1, /* f8-ff */ }; +const signed char hexval_lc_table[256] = { + -1, -1, -1, -1, -1, -1, -1, -1, /* 00-07 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 08-0f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 10-17 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 18-1f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 20-27 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 28-2f */ + 0, 1, 2, 3, 4, 5, 6, 7, /* 30-37 */ + 8, 9, -1, -1, -1, -1, -1, -1, /* 38-3f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 40-47 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 48-4f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 50-57 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 58-5f */ + -1, 10, 11, 12, 13, 14, 15, -1, /* 60-67 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 68-6f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 70-77 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 78-7f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 80-87 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 88-8f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 90-97 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* 98-9f */ + -1, -1, -1, -1, -1, -1, -1, -1, /* a0-a7 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* a8-af */ + -1, -1, -1, -1, -1, -1, -1, -1, /* b0-b7 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* b8-bf */ + -1, -1, -1, -1, -1, -1, -1, -1, /* c0-c7 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* c8-cf */ + -1, -1, -1, -1, -1, -1, -1, -1, /* d0-d7 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* d8-df */ + -1, -1, -1, -1, -1, -1, -1, -1, /* e0-e7 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* e8-ef */ + -1, -1, -1, -1, -1, -1, -1, -1, /* f0-f7 */ + -1, -1, -1, -1, -1, -1, -1, -1, /* f8-ff */ +}; + int hex_to_bytes(unsigned char *binary, const char *hex, size_t len) { for (; len; len--, hex += 2) { - unsigned int val = (hexval(hex[0]) << 4) | hexval(hex[1]); + unsigned int val = (hexval(hex[0], HEX_KIND_MIXED) << 4) | hexval(hex[1], HEX_KIND_MIXED); if (val & ~0xff) return -1; diff --git a/hex-ll.h b/hex-ll.h index a381fa85562234..da1b5239b2bc2d 100644 --- a/hex-ll.h +++ b/hex-ll.h @@ -1,10 +1,16 @@ #ifndef HEX_LL_H #define HEX_LL_H +enum hexkind { + HEX_KIND_MIXED = 0, + HEX_KIND_LOWER = 1, +}; + extern const signed char hexval_table[256]; -static inline unsigned int hexval(unsigned char c) +extern const signed char hexval_lc_table[256]; +static inline unsigned int hexval(unsigned char c, enum hexkind kind) { - return hexval_table[c]; + return kind == HEX_KIND_MIXED ? hexval_table[c] : hexval_lc_table[c]; } /* @@ -13,8 +19,8 @@ static inline unsigned int hexval(unsigned char c) */ static inline int hex2chr(const char *s) { - unsigned int val = hexval(s[0]); - return (val & ~0xf) ? val : (val << 4) | hexval(s[1]); + unsigned int val = hexval(s[0], HEX_KIND_MIXED); + return (val & ~0xf) ? val : (val << 4) | hexval(s[1], HEX_KIND_MIXED); } /* diff --git a/pkt-line.c b/pkt-line.c index 3fc3e9ea7059be..338075558c2e9e 100644 --- a/pkt-line.c +++ b/pkt-line.c @@ -378,10 +378,10 @@ int packet_length(const char lenbuf_hex[4], size_t size) { if (size < 4) BUG("buffer too small"); - return hexval(lenbuf_hex[0]) << 12 | - hexval(lenbuf_hex[1]) << 8 | - hexval(lenbuf_hex[2]) << 4 | - hexval(lenbuf_hex[3]); + return hexval(lenbuf_hex[0], HEX_KIND_MIXED) << 12 | + hexval(lenbuf_hex[1], HEX_KIND_MIXED) << 8 | + hexval(lenbuf_hex[2], HEX_KIND_MIXED) << 4 | + hexval(lenbuf_hex[3], HEX_KIND_MIXED); } static const char *find_packfile_uri_path(const char *buffer) From bc32a0b005e03e6b14795ab8a437fd587b214a09 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 7 Sep 2026 19:59:35 +0000 Subject: [PATCH 022/316] hex: allow specifying hex type with hex2chr We have several places where we use hex2chr. One of those is parsing object IDs, but others decode quoted-printable or percent encoding. All of them accept both uppercase and lowercase hex. In a future commit, we'll change some of these cases, so make hex2chr accept the kind of encoding to use: lowercase only hex or any kind of hex. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- hex-ll.h | 6 +++--- hex.c | 2 +- mailinfo.c | 2 +- ref-filter.c | 2 +- strbuf.c | 2 +- url.c | 2 +- urlmatch.c | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/hex-ll.h b/hex-ll.h index da1b5239b2bc2d..26847c7b2f81bf 100644 --- a/hex-ll.h +++ b/hex-ll.h @@ -17,10 +17,10 @@ static inline unsigned int hexval(unsigned char c, enum hexkind kind) * Convert two consecutive hexadecimal digits into a char. Return a * negative value on error. Don't run over the end of short strings. */ -static inline int hex2chr(const char *s) +static inline int hex2chr(const char *s, enum hexkind kind) { - unsigned int val = hexval(s[0], HEX_KIND_MIXED); - return (val & ~0xf) ? val : (val << 4) | hexval(s[1], HEX_KIND_MIXED); + unsigned int val = hexval(s[0], kind); + return (val & ~0xf) ? val : (val << 4) | hexval(s[1], kind); } /* diff --git a/hex.c b/hex.c index f02832140d2d43..6150bdcbf83de3 100644 --- a/hex.c +++ b/hex.c @@ -9,7 +9,7 @@ static int get_hash_hex_algop(const char *hex, unsigned char *hash, const struct git_hash_algo *algop) { for (size_t i = 0; i < algop->rawsz; i++) { - int val = hex2chr(hex); + int val = hex2chr(hex, HEX_KIND_MIXED); if (val < 0) return -1; *hash++ = val; diff --git a/mailinfo.c b/mailinfo.c index 13949ff31e1769..85c3119048fe5e 100644 --- a/mailinfo.c +++ b/mailinfo.c @@ -396,7 +396,7 @@ static int decode_q_segment(struct strbuf *out, const struct strbuf *q_seg, int ch, d = *in; if (d == '\n' || !d) break; /* drop trailing newline */ - ch = hex2chr(in); + ch = hex2chr(in, HEX_KIND_MIXED); if (ch >= 0) { strbuf_addch(out, ch); in += 2; diff --git a/ref-filter.c b/ref-filter.c index 29aca08ce7b333..884bcd8fc5f593 100644 --- a/ref-filter.c +++ b/ref-filter.c @@ -3567,7 +3567,7 @@ static void append_literal(const char *cp, const char *ep, struct ref_formatting if (cp[1] == '%') cp++; else { - int ch = hex2chr(cp + 1); + int ch = hex2chr(cp + 1, HEX_KIND_MIXED); if (0 <= ch) { strbuf_addch(s, ch); cp += 3; diff --git a/strbuf.c b/strbuf.c index 44955669e8c504..88d23f8ac5f5c4 100644 --- a/strbuf.c +++ b/strbuf.c @@ -457,7 +457,7 @@ size_t strbuf_expand_literal(struct strbuf *sb, const char *placeholder) return 1; case 'x': /* %x00 == NUL, %x0a == LF, etc. */ - ch = hex2chr(placeholder + 1); + ch = hex2chr(placeholder + 1, HEX_KIND_MIXED); if (ch < 0) return 0; strbuf_addch(sb, ch); diff --git a/url.c b/url.c index a59818278f49df..b4d72f784a913d 100644 --- a/url.c +++ b/url.c @@ -62,7 +62,7 @@ static char *url_decode_internal(const char **query, int len, } if (c == '%' && (len < 0 || len >= 3)) { - int val = hex2chr(q + 1); + int val = hex2chr(q + 1, HEX_KIND_MIXED); if (0 < val) { strbuf_addch(out, val); q += 3; diff --git a/urlmatch.c b/urlmatch.c index 20bc2d009cd0dd..989f1d794b909f 100644 --- a/urlmatch.c +++ b/urlmatch.c @@ -50,7 +50,7 @@ static int append_normalized_escapes(struct strbuf *buf, if (ch == '%') { if (from_len < 2) return 0; - ch = hex2chr(from); + ch = hex2chr(from, HEX_KIND_MIXED); if (ch < 0) return 0; from += 2; From a8552c1abd69ee04f5449ea2535dc52be6e237b7 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 7 Sep 2026 19:59:36 +0000 Subject: [PATCH 023/316] hex: make hex_to_bytes accept kind of hex to use Similarly to the previous commit, introduce an option for hex_to_bytes to allow us to specify the kind of hex to use: lowercase only or not. For now, everything remains the same as before, but we will change things in a future commit. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- builtin/index-pack.c | 2 +- diagnose.c | 2 +- hex-ll.c | 4 ++-- hex-ll.h | 2 +- http-push.c | 5 +++-- notes.c | 5 +++-- object-file.c | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/builtin/index-pack.c b/builtin/index-pack.c index bc86925ad04340..9660e5967b0160 100644 --- a/builtin/index-pack.c +++ b/builtin/index-pack.c @@ -1866,7 +1866,7 @@ static void repack_local_links(void) while (strbuf_getline_lf(&line, out) != EOF) { unsigned char binary[GIT_MAX_RAWSZ]; if (line.len != the_hash_algo->hexsz || - !hex_to_bytes(binary, line.buf, line.len)) + !hex_to_bytes(binary, line.buf, line.len, HEX_KIND_MIXED)) die(_("index-pack: Expecting full hex object ID lines only from pack-objects.")); /* diff --git a/diagnose.c b/diagnose.c index 5092bf80d35fdd..fc11cea22938b5 100644 --- a/diagnose.c +++ b/diagnose.c @@ -112,7 +112,7 @@ static void loose_objs_stats(struct strbuf *buf, const char *path) while ((e = readdir_skip_dot_and_dotdot(dir)) != NULL) if (get_dtype(e, &count_path, 0) == DT_DIR && strlen(e->d_name) == 2 && - !hex_to_bytes(&c, e->d_name, 1)) { + !hex_to_bytes(&c, e->d_name, 1, HEX_KIND_MIXED)) { strbuf_setlen(&count_path, base_path_len); strbuf_addf(&count_path, "%s/", e->d_name); total += (count = count_files(&count_path)); diff --git a/hex-ll.c b/hex-ll.c index 8f5a4e46440b7f..3b7d22a82449e0 100644 --- a/hex-ll.c +++ b/hex-ll.c @@ -71,10 +71,10 @@ const signed char hexval_lc_table[256] = { -1, -1, -1, -1, -1, -1, -1, -1, /* f8-ff */ }; -int hex_to_bytes(unsigned char *binary, const char *hex, size_t len) +int hex_to_bytes(unsigned char *binary, const char *hex, size_t len, enum hexkind kind) { for (; len; len--, hex += 2) { - unsigned int val = (hexval(hex[0], HEX_KIND_MIXED) << 4) | hexval(hex[1], HEX_KIND_MIXED); + unsigned int val = (hexval(hex[0], kind) << 4) | hexval(hex[1], kind); if (val & ~0xff) return -1; diff --git a/hex-ll.h b/hex-ll.h index 26847c7b2f81bf..fe698f0c762ef7 100644 --- a/hex-ll.h +++ b/hex-ll.h @@ -28,6 +28,6 @@ static inline int hex2chr(const char *s, enum hexkind kind) * values to `binary` as `len` bytes. Return 0 on success, or -1 if * the input does not consist of hex digits). */ -int hex_to_bytes(unsigned char *binary, const char *hex, size_t len); +int hex_to_bytes(unsigned char *binary, const char *hex, size_t len, enum hexkind kind); #endif diff --git a/http-push.c b/http-push.c index 94a1fac9ab0fcd..0cc990d395bbd3 100644 --- a/http-push.c +++ b/http-push.c @@ -1030,12 +1030,13 @@ static int get_oid_hex_from_objpath(const char *path, struct object_id *oid) if (strlen(path) != the_hash_algo->hexsz + 1) return -1; - if (hex_to_bytes(oid->hash, path, 1)) + if (hex_to_bytes(oid->hash, path, 1, HEX_KIND_MIXED)) return -1; path += 2; path++; /* skip '/' */ - return hex_to_bytes(oid->hash + 1, path, the_hash_algo->rawsz - 1); + return hex_to_bytes(oid->hash + 1, path, the_hash_algo->rawsz - 1, + HEX_KIND_MIXED); } static void process_ls_object(struct remote_ls_ctx *ls) diff --git a/notes.c b/notes.c index ec9c2cb150d4e3..99b8b15d819214 100644 --- a/notes.c +++ b/notes.c @@ -428,7 +428,7 @@ static void load_subtree(struct notes_tree *t, struct leaf_node *subtree, goto handle_non_note; if (hex_to_bytes(object_oid.hash + prefix_len, entry.path, - hashsz - prefix_len)) + hashsz - prefix_len, HEX_KIND_MIXED)) goto handle_non_note; /* entry.path is not a SHA1 */ memset(object_oid.hash + hashsz, 0, GIT_MAX_RAWSZ - hashsz); @@ -442,7 +442,8 @@ static void load_subtree(struct notes_tree *t, struct leaf_node *subtree, /* internal nodes must be trees */ goto handle_non_note; - if (hex_to_bytes(object_oid.hash + len++, entry.path, 1)) + if (hex_to_bytes(object_oid.hash + len++, entry.path, 1, + HEX_KIND_MIXED)) goto handle_non_note; /* entry.path is not a SHA1 */ /* diff --git a/object-file.c b/object-file.c index ec35c318bc9fe7..5884e726bb36d5 100644 --- a/object-file.c +++ b/object-file.c @@ -1073,7 +1073,7 @@ int for_each_file_in_obj_subdir(unsigned int subdir_nr, strbuf_add(path, de->d_name, namelen); if (namelen == algop->hexsz - 2 && !hex_to_bytes(oid.hash + 1, de->d_name, - algop->rawsz - 1)) { + algop->rawsz - 1, HEX_KIND_MIXED)) { oid_set_algo(&oid, algop); memset(oid.hash + algop->rawsz, 0, GIT_MAX_RAWSZ - algop->rawsz); From cde061f8eeccbfb87b714038f424acff7aa09b10 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 7 Sep 2026 19:59:37 +0000 Subject: [PATCH 024/316] hex: label usages of hex parsing for object IDs In preparation for a future change, label the hex parsing we're doing for object IDs by defining a constant called HEX_KIND_OID. This is currently the same as HEX_KIND_MIXED, so there is no functional change here. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- diagnose.c | 2 +- hex-ll.h | 2 ++ hex.c | 2 +- http-push.c | 4 ++-- notes.c | 2 +- object-file.c | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/diagnose.c b/diagnose.c index fc11cea22938b5..9c652d36a6649c 100644 --- a/diagnose.c +++ b/diagnose.c @@ -112,7 +112,7 @@ static void loose_objs_stats(struct strbuf *buf, const char *path) while ((e = readdir_skip_dot_and_dotdot(dir)) != NULL) if (get_dtype(e, &count_path, 0) == DT_DIR && strlen(e->d_name) == 2 && - !hex_to_bytes(&c, e->d_name, 1, HEX_KIND_MIXED)) { + !hex_to_bytes(&c, e->d_name, 1, HEX_KIND_OID)) { strbuf_setlen(&count_path, base_path_len); strbuf_addf(&count_path, "%s/", e->d_name); total += (count = count_files(&count_path)); diff --git a/hex-ll.h b/hex-ll.h index fe698f0c762ef7..9da76f17e8a1d7 100644 --- a/hex-ll.h +++ b/hex-ll.h @@ -6,6 +6,8 @@ enum hexkind { HEX_KIND_LOWER = 1, }; +#define HEX_KIND_OID HEX_KIND_MIXED + extern const signed char hexval_table[256]; extern const signed char hexval_lc_table[256]; static inline unsigned int hexval(unsigned char c, enum hexkind kind) diff --git a/hex.c b/hex.c index 6150bdcbf83de3..4e1e81af3f476b 100644 --- a/hex.c +++ b/hex.c @@ -9,7 +9,7 @@ static int get_hash_hex_algop(const char *hex, unsigned char *hash, const struct git_hash_algo *algop) { for (size_t i = 0; i < algop->rawsz; i++) { - int val = hex2chr(hex, HEX_KIND_MIXED); + int val = hex2chr(hex, HEX_KIND_OID); if (val < 0) return -1; *hash++ = val; diff --git a/http-push.c b/http-push.c index 0cc990d395bbd3..132d26d6a1477c 100644 --- a/http-push.c +++ b/http-push.c @@ -1030,13 +1030,13 @@ static int get_oid_hex_from_objpath(const char *path, struct object_id *oid) if (strlen(path) != the_hash_algo->hexsz + 1) return -1; - if (hex_to_bytes(oid->hash, path, 1, HEX_KIND_MIXED)) + if (hex_to_bytes(oid->hash, path, 1, HEX_KIND_OID)) return -1; path += 2; path++; /* skip '/' */ return hex_to_bytes(oid->hash + 1, path, the_hash_algo->rawsz - 1, - HEX_KIND_MIXED); + HEX_KIND_OID); } static void process_ls_object(struct remote_ls_ctx *ls) diff --git a/notes.c b/notes.c index 99b8b15d819214..7e9e3eb2d2cb27 100644 --- a/notes.c +++ b/notes.c @@ -443,7 +443,7 @@ static void load_subtree(struct notes_tree *t, struct leaf_node *subtree, goto handle_non_note; if (hex_to_bytes(object_oid.hash + len++, entry.path, 1, - HEX_KIND_MIXED)) + HEX_KIND_OID)) goto handle_non_note; /* entry.path is not a SHA1 */ /* diff --git a/object-file.c b/object-file.c index 5884e726bb36d5..b44f3c7bf805b2 100644 --- a/object-file.c +++ b/object-file.c @@ -1073,7 +1073,7 @@ int for_each_file_in_obj_subdir(unsigned int subdir_nr, strbuf_add(path, de->d_name, namelen); if (namelen == algop->hexsz - 2 && !hex_to_bytes(oid.hash + 1, de->d_name, - algop->rawsz - 1, HEX_KIND_MIXED)) { + algop->rawsz - 1, HEX_KIND_OID)) { oid_set_algo(&oid, algop); memset(oid.hash + algop->rawsz, 0, GIT_MAX_RAWSZ - algop->rawsz); From f818ba400f8d832307b5f29fe65f49145993e4c2 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 7 Sep 2026 19:59:38 +0000 Subject: [PATCH 025/316] object-name: use hexval We've open-coded a different implementation of parsing hex values here when we already have a perfectly good one in hexval. This implementation will almost certainly be slower because it isn't table-driven, unlike the other one, and since it's not constant time it has no other advantages either. To tidy things up and prepare for future work, switch to hexval in this case. Because hexval returns an unsigned int, check to see if the value is invalid by looking for any bits beyond a single unsigned character. In addition, be sure to continue to force the hexadecimal value to lowercase. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- object-name.c | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/object-name.c b/object-name.c index 83efba0ba668e5..8f0f169090da94 100644 --- a/object-name.c +++ b/object-name.c @@ -236,20 +236,13 @@ static int parse_oid_prefix(const char *name, int len, { for (int i = 0; i < len; i++) { unsigned char c = name[i]; - unsigned char val; - if (c >= '0' && c <= '9') { - val = c - '0'; - } else if (c >= 'a' && c <= 'f') { - val = c - 'a' + 10; - } else if (c >= 'A' && c <='F') { - val = c - 'A' + 10; - c -= 'A' - 'a'; - } else { + int val = hexval(c, HEX_KIND_OID); + + if (val & ~0xff) return -1; - } if (hex_out) - hex_out[i] = c; + hex_out[i] = tolower(c); if (oid_out) { if (!(i & 1)) val <<= 4; From c261979e899d4d3b15b44ca774cecfcd0ba2d294 Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 7 Sep 2026 19:59:39 +0000 Subject: [PATCH 026/316] t5324: adjust tests for corrupt commit-graph In a future commit, we'll no longer allow uppercase object IDs. When that happens, t5324 will fail because the error message from corrupting commit-graph files will be different. The current test looks for an error message that occurs when the commit-graph file has a name that is parseable as a valid object ID but is not a valid commit-graph file (which, on case-sensitive systems, is of course checked case sensitively). However, in the future, we need the object ID to continue to be valid, and that means that the hex character we use must be lowercase. In SHA-256, though, one of the characters we want to corrupt is legitimately already an "a", so switch to using the character "b" in both of these cases, which is the correct character for neither SHA-1 nor SHA-256. If we adopt another hash algorithm in the future, we may need to adjust these values again. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- t/t5324-split-commit-graph.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/t/t5324-split-commit-graph.sh b/t/t5324-split-commit-graph.sh index bf7ba0e5580d84..53cddfe43711cc 100755 --- a/t/t5324-split-commit-graph.sh +++ b/t/t5324-split-commit-graph.sh @@ -349,7 +349,7 @@ test_expect_success 'verify after commit-graph-chain corruption (base)' ' test_must_fail git commit-graph verify 2>test_err && grep -v "^+" test_err >err && test_grep "invalid commit-graph chain" err && - corrupt_file "$graphdir/commit-graph-chain" 30 "A" && + corrupt_file "$graphdir/commit-graph-chain" 30 "b" && test_must_fail git commit-graph verify 2>test_err && grep -v "^+" test_err >err && test_grep "unable to find all commit-graph files" err @@ -364,7 +364,7 @@ test_expect_success 'verify after commit-graph-chain corruption (tip)' ' test_must_fail git commit-graph verify 2>test_err && grep -v "^+" test_err >err && test_grep "invalid commit-graph chain" err && - corrupt_file "$graphdir/commit-graph-chain" 70 "A" && + corrupt_file "$graphdir/commit-graph-chain" 70 "b" && test_must_fail git commit-graph verify 2>test_err && grep -v "^+" test_err >err && test_grep "unable to find all commit-graph files" err From dbbd2e7ee3619df7ce384c77b5b0f5e3f02adffd Mon Sep 17 00:00:00 2001 From: "brian m. carlson" Date: Mon, 7 Sep 2026 19:59:40 +0000 Subject: [PATCH 027/316] hex: allow only lowercase object IDs in breaking changes mode Git has historically allowed either lowercase or uppercase hex for object IDs, but it has always emitted only lowercase. This has caused people to expect only lowercase and not handle uppercase. As an example, Git's own example hooks look for "[0-9a-f]" in several places, but there are many other Git-adjacent pieces of software, including Gitolite, which make the assumption that object IDs are always lowercase. This is not to criticize the authors of these projects, but rather to point out how common this assumption is. In fact, it's so common that we had only one test in our codebase that failed when we reject uppercase object IDs. More critically, it leads people to make security-based assumptions that an object ID either does not contain uppercase characters or that an object ID can be expressed uniquely in hex form, neither of which are currently true. Git itself normally uses binary object IDs, which avoids many of these problems, but most other projects deal primarily in hex object IDs, so they are more affected. In preparation for Git 3.0, only allow lowercase hex object IDs in breaking changes mode and document this as well. Add a new test to verify we reject new uppercase object IDs. Signed-off-by: brian m. carlson Signed-off-by: Junio C Hamano --- Documentation/BreakingChanges.adoc | 5 +++++ hex-ll.h | 4 ++++ t/t1503-rev-parse-verify.sh | 5 +++++ 3 files changed, 14 insertions(+) diff --git a/Documentation/BreakingChanges.adoc b/Documentation/BreakingChanges.adoc index 73bb939359c72e..dbc46d14e33dce 100644 --- a/Documentation/BreakingChanges.adoc +++ b/Documentation/BreakingChanges.adoc @@ -171,6 +171,11 @@ JGit, libgit2 and Gitoxide need to support it. matches the default branch name used in new repositories by many of the big Git forges. +* Git will accept hex object IDs only in lowercase. The fact that Git has + historically allowed uppercase characters in hex object IDs has been the + source of a variety of bugs and security problems in software using Git. We + don't expect most users to notice any change. + * Git will require Rust as a mandatory part of the build process. While Git already started to adopt Rust in Git 2.49, all parts written in Rust are optional for the time being. This includes: diff --git a/hex-ll.h b/hex-ll.h index 9da76f17e8a1d7..2f9c8d7c25fb73 100644 --- a/hex-ll.h +++ b/hex-ll.h @@ -6,7 +6,11 @@ enum hexkind { HEX_KIND_LOWER = 1, }; +#ifdef WITH_BREAKING_CHANGES +#define HEX_KIND_OID HEX_KIND_LOWER +#else #define HEX_KIND_OID HEX_KIND_MIXED +#endif extern const signed char hexval_table[256]; extern const signed char hexval_lc_table[256]; diff --git a/t/t1503-rev-parse-verify.sh b/t/t1503-rev-parse-verify.sh index 87638a4a2c71f9..f07b45de5a3fc1 100755 --- a/t/t1503-rev-parse-verify.sh +++ b/t/t1503-rev-parse-verify.sh @@ -60,6 +60,11 @@ test_expect_success 'works with one good rev' ' test "$rev_head" = "$HASH4" ' +test_expect_success WITH_BREAKING_CHANGES 'rejects uppercase revs' ' + UC_HASH=$(echo "$HASH1" | tr a-f A-F) && + test_must_fail git rev-parse --verify "$UC_HASH" +' + test_expect_success 'fails with any bad rev or many good revs' ' test_must_fail git rev-parse --verify 2>error && test_grep "single revision" error && From 8d0a01e9d83b4d806477733c2d6874eaf5f4eb38 Mon Sep 17 00:00:00 2001 From: Pia Park Date: Tue, 8 Sep 2026 00:10:16 -0700 Subject: [PATCH 028/316] midx-write: skip writes with no object entries A MIDX write can find packs without finding any objects, either because the packs are empty or because an incremental write finds only objects already indexed by an earlier layer. The existing early exit checks only the number of packs, so these cases publish a zero-object MIDX without a reverse index. Empty MIDXs were already published by 662148c435 (midx: write object offsets, 2018-07-12); the failure mode appeared in 27afc272c4 (midx: implement writing incremental MIDX bitmaps, 2025-03-20), when incremental bitmap writes began loading reverse indexes from prior layers. Reproducible on master as of b8242b093d (The 23rd batch, 2026-09-07), starting with a non-incremental write: git init --bare --object-format=sha1 empty.git && ( cd empty.git && git pack-objects objects/pack/pack Signed-off-by: Junio C Hamano --- midx-write.c | 5 +- t/t5319-multi-pack-index.sh | 35 ++++++++++++ t/t5326-multi-pack-bitmaps.sh | 13 +++-- t/t5334-incremental-multi-pack-index.sh | 75 +++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 9 deletions(-) diff --git a/midx-write.c b/midx-write.c index 85371022543836..3038bbfad2600f 100644 --- a/midx-write.c +++ b/midx-write.c @@ -1617,9 +1617,8 @@ static int write_midx_internal(struct write_midx_opts *opts) } if (!ctx.entries_nr) { - if (opts->flags & MIDX_WRITE_BITMAP) - warning(_("refusing to write multi-pack .bitmap without any objects")); - opts->flags &= ~(MIDX_WRITE_REV_INDEX | MIDX_WRITE_BITMAP); + result = 0; + goto cleanup; } if (ctx.incremental) { diff --git a/t/t5319-multi-pack-index.sh b/t/t5319-multi-pack-index.sh index 2b8ff6f3ed28cd..931182ba2cc7a4 100755 --- a/t/t5319-multi-pack-index.sh +++ b/t/t5319-multi-pack-index.sh @@ -54,6 +54,41 @@ test_expect_success "don't write midx with no packs" ' test_path_is_missing pack/multi-pack-index ' +test_expect_success 'skip non-incremental MIDX with no objects' ' + git init --bare empty.git && + ( + cd empty.git && + git pack-objects objects/pack/pack files.expect && + + for bitmap in "" --bitmap + do + git multi-pack-index write $bitmap >out 2>&1 && + test_must_be_empty out && + test_path_is_missing objects/pack/multi-pack-index && + ls objects/pack >files.actual && + test_cmp files.expect files.actual || return 1 + done && + + git multi-pack-index write --incremental --bitmap && + test_dir_is_empty objects/pack/multi-pack-index.d && + + echo blob | git hash-object -w --stdin >in && + git pack-objects objects/pack/pack in && + git pack-objects objects/pack/pack files.expect && git multi-pack-index write --bitmap --stdin-packs \ - err && + out 2>&1 && - test_grep "bitmap without any objects" err && - - test_path_is_file $midx && - test_path_is_missing $midx-$(midx_checksum $objdir).bitmap + test_must_be_empty out && + test_path_is_missing $midx && + ls $objdir/pack >files.actual && + test_cmp files.expect files.actual ) ' diff --git a/t/t5334-incremental-multi-pack-index.sh b/t/t5334-incremental-multi-pack-index.sh index f0b82b5f65cf40..fbcc19feeb01d6 100755 --- a/t/t5334-incremental-multi-pack-index.sh +++ b/t/t5334-incremental-multi-pack-index.sh @@ -195,4 +195,79 @@ test_expect_success 'non-incremental write with existing incremental chain' ' ) ' +test_expect_success 'skip initial MIDX layer with no objects' ' + git init empty && + ( + cd empty && + git config maintenance.auto false && + git pack-objects $packdir/pack out 2>&1 && + test_must_be_empty out && + test_dir_is_empty "$midxdir" || return 1 + done && + + write_midx_layer && + test_line_count = 1 "$midx_chain" && + git multi-pack-index verify + ) +' + +test_expect_success 'skip MIDX layer with empty pack' ' + git init empty-pack && + ( + cd empty-pack && + git config maintenance.auto false && + write_midx_layer && + + git pack-objects $packdir/pack files.expect && + + for bitmap in --bitmap --no-bitmap + do + git multi-pack-index write --incremental "$bitmap" >out 2>&1 && + test_must_be_empty out && + test_cmp chain.expect "$midx_chain" && + ls "$packdir" "$midxdir" >files.actual && + test_cmp files.expect files.actual || return 1 + done && + + write_midx_layer && + test_line_count = 2 "$midx_chain" && + git multi-pack-index verify && + git rev-list --test-bitmap 2.2 + ) +' + +test_expect_success 'skip MIDX layer with duplicate pack' ' + git init duplicate-pack && + ( + cd duplicate-pack && + git config maintenance.auto false && + write_midx_layer && + + git rev-parse HEAD^{tree} >in && + git pack-objects $packdir/pack files.expect && + + for bitmap in --bitmap --no-bitmap + do + git multi-pack-index write --incremental "$bitmap" >out 2>&1 && + test_must_be_empty out && + test_cmp chain.expect "$midx_chain" && + ls "$packdir" "$midxdir" >files.actual && + test_cmp files.expect files.actual || return 1 + done && + + write_midx_layer && + test_line_count = 2 "$midx_chain" && + git multi-pack-index verify && + git rev-list --test-bitmap 2.2 + ) +' + test_done From 6cbc556c76b0f2d160115c564bb11b837637a9fb Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Mon, 14 Sep 2026 15:01:49 -0700 Subject: [PATCH 029/316] advice: give cut-and-pasteable advice to squelch Advice messages that the advise_if_enabled() helper emits tell the user how to squelch a particular piece of advice by setting a configuration variable. The message it gives says: hint: Disable this message with "git config set advice.FOO false" However, cutting and pasting the given hint would set the configuration variable in the per-repository configuration file (which is the default behavior for 'git config set'). As the user most likely sets it after seeing advice and understanding its ramifications, the choice of squelching or continuing to see the advice message is better controlled per-user, not per-repository. In addition, some advice, such as advice.defaultBranchName, is applicable only once before a new repository is created, so setting it in the per-repository configuration file is far too late. Add '--global' to the 'git config set' command line so that the configuration is set for the user rather than per repository. Initial-work-by: Vsevolod Myalitsin Helped-by: Jeff King Signed-off-by: Junio C Hamano --- advice.c | 2 +- t/t0018-advice.sh | 2 +- t/t3200-branch.sh | 2 +- t/t3404-rebase-interactive.sh | 6 +++--- t/t3501-revert-cherry-pick.sh | 2 +- t/t3507-cherry-pick-conflict.sh | 4 ++-- t/t3602-rm-sparse-checkout.sh | 2 +- t/t3700-add.sh | 6 +++--- t/t3705-add-sparse-checkout.sh | 2 +- t/t7002-mv-sparse-checkout.sh | 4 ++-- t/t7004-tag.sh | 2 +- t/t7400-submodule-basic.sh | 2 +- 12 files changed, 18 insertions(+), 18 deletions(-) diff --git a/advice.c b/advice.c index 63bf8b0c5f0481..d81afc80d11281 100644 --- a/advice.c +++ b/advice.c @@ -96,7 +96,7 @@ static struct { static const char turn_off_instructions[] = N_("\n" - "Disable this message with \"git config set advice.%s false\""); + "Disable this message with \"git config set --global advice.%s false\""); static void vadvise(const char *advice, int display_instructions, const char *key, va_list params) diff --git a/t/t0018-advice.sh b/t/t0018-advice.sh index f68e08d0b14ec9..8f05b5ae6c869c 100755 --- a/t/t0018-advice.sh +++ b/t/t0018-advice.sh @@ -10,7 +10,7 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME test_expect_success 'advice should be printed when config variable is unset' ' cat >expect <<-\EOF && hint: This is a piece of advice - hint: Disable this message with "git config set advice.nestedTag false" + hint: Disable this message with "git config set --global advice.nestedTag false" EOF test-tool advise "This is a piece of advice" 2>actual && test_cmp expect actual diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh index cdb6c6a6342a0c..0d7d9d39579217 100755 --- a/t/t3200-branch.sh +++ b/t/t3200-branch.sh @@ -1751,7 +1751,7 @@ test_expect_success 'errors if given a bad branch name' ' cat <<-EOF >expect && fatal: ${SQ}foo..bar${SQ} is not a valid branch name hint: See ${SQ}git help check-ref-format${SQ} - hint: Disable this message with "git config set advice.refSyntax false" + hint: Disable this message with "git config set --global advice.refSyntax false" EOF test_must_fail git branch foo..bar >actual 2>&1 && test_cmp expect actual diff --git a/t/t3404-rebase-interactive.sh b/t/t3404-rebase-interactive.sh index ff11abb2f222c4..b3ee96f560b4d9 100755 --- a/t/t3404-rebase-interactive.sh +++ b/t/t3404-rebase-interactive.sh @@ -2341,20 +2341,20 @@ test_expect_success 'non-merge commands reject merge commits' ' error: ${SQ}pick${SQ} does not accept merge commits hint: ${SQ}pick${SQ} does not take a merge commit. If you wanted to hint: replay the merge, use ${SQ}merge -C${SQ} on the commit. - hint: Disable this message with "git config set advice.rebaseTodoError false" + hint: Disable this message with "git config set --global advice.rebaseTodoError false" error: invalid line 1: pick $oid error: ${SQ}reword${SQ} does not accept merge commits hint: ${SQ}reword${SQ} does not take a merge commit. If you wanted to hint: replay the merge and reword the commit message, use hint: ${SQ}merge -c${SQ} on the commit - hint: Disable this message with "git config set advice.rebaseTodoError false" + hint: Disable this message with "git config set --global advice.rebaseTodoError false" error: invalid line 2: reword $oid error: ${SQ}edit${SQ} does not accept merge commits hint: ${SQ}edit${SQ} does not take a merge commit. If you wanted to hint: replay the merge, use ${SQ}merge -C${SQ} on the commit, and then hint: ${SQ}break${SQ} to give the control back to you so that you can hint: do ${SQ}git commit --amend && git rebase --continue${SQ}. - hint: Disable this message with "git config set advice.rebaseTodoError false" + hint: Disable this message with "git config set --global advice.rebaseTodoError false" error: invalid line 3: edit $oid error: cannot squash merge commit into another commit error: invalid line 4: fixup $oid diff --git a/t/t3501-revert-cherry-pick.sh b/t/t3501-revert-cherry-pick.sh index 939e7a16a636d4..2abbf071ce33a7 100755 --- a/t/t3501-revert-cherry-pick.sh +++ b/t/t3501-revert-cherry-pick.sh @@ -177,7 +177,7 @@ test_expect_success 'advice from failed revert' ' hint: You can instead skip this commit with "git revert --skip". hint: To abort and get back to the state before "git revert", hint: run "git revert --abort". - hint: Disable this message with "git config set advice.mergeConflict false" + hint: Disable this message with "git config set --global advice.mergeConflict false" EOF test_commit --append --no-tag "double-add dream" dream dream && test_must_fail git revert HEAD^ 2>actual && diff --git a/t/t3507-cherry-pick-conflict.sh b/t/t3507-cherry-pick-conflict.sh index 44596cb1e8036c..de5c073e644816 100755 --- a/t/t3507-cherry-pick-conflict.sh +++ b/t/t3507-cherry-pick-conflict.sh @@ -60,7 +60,7 @@ test_expect_success 'advice from failed cherry-pick' ' hint: You can instead skip this commit with "git cherry-pick --skip". hint: To abort and get back to the state before "git cherry-pick", hint: run "git cherry-pick --abort". - hint: Disable this message with "git config set advice.mergeConflict false" + hint: Disable this message with "git config set --global advice.mergeConflict false" EOF test_must_fail git cherry-pick picked 2>actual && @@ -75,7 +75,7 @@ test_expect_success 'advice from failed cherry-pick --no-commit' " error: could not apply \$picked... picked hint: after resolving the conflicts, mark the corrected paths hint: with 'git add ' or 'git rm ' - hint: Disable this message with \"git config set advice.mergeConflict false\" + hint: Disable this message with \"git config set --global advice.mergeConflict false\" EOF test_must_fail git cherry-pick --no-commit picked 2>actual && diff --git a/t/t3602-rm-sparse-checkout.sh b/t/t3602-rm-sparse-checkout.sh index 252df28bbf4329..bccb31a5a1ab92 100755 --- a/t/t3602-rm-sparse-checkout.sh +++ b/t/t3602-rm-sparse-checkout.sh @@ -20,7 +20,7 @@ test_expect_success 'setup' " hint: If you intend to update such entries, try one of the following: hint: * Use the --sparse option. hint: * Disable or modify the sparsity rules. - hint: Disable this message with \"git config set advice.updateSparsePath false\" + hint: Disable this message with \"git config set --global advice.updateSparsePath false\" EOF echo b | cat sparse_error_header - >sparse_entry_b_error && diff --git a/t/t3700-add.sh b/t/t3700-add.sh index 2947bf9a6b1404..59e48482a28c76 100755 --- a/t/t3700-add.sh +++ b/t/t3700-add.sh @@ -31,7 +31,7 @@ test_expect_success 'Test with no pathspecs' ' cat >expect <<-EOF && Nothing specified, nothing added. hint: Maybe you wanted to say ${SQ}git add .${SQ}? - hint: Disable this message with "git config set advice.addEmptyPathspec false" + hint: Disable this message with "git config set --global advice.addEmptyPathspec false" EOF git add 2>actual && test_cmp expect actual @@ -386,7 +386,7 @@ test_expect_success '"git add" a embedded repository' ' hint: git rm --cached inner1 hint: hint: See "git help submodule" for more information. - hint: Disable this message with "git config set advice.addEmbeddedRepo false" + hint: Disable this message with "git config set --global advice.addEmbeddedRepo false" warning: adding embedded git repository: inner2 EOF test_cmp expect actual @@ -425,7 +425,7 @@ cat >expect.err <<\EOF The following paths are ignored by one of your .gitignore files: ignored-file hint: Use -f if you really want to add them. -hint: Disable this message with "git config set advice.addIgnoredFile false" +hint: Disable this message with "git config set --global advice.addIgnoredFile false" EOF cat >expect.out <<\EOF add 'track-this' diff --git a/t/t3705-add-sparse-checkout.sh b/t/t3705-add-sparse-checkout.sh index 975f9218b05cd7..2e97e3c0032e66 100755 --- a/t/t3705-add-sparse-checkout.sh +++ b/t/t3705-add-sparse-checkout.sh @@ -54,7 +54,7 @@ test_expect_success 'setup' " hint: If you intend to update such entries, try one of the following: hint: * Use the --sparse option. hint: * Disable or modify the sparsity rules. - hint: Disable this message with \"git config set advice.updateSparsePath false\" + hint: Disable this message with \"git config set --global advice.updateSparsePath false\" EOF echo sparse_entry | cat sparse_error_header - >sparse_entry_error && diff --git a/t/t7002-mv-sparse-checkout.sh b/t/t7002-mv-sparse-checkout.sh index 9c0e82ba3190a7..666317fdf9aa62 100755 --- a/t/t7002-mv-sparse-checkout.sh +++ b/t/t7002-mv-sparse-checkout.sh @@ -32,7 +32,7 @@ test_expect_success 'setup' " hint: If you intend to update such entries, try one of the following: hint: * Use the --sparse option. hint: * Disable or modify the sparsity rules. - hint: Disable this message with \"git config set advice.updateSparsePath false\" + hint: Disable this message with \"git config set --global advice.updateSparsePath false\" EOF cat >dirty_error_header <<-EOF && @@ -45,7 +45,7 @@ test_expect_success 'setup' " hint: To correct the sparsity of these paths, do the following: hint: * Use \"git add --sparse \" to update the index hint: * Use \"git sparse-checkout reapply\" to apply the sparsity rules - hint: Disable this message with \"git config set advice.updateSparsePath false\" + hint: Disable this message with \"git config set --global advice.updateSparsePath false\" EOF " diff --git a/t/t7004-tag.sh b/t/t7004-tag.sh index 8c795d72188cfe..49cdb6fdb0a5f0 100755 --- a/t/t7004-tag.sh +++ b/t/t7004-tag.sh @@ -1887,7 +1887,7 @@ test_expect_success 'recursive tagging should give advice' ' hint: already a tag. If you meant to tag the object that it points to, use: hint: hint: git tag -f nested annotated-v4.0^{} - hint: Disable this message with "git config set advice.nestedTag false" + hint: Disable this message with "git config set --global advice.nestedTag false" EOF git tag -m nested nested annotated-v4.0 2>actual && test_cmp expect actual diff --git a/t/t7400-submodule-basic.sh b/t/t7400-submodule-basic.sh index eefdecb0bd7538..36ff5b9546bbce 100755 --- a/t/t7400-submodule-basic.sh +++ b/t/t7400-submodule-basic.sh @@ -231,7 +231,7 @@ test_expect_success 'submodule add to .gitignored path fails' ' The following paths are ignored by one of your .gitignore files: submod hint: Use -f if you really want to add them. - hint: Disable this message with "git config set advice.addIgnoredFile false" + hint: Disable this message with "git config set --global advice.addIgnoredFile false" EOF # Does not use test_commit due to the ignore echo "*" > .gitignore && From 6a559670c29142e2faaff97cd6fd6de4738dd889 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 15 Sep 2026 07:51:43 +0000 Subject: [PATCH 030/316] range-diff: add --matched-only to skip one-sided commits Reviewing a range-diff often means scrolling past commits that were simply added or dropped, when only the ones that correspond between the two ranges are of interest. --left-only and --right-only already each suppress one of those one-sided groups, but they are defined as "only show this side" and so cannot be given together, which is exactly why show_range_diff() already rejected that combination. Give the "show only the commits that correspond on both sides" behavior its own name, --matched-only, instead of asking users to reach for a combination that errors out. Extend the existing '--left-only'/'--right-only' conflict check to also reject any combination with --matched-only, since all three narrow the output in ways that cannot be combined. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/git-range-diff.adoc | 9 ++++- builtin/range-diff.c | 5 ++- range-diff.c | 11 +++++- range-diff.h | 2 +- t/t3206-range-diff.sh | 63 +++++++++++++++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/Documentation/git-range-diff.adoc b/Documentation/git-range-diff.adoc index 5cc5e2ed56734b..d448f897d6e75e 100644 --- a/Documentation/git-range-diff.adoc +++ b/Documentation/git-range-diff.adoc @@ -10,7 +10,8 @@ SYNOPSIS [synopsis] git range-diff [--color=[]] [--no-color] [] [--no-dual-color] [--creation-factor=] - [--left-only | --right-only] [--diff-merges=] + [--left-only | --right-only | --matched-only] + [--diff-merges=] [--remerge-diff] [--no-notes | --notes[=]] ( | ... | ) [[--] ...] @@ -82,6 +83,12 @@ to revert to color all lines according to the outer diff markers Suppress commits that are missing from the second specified range (or the "right range" when using the `...` form). +`--matched-only`:: + Only emit commits that have a corresponding commit in the other + range, suppressing any commit that exists on only one side. Useful + to skip added or removed commits when reviewing how the commits + that survived a rebase changed. + `--diff-merges=`:: Instead of ignoring merge commits, generate diffs for them using the corresponding `--diff-merges=` option of linkgit:git-log[1], diff --git a/builtin/range-diff.c b/builtin/range-diff.c index e54c0f7fe15699..8059f92eaafa28 100644 --- a/builtin/range-diff.c +++ b/builtin/range-diff.c @@ -46,7 +46,7 @@ int cmd_range_diff(int argc, .diffopt = &diffopt, .log_arg = &log_arg }; - int simple_color = -1, left_only = 0, right_only = 0; + int simple_color = -1, left_only = 0, right_only = 0, matched_only = 0; struct option range_diff_options[] = { OPT_INTEGER(0, "creation-factor", &range_diff_opts.creation_factor, @@ -68,6 +68,8 @@ int cmd_range_diff(int argc, N_("only emit output related to the first range")), OPT_BOOL(0, "right-only", &right_only, N_("only emit output related to the second range")), + OPT_BOOL(0, "matched-only", &matched_only, + N_("only emit commits that have a corresponding commit in the other range")), OPT_END() }; struct option *options; @@ -186,6 +188,7 @@ int cmd_range_diff(int argc, range_diff_opts.dual_color = simple_color < 1; range_diff_opts.left_only = left_only; range_diff_opts.right_only = right_only; + range_diff_opts.matched_only = matched_only; res = show_range_diff(range1.buf, range2.buf, &range_diff_opts); strvec_clear(&log_arg); diff --git a/range-diff.c b/range-diff.c index 8e2dd2eb193eb9..fa895f576065ee 100644 --- a/range-diff.c +++ b/range-diff.c @@ -591,8 +591,15 @@ int show_range_diff(const char *range1, const char *range2, struct string_list branch2 = STRING_LIST_INIT_DUP; unsigned int include_merges = range_diff_opts->include_merges; - if (range_diff_opts->left_only && range_diff_opts->right_only) - res = error(_("options '%s' and '%s' cannot be used together"), "--left-only", "--right-only"); + if (range_diff_opts->left_only + range_diff_opts->right_only + + range_diff_opts->matched_only > 1) + res = error(_("options '%s', '%s', or '%s' cannot be used together"), + "--left-only", "--right-only", "--matched-only"); + + if (range_diff_opts->matched_only) { + range_diff_opts->left_only = 1; + range_diff_opts->right_only = 1; + } if (!res && read_patches(range1, &branch1, range_diff_opts->log_arg, include_merges)) res = error(_("could not parse log for '%s'"), range1); diff --git a/range-diff.h b/range-diff.h index 9b70a80009e257..effd10b9b8f436 100644 --- a/range-diff.h +++ b/range-diff.h @@ -19,7 +19,7 @@ struct range_diff_options { int creation_factor; unsigned dual_color:1; - unsigned left_only:1, right_only:1; + unsigned left_only:1, right_only:1, matched_only:1; unsigned include_merges:1; size_t max_memory; const struct diff_options *diffopt; /* may be NULL */ diff --git a/t/t3206-range-diff.sh b/t/t3206-range-diff.sh index ef92704de39830..f85fd0c4adbc20 100755 --- a/t/t3206-range-diff.sh +++ b/t/t3206-range-diff.sh @@ -860,6 +860,69 @@ test_expect_success '--left-only/--right-only' ' test_cmp expect actual ' +test_expect_success '--left-only, --right-only and --matched-only are incompatible' ' + test_must_fail git range-diff --left-only --right-only ...common 2>err && + test_grep "cannot be used together" err && + + test_must_fail git range-diff --left-only --matched-only ...common 2>err && + test_grep "cannot be used together" err && + + test_must_fail git range-diff --right-only --matched-only ...common 2>err && + test_grep "cannot be used together" err && + + test_must_fail git range-diff --left-only --right-only --matched-only \ + ...common 2>err && + test_grep "cannot be used together" err +' + +test_expect_success '--left-only, --right-only and --matched-only each suppress one-sided commits' ' + test_create_repo matched-only && + ( + cd matched-only && + git switch --orphan combined-old && + test_commit c-first && + test_commit c-old-only && + test_commit c-common && + git switch -C combined-new c-first && + test_commit c-new-only && + git cherry-pick c-common && + + old_only_oid=$(git rev-parse --short=7 c-old-only) && + new_only_oid=$(git rev-parse --short=7 c-new-only) && + common_old_oid=$(git rev-parse --short=7 c-common) && + common_new_oid=$(git rev-parse --short=7 HEAD) && + + git range-diff -s --abbrev=7 combined-old...combined-new >actual && + cat >expect <<-EOF && + 1: $old_only_oid < -: ------- c-old-only + -: ------- > 1: $new_only_oid c-new-only + 2: $common_old_oid = 2: $common_new_oid c-common + EOF + test_cmp expect actual && + + git range-diff -s --abbrev=7 --left-only combined-old...combined-new \ + >actual && + cat >expect <<-EOF && + 1: $old_only_oid < -: ------- c-old-only + 2: $common_old_oid = 2: $common_new_oid c-common + EOF + test_cmp expect actual && + + git range-diff -s --abbrev=7 --right-only combined-old...combined-new \ + >actual && + cat >expect <<-EOF && + -: ------- > 1: $new_only_oid c-new-only + 2: $common_old_oid = 2: $common_new_oid c-common + EOF + test_cmp expect actual && + + git range-diff -s --abbrev=7 --matched-only combined-old...combined-new \ + >actual && + echo "2: $common_old_oid = 2: $common_new_oid c-common" >expect && + test_cmp expect actual + ) +' + test_expect_success 'ranges with pathspecs' ' git range-diff topic...mode-only-change -- other-file >actual && test_line_count = 2 actual && From c78f853e37e97ce1d6e768b34a758d7f3aa2f47a Mon Sep 17 00:00:00 2001 From: Mike Hommey Date: Thu, 17 Sep 2026 15:04:15 +0900 Subject: [PATCH 031/316] move rust gitcore crate to a different subdirectory Having `Cargo.toml` at the top-level of the repository implies that one can run `cargo build` directly, but this doesn't produce anything useful on its own. Additionally, when including the git source as a submodule of a Rust project, it prevents the git source from being included at all in the crate package because cargo skips directories that contain a Cargo.toml, assuming that everything in the directory is relevant to the crate. Move all Rust-specific files into a dedicated `rust/` subdirectory. Signed-off-by: Mike Hommey Signed-off-by: Junio C Hamano --- .gitignore | 4 ++-- Makefile | 24 ++++++++++++------------ ci/run-rust-checks.sh | 6 +++--- meson.build | 2 +- Cargo.toml => rust/Cargo.toml | 0 build.rs => rust/build.rs | 0 {src => rust}/cargo-meson.sh | 0 {src => rust}/meson.build | 16 ++++++++-------- {src => rust/src}/csum_file.rs | 0 {src => rust/src}/hash.rs | 0 {src => rust/src}/lib.rs | 0 {src => rust/src}/loose.rs | 0 {src => rust/src}/varint.rs | 0 13 files changed, 26 insertions(+), 26 deletions(-) rename Cargo.toml => rust/Cargo.toml (100%) rename build.rs => rust/build.rs (100%) rename {src => rust}/cargo-meson.sh (100%) rename {src => rust}/meson.build (81%) rename {src => rust/src}/csum_file.rs (100%) rename {src => rust/src}/hash.rs (100%) rename {src => rust/src}/lib.rs (100%) rename {src => rust/src}/loose.rs (100%) rename {src => rust/src}/varint.rs (100%) diff --git a/.gitignore b/.gitignore index 4da58c6754899e..add65976435810 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ /fuzz_corpora -/target/ -/Cargo.lock /GIT-BUILD-DIR /GIT-BUILD-OPTIONS /GIT-CFLAGS @@ -261,3 +259,5 @@ Release/ /contrib/buildsystems/out /contrib/libgit-rs/target /contrib/libgit-sys/target +/rust/target +/rust/Cargo.lock diff --git a/Makefile b/Makefile index c649c93c510332..67e74c30cce45d 100644 --- a/Makefile +++ b/Makefile @@ -959,7 +959,7 @@ RUST_LIB_NAME = gitcore.lib else RUST_LIB_NAME = libgitcore.a endif -RUST_LIB = target$(if $(CARGO_BUILD_TARGET),/$(CARGO_BUILD_TARGET))/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME) +RUST_LIB = rust/target$(if $(CARGO_BUILD_TARGET),/$(CARGO_BUILD_TARGET))/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME) endif GITLIBS = common-main.o $(LIB_FILE) @@ -1571,11 +1571,11 @@ CLAR_TEST_OBJS += $(UNIT_TEST_DIR)/unit-test.o UNIT_TEST_OBJS += $(UNIT_TEST_DIR)/test-lib.o -RUST_SOURCES += src/csum_file.rs -RUST_SOURCES += src/hash.rs -RUST_SOURCES += src/lib.rs -RUST_SOURCES += src/loose.rs -RUST_SOURCES += src/varint.rs +RUST_SOURCES += rust/src/csum_file.rs +RUST_SOURCES += rust/src/hash.rs +RUST_SOURCES += rust/src/lib.rs +RUST_SOURCES += rust/src/loose.rs +RUST_SOURCES += rust/src/varint.rs GIT-VERSION-FILE: FORCE @OLD=$$(cat $@ 2>/dev/null || :) && \ @@ -3038,8 +3038,8 @@ $(LIB_FILE): $(LIB_OBJS) ifndef NO_RUST ifeq ($(RUST_TARGETS),) -$(RUST_LIB): Cargo.toml $(RUST_SOURCES) $(LIB_FILE) - $(QUIET_CARGO)cargo build $(CARGO_ARGS) +$(RUST_LIB): rust/Cargo.toml $(RUST_SOURCES) $(LIB_FILE) + $(QUIET_CARGO)cargo build --manifest-path rust/Cargo.toml $(CARGO_ARGS) else ifneq ($(words $(RUST_TARGETS)),1) ifneq ($(uname_S),Darwin) @@ -3047,9 +3047,9 @@ $(error Building universal Rust libraries requires macOS (lipo is not available endif endif -RUST_MEMBER_LIBS = $(foreach target,$(RUST_TARGETS),target/$(target)/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME)) -$(RUST_MEMBER_LIBS): target/%/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME): Cargo.toml $(RUST_SOURCES) $(LIB_FILE) - $(QUIET_CARGO)cargo build $(CARGO_ARGS) --target $* +RUST_MEMBER_LIBS = $(foreach target,$(RUST_TARGETS),rust/target/$(target)/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME)) +$(RUST_MEMBER_LIBS): rust/target/%/$(RUST_BUILD_CONFIG)/$(RUST_LIB_NAME): rust/Cargo.toml $(RUST_SOURCES) $(LIB_FILE) + $(QUIET_CARGO)cargo build --manifest-path rust/Cargo.toml $(CARGO_ARGS) --target $* $(RUST_LIB): $(RUST_MEMBER_LIBS) $(call mkdir_p_parent_template) @@ -3913,7 +3913,7 @@ clean: profile-clean coverage-clean cocciclean $(RM) $(FUZZ_PROGRAMS) $(RM) $(SP_OBJ) $(RM) $(HCC) - $(RM) -r Cargo.lock target/ + $(RM) -r rust/Cargo.lock rust/target/ $(RM) version-def.h $(RM) -r $(dep_dirs) $(compdb_dir) compile_commands.json $(RM) $(test_bindir_programs) diff --git a/ci/run-rust-checks.sh b/ci/run-rust-checks.sh index b5ad9e8dc6f71f..47fccc3a02e195 100755 --- a/ci/run-rust-checks.sh +++ b/ci/run-rust-checks.sh @@ -4,17 +4,17 @@ set +x -if ! group "Check Rust formatting" cargo fmt --all --check +if ! group "Check Rust formatting" cargo fmt --manifest-path rust/Cargo.toml --all --check then RET=1 fi -if ! group "Check for common Rust mistakes" cargo clippy --all-targets --all-features -- -Dwarnings +if ! group "Check for common Rust mistakes" cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -Dwarnings then RET=1 fi -if ! group "Check for minimum required Rust version" cargo msrv verify +if ! group "Check for minimum required Rust version" cargo msrv --path rust verify then RET=1 fi diff --git a/meson.build b/meson.build index 0a95d90d211fd6..432e306b214757 100644 --- a/meson.build +++ b/meson.build @@ -1795,7 +1795,7 @@ libgit_sources += version_def_h rust_option = get_option('rust') if rust_option.allowed() - subdir('src') + subdir('rust') libgit_c_args += '-DWITH_RUST' if host_machine.system() == 'windows' diff --git a/Cargo.toml b/rust/Cargo.toml similarity index 100% rename from Cargo.toml rename to rust/Cargo.toml diff --git a/build.rs b/rust/build.rs similarity index 100% rename from build.rs rename to rust/build.rs diff --git a/src/cargo-meson.sh b/rust/cargo-meson.sh similarity index 100% rename from src/cargo-meson.sh rename to rust/cargo-meson.sh diff --git a/src/meson.build b/rust/meson.build similarity index 81% rename from src/meson.build rename to rust/meson.build index 41a4b231e660c4..4c617371a5cb04 100644 --- a/src/meson.build +++ b/rust/meson.build @@ -1,9 +1,9 @@ libgit_rs_sources = [ - 'csum_file.rs', - 'hash.rs', - 'lib.rs', - 'loose.rs', - 'varint.rs', + 'src/csum_file.rs', + 'src/hash.rs', + 'src/lib.rs', + 'src/loose.rs', + 'src/varint.rs', ] # Unfortunately we must use a wrapper command to move the output file into the @@ -13,7 +13,7 @@ libgit_rs_sources = [ cargo_command = [ shell, meson.current_source_dir() / 'cargo-meson.sh', - meson.project_source_root(), + meson.current_source_dir(), meson.current_build_dir(), ] if get_option('buildtype') == 'release' @@ -22,7 +22,7 @@ endif libgit_rs = custom_target('git_rs', input: libgit_rs_sources + [ - meson.project_source_root() / 'Cargo.toml', + meson.current_source_dir() / 'Cargo.toml', ], output: 'libgitcore.a', command: cargo_command, @@ -35,7 +35,7 @@ if get_option('tests') args: [ 'test', '--manifest-path', - meson.project_source_root() / 'Cargo.toml', + meson.current_source_dir() / 'Cargo.toml', '--target-dir', meson.current_build_dir() / 'target', ], diff --git a/src/csum_file.rs b/rust/src/csum_file.rs similarity index 100% rename from src/csum_file.rs rename to rust/src/csum_file.rs diff --git a/src/hash.rs b/rust/src/hash.rs similarity index 100% rename from src/hash.rs rename to rust/src/hash.rs diff --git a/src/lib.rs b/rust/src/lib.rs similarity index 100% rename from src/lib.rs rename to rust/src/lib.rs diff --git a/src/loose.rs b/rust/src/loose.rs similarity index 100% rename from src/loose.rs rename to rust/src/loose.rs diff --git a/src/varint.rs b/rust/src/varint.rs similarity index 100% rename from src/varint.rs rename to rust/src/varint.rs From cf9d8ab6fec61b15a32ef9abb390f14271c8a019 Mon Sep 17 00:00:00 2001 From: Erik Cervin-Edin Date: Tue, 22 Sep 2026 15:04:35 +0200 Subject: [PATCH 032/316] 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, 22 Sep 2026 15:04:37 +0200 Subject: [PATCH 033/316] commit: allow -c/-C for all kinds of --fixup The fixup code path in prepare_to_commit() only knows how to take a message body from -m or -F. Now that the previous commit allows -m and -F for all --fixup variations, -c/-C are the odd ones out. Teach the fixup path to look up the ref given to -c/-C and seed its message when preparing to amend the commit, mirroring what --fixup=amend does when no message source is given. With this in place, -m/-F/-c/-C all behave consistently across the plain, amend, and reword --fixup forms. The incompatibility check grouping -C, -c, and --fixup can go away entirely, as -C and -c are already rejected together by the check that groups them with -m and -F. Helped-by: Junio C Hamano Signed-off-by: Erik Cervin-Edin Signed-off-by: Junio C Hamano --- Documentation/git-commit.adoc | 9 ++-- builtin/commit.c | 14 +++--- t/t7500-commit-template-squash-signoff.sh | 60 +++++++++++++++++++++-- 3 files changed, 70 insertions(+), 13 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..ba6a9d02b8ce82 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); } @@ -1339,9 +1344,6 @@ 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_opt3(!!use_message, "-C", - !!edit_message, "-c", - !!fixup_message, "--fixup"); 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: Wed, 23 Sep 2026 18:55:59 +0000 Subject: [PATCH 034/316] object-name: explain why ~N fails in a shallow clone Asking for a commit's ancestor with ~N in a shallow clone that does not have N commits of history locally fails with a bare "is not a commit" error, with no indication that the repository being shallow is the reason, or what to do about it. Add a hint, shown when a walk runs out of parents exactly at a recorded shallow boundary, not merely because the repository happens to be shallow elsewhere. When the revision is given as a remote-tracking branch, the remote and branch are exactly named in the suggested command, found by reverse mapping through the remote's real refspec instead of assuming a refs/remotes// layout, since a refspec can put branches anywhere. For ~N it suggests the exact --deepen needed, accounting for history already present. For ^N it always suggests --deepen=1: a shallow boundary commit has no parents recorded at all, so one more generation reveals its complete real parent list regardless of which index was asked for. The hint only suggests that older history might exist. A recorded shallow boundary does not distinguish a depth cutoff from a commit that is the true root: cloning with a depth that happens to exactly match the available history still records that root commit as a boundary, and only the remote knows for certain whether deepening would fetch anything new. The advice is threaded through GET_OID_QUIETLY so it is not shown during the internal re-resolution some commands do while building a better error message, which would otherwise print it twice for the same failing argument. A chained ^^^ resolves one parent at a time, and each step re-resolves everything before it through a fresh, always non-quiet lookup, so a chained caret landing past a shallow boundary printed the hint twice instead of once. Thread the quiet bit through that inner lookup too, so it stays quiet exactly when the outer resolution asked it to. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- Documentation/config/advice.adoc | 4 + advice.c | 1 + advice.h | 1 + object-name.c | 131 +++++++++++++++++++++++++++++-- shallow.c | 8 ++ shallow.h | 8 ++ t/t1500-rev-parse.sh | 120 ++++++++++++++++++++++++++++ 7 files changed, 266 insertions(+), 7 deletions(-) diff --git a/Documentation/config/advice.adoc b/Documentation/config/advice.adoc index 257db58918179a..bf930b09bda9b8 100644 --- a/Documentation/config/advice.adoc +++ b/Documentation/config/advice.adoc @@ -119,6 +119,10 @@ all advice messages. give directions on how to proceed from the current state. sequencerInUse:: Shown when a sequencer command is already in progress. + shallowHistory:: + Shown when `~` cannot resolve enough ancestors of + `` because history stops at a shallow boundary, to + suggest fetching more history. skippedCherryPicks:: Shown when linkgit:git-rebase[1] skips a commit that has already been cherry-picked onto the upstream branch. diff --git a/advice.c b/advice.c index 0018501b7bc103..034eaa42c0cccc 100644 --- a/advice.c +++ b/advice.c @@ -79,6 +79,7 @@ static struct { [ADVICE_RM_HINTS] = { "rmHints" }, [ADVICE_SEQUENCER_IN_USE] = { "sequencerInUse" }, [ADVICE_SET_UPSTREAM_FAILURE] = { "setUpstreamFailure" }, + [ADVICE_SHALLOW_HISTORY] = { "shallowHistory" }, [ADVICE_SKIPPED_CHERRY_PICKS] = { "skippedCherryPicks" }, [ADVICE_SPARSE_INDEX_EXPANDED] = { "sparseIndexExpanded" }, [ADVICE_STATUS_AHEAD_BEHIND_WARNING] = { "statusAheadBehindWarning" }, diff --git a/advice.h b/advice.h index 8def28068861df..10ff712b053615 100644 --- a/advice.h +++ b/advice.h @@ -46,6 +46,7 @@ enum advice_type { ADVICE_RM_HINTS, ADVICE_SEQUENCER_IN_USE, ADVICE_SET_UPSTREAM_FAILURE, + ADVICE_SHALLOW_HISTORY, ADVICE_SKIPPED_CHERRY_PICKS, ADVICE_SPARSE_INDEX_EXPANDED, ADVICE_STATUS_AHEAD_BEHIND_WARNING, diff --git a/object-name.c b/object-name.c index 46159466ac543a..34d405d1226350 100644 --- a/object-name.c +++ b/object-name.c @@ -22,6 +22,7 @@ #include "repo-settings.h" #include "repository.h" #include "setup.h" +#include "shallow.h" #include "midx.h" #include "commit-reach.h" #include "date.h" @@ -825,13 +826,120 @@ static int get_oid_basic(struct repository *r, const char *str, int len, return 0; } +struct remote_tracking_search { + char *dst; + char *remote_name; + char *branch_name; +}; + +static int search_remote_tracking(struct remote *remote, void *cb_data) +{ + struct remote_tracking_search *search = cb_data; + struct refspec_item query; + + memset(&query, 0, sizeof(query)); + query.dst = search->dst; + if (remote_find_tracking(remote, &query)) + return 0; + search->remote_name = xstrdup(remote->name); + search->branch_name = query.src; + return 1; +} + +/* + * If "name" resolves to a remote-tracking ref, find which configured + * remote it actually belongs to and what branch of that remote's it is, + * by reverse mapping through each remote's real fetch refspec instead + * of assuming the common "refs/remotes//" layout, + * since a remote's refspec need not put its branches there at all. On a + * match, fill "remote_out" and "branch_out" with newly allocated copies + * and return true. Otherwise leave them untouched and return false. + */ +static bool parse_remote_tracking_name(struct repository *r, + const char *name, int namelen, + char **remote_out, char **branch_out) +{ + struct object_id oid; + char *real_ref = NULL; + struct remote_tracking_search search = { 0 }; + const char *branch_name; + + if (!repo_dwim_ref(r, name, namelen, &oid, &real_ref, 0) || !real_ref) + return false; + + search.dst = real_ref; + if (!for_each_remote(search_remote_tracking, &search)) { + free(real_ref); + return false; + } + free(real_ref); + + branch_name = search.branch_name; + skip_prefix(branch_name, "refs/heads/", &branch_name); + *remote_out = search.remote_name; + *branch_out = xstrdup(branch_name); + free(search.branch_name); + return true; +} + +/* + * When a "name~" or "name^" walk runs out of parents at "commit", + * and that is because "commit" is where this shallow repository's history + * was cut off (rather than commit being a real root commit), let the + * user know that fetching more history might be what they are after. + * + * "suggested_depth" is the --deepen value to recommend. For "name^" + * this is always 1: deepening by one generation fetches "commit"'s real + * parent list in full, whatever it turns out to contain, regardless of + * which parent index asked for. + */ +static void advise_if_shallow_cutoff(struct repository *r, + const char *name, int namelen, + struct commit *commit, + unsigned lookup_flags, + int suggested_depth) +{ + char *remote = NULL, *branch = NULL; + struct strbuf cmd = STRBUF_INIT; + + if (lookup_flags & GET_OID_QUIETLY) + return; + if (!is_repository_shallow(r)) + return; + if (!commit_is_shallow_boundary(r, &commit->object.oid)) + return; + + if (parse_remote_tracking_name(r, name, namelen, &remote, &branch)) + strbuf_addf(&cmd, "git fetch --deepen=%d %s %s", + suggested_depth, remote, branch); + else + strbuf_addf(&cmd, "git fetch --deepen=%d ", + suggested_depth); + free(remote); + free(branch); + + advise_if_enabled(ADVICE_SHALLOW_HISTORY, + _("'%.*s' does not have that many ancestors locally.\n" + "History stops at %s because this repository is a\n" + "shallow clone, and might have more history upstream.\n" + "To check, try:\n" + "\n" + " %s"), + namelen, name, + repo_find_unique_abbrev(r, &commit->object.oid, DEFAULT_ABBREV), + cmd.buf); + strbuf_release(&cmd); +} + static enum get_oid_result get_parent(struct repository *r, const char *name, int len, - struct object_id *result, int idx) + struct object_id *result, int idx, + unsigned lookup_flags) { struct object_id oid; enum get_oid_result ret = get_oid_1(r, name, len, &oid, - GET_OID_COMMITTISH); + GET_OID_COMMITTISH | + (lookup_flags & GET_OID_QUIETLY)); struct commit *commit; struct commit_list *p; @@ -852,19 +960,22 @@ static enum get_oid_result get_parent(struct repository *r, } p = p->next; } + advise_if_shallow_cutoff(r, name, len, commit, lookup_flags, 1); return MISSING_OBJECT; } static enum get_oid_result get_nth_ancestor(struct repository *r, const char *name, int len, struct object_id *result, - int generation) + int generation, + unsigned lookup_flags) { struct object_id oid; struct commit *commit; int ret; - ret = get_oid_1(r, name, len, &oid, GET_OID_COMMITTISH); + ret = get_oid_1(r, name, len, &oid, + GET_OID_COMMITTISH | (lookup_flags & GET_OID_QUIETLY)); if (ret) return ret; commit = lookup_commit_reference(r, &oid); @@ -872,8 +983,14 @@ static enum get_oid_result get_nth_ancestor(struct repository *r, return MISSING_OBJECT; while (generation--) { - if (repo_parse_commit(r, commit) || !commit->parents) + if (repo_parse_commit(r, commit)) + return MISSING_OBJECT; + if (!commit->parents) { + /* Remaining "generation" plus this failed step is the actual gap. */ + advise_if_shallow_cutoff(r, name, len, commit, + lookup_flags, generation + 1); return MISSING_OBJECT; + } commit = commit->parents->item; } oidcpy(result, &commit->object.oid); @@ -1120,9 +1237,9 @@ static enum get_oid_result get_oid_1(struct repository *r, else if (num > INT_MAX) return MISSING_OBJECT; if (has_suffix == '^') - return get_parent(r, name, len1, oid, num); + return get_parent(r, name, len1, oid, num, lookup_flags); /* else if (has_suffix == '~') -- goes without saying */ - return get_nth_ancestor(r, name, len1, oid, num); + return get_nth_ancestor(r, name, len1, oid, num, lookup_flags); } ret = peel_onion(r, name, len, oid, lookup_flags); diff --git a/shallow.c b/shallow.c index 07cae44ae52a04..f75bbd349abdaf 100644 --- a/shallow.c +++ b/shallow.c @@ -94,6 +94,14 @@ int is_repository_shallow(struct repository *r) return r->parsed_objects->is_shallow; } +int commit_is_shallow_boundary(struct repository *r, + const struct object_id *oid) +{ + struct commit_graft *graft = lookup_commit_graft(r, oid); + + return graft && graft->nr_parent == -1; +} + static void reset_repository_shallow(struct repository *r) { r->parsed_objects->is_shallow = -1; diff --git a/shallow.h b/shallow.h index e20ca4c21ba6d7..bd79e2e3791846 100644 --- a/shallow.h +++ b/shallow.h @@ -15,6 +15,14 @@ int register_shallow(struct repository *r, const struct object_id *oid); int unregister_shallow(const struct object_id *oid); int is_repository_shallow(struct repository *r); +/* + * True if "oid" is where this shallow repository's history was cut off, + * i.e. a shallow-graft commit with no parents recorded locally, as + * opposed to a commit that is actually a root. + */ +int commit_is_shallow_boundary(struct repository *r, + const struct object_id *oid); + /* * Lock for updating the $GIT_DIR/shallow file. * diff --git a/t/t1500-rev-parse.sh b/t/t1500-rev-parse.sh index 38067d95f7f12b..54d43c495c1114 100755 --- a/t/t1500-rev-parse.sh +++ b/t/t1500-rev-parse.sh @@ -193,6 +193,126 @@ test_expect_success 'rev-parse --is-shallow-repository in non-shallow repo' ' test_cmp expect actual ' +check_shallow_history_advice () { + name=$1 oid=$2 cmd=$3 && + grep '^hint:' err >actual && + cat >expect <<-EOF && + hint: '$name' does not have that many ancestors locally. + hint: History stops at $oid because this repository is a + hint: shallow clone, and might have more history upstream. + hint: To check, try: + hint: + hint: $cmd + hint: Disable this message with "git config set advice.shallowHistory false" + EOF + test_cmp expect actual +} + +test_expect_success 'shallowHistory advice on ~N beyond shallow boundary' ' + test_commit shallow_advice_1 && + test_commit shallow_advice_2 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice && + test_when_finished "rm -rf shallow-advice" && + oid=$(git -C shallow-advice rev-parse --short origin/main) && + test_must_fail git -C shallow-advice rev-parse origin/main~1 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" +' + +test_expect_success 'shallowHistory advice accounts for depth already present' ' + test_commit shallow_partial_1 && + test_commit shallow_partial_2 && + test_commit shallow_partial_3 && + test_commit shallow_partial_4 && + test_commit shallow_partial_5 && + test_commit shallow_partial_6 && + git clone --no-local --depth=3 --branch main --single-branch \ + .git shallow-advice-partial && + test_when_finished "rm -rf shallow-advice-partial" && + ( + cd shallow-advice-partial && + oid=$(git rev-parse --short origin/main~2) && + test_must_fail git rev-parse origin/main~5 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=3 origin main" && + git fetch --deepen=3 origin && + git rev-parse origin/main~5 && + test_must_fail git rev-parse origin/main~6 + ) +' + +test_expect_success 'shallowHistory advice on ^N (first parent) beyond shallow boundary' ' + test_commit shallow_caret_1 && + test_commit shallow_caret_2 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-caret && + test_when_finished "rm -rf shallow-advice-caret" && + oid=$(git -C shallow-advice-caret rev-parse --short origin/main) && + test_must_fail git -C shallow-advice-caret rev-parse origin/main^1 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" +' + +test_expect_success 'shallowHistory advice on chained ^ shows the hint only once' ' + test_commit shallow_chain_1 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-chain && + test_when_finished "rm -rf shallow-advice-chain" && + oid=$(git -C shallow-advice-chain rev-parse --short origin/main) && + test_must_fail git -C shallow-advice-chain rev-parse origin/main^^ 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" +' + +test_expect_success 'shallowHistory advice on ^N suggests deepen=1 even for a merge parent' ' + test_commit shallow_merge_base && + git checkout -q -b shallow-merge-side && + test_commit shallow_merge_side1 && + git checkout -q main && + test_commit shallow_merge_main1 && + git merge -q --no-ff shallow-merge-side -m "shallow merge commit" && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-merge && + test_when_finished "rm -rf shallow-advice-merge" && + ( + cd shallow-advice-merge && + oid=$(git rev-parse --short origin/main) && + test_must_fail git rev-parse origin/main^2 2>err && + check_shallow_history_advice origin/main "$oid" \ + "git fetch --deepen=1 origin main" && + git fetch -q --deepen=1 origin && + git rev-parse origin/main^1 && + git rev-parse origin/main^2 + ) +' + +test_expect_success 'shallowHistory advice can be disabled' ' + test_commit shallow_off_1 && + git clone --no-local --depth=1 --branch main --single-branch \ + .git shallow-advice-off && + test_when_finished "rm -rf shallow-advice-off" && + test_must_fail git -C shallow-advice-off \ + -c advice.shallowHistory=false rev-parse origin/main~1 2>err && + test_grep ! "^hint:" err +' + +test_expect_success 'shallowHistory advice not shown for a non-shallow repository' ' + test_must_fail git rev-parse HEAD~100000 2>err && + test_grep ! "^hint:" err +' + +test_expect_success 'shallowHistory advice not shown when resolution succeeds' ' + test_commit shallow_ok_1 && + test_commit shallow_ok_2 && + test_commit shallow_ok_3 && + git clone --no-local --depth=3 --branch main --single-branch \ + .git shallow-advice-ok && + test_when_finished "rm -rf shallow-advice-ok" && + git -C shallow-advice-ok rev-parse origin/main~1 >actual 2>err && + test_grep ! "^hint:" err +' + test_expect_success 'rev-parse --show-object-format in repo' ' test_oid algo >expect && git rev-parse --show-object-format >actual && From d7e7b694b8a9d60b872ec06888e6173d53999600 Mon Sep 17 00:00:00 2001 From: Maciej Ciemborowicz Date: Fri, 25 Sep 2026 00:33:10 +0200 Subject: [PATCH 035/316] refs: report old values to transaction hooks The reference-transaction hook reports an all-zero old object ID whenever the caller does not supply an expected old value. Consequently, batched branch, tag, and remote-ref deletions report zero as both the old and new object IDs because refs_delete_refs() intentionally queues unconditional deletions. Changing those callers to provide expected old values would make the deletions conditional and alter existing command behavior. Instead, record the current raw ref value separately for the hook. Read it before the "preparing" hook, then refresh it after the backend has locked the refs so that the "prepared" and later phases report the value protected by the transaction's locks. Keep this value separate from old_oid and old_target so it does not set REF_HAVE_OLD or otherwise constrain the update. Only resolve these values when a reference-transaction hook exists. Preserve symbolic refs as targets, consistent with the hook's existing symref format. Document that an unlocked "preparing" value may differ from later phases if the ref changes before it is locked. Add coverage for batched branch deletion, tag deletion, and remote pruning. Also exercise a concurrent update from the "preparing" hook to verify that the deletion remains unconditional while later hook phases report the value actually removed. Signed-off-by: Maciej Ciemborowicz Signed-off-by: Junio C Hamano --- Documentation/githooks.adoc | 17 +++++---- refs.c | 54 ++++++++++++++++++++++++--- refs/refs-internal.h | 8 ++++ t/t1416-ref-transaction-hooks.sh | 64 +++++++++++++++++++++++++++++++- 4 files changed, 128 insertions(+), 15 deletions(-) diff --git a/Documentation/githooks.adoc b/Documentation/githooks.adoc index ed045940d18ba5..f60dd1d582f16b 100644 --- a/Documentation/githooks.adoc +++ b/Documentation/githooks.adoc @@ -509,14 +509,15 @@ receives on standard input a line of the format: SP SP LF where `` is the old object name passed into the reference -transaction, `` is the new object name to be stored in the -ref and `` is the full name of the ref. When force updating -the reference regardless of its current value or when the reference is -to be created anew, `` is the all-zeroes object name. To -distinguish these cases, you can inspect the current value of -`` via `git rev-parse`. During the "preparing" state, symbolic -references are not resolved: `` will reflect the symbolic reference -itself rather than the object it points to. +transaction, or the value observed while preparing the transaction if no +old object name was passed. `` is the new object name to be +stored in the ref and `` is the full name of the ref. When the +reference does not exist, `` is the all-zeroes object name. +Because references are not yet locked in the "preparing" state, its observed +old value may differ from the value reported in subsequent states if the +reference changes before it is locked. During the "preparing" state, +symbolic references are not resolved: `` will reflect the symbolic +reference itself rather than the object it points to. For symbolic reference updates the `` and `` fields could denote references instead of objects. A reference will be diff --git a/refs.c b/refs.c index 92d5df5b71fa4b..d2d25402c385f5 100644 --- a/refs.c +++ b/refs.c @@ -1260,6 +1260,7 @@ void ref_transaction_free(struct ref_transaction *transaction) free(transaction->updates[i]->committer_info); free((char *)transaction->updates[i]->new_target); free((char *)transaction->updates[i]->old_target); + free(transaction->updates[i]->hook_old_target); free((char *)transaction->updates[i]->rejection_details); free(transaction->updates[i]); } @@ -2606,6 +2607,8 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_ struct transaction_feed_cb_data *feed_cb_data = pp_task_cb; struct strbuf *buf = &feed_cb_data->buf; struct ref_update *update; + const struct object_id *old_oid; + const char *old_target; size_t i = feed_cb_data->index++; int ret; @@ -2619,12 +2622,18 @@ static int transaction_hook_feed_stdin(int hook_stdin_fd, void *pp_cb, void *pp_ strbuf_reset(buf); - if (!(update->flags & REF_HAVE_OLD)) - strbuf_addf(buf, "%s ", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo))); - else if (update->old_target) - strbuf_addf(buf, "ref:%s ", update->old_target); + if (update->flags & REF_HAVE_OLD) { + old_oid = &update->old_oid; + old_target = update->old_target; + } else { + old_oid = &update->hook_old_oid; + old_target = update->hook_old_target; + } + + if (old_target) + strbuf_addf(buf, "ref:%s ", old_target); else - strbuf_addf(buf, "%s ", oid_to_hex(&update->old_oid)); + strbuf_addf(buf, "%s ", oid_to_hex(old_oid)); if (!(update->flags & REF_HAVE_NEW)) strbuf_addf(buf, "%s ", oid_to_hex(null_oid(transaction->ref_store->repo->hash_algo))); @@ -2660,6 +2669,36 @@ static void transaction_feed_cb_data_free(void *data) free(d); } +static void resolve_transaction_hook_old_values(struct ref_transaction *transaction) +{ + struct ref_store *refs = transaction->ref_store; + struct strbuf referent = STRBUF_INIT; + + if (!hook_exists(refs->repo, "reference-transaction")) + return; + + for (size_t i = 0; i < transaction->nr; i++) { + struct ref_update *update = transaction->updates[i]; + unsigned int type = 0; + int failure_errno; + + if (update->flags & (REF_HAVE_OLD | REF_LOG_ONLY)) + continue; + + oidclr(&update->hook_old_oid, refs->repo->hash_algo); + FREE_AND_NULL(update->hook_old_target); + strbuf_reset(&referent); + + if (!refs_read_raw_ref(refs, update->refname, + &update->hook_old_oid, &referent, + &type, &failure_errno) && + (type & REF_ISSYMREF)) + update->hook_old_target = xstrdup(referent.buf); + } + + strbuf_release(&referent); +} + static int run_transaction_hook(struct ref_transaction *transaction, const char *state) { @@ -2709,6 +2748,8 @@ int ref_transaction_prepare(struct ref_transaction *transaction, if (ref_update_reject_duplicates(&transaction->refnames, err)) return REF_TRANSACTION_ERROR_GENERIC; + resolve_transaction_hook_old_values(transaction); + /* Preparing checks before locking references */ ret = run_transaction_hook(transaction, "preparing"); if (ret) { @@ -2720,6 +2761,9 @@ int ref_transaction_prepare(struct ref_transaction *transaction, if (ret) return ret; + /* Refresh old values now that the references are locked. */ + resolve_transaction_hook_old_values(transaction); + ret = run_transaction_hook(transaction, "prepared"); if (ret) { ref_transaction_abort(transaction, err); diff --git a/refs/refs-internal.h b/refs/refs-internal.h index c3ac7b556f3716..a7471b24810e38 100644 --- a/refs/refs-internal.h +++ b/refs/refs-internal.h @@ -99,6 +99,14 @@ struct ref_update { */ struct object_id old_oid; + /* + * The old value observed for the reference-transaction hook when the + * caller did not provide an expected old value. Unlike old_oid and + * old_target, these fields do not constrain the update. + */ + struct object_id hook_old_oid; + char *hook_old_target; + /* * If the new_oid points to a tag object, set this to the peeled * object ID for optimized retrieval without needed to hit the odb. diff --git a/t/t1416-ref-transaction-hooks.sh b/t/t1416-ref-transaction-hooks.sh index 4fe9d9b23465ab..fcc74049435684 100755 --- a/t/t1416-ref-transaction-hooks.sh +++ b/t/t1416-ref-transaction-hooks.sh @@ -14,6 +14,66 @@ test_expect_success setup ' POST_OID=$(git rev-parse POST) ' +test_expect_success 'hook gets old values for batched unconditional deletion' ' + test_when_finished "rm -f actual" && + test_when_finished "git remote remove origin && rm -rf empty.git" && + git init --bare empty.git && + git remote add origin ./empty.git && + git branch delete-a PRE && + git branch delete-b POST && + git tag delete-tag POST && + git update-ref refs/remotes/origin/to-prune $PRE_OID && + test_hook reference-transaction <<-\EOF && + if test "$1" = committed + then + cat >>actual + fi + EOF + git branch -D delete-a delete-b && + git tag -d delete-tag && + git remote prune origin && + cat >expect <<-EOF && + $PRE_OID $ZERO_OID refs/heads/delete-a + $POST_OID $ZERO_OID refs/heads/delete-b + $POST_OID $ZERO_OID refs/tags/delete-tag + $PRE_OID $ZERO_OID refs/remotes/origin/to-prune + EOF + test_cmp expect actual +' + +test_expect_success 'unconditional deletion remains unconditional' ' + test_when_finished "rm -f actual" && + test_when_finished "rm -f \"$(git rev-parse --git-path delete-race-once)\"" && + git branch delete-race PRE && + test_hook reference-transaction <<-\EOF && + state=$1 + while read -r old new ref + do + if test "$state" != aborted + then + case "$new" in + *[!0]*) ;; + *) echo "$state $old $new $ref" >>actual ;; + esac + fi + done + marker=$(git rev-parse --git-path delete-race-once) + if test "$state" = preparing && test ! -e "$marker" + then + >"$marker" + git update-ref refs/heads/delete-race POST + fi + EOF + git branch -D delete-race && + cat >expect <<-EOF && + preparing $PRE_OID $ZERO_OID refs/heads/delete-race + prepared $POST_OID $ZERO_OID refs/heads/delete-race + committed $POST_OID $ZERO_OID refs/heads/delete-race + EOF + test_cmp expect actual && + test_must_fail git show-ref --verify refs/heads/delete-race +' + test_expect_success 'hook allows updating ref if successful' ' git reset --hard PRE && test_hook reference-transaction <<-\EOF && @@ -65,7 +125,7 @@ test_expect_success 'hook gets all queued updates in prepared state' ' fi EOF cat >expect <<-EOF && - $ZERO_OID $POST_OID refs/heads/main + $PRE_OID $POST_OID refs/heads/main EOF git update-ref HEAD POST <<-EOF && update HEAD $ZERO_OID $POST_OID @@ -87,7 +147,7 @@ test_expect_success 'hook gets all queued updates in committed state' ' fi EOF cat >expect <<-EOF && - $ZERO_OID $POST_OID refs/heads/main + $PRE_OID $POST_OID refs/heads/main EOF git update-ref HEAD POST && test_cmp expect actual From 46ec7ad73a74c76273fd7520fba324233579898a Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 24 Sep 2026 08:28:02 +0000 Subject: [PATCH 036/316] git-p4: avoid shell interpretation of commit ids in applyCommit applyCommit() builds a `git diff-tree ... | git apply ...` pipeline as a shell command string, interpolating the commit id and running it via os.system()/system(shell=True). The id usually comes from `git rev-list` output (safe, plain SHA-1s), but it can also come verbatim from the user-supplied `--commit` option, which is never validated (git-p4.py:2620-2631). A value such as `$(some-command)` passed to `--commit` is executed by the shell during command substitution, even though the value is wrapped in double quotes. Replace the shell pipeline with two argument-vector subprocess calls connected directly through a pipe, matching the pattern already used throughout this file (read_pipe, read_pipe_lines, p4_system). This removes the shell entirely, rather than relying on quoting the interpolated value. The shell-based call that applied the patch for real went through git-p4.py's own system() helper, which raises CalledProcessError on a non-zero exit status, so a failed apply aborted the submit. Keep that behaviour by giving the new helper the same ignore_error contract system() uses: it raises unless the caller asks for the status, and the two callers that test the status for themselves ask for it. Add a regression test exercising `git p4 submit --commit` with a shell metacharacter payload, verifying it is never interpreted. Signed-off-by: Anupam Mediratta Signed-off-by: Junio C Hamano --- git-p4.py | 35 ++++++++++++++++++++++--------- t/t9803-git-p4-shell-metachars.sh | 16 ++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/git-p4.py b/git-p4.py index c0ca7becaf4861..e7168315546df6 100755 --- a/git-p4.py +++ b/git-p4.py @@ -465,6 +465,26 @@ def p4_system(cmd, *k, **kw): raise subprocess.CalledProcessError(retcode, real_cmd) +def diffTreeApply(id, applyArgs, ignore_error=False): + """Pipe `git diff-tree --full-index -p ` into `git apply ` + without a shell, so id can never be interpreted as shell syntax. Returns + the exit status of git apply, raising CalledProcessError on a non-zero + status unless ignore_error is set.""" + diffArgv = ["git", "diff-tree", "--full-index", "-p", id] + applyArgv = ["git", "apply"] + applyArgs + if verbose: + print("TryPatch: %s | %s" % (" ".join(diffArgv), " ".join(applyArgv))) + diffProc = subprocess.Popen(diffArgv, stdout=subprocess.PIPE) + applyProc = subprocess.Popen(applyArgv, stdin=diffProc.stdout) + diffProc.stdout.close() + applyProc.wait() + diffProc.wait() + retcode = applyProc.returncode + if retcode and not ignore_error: + raise subprocess.CalledProcessError(retcode, applyArgv) + return retcode + + def die_bad_access(s): die("failure accessing depot: {0}".format(s.rstrip())) @@ -2234,16 +2254,11 @@ def applyCommit(self, id): else: die("unknown modifier %s for %s" % (modifier, path)) - diffcmd = "git diff-tree --full-index -p \"%s\"" % (id) - patchcmd = diffcmd + " | git apply " - tryPatchCmd = patchcmd + "--check -" - applyPatchCmd = patchcmd + "--check --apply -" + tryPatchArgs = ["--check", "-"] + applyPatchArgs = ["--check", "--apply", "-"] patch_succeeded = True - if verbose: - print("TryPatch: %s" % tryPatchCmd) - - if os.system(tryPatchCmd) != 0: + if diffTreeApply(id, tryPatchArgs, ignore_error=True) != 0: fixed_rcs_keywords = False patch_succeeded = False print("Unfortunately applying the change failed!") @@ -2279,7 +2294,7 @@ def applyCommit(self, id): if fixed_rcs_keywords: print("Retrying the patch with RCS keywords cleaned up") - if os.system(tryPatchCmd) == 0: + if diffTreeApply(id, tryPatchArgs, ignore_error=True) == 0: patch_succeeded = True print("Patch succeesed this time with RCS keywords cleaned") @@ -2291,7 +2306,7 @@ def applyCommit(self, id): # # Apply the patch for real, and do add/delete/+x handling. # - system(applyPatchCmd, shell=True) + diffTreeApply(id, applyPatchArgs) for f in filesToChangeType: p4_edit(f, "-t", "auto") diff --git a/t/t9803-git-p4-shell-metachars.sh b/t/t9803-git-p4-shell-metachars.sh index 2913277013da56..ef8fd6e0941d4b 100755 --- a/t/t9803-git-p4-shell-metachars.sh +++ b/t/t9803-git-p4-shell-metachars.sh @@ -105,4 +105,20 @@ test_expect_success 'branch with shell char' ' ) ' +test_expect_success 'git p4 submit --commit does not execute shell metachars in commit id' ' + git p4 clone --dest="$git" //depot && + test_when_finished cleanup_git && + ( + cd "$git" && + git config git-p4.skipSubmitEditCheck true && + echo f3 >file3 && + git add file3 && + git commit -m "add file3" && + name='"'"'$(touch${IFS}injection-marker)'"'"' && + git branch "$name" HEAD && + P4EDITOR="test-tool chmtime +5" git p4 submit --commit "$name" + ) && + test_path_is_missing "$cli/injection-marker" +' + test_done From 8989e7aecdd2513cd1d32e0dfde30f5eb3d9b170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mart=C3=AD?= Date: Sun, 27 Sep 2026 22:46:21 +0000 Subject: [PATCH 037/316] credential/libsecret: load secrets explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keyring_get() searches with SECRET_SEARCH_LOAD_SECRETS, then passes secret_item_get_secret() of the first match unchecked to secret_value_get_text() and secret_value_unref(). As libsecret documents, that secret can be NULL: the search does not load secrets of locked items, such as when SECRET_SEARCH_UNLOCK fails to unlock them, and it ignores errors from loading secrets. The GNOME keyring daemon also silently leaves out of its reply any item which is locked or which was deleted after the search matched it, e.g. by a concurrent "credential erase" from another git process. We then print secret_value_get_text: assertion 'value' failed secret_value_unref: assertion 'value != NULL' failed before git falls back to prompting for the password. We could keep the flag and load the secret explicitly only when it is NULL, but SECRET_SEARCH_LOAD_SECRETS is not part of the search call: libsecret implements it as a separate GetSecrets D-Bus call after SearchItems. Drop the flag and instead always load the one secret we use with secret_item_load_secret_sync(), which reports errors. This takes as many D-Bus calls as before, and leaves a single code path that runs every time, rather than a fallback that only runs in a rare race. libsecret's own secret-tool also loads each secret explicitly after searching. An inaccessible item now produces a useful error message instead of the assertion failures, and git still falls back to prompting. The race needs a concurrent process or a locked keyring to trigger, so there is no test. Signed-off-by: Daniel Martí Signed-off-by: Junio C Hamano --- .../libsecret/git-credential-libsecret.c | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/contrib/credential/libsecret/git-credential-libsecret.c b/contrib/credential/libsecret/git-credential-libsecret.c index 941b2afd5eed5d..ad4f60e4d7f726 100644 --- a/contrib/credential/libsecret/git-credential-libsecret.c +++ b/contrib/credential/libsecret/git-credential-libsecret.c @@ -126,7 +126,7 @@ static int keyring_get(struct credential *c) items = secret_service_search_sync(service, &schema, attributes, - SECRET_SEARCH_LOAD_SECRETS | SECRET_SEARCH_UNLOCK, + SECRET_SEARCH_UNLOCK, NULL, &error); g_hash_table_unref(attributes); @@ -143,6 +143,18 @@ static int keyring_get(struct credential *c) gchar **parts; item = items->data; + + /* + * Load the secret explicitly rather than via + * SECRET_SEARCH_LOAD_SECRETS, which skips locked items and + * ignores load failures, leaving the secret NULL. + */ + if (!secret_item_load_secret_sync(item, NULL, &error)) { + g_critical("could not load secret: %s", error->message); + g_error_free(error); + g_list_free_full(items, g_object_unref); + return EXIT_FAILURE; + } secret = secret_item_get_secret(item); attributes = secret_item_get_attributes(item); From 9607d0115d0edf32698ae395163f77e67341794f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:11:35 -0500 Subject: [PATCH 038/316] pack-objects: introduce `stdin_packs_context` struct `stdin_packs_read_input()` currently receives a pointer to the `rev_info` struct and '--stdin-packs' mode separately as arguments, but the object enumeration callbacks only receive a pointer to the `rev_info` struct. Wrap the pair in a new `stdin_packs_context` struct so that a future change may reference the '--stdin-packs' mode within the various object enumeration callbacks. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/pack-objects.c | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c index af9390a46b9a69..a553064fcce0f1 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -3804,11 +3804,17 @@ static int git_pack_config(const char *k, const char *v, static int stdin_packs_found_nr; static int stdin_packs_hints_nr; +struct stdin_packs_context { + struct rev_info *revs; /* must be non-NULL */ + enum stdin_packs_mode mode; +}; + static int add_object_entry_from_pack(const struct object_id *oid, struct packed_git *p, uint32_t pos, void *_data) { + struct stdin_packs_context *ctx = _data; off_t ofs; struct object_info oi = OBJECT_INFO_INIT; enum object_type type = OBJ_NONE; @@ -3827,7 +3833,6 @@ static int add_object_entry_from_pack(const struct object_id *oid, die(_("could not get type of object %s in pack %s"), oid_to_hex(oid), p->pack_name); } else if (type == OBJ_COMMIT) { - struct rev_info *revs = _data; /* * commits in included packs are used as starting points * for the subsequent revision walk @@ -3840,7 +3845,7 @@ static int add_object_entry_from_pack(const struct object_id *oid, * However, we'll only add those objects to the packing * list after checking `want_object_in_pack()` below. */ - add_pending_oid(revs, NULL, oid, 0); + add_pending_oid(ctx->revs, NULL, oid, 0); } if (!want_object_in_pack(oid, 0, &p, &ofs)) @@ -3954,8 +3959,9 @@ static int stdin_packs_include_check(struct commit *commit, void *data) } static void stdin_packs_add_pack_entries(struct strmap *packs, - struct rev_info *revs) + struct stdin_packs_context *ctx) { + struct rev_info *revs = ctx->revs; struct string_list keys = STRING_LIST_INIT_NODUP; struct string_list_item *item; struct hashmap_iter iter; @@ -3994,15 +4000,14 @@ static void stdin_packs_add_pack_entries(struct strmap *packs, (info->kind & STDIN_PACK_EXCLUDE_OPEN)) for_each_object_in_pack(info->p, add_object_entry_from_pack, - revs, + ctx, ODB_FOR_EACH_OBJECT_PACK_ORDER); } string_list_clear(&keys, 0); } -static void stdin_packs_read_input(struct rev_info *revs, - enum stdin_packs_mode mode) +static void stdin_packs_read_input(struct stdin_packs_context *ctx) { struct strbuf buf = STRBUF_INIT; struct strmap packs = STRMAP_INIT; @@ -4017,7 +4022,7 @@ 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 == '!' && ctx->mode == STDIN_PACKS_MODE_FOLLOW) kind = STDIN_PACK_EXCLUDE_OPEN; if (kind != STDIN_PACK_INCLUDE) @@ -4082,19 +4087,23 @@ 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, ctx); strbuf_release(&buf); strmap_clear(&packs, 1); } -static void add_unreachable_loose_objects(struct rev_info *revs); +static void add_unreachable_loose_objects(struct stdin_packs_context *ctx); static void read_stdin_packs(struct repository *repo, enum stdin_packs_mode mode, int rev_list_unpacked) { int prev_fetch_if_missing = repo->fetch_if_missing; struct rev_info revs; + struct stdin_packs_context ctx = { + .revs = &revs, + .mode = mode, + }; /* * The revision walk may hit objects that are promised, only. As the @@ -4131,9 +4140,9 @@ static void read_stdin_packs(struct repository *repo, */ ignore_packed_keep_in_core_open = 1; } - stdin_packs_read_input(&revs, mode); + stdin_packs_read_input(&ctx); if (rev_list_unpacked) - add_unreachable_loose_objects(&revs); + add_unreachable_loose_objects(&ctx); if (prepare_revision_walk(&revs)) die(_("revision walk setup failed")); @@ -4541,7 +4550,7 @@ static void add_objects_in_unpacked_packs(void) static int add_loose_object(const struct object_id *oid, const char *path, void *data) { - struct rev_info *revs = data; + struct stdin_packs_context *ctx = data; enum object_type type = odb_read_object_info(the_repository->objects, oid, NULL); if (type < 0) { @@ -4563,8 +4572,8 @@ static int add_loose_object(const struct object_id *oid, const char *path, add_object_entry(oid, type, "", 0); } - if (revs && type == OBJ_COMMIT) - add_pending_oid(revs, NULL, oid, 0); + if (ctx && type == OBJ_COMMIT) + add_pending_oid(ctx->revs, NULL, oid, 0); return 0; } @@ -4574,10 +4583,10 @@ static int add_loose_object(const struct object_id *oid, const char *path, * add_object_entry will weed out duplicates, so we just add every * loose object we find. */ -static void add_unreachable_loose_objects(struct rev_info *revs) +static void add_unreachable_loose_objects(struct stdin_packs_context *ctx) { for_each_loose_file_in_source(the_repository->objects->sources, - add_loose_object, NULL, NULL, revs); + add_loose_object, NULL, NULL, ctx); } static int has_sha1_pack_kept_or_nonlocal(const struct object_id *oid) From 0ad7b239b236282c631cf0541578078c8c422742 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:11:39 -0500 Subject: [PATCH 039/316] pack-objects: ensure tree/tag closure with '--stdin-packs=follow' Since 5ee86c273bf (repack: exclude cruft pack(s) from the MIDX where possible, 2025-06-23), when the 'repack.midxMustContainCruft' configuration is set to "false", geometric repacks use '--stdin-packs=follow' to copy needed objects out of cruft packs so the MIDX can omit those packs. In cd846bacc7d (pack-objects: introduce '--stdin-packs=follow', 2025-06-23), this behavior changed such that whenever excluded-open ('!') packs are present, the walk stops at objects in excluded-closed ('^') packs. Geometric repacks use '^' for retained packs already in the MIDX, relying on the indexed object set being closed under reachability. However, the walk introduced in cd846bacc7d starts only from commit objects. A geometric repack can therefore produce a MIDX that does not maintain reachability closure for lone trees (that are not reachable from any commit otherwise in the closure). A subsequent repack with '!' packs can stop at that tree in a retained '^' pack even if a new commit reaches it. If the cruft pack remains excluded, and the bitmap selection picks one or more commits which reach that tree, the MIDX cannot generate a bitmap for that commit. Add trees and tags from included and '!' packs (and loose ones with '--unpacked') as roots in '--stdin-packs=follow' mode. This rescues their descendants even when no input commit reaches them. Walk these roots after the existing traversal, preserving the `SEEN` bit to avoid redundant traversals. This gives directly enumerated commits priority for the path prefixes used by name hashes and delta attributes. Tags can introduce commits in the second walk, so path selection remains best-effort. Collect the extra roots in an oidset to avoid queuing duplicates. This uses memory for each distinct root and walks its unvisited descendants. Objects in '^' packs remain cutoffs to avoid rewalking packs that are known to be closed under reachability, provided '!' packs are present. This does not repair existing MIDXs lacking closure; those need a full repack. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- Documentation/git-pack-objects.adoc | 2 + builtin/pack-objects.c | 43 ++++++++++++++- t/t5331-pack-objects-stdin.sh | 81 +++++++++++++++++++++++++++++ t/t7704-repack-cruft.sh | 20 +++++++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/Documentation/git-pack-objects.adoc b/Documentation/git-pack-objects.adoc index 65cd00c152f495..1564d44f49d280 100644 --- a/Documentation/git-pack-objects.adoc +++ b/Documentation/git-pack-objects.adoc @@ -112,6 +112,8 @@ pack may include additional objects based on the following: 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. +Trees and tags in included or `!` packs are followed even when no +commit reaches them, as are loose trees and tags with `--unpacked`. + 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 a553064fcce0f1..fb603059a92444 100644 --- a/builtin/pack-objects.c +++ b/builtin/pack-objects.c @@ -3807,6 +3807,7 @@ static int stdin_packs_hints_nr; struct stdin_packs_context { struct rev_info *revs; /* must be non-NULL */ enum stdin_packs_mode mode; + struct oidset extra_roots; }; static int add_object_entry_from_pack(const struct object_id *oid, @@ -3846,6 +3847,9 @@ static int add_object_entry_from_pack(const struct object_id *oid, * list after checking `want_object_in_pack()` below. */ add_pending_oid(ctx->revs, NULL, oid, 0); + } else if (ctx->mode == STDIN_PACKS_MODE_FOLLOW && + (type == OBJ_TREE || type == OBJ_TAG)) { + oidset_insert(&ctx->extra_roots, oid); } if (!want_object_in_pack(oid, 0, &p, &ofs)) @@ -4103,7 +4107,10 @@ static void read_stdin_packs(struct repository *repo, struct stdin_packs_context ctx = { .revs = &revs, .mode = mode, + .extra_roots = OIDSET_INIT, }; + struct oidset_iter iter; + const struct object_id *oid; /* * The revision walk may hit objects that are promised, only. As the @@ -4151,6 +4158,34 @@ static void read_stdin_packs(struct repository *repo, show_object_pack_hint, &mode); + /* + * Trees and tags need closure even when no commit reaches them. + * Defer adding these roots to revs.pending until the first walk + * finishes. Otherwise a subtree may be visited and marked SEEN + * before its commit's root tree, using "a" instead of "sub/a" + * for a blob's namehash and delta attributes. + * + * Tags may introduce more commits in the second walk, so this + * does not *always* guarantee that trees are always visited + * with their full paths. + */ + oidset_iter_init(&ctx.extra_roots, &iter); + while ((oid = oidset_iter_next(&iter))) { + struct object *obj = lookup_object(repo, oid); + + if (!obj || !(obj->flags & SEEN)) + add_pending_oid(&revs, NULL, oid, 0); + } + if (revs.pending.nr) { + if (prepare_revision_walk(&revs)) + die(_("revision walk setup failed")); + traverse_commit_list(&revs, + show_commit_pack_hint, + show_object_pack_hint, + &mode); + } + oidset_clear(&ctx.extra_roots); + release_revisions(&revs); trace2_data_intmax("pack-objects", the_repository, "stdin_packs_found", @@ -4572,8 +4607,14 @@ static int add_loose_object(const struct object_id *oid, const char *path, add_object_entry(oid, type, "", 0); } - if (ctx && type == OBJ_COMMIT) + if (!ctx) + return 0; + + if (type == OBJ_COMMIT) add_pending_oid(ctx->revs, NULL, oid, 0); + else if (ctx->mode == STDIN_PACKS_MODE_FOLLOW && + (type == OBJ_TREE || type == OBJ_TAG)) + oidset_insert(&ctx->extra_roots, oid); return 0; } diff --git a/t/t5331-pack-objects-stdin.sh b/t/t5331-pack-objects-stdin.sh index c74b5861af322f..aa79ecdf13c4fb 100755 --- a/t/t5331-pack-objects-stdin.sh +++ b/t/t5331-pack-objects-stdin.sh @@ -520,4 +520,85 @@ test_expect_success '--stdin-packs with !-delimited pack without follow' ' ) ' +test_expect_success '--stdin-packs=follow traverses a tree-only input pack' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit base && + tree=$(git rev-parse HEAD^{tree}) && + P=$(echo "$tree" | git pack-objects $packdir/pack) && + echo "pack-$P.pack" >in && + + # Only --stdin-packs=follow should start a walk from the tree. + : >trace.txt && + GIT_TRACE2_EVENT="$(pwd)/trace.txt" git pack-objects \ + --stdin-packs --stdout /dev/null && + + test_trace2_data pack-objects stdin_packs_hints 0 expect.raw && + sort expect.raw >expect && + objects_in_packs $P >actual && + + test_cmp expect actual + ) +' + +test_expect_success '--stdin-packs=follow traverses an excluded-open tag' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + test_commit --annotate base && + + # Put the commit, tree, and blob in one pack, and the tag in another. + # Give only the second pack as input with a "!" prefix. The result + # must contain the commit, tree, and blob, but not the tag. + P=$(echo HEAD | git pack-objects --revs $packdir/pack) && + objects_in_packs $P >expect && + + git rev-parse base >in && + P=$(git pack-objects $packdir/pack in && + P=$(git pack-objects --stdin-packs=follow $packdir/pack actual && + + test_cmp expect actual + ) +' + +test_expect_success '--stdin-packs=follow respects delta attributes for subtree contents' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + echo "sub/* -delta" >.gitattributes && + mkdir sub && + test-tool genrandom seed 8192 >sub/a && + cp sub/a sub/b && + echo modified >>sub/b && + git add sub && + git commit -m base && + + # If the subtree is visited first, the blobs are found as a and + # b, so the sub/* attribute does not apply. + git rev-parse HEAD HEAD:sub >in && + P=$(git pack-objects $packdir/pack in && + + git pack-objects --stdin-packs=follow $packdir/pack actual && + printf "%s\n" "$ZERO_OID" "$ZERO_OID" >expect && + test_cmp expect actual + ) +' + test_done diff --git a/t/t7704-repack-cruft.sh b/t/t7704-repack-cruft.sh index b342e82447dc43..b49f22878f7faa 100755 --- a/t/t7704-repack-cruft.sh +++ b/t/t7704-repack-cruft.sh @@ -767,6 +767,26 @@ test_expect_success 'repack --write-midx excludes cruft where possible' ' ) ' +test_expect_success 'geometric repack rescues descendants of loose trees' ' + git init loose-tree-cruft && + ( + cd loose-tree-cruft && + git config repack.midxMustContainCruft false && + test_commit base && + blob=$(echo cruft | git hash-object -w --stdin) && + GIT_TEST_MULTI_PACK_INDEX=0 git repack --cruft -d && + + printf "100644 blob %s\tfile\n" "$blob" | git mktree && + GIT_TEST_MULTI_PACK_INDEX=0 git repack -d --geometric=2 \ + --write-midx --write-bitmap-index && + + test-tool read-midx --show-objects $objdir >midx && + cruft=$(ls $packdir/*.mtimes) && + test_grep ! "$(basename "$cruft" .mtimes).idx" midx && + test_grep "^$blob " midx + ) +' + test_expect_success 'repack --write-midx includes cruft when instructed' ' setup_cruft_exclude_tests exclude-cruft-when-instructed && ( From 3fefe19e1d695468611cddfbf40e11f2c27231bd Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:11:44 -0500 Subject: [PATCH 040/316] repack: retain cruft packs in MIDXs after incremental repacks An incremental repack can write a commit and tree into a new pack while leaving objects they reach in an existing cruft pack. For example, a commit can make a previously unreachable blob reachable again. Since 'repack' will invoke 'pack-objects' with '--incremental', it will not copy the blob out of its cruft pack. When the 'repack.midxMustContainCruft' configuration is set to "false", writing the first MIDX after such a repack may omit that cruft pack. The new pack bypasses the `!names.nr` fallback, and there are no previous MIDX packs for `midx_has_unknown_packs()` to check. Selecting the new commit for bitmap coverage then fails because its reachable objects are not all in the MIDX. The omission dates all the way back to 5ee86c273bf (repack: exclude cruft pack(s) from the MIDX where possible, 2025-06-23). It relies on geometric repacking to copy once-cruft objects with '--stdin-packs=follow'. However, an ordinary incremental repack makes no such guarantee. Require the MIDX to include cruft packs in that case, even when a new pack was written. This fixes ordinary '--write-midx'. The separate '--write-midx=incremental' writer does not consult this flag and needs its own handling. Exercise this with the existing fixture that makes a cruft commit reachable again and adds a new (unpacked) commit on top, and ensure that the incremental repack is able to successfully write a reachability bitmap. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/repack.c | 6 ++++++ t/t7704-repack-cruft.sh | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/builtin/repack.c b/builtin/repack.c index c4360382c1fce2..b7596d488da9f1 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -539,6 +539,12 @@ int cmd_repack(int argc, strvec_push(&cmd.args, "--stdin-packs=follow"); strvec_push(&cmd.args, "--unpacked"); } else { + /* + * Incremental repacks do not copy already-packed objects, + * so cruft packs may be required to form a reachability + * closure for the MIDX. + */ + midx_must_contain_cruft = 1; strvec_push(&cmd.args, "--unpacked"); strvec_push(&cmd.args, "--incremental"); } diff --git a/t/t7704-repack-cruft.sh b/t/t7704-repack-cruft.sh index b49f22878f7faa..f7f83e70ffe970 100755 --- a/t/t7704-repack-cruft.sh +++ b/t/t7704-repack-cruft.sh @@ -787,6 +787,17 @@ test_expect_success 'geometric repack rescues descendants of loose trees' ' ) ' +test_expect_success 'incremental repack includes cruft for MIDX bitmaps' ' + setup_cruft_exclude_tests incremental-cruft && + ( + cd incremental-cruft && + + GIT_TEST_MULTI_PACK_INDEX=0 \ + git repack -d --write-midx --write-bitmap-index && + git rev-list --test-bitmap HEAD + ) +' + test_expect_success 'repack --write-midx includes cruft when instructed' ' setup_cruft_exclude_tests exclude-cruft-when-instructed && ( From 2cb1175d946d154fdc1808328a9b413ca42962e2 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:11:47 -0500 Subject: [PATCH 041/316] repack: use a sorted list for explicitly kept packs `existing_packs_collect()` performs a linear search through the '--keep-pack' arguments for each local pack. Typically the number of such arguments is small enough that the difference between a linear and binary search is just noise (especially compared with the amount of work that 'repack' is about to perform). However, an additional caller will wish to search through the same list. To prevent that caller from having to duplicate the clunky for-loop in `existing_packs_collect()`, sort the list using `fspathcmp()` and replace the existing caller's loop with `string_list_has_string()`. This does not change the overall behavior of '--keep-pack' arguments. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/repack.c | 3 +++ repack.c | 8 +------- repack.h | 4 ++++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/builtin/repack.c b/builtin/repack.c index b7596d488da9f1..88b05e96b5b976 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -2,6 +2,7 @@ #include "builtin.h" #include "config.h" +#include "dir.h" #include "environment.h" #include "parse-options.h" #include "path.h" @@ -455,6 +456,8 @@ int cmd_repack(int argc, packtmp = mkpathdup("%s/%s", packdir, packtmp_name); existing.repo = repo; + keep_pack_list.cmp = fspathcmp; + string_list_sort(&keep_pack_list); existing_packs_collect(&existing, &keep_pack_list); if (geometry.split_factor) { diff --git a/repack.c b/repack.c index d2aa58e13484fd..fa748ce46ce098 100644 --- a/repack.c +++ b/repack.c @@ -1,5 +1,4 @@ #include "git-compat-util.h" -#include "dir.h" #include "midx.h" #include "odb.h" #include "packfile.h" @@ -131,7 +130,6 @@ void existing_packs_collect(struct existing_packs *existing, struct strbuf buf = STRBUF_INIT; repo_for_each_pack(existing->repo, p) { - size_t i; const char *base; if (p->multi_pack_index) @@ -142,15 +140,11 @@ void existing_packs_collect(struct existing_packs *existing, base = pack_basename(p); - for (i = 0; i < extra_keep->nr; i++) - if (!fspathcmp(base, extra_keep->items[i].string)) - break; - strbuf_reset(&buf); strbuf_addstr(&buf, base); strbuf_strip_suffix(&buf, ".pack"); - if ((extra_keep->nr > 0 && i < extra_keep->nr) || p->pack_keep) + if (p->pack_keep || string_list_has_string(extra_keep, base)) string_list_append(&existing->kept_packs, buf.buf); else if (p->is_cruft) string_list_append(&existing->cruft_packs, buf.buf); diff --git a/repack.h b/repack.h index 61e554e4ed383d..9f95e3a26f48b4 100644 --- a/repack.h +++ b/repack.h @@ -76,6 +76,10 @@ struct existing_packs { * or packs->kept based on whether each pack has a corresponding * .keep file or not. Packs without a .keep file are not to be kept * if we are going to pack everything into one file. + * + * A non-empty extra_keep must be sorted and use fspathcmp() as its + * comparator. Its entries are pack basenames, including the ".pack" + * suffix. */ void existing_packs_collect(struct existing_packs *existing, const struct string_list *extra_keep); From 46df3288a49c379222193f7103f7795009f73757 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:11:51 -0500 Subject: [PATCH 042/316] repack: follow kept packs when omitting cruft from the MIDX When performing a geometric repack with 'repack.midxMustContainCruft' set to "false", Git uses '--stdin-packs=follow' to copy (once-cruft) objects needed for reachability closure out of cruft packs. .keep packs do not normally participate in that walk, though they *are* included in the resulting MIDX. A .keep pack can contain a commit that reaches an object whose only copy is in a cruft pack. When there is no previous MIDX and the repack writes a new pack, neither `midx_has_unknown_packs()` nor the `!names.nr` fallback require that cruft pack to be included. If the kept commit (or a descendant of it) is selected for bitmap coverage, the bitmap writer fails because the MIDX does not contain all of its reachable objects. Pass excluded kept packs to the follow walk, using '!' for packs outside the existing MIDX and '^' for packs already covered by it. This copies needed objects out of cruft without copying objects in the kept packs. It also avoids including all cruft merely because a concurrent push has installed a temporary '.keep' file. With '--pack-kept-objects', packs with '.keep' files participate in the geometric repack, and thus may have their objects copied. Packs specified as kept via '--keep-pack' still exclude their objects, even when their packs fall below the geometric split. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- builtin/repack.c | 31 ++++++++++++++++++++++++++--- repack-midx.c | 5 +++++ t/t7704-repack-cruft.sh | 43 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/builtin/repack.c b/builtin/repack.c index 88b05e96b5b976..27d6668a4ab6ec 100644 --- a/builtin/repack.c +++ b/builtin/repack.c @@ -476,9 +476,11 @@ int cmd_repack(int argc, show_progress = !po_args.quiet && isatty(2); strvec_push(&cmd.args, "--keep-true-parents"); - for (i = 0; i < keep_pack_list.nr; i++) - strvec_pushf(&cmd.args, "--keep-pack=%s", - keep_pack_list.items[i].string); + /* Geometric follow walks exclude these packs through stdin instead. */ + if (!(geometry.split_factor && !midx_must_contain_cruft)) + for (i = 0; i < keep_pack_list.nr; i++) + strvec_pushf(&cmd.args, "--keep-pack=%s", + keep_pack_list.items[i].string); strvec_push(&cmd.args, "--non-empty"); if (!geometry.split_factor) { /* @@ -593,6 +595,29 @@ int cmd_repack(int argc, fprintf(in, "%c%s\n", marker, basename); } + if (!midx_must_contain_cruft) { + struct strbuf buf = STRBUF_INIT; + + for_each_string_list_item(item, &existing.kept_packs) { + char marker = '^'; + + strbuf_reset(&buf); + strbuf_addf(&buf, "%s.pack", item->string); + + if (po_args.pack_kept_objects && + !string_list_has_string(&keep_pack_list, + buf.buf)) + continue; + + /* Exclusions override any inclusion above. */ + if (!string_list_has_string(&existing.midx_packs, + buf.buf)) + marker = '!'; + + fprintf(in, "%c%s\n", marker, buf.buf); + } + strbuf_release(&buf); + } fclose(in); } diff --git a/repack-midx.c b/repack-midx.c index 64c7f8d0f42372..9f7786aaac5af7 100644 --- a/repack-midx.c +++ b/repack-midx.c @@ -197,6 +197,7 @@ static void midx_included_packs(struct string_list *include, } if (opts->midx_must_contain_cruft || + (!geometry->split_factor && existing->kept_packs.nr) || midx_has_unknown_packs(include, geometry, existing)) { /* * If there are one or more unknown pack(s) present (see @@ -209,6 +210,10 @@ static void midx_included_packs(struct string_list *include, * reachability closure if the MIDX is bitmapped and one * or more of the bitmap's selected commits reaches a * once-cruft object that was later made reachable. + * + * Kept packs may also depend on cruft objects, since + * they are included above without necessarily being + * traversed by a non-geometric repack. */ for_each_string_list_item(item, &existing->cruft_packs) { /* diff --git a/t/t7704-repack-cruft.sh b/t/t7704-repack-cruft.sh index f7f83e70ffe970..bc6d6f588fa903 100755 --- a/t/t7704-repack-cruft.sh +++ b/t/t7704-repack-cruft.sh @@ -798,6 +798,49 @@ test_expect_success 'incremental repack includes cruft for MIDX bitmaps' ' ) ' +test_expect_success 'geometric repack follows kept packs to cruft objects' ' + setup_cruft_exclude_tests kept-cruft && + ( + cd kept-cruft && + + # Put HEAD in a kept pack, while its parent is still in + # a cruft pack. + pack=$(echo "HEAD^..HEAD" | git pack-objects --revs $packdir/pack) && + git prune-packed && + GIT_TEST_MULTI_PACK_INDEX=0 \ + git repack -d --geometric=2 --write-midx --write-bitmap-index \ + --keep-pack=pack-$pack.pack && + + test-tool find-pack -c 1 HEAD && + test-tool read-midx --show-objects $objdir >midx && + cruft=$(ls $packdir/*.mtimes) && + test_grep ! "$(basename "$cruft" .mtimes).idx" midx + ) +' + +test_expect_success 'full repack retains cruft pack in MIDX for unreachable kept objects' ' + setup_cruft_exclude_tests unreachable-kept-cruft && + ( + cd unreachable-kept-cruft && + + pack=$(echo "HEAD^..HEAD" | git pack-objects --revs $packdir/pack) && + touch $packdir/pack-$pack.keep && + + # Make the kept commit unreachable so that the full + # repack leaves its parent in a cruft pack. + git reset --hard one && + git tag -d four && + git reflog expire --all --expire=all && + + GIT_TEST_MULTI_PACK_INDEX=0 \ + git repack -a --write-midx --write-bitmap-index && + + test-tool read-midx --show-objects $objdir >midx && + cruft=$(ls $packdir/*.mtimes) && + test_grep "$(basename "$cruft" .mtimes).idx" midx + ) +' + test_expect_success 'repack --write-midx includes cruft when instructed' ' setup_cruft_exclude_tests exclude-cruft-when-instructed && ( From 284c5938cb2e035e891f936ef185f6c3883d010d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:11:58 -0500 Subject: [PATCH 043/316] repack: track the preferred pack explicitly in MIDX write steps A MIDX write step marks preferred packs in its string-list entries and chooses the last marked entry when executing the step. That makes the choice depend on list order, preventing the list from being sorted for membership checks. Record the last candidate directly in the step, borrowing its name from the write list. This preserves preferred-pack selection while allowing the list to be sorted without changing that choice. Signed-off-by: Junio C Hamano --- repack-midx.c | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/repack-midx.c b/repack-midx.c index 9f7786aaac5af7..61832114671f05 100644 --- a/repack-midx.c +++ b/repack-midx.c @@ -404,6 +404,7 @@ struct midx_compaction_step { uint32_t objects_nr; char *csum; + const char *preferred_pack; /* points into u.write */ enum { MIDX_COMPACTION_STEP_UNKNOWN, @@ -441,8 +442,6 @@ static int midx_compaction_step_exec_write(struct midx_compaction_step *step, { struct child_process cmd = CHILD_PROCESS_INIT; struct string_list hash = STRING_LIST_INIT_DUP; - struct string_list_item *item; - const char *preferred_pack = NULL; int ret = 0; if (!step->u.write.nr) { @@ -450,19 +449,14 @@ static int midx_compaction_step_exec_write(struct midx_compaction_step *step, goto out; } - for_each_string_list_item(item, &step->u.write) { - if (item->util) - preferred_pack = item->string; - } - repack_prepare_midx_command(&cmd, opts, "write"); strvec_pushl(&cmd.args, "--incremental", "--no-write-chain-file", NULL); strvec_pushf(&cmd.args, "--base=%s", base ? base : "none"); - if (preferred_pack) { + if (step->preferred_pack) { struct strbuf buf = STRBUF_INIT; - strbuf_addstr(&buf, preferred_pack); + strbuf_addstr(&buf, step->preferred_pack); strbuf_strip_suffix(&buf, ".idx"); strbuf_addstr(&buf, ".pack"); @@ -719,7 +713,7 @@ static int repack_make_midx_compaction_plan(struct repack_write_midx_opts *opts, item = string_list_append(&step.u.write, buf.buf); if (p->multi_pack_index || i == opts->geometry->pack_nr - 1) - item->util = (void *)1; /* mark as preferred */ + step.preferred_pack = item->string; if (unsigned_add_overflows(step.objects_nr, p->num_objects)) { ret = error(_("too many objects in MIDX compaction step")); @@ -797,7 +791,7 @@ static int repack_make_midx_compaction_plan(struct repack_write_midx_opts *opts, item = string_list_append(&step.u.write, buf.buf); if (pack_int_id == preferred_pack_idx) - item->util = (void *)1; /* mark as preferred */ + step.preferred_pack = item->string; } if (unsigned_add_overflows(step.objects_nr, m->num_objects)) { From 4a5580939c147a88566c74d2884c7ec2ee0597de Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:12:02 -0500 Subject: [PATCH 044/316] repack: defer allocating the append plan's write step The append plan creates a write step before iterating over newly written packs. The next change will consider additional packs and skip those already covered by the MIDX chain, so a non-empty candidate list will no longer imply that a write step is needed. Allocate the step when adding its first pack instead. Continue to consider only newly written packs here, preserving the existing plan. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- repack-midx.c | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/repack-midx.c b/repack-midx.c index 61832114671f05..06eadb9df82dc4 100644 --- a/repack-midx.c +++ b/repack-midx.c @@ -559,33 +559,28 @@ static void repack_make_midx_append_plan(struct repack_write_midx_opts *opts, struct odb_source_files *files = odb_source_files_downcast(opts->existing->source); struct multi_pack_index *m; struct midx_compaction_step *steps = NULL; - struct midx_compaction_step *step; + struct midx_compaction_step *step = NULL; + struct strbuf buf = STRBUF_INIT; size_t steps_nr = 0, steps_alloc = 0; + uint32_t i; odb_reprepare(opts->existing->repo->objects); m = get_multi_pack_index(files->packed); - if (opts->names->nr) { - struct strbuf buf = STRBUF_INIT; - uint32_t i; - - ALLOC_GROW(steps, st_add(steps_nr, 1), steps_alloc); - - step = &steps[steps_nr++]; - memset(step, 0, sizeof(*step)); - - step->type = MIDX_COMPACTION_STEP_WRITE; - string_list_init_dup(&step->u.write); - - for (i = 0; i < opts->names->nr; i++) { - strbuf_reset(&buf); - strbuf_addf(&buf, "pack-%s.idx", - opts->names->items[i].string); - string_list_append(&step->u.write, buf.buf); + for (i = 0; i < opts->names->nr; i++) { + if (!step) { + ALLOC_GROW(steps, st_add(steps_nr, 1), steps_alloc); + step = &steps[steps_nr++]; + memset(step, 0, sizeof(*step)); + step->type = MIDX_COMPACTION_STEP_WRITE; + string_list_init_dup(&step->u.write); } - - strbuf_release(&buf); + strbuf_reset(&buf); + strbuf_addf(&buf, "pack-%s.idx", + opts->names->items[i].string); + string_list_append(&step->u.write, buf.buf); } + strbuf_release(&buf); for (; m; m = m->base_midx) { ALLOC_GROW(steps, st_add(steps_nr, 1), steps_alloc); From 407e99952816b0c9a61cfd43199cfb48498d836f Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Wed, 30 Sep 2026 23:12:05 -0500 Subject: [PATCH 045/316] repack: include required packs in incremental MIDX writes The append plan introduced in 06733a50eee (repack: allow `--write-midx=incremental` without `--geometric`, 2026-05-19) adds only newly written packs to the existing MIDX chain. The bitmap writer can use objects from the new layer and all retained base layers, but the plan omits preexisting packs outside the chain. Bitmap generation fails if a selected commit reaches an object absent from the resulting chain. The geometric plan from 1da62fb5c86 (repack: implement incremental MIDX repacking, 2026-05-19) can omit kept and cruft packs, since neither necessarily participates in the geometric repack. Such packs can also be lost when replacing a tip layer that contains them. Neither plan consults `midx_included_packs()`, so the rules for retaining cruft in ordinary MIDX writes do not protect incremental writes. Use that selection logic to add missing packs to each plan's write step. Skip packs in retained base layers, but include required packs from a replaced tip. Count added objects when choosing which layers to compact, without changing the preferred pack. Write and verify bitmaps in the existing append test: its existing checks do not detect the omitted pack containing the first commit. Cover the no-new-pack case separately with a reachable blob in a cruft pack. Signed-off-by: Taylor Blau Signed-off-by: Junio C Hamano --- Documentation/git-repack.adoc | 5 +- repack-midx.c | 83 ++++++++++++++++++++++++------ t/t7705-repack-incremental-midx.sh | 63 ++++++++++++++++++----- 3 files changed, 122 insertions(+), 29 deletions(-) diff --git a/Documentation/git-repack.adoc b/Documentation/git-repack.adoc index a1f9e64f668750..ee59b76580a76a 100644 --- a/Documentation/git-repack.adoc +++ b/Documentation/git-repack.adoc @@ -303,8 +303,9 @@ linkgit:git-multi-pack-index[1]). flat MIDX. + Without `--geometric`, a new MIDX layer is appended to the existing -chain (or a new chain is started) containing whatever packs were written -by the repack. Existing layers are preserved as-is. +chain (or a new chain is started) containing newly written packs and any +other required packs not already in the chain. Existing layers are +preserved as-is. + When combined with `--geometric`, the incremental mode maintains a chain of MIDX layers that is compacted over time using a geometric merging diff --git a/repack-midx.c b/repack-midx.c index 06eadb9df82dc4..58776c4469122b 100644 --- a/repack-midx.c +++ b/repack-midx.c @@ -7,6 +7,7 @@ #include "odb.h" #include "oidset.h" #include "pack-bitmap.h" +#include "packfile.h" #include "path.h" #include "refs.h" #include "run-command.h" @@ -73,7 +74,8 @@ void midx_snapshot_refs(struct repository *repo, struct tempfile *f) static int midx_has_unknown_packs(struct string_list *include, struct pack_geometry *geometry, - struct existing_packs *existing) + struct existing_packs *existing, + struct multi_pack_index *base) { struct string_list_item *item; @@ -91,6 +93,8 @@ static int midx_has_unknown_packs(struct string_list *include, * MIDX. Note this function is called before the include * list is populated with any cruft pack(s). * + * - In a MIDX layer retained as part of the new chain's base. + * * - Below the geometric split line (if using pack geometry), * indicating that the pack won't be included in the new * MIDX, but its contents were rolled up as part of the @@ -99,7 +103,8 @@ static int midx_has_unknown_packs(struct string_list *include, * - In the existing non-kept packs list (if not using pack * geometry), and marked as non-deleted. */ - if (string_list_has_string(include, pack_name)) { + if (string_list_has_string(include, pack_name) || + midx_contains_pack(base, pack_name)) { continue; } else if (geometry) { struct strbuf buf = STRBUF_INIT; @@ -141,7 +146,8 @@ static int midx_has_unknown_packs(struct string_list *include, } static void midx_included_packs(struct string_list *include, - struct repack_write_midx_opts *opts) + struct repack_write_midx_opts *opts, + struct multi_pack_index *base) { struct existing_packs *existing = opts->existing; struct pack_geometry *geometry = opts->geometry; @@ -198,7 +204,7 @@ static void midx_included_packs(struct string_list *include, if (opts->midx_must_contain_cruft || (!geometry->split_factor && existing->kept_packs.nr) || - midx_has_unknown_packs(include, geometry, existing)) { + midx_has_unknown_packs(include, geometry, existing, base)) { /* * If there are one or more unknown pack(s) present (see * midx_has_unknown_packs() for what makes a pack @@ -336,7 +342,7 @@ static int write_midx_included_packs(struct repack_write_midx_opts *opts) struct packed_git *preferred = pack_geometry_preferred_pack(opts->geometry); int ret = 0; - midx_included_packs(&include, opts); + midx_included_packs(&include, opts, NULL); if (!include.nr) goto done; @@ -547,9 +553,50 @@ static void midx_compaction_step_release(struct midx_compaction_step *step) free(step->csum); } +static int midx_compaction_step_include_packs(struct midx_compaction_step *step, + struct repack_write_midx_opts *opts, + struct multi_pack_index *base) +{ + struct odb_source_files *files = odb_source_files_downcast(opts->existing->source); + struct string_list include = STRING_LIST_INIT_DUP; + struct string_list_item *item; + struct strbuf path = STRBUF_INIT; + int ret = 0; + + midx_included_packs(&include, opts, base); + string_list_sort(&step->u.write); + + for_each_string_list_item(item, &include) { + struct packed_git *p; + + if (string_list_has_string(&step->u.write, item->string) || + midx_contains_pack(base, item->string)) + continue; + + strbuf_reset(&path); + strbuf_addf(&path, "%s/%s", opts->packdir, item->string); + p = packfile_store_load_pack(files->packed, path.buf, 1); + if (!p || open_pack_index(p)) { + ret = error(_("cannot open index for %s"), path.buf); + goto out; + } + if (unsigned_add_overflows(step->objects_nr, p->num_objects)) { + ret = error(_("too many objects in MIDX compaction step")); + goto out; + } + step->objects_nr += p->num_objects; + string_list_insert(&step->u.write, item->string); + } + +out: + strbuf_release(&path); + string_list_clear(&include, 0); + return ret; +} + /* - * Build an append-only MIDX plan: a single WRITE step for the freshly - * written packs, plus COPY steps for every existing layer. No + * Build an append-only MIDX plan: a single WRITE step for packs not + * already in the chain, plus COPY steps for every existing layer. No * compaction or merging is performed. */ static void repack_make_midx_append_plan(struct repack_write_midx_opts *opts, @@ -557,17 +604,20 @@ static void repack_make_midx_append_plan(struct repack_write_midx_opts *opts, size_t *steps_nr_p) { struct odb_source_files *files = odb_source_files_downcast(opts->existing->source); + struct string_list include = STRING_LIST_INIT_DUP; + struct string_list_item *item; struct multi_pack_index *m; struct midx_compaction_step *steps = NULL; struct midx_compaction_step *step = NULL; - struct strbuf buf = STRBUF_INIT; size_t steps_nr = 0, steps_alloc = 0; - uint32_t i; odb_reprepare(opts->existing->repo->objects); m = get_multi_pack_index(files->packed); - for (i = 0; i < opts->names->nr; i++) { + midx_included_packs(&include, opts, m); + for_each_string_list_item(item, &include) { + if (midx_contains_pack(m, item->string)) + continue; if (!step) { ALLOC_GROW(steps, st_add(steps_nr, 1), steps_alloc); step = &steps[steps_nr++]; @@ -575,12 +625,9 @@ static void repack_make_midx_append_plan(struct repack_write_midx_opts *opts, step->type = MIDX_COMPACTION_STEP_WRITE; string_list_init_dup(&step->u.write); } - strbuf_reset(&buf); - strbuf_addf(&buf, "pack-%s.idx", - opts->names->items[i].string); - string_list_append(&step->u.write, buf.buf); + string_list_append(&step->u.write, item->string); } - strbuf_release(&buf); + string_list_clear(&include, 0); for (; m; m = m->base_midx) { ALLOC_GROW(steps, st_add(steps_nr, 1), steps_alloc); @@ -729,6 +776,12 @@ static int repack_make_midx_compaction_plan(struct repack_write_midx_opts *opts, if (opts->geometry->midx_tip_rewritten) m = m->base_midx; + if (midx_compaction_step_include_packs(&step, opts, m) < 0) { + midx_compaction_step_release(&step); + ret = -1; + goto out; + } + trace2_data_string("repack", opts->existing->repo, "midx:rewrote-tip", opts->geometry->midx_tip_rewritten ? "true" : "false"); diff --git a/t/t7705-repack-incremental-midx.sh b/t/t7705-repack-incremental-midx.sh index 25a8c40e8ee5cc..4760c920a50808 100755 --- a/t/t7705-repack-incremental-midx.sh +++ b/t/t7705-repack-incremental-midx.sh @@ -74,7 +74,7 @@ test_expect_success '--write-midx=incremental without --geometric' ' git repack -d && test_commit second && - git repack --write-midx=incremental && + git repack --write-midx=incremental --write-bitmap-index && git multi-pack-index verify && test_line_count = 1 $midx_chain && @@ -83,7 +83,7 @@ test_expect_success '--write-midx=incremental without --geometric' ' # A second repack appends a new layer without # disturbing the existing one. test_commit third && - git repack --write-midx=incremental && + git repack --write-midx=incremental --write-bitmap-index && git multi-pack-index verify && test_line_count = 2 $midx_chain && @@ -91,10 +91,50 @@ test_expect_success '--write-midx=incremental without --geometric' ' head -n 1 $midx_chain >actual && test_cmp expect actual && + git rev-list --test-bitmap HEAD && git fsck ) ' +test_expect_success 'incremental MIDX includes cruft without a new pack' ' + git init incremental-cruft && + ( + cd incremental-cruft && + git config repack.midxMustContainCruft false && + + test_commit base && + echo cruft | git hash-object -w --stdin && + git repack --cruft -d && + test_commit cruft && + git repack -d && + + # All objects are packed, but the new MIDX still needs cruft. + git repack --write-midx=incremental --write-bitmap-index && + git rev-list --test-bitmap HEAD + ) +' + +test_expect_success 'geometric incremental MIDX retains cruft when replacing its tip' ' + git init geometric-incremental-cruft && + ( + cd geometric-incremental-cruft && + git config repack.midxNewLayerThreshold 1 && + + test_commit base && + echo cruft | git hash-object -w --stdin && + git repack --cruft -d && + git multi-pack-index write --incremental --bitmap && + test_commit cruft && + + # Pack the new commit and tree, leaving the blob in cruft. + git repack -d && + git repack --geometric=2 --write-midx=incremental \ + --write-bitmap-index && + test_line_count = 1 $midx_chain && + git rev-list --test-bitmap HEAD + ) +' + test_expect_success 'below layer threshold, tip packs excluded' ' git init below-layer-threshold-tip-packs-excluded && ( @@ -338,7 +378,7 @@ test_expect_success 'geometric rollup with surviving tip packs' ' ) ' -test_expect_success 'kept packs are excluded from repack' ' +test_expect_success 'kept packs are excluded from repack but included in MIDX' ' git init kept-packs-excluded-from-repack && ( cd kept-packs-excluded-from-repack && @@ -353,21 +393,20 @@ test_expect_success 'kept packs are excluded from repack' ' test_commit "$i" && git repack -d || return 1 done && - keep=$(ls $packdir/pack-*.idx | head -n 1) && - touch "${keep%.idx}.keep" && + keep=$(test-tool find-pack A) && + touch "${keep%.pack}.keep" && - # The kept pack is excluded as a repacking candidate - # entirely, so no rollup occurs as there is only one - # non-kept pack. A new MIDX layer is written containing - # that pack. - git repack --geometric=2 -d --write-midx=incremental && + # Neither pack is repacked, but both are needed for the + # bitmap of B, which reaches objects in the kept pack. + git repack --geometric=2 -d --write-midx=incremental \ + --write-bitmap-index && test-tool read-midx $objdir >actual && grep "^pack-.*\.idx$" actual >actual.packs && - test_line_count = 1 actual.packs && - test_grep ! "$keep" actual.packs && + test_line_count = 2 actual.packs && git multi-pack-index verify && + git rev-list --test-bitmap HEAD && # All objects (from both kept and non-kept packs) # must still be accessible. From 608ec22eb8242fbfab58ee6c18f1d2a9d7c0bc96 Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:13 +0530 Subject: [PATCH 046/316] repo: add path.toplevel with absolute and relative suffix formatting Scripts frequently need to find the root directory of a repository's working tree. Currently, this requires using `git rev-parse --show-toplevel` or inferring it from other repository information. Introduce `path.toplevel.absolute` and `path.toplevel.relative` keys to `git repo info`. This allows scripts to retrieve the top-level working tree path in a predictable, strictly formatted manner without relying on `rev-parse`. If requested in a bare repository where no working tree exists, the command returns an empty string. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 10 ++++++++++ builtin/repo.c | 24 ++++++++++++++++++++++++ t/t1900-repo-info.sh | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index ed7d80c690c720..e34abe5feae134 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -119,6 +119,16 @@ values that they return: `path.gitdir.relative`:: The path to the Git repository directory relative to the current working directory. +`path.toplevel.absolute`:: + The canonical absolute path to the top-level directory of the + repository's working tree. Outputs an empty string if the repository + is bare. + +`path.toplevel.relative`:: + The path to the top-level directory of the repository's working + tree relative to the current working directory. Outputs an empty + string if the repository is bare. + `references.format`:: The reference storage format. The valid values are: + diff --git a/builtin/repo.c b/builtin/repo.c index 84e012f83f65ae..c31e9cfa707e24 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -121,6 +121,28 @@ static int get_path_gitdir_relative(struct repository *repo, struct strbuf *buf) return 0; } +static int get_path_toplevel_absolute(struct repository *repo, struct strbuf *buf) +{ + const char *work_tree = repo_get_work_tree(repo); + + if (!work_tree) + return 0; + + format_path(buf, work_tree, "", PATH_FORMAT_CANONICAL); + return 0; +} + +static int get_path_toplevel_relative(struct repository *repo, struct strbuf *buf) +{ + const char *work_tree = repo_get_work_tree(repo); + + if (!work_tree) + return 0; + + format_path(buf, work_tree, repo->prefix, PATH_FORMAT_RELATIVE); + return 0; +} + static int get_references_format(struct repository *repo, struct strbuf *buf) { strbuf_addstr(buf, @@ -137,6 +159,8 @@ static const struct repo_info_field repo_info_field[] = { { "path.commondir.relative", get_path_commondir_relative }, { "path.gitdir.absolute", get_path_gitdir_absolute }, { "path.gitdir.relative", get_path_gitdir_relative }, + { "path.toplevel.absolute", get_path_toplevel_absolute }, + { "path.toplevel.relative", get_path_toplevel_relative }, { "references.format", get_references_format }, }; diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index c85d390f438e80..9417d1ab659f5d 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -213,4 +213,39 @@ test_repo_info_path 'gitdir with explicit GIT_DIR' 'gitdir' \ '.git' \ 'GIT_DIR="../.git" && export GIT_DIR' +test_expect_success 'path.toplevel absolute and relative' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + mkdir -p repo/sub && + cd repo/sub && + + ROOT="$(test-tool path-utils real_path ..)" && + + echo "path.toplevel.absolute=$ROOT" >expect.abs && + git repo info path.toplevel.absolute >actual.abs && + test_cmp expect.abs actual.abs && + + echo "path.toplevel.relative=../" >expect.rel && + git repo info path.toplevel.relative >actual.rel && + test_cmp expect.rel actual.rel + ) +' + +test_expect_success 'path.toplevel absolute and relative in a bare repository' ' + test_when_finished "rm -rf bare.git" && + git init --bare bare.git && + ( + cd bare.git && + + echo "path.toplevel.absolute=" >expect.abs && + git repo info path.toplevel.absolute >actual.abs && + test_cmp expect.abs actual.abs && + + echo "path.toplevel.relative=" >expect.rel && + git repo info path.toplevel.relative >actual.rel && + test_cmp expect.rel actual.rel + ) +' + test_done From 81ee59205acf9194cd6e4f76b5bfa08e10b310ef Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:14 +0530 Subject: [PATCH 047/316] submodule: use repository to find superproject `get_superproject_working_tree()` currently uses `xgetcwd()` to determine the repository whose superproject should be checked. This is incorrect when `--git-dir` points to a repository different from the one associated with the current working directory. In that case, the current working directory may cause the function to return the wrong superproject or an empty result. Pass the `repository` to `get_superproject_working_tree()` so that the superproject is determined from the repository being inspected rather than the current working directory. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- builtin/rev-parse.c | 2 +- submodule.c | 43 +++++++++++++++++++------------------- submodule.h | 2 +- t/t7400-submodule-basic.sh | 19 +++++++++++++++++ 4 files changed, 43 insertions(+), 23 deletions(-) diff --git a/builtin/rev-parse.c b/builtin/rev-parse.c index 43693454d53f0c..e1a6da0076dd1d 100644 --- a/builtin/rev-parse.c +++ b/builtin/rev-parse.c @@ -997,7 +997,7 @@ int cmd_rev_parse(int argc, } if (!strcmp(arg, "--show-superproject-working-tree")) { struct strbuf superproject = STRBUF_INIT; - if (get_superproject_working_tree(&superproject)) + if (get_superproject_working_tree(the_repository, &superproject)) print_path(superproject.buf, prefix, format, DEFAULT_UNMODIFIED); strbuf_release(&superproject); continue; diff --git a/submodule.c b/submodule.c index 5c9257588856a9..78856f9840485e 100644 --- a/submodule.c +++ b/submodule.c @@ -2610,34 +2610,35 @@ void absorb_git_dir_into_superproject(const char *path, absorb_git_dir_into_superproject_recurse(path, super_prefix); } -int get_superproject_working_tree(struct strbuf *buf) +int get_superproject_working_tree(struct repository *r, struct strbuf *buf) { struct child_process cp = CHILD_PROCESS_INIT; struct strbuf sb = STRBUF_INIT; struct strbuf one_up = STRBUF_INIT; - char *cwd = xgetcwd(); + struct strbuf target_wt = STRBUF_INIT; + const char *worktree; int ret = 0; const char *subpath; int code; ssize_t len; - if (!is_inside_work_tree(the_repository)) - /* - * FIXME: - * We might have a superproject, but it is harder - * to determine. - */ + worktree = repo_get_work_tree(r); + if (!worktree) + goto out; + + if (!strbuf_realpath(&target_wt, worktree, 0)) goto out; - if (!strbuf_realpath(&one_up, "../", 0)) + strbuf_addf(&one_up, "%s/..", target_wt.buf); + if (!strbuf_realpath(&one_up, one_up.buf, 0)) goto out; - subpath = relative_path(cwd, one_up.buf, &sb); + subpath = relative_path(target_wt.buf, one_up.buf, &sb); prepare_submodule_repo_env(&cp.env); strvec_pop(&cp.env); - strvec_pushl(&cp.args, "--literal-pathspecs", "-C", "..", + strvec_pushl(&cp.args, "--literal-pathspecs", "-C", one_up.buf, "ls-files", "-z", "--stage", "--full-name", "--", subpath, NULL); strbuf_reset(&sb); @@ -2648,14 +2649,14 @@ int get_superproject_working_tree(struct strbuf *buf) cp.git_cmd = 1; if (start_command(&cp)) - die(_("could not start ls-files in ..")); + die(_("could not start ls-files in %s"), one_up.buf); len = strbuf_read(&sb, cp.out, PATH_MAX); close(cp.out); if (starts_with(sb.buf, "160000")) { int super_sub_len; - int cwd_len = strlen(cwd); + int wt_len = target_wt.len; char *super_sub, *super_wt; /* @@ -2666,12 +2667,12 @@ int get_superproject_working_tree(struct strbuf *buf) super_sub = strchr(sb.buf, '\t') + 1; super_sub_len = strlen(super_sub); - if (super_sub_len > cwd_len || - strcmp(&cwd[cwd_len - super_sub_len], super_sub)) - BUG("returned path string doesn't match cwd?"); + if (super_sub_len > wt_len || + strcmp(&target_wt.buf[wt_len - super_sub_len], super_sub)) + BUG("returned path string doesn't match worktree?"); - super_wt = xstrdup(cwd); - super_wt[cwd_len - super_sub_len] = '\0'; + super_wt = xstrdup(target_wt.buf); + super_wt[wt_len - super_sub_len] = '\0'; strbuf_realpath(buf, super_wt, 1); ret = 1; @@ -2681,10 +2682,10 @@ int get_superproject_working_tree(struct strbuf *buf) code = finish_command(&cp); if (code == 128) - /* '../' is not a git repository */ + /* parent directory is not a git repository */ ret = 0; else if (code == 0 && len == 0) - /* There is an unrelated git repository at '../' */ + /* There is an unrelated git repository at parent directory */ ret = 0; else if (code) die(_("ls-tree returned unexpected return code %d"), code); @@ -2692,7 +2693,7 @@ int get_superproject_working_tree(struct strbuf *buf) out: strbuf_release(&sb); strbuf_release(&one_up); - free(cwd); + strbuf_release(&target_wt); return ret; } diff --git a/submodule.h b/submodule.h index b10e16e6c063d2..1a465a1208c820 100644 --- a/submodule.h +++ b/submodule.h @@ -170,6 +170,6 @@ void absorb_git_dir_into_superproject(const char *path, * project is a submodule of. If this repository is not a submodule of * another repository, return 0. */ -int get_superproject_working_tree(struct strbuf *buf); +int get_superproject_working_tree(struct repository *r, struct strbuf *buf); #endif diff --git a/t/t7400-submodule-basic.sh b/t/t7400-submodule-basic.sh index eefdecb0bd7538..5c3d9f2829ff1f 100755 --- a/t/t7400-submodule-basic.sh +++ b/t/t7400-submodule-basic.sh @@ -1549,4 +1549,23 @@ test_expect_success 'submodule add fails when name is reused' ' ) ' +test_expect_success 'path.superproject-root works with --git-dir' ' + test_when_finished "rm -rf sub super" && + git init sub && + test_commit -C sub initial && + git init super && + ( + cd super && + git -c protocol.file.allow=always submodule add "../sub" sub && + git commit -m "add submodule" && + + SUPER_ROOT="$(test-tool path-utils real_path .)" && + MODULE_DIR="$SUPER_ROOT/.git/modules/sub" && + + echo "path.superproject-root.absolute=$SUPER_ROOT" >expect && + git --git-dir="$MODULE_DIR" repo info path.superproject-root.absolute >actual && + test_cmp expect actual + ) +' + test_done From 86c88d9b3634affd26523edf376150369b923920 Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:15 +0530 Subject: [PATCH 048/316] repo: add path.superproject-root with absolute and relative suffixes Scripts working in multi-repository setups often need to identify the top-level working tree of a superproject from within a submodule. Currently, this is only exposed via `git rev-parse --show-superproject-working-tree`. Introduce `path.superproject-root.absolute` and `path.superproject-root.relative` keys to `git repo info`. This exposes the core submodule context via a scriptable config-like key using standard format rules. If requested when not inside a submodule, the command returns an empty string. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 10 ++++++++++ builtin/repo.c | 31 +++++++++++++++++++++++++++++ t/t1900-repo-info.sh | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index e34abe5feae134..e524a07f53668b 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -119,6 +119,16 @@ values that they return: `path.gitdir.relative`:: The path to the Git repository directory relative to the current working directory. +`path.superproject-root.absolute`:: + The canonical absolute path to the working tree root of the superproject + if the current repository is an initialized submodule. Outputs an empty + string if not in a submodule. + +`path.superproject-root.relative`:: + The path to the working tree root of the superproject relative to the + current working directory if the current repository is an initialized + submodule. Outputs an empty string if not in a submodule. + `path.toplevel.absolute`:: The canonical absolute path to the top-level directory of the repository's working tree. Outputs an empty string if the repository diff --git a/builtin/repo.c b/builtin/repo.c index c31e9cfa707e24..27ebb7a1c9b846 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -18,6 +18,7 @@ #include "strbuf.h" #include "string-list.h" #include "shallow.h" +#include "submodule.h" #include "tree.h" #include "tree-walk.h" #include "utf8.h" @@ -121,6 +122,34 @@ static int get_path_gitdir_relative(struct repository *repo, struct strbuf *buf) return 0; } +static int get_path_superproject_absolute(struct repository *repo, struct strbuf *buf) +{ + struct strbuf superproject = STRBUF_INIT; + + if (!get_superproject_working_tree(repo, &superproject)) { + strbuf_release(&superproject); + return 0; + } + + format_path(buf, superproject.buf, "", PATH_FORMAT_CANONICAL); + strbuf_release(&superproject); + return 0; +} + +static int get_path_superproject_relative(struct repository *repo, struct strbuf *buf) +{ + struct strbuf superproject = STRBUF_INIT; + + if (!get_superproject_working_tree(repo, &superproject)) { + strbuf_release(&superproject); + return 0; + } + + format_path(buf, superproject.buf, repo->prefix, PATH_FORMAT_RELATIVE); + strbuf_release(&superproject); + return 0; +} + static int get_path_toplevel_absolute(struct repository *repo, struct strbuf *buf) { const char *work_tree = repo_get_work_tree(repo); @@ -159,6 +188,8 @@ static const struct repo_info_field repo_info_field[] = { { "path.commondir.relative", get_path_commondir_relative }, { "path.gitdir.absolute", get_path_gitdir_absolute }, { "path.gitdir.relative", get_path_gitdir_relative }, + { "path.superproject-root.absolute", get_path_superproject_absolute }, + { "path.superproject-root.relative", get_path_superproject_relative }, { "path.toplevel.absolute", get_path_toplevel_absolute }, { "path.toplevel.relative", get_path_toplevel_relative }, { "references.format", get_references_format }, diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index 9417d1ab659f5d..eec576a1d9e760 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -213,6 +213,45 @@ test_repo_info_path 'gitdir with explicit GIT_DIR' 'gitdir' \ '.git' \ 'GIT_DIR="../.git" && export GIT_DIR' +test_expect_success 'path.superproject-root absolute and relative' ' + test_when_finished "rm -rf sub super" && + git init sub && + test_commit -C sub initial && + git init super && + ( + cd super && + git -c protocol.file.allow=always submodule add "../sub" sub && + git commit -m "add submodule" && + + cd sub && + ROOT="$(test-tool path-utils real_path ..)" && + + echo "path.superproject-root.absolute=$ROOT" >expect.abs && + git repo info path.superproject-root.absolute >actual.abs && + test_cmp expect.abs actual.abs && + + echo "path.superproject-root.relative=../" >expect.rel && + git repo info path.superproject-root.relative >actual.rel && + test_cmp expect.rel actual.rel + ) +' + +test_expect_success 'path.superproject-root returns empty when not in a submodule' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + + echo "path.superproject-root.absolute=" >expect.abs && + git repo info path.superproject-root.absolute >actual.abs && + test_cmp expect.abs actual.abs && + + echo "path.superproject-root.relative=" >expect.rel && + git repo info path.superproject-root.relative >actual.rel && + test_cmp expect.rel actual.rel + ) +' + test_expect_success 'path.toplevel absolute and relative' ' test_when_finished "rm -rf repo" && git init repo && From ea076162e54ccb7c26e695d347a6c041db77ff2b Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:16 +0530 Subject: [PATCH 049/316] repo: add path.hooks with absolute and relative suffixes Hooks are an integral part of a repository's configuration and are commonly used by tooling to automate repository-specific workflows. Currently, scripts typically retrieve the hooks directory by invoking `git rev-parse --git-path hooks`. Introduce `path.hooks.absolute` and `path.hooks.relative` keys to `git repo info`. This exposes the hooks directory as a scriptable config-like key using standard format rules, allowing scripts to retrieve it through the same interface as other repository path information. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 9 +++++++++ builtin/repo.c | 22 ++++++++++++++++++++++ t/t1900-repo-info.sh | 28 ++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index e524a07f53668b..20836cf8f6a40b 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -119,6 +119,15 @@ values that they return: `path.gitdir.relative`:: The path to the Git repository directory relative to the current working directory. +`path.hooks.absolute`:: + The canonical absolute path to the repository's hooks directory. + Respects the `core.hooksPath` configuration. If `core.hooksPath` is + set to `/dev/null`, that value is returned unchanged. + +`path.hooks.relative`:: + The path to the repository's hooks directory relative to the current + working directory. Respects the `core.hooksPath` configuration. + `path.superproject-root.absolute`:: The canonical absolute path to the working tree root of the superproject if the current repository is an initialized submodule. Outputs an empty diff --git a/builtin/repo.c b/builtin/repo.c index 27ebb7a1c9b846..01666dc17c2c47 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -122,6 +122,26 @@ static int get_path_gitdir_relative(struct repository *repo, struct strbuf *buf) return 0; } +static int get_path_hooks_absolute(struct repository *repo, struct strbuf *buf) +{ + struct strbuf hooks_path = STRBUF_INIT; + + repo_git_path_replace(repo, &hooks_path, "hooks"); + format_path(buf, hooks_path.buf, "", PATH_FORMAT_CANONICAL); + strbuf_release(&hooks_path); + return 0; +} + +static int get_path_hooks_relative(struct repository *repo, struct strbuf *buf) +{ + struct strbuf hooks_path = STRBUF_INIT; + + repo_git_path_replace(repo, &hooks_path, "hooks"); + format_path(buf, hooks_path.buf, repo->prefix, PATH_FORMAT_RELATIVE); + strbuf_release(&hooks_path); + return 0; +} + static int get_path_superproject_absolute(struct repository *repo, struct strbuf *buf) { struct strbuf superproject = STRBUF_INIT; @@ -188,6 +208,8 @@ static const struct repo_info_field repo_info_field[] = { { "path.commondir.relative", get_path_commondir_relative }, { "path.gitdir.absolute", get_path_gitdir_absolute }, { "path.gitdir.relative", get_path_gitdir_relative }, + { "path.hooks.absolute", get_path_hooks_absolute }, + { "path.hooks.relative", get_path_hooks_relative }, { "path.superproject-root.absolute", get_path_superproject_absolute }, { "path.superproject-root.relative", get_path_superproject_relative }, { "path.toplevel.absolute", get_path_toplevel_absolute }, diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index eec576a1d9e760..1da5db4942d389 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -174,7 +174,11 @@ test_repo_info_path () { cd repo/sub && ROOT="$(test-tool path-utils real_path ..)" && export ROOT && eval "$init_command" && - echo "path.$field_name.absolute=$ROOT/$expected_dir" >expect && + case "$expected_dir" in + /*) EXPECT_ABS="$expected_dir" ;; + *) EXPECT_ABS="$ROOT/$expected_dir" ;; + esac && + echo "path.$field_name.absolute=$EXPECT_ABS" >expect && git repo info "path.$field_name.absolute" >actual && test_cmp expect actual ) @@ -188,7 +192,11 @@ test_repo_info_path () { cd repo/sub && ROOT="$(test-tool path-utils real_path ..)" && export ROOT && eval "$init_command" && - echo "path.$field_name.relative=../$expected_dir" >expect && + case "$expected_dir" in + /*) EXPECT_REL="$(test-tool path-utils relative_path "$expected_dir" "$PWD")" ;; + *) EXPECT_REL="../$expected_dir" ;; + esac && + echo "path.$field_name.relative=$EXPECT_REL" >expect && git repo info "path.$field_name.relative" >actual && test_cmp expect actual ) @@ -213,6 +221,22 @@ test_repo_info_path 'gitdir with explicit GIT_DIR' 'gitdir' \ '.git' \ 'GIT_DIR="../.git" && export GIT_DIR' +test_repo_info_path 'hooks standard' 'hooks' '.git/hooks' + +test_repo_info_path 'hooks with core.hooksPath override' 'hooks' \ + 'custom-hooks' \ + 'git config core.hooksPath "$ROOT/custom-hooks" && mkdir -p "$ROOT/custom-hooks"' + +# /dev/null is not a real, canonicalizable filesystem path on Windows, +# so path resolution for core.hooksPath=/dev/null cannot be expected to +# produce a literal "/dev/null" the way it does on POSIX systems. +if ! test_have_prereq MINGW +then + test_repo_info_path 'hooks with core.hooksPath=/dev/null' 'hooks' \ + '/dev/null' \ + 'git config core.hooksPath /dev/null' +fi + test_expect_success 'path.superproject-root absolute and relative' ' test_when_finished "rm -rf sub super" && git init sub && From f8ca76b3df6b1a802ba37cf2ddb50cb7ea349b49 Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:17 +0530 Subject: [PATCH 050/316] repo: add path.index with absolute and relative suffixes The repository index is a fundamental component used by Git and related tooling to track the working tree state. Scripts that interact with the index currently retrieve its location by invoking `git rev-parse --git-path index`. Introduce `path.index.absolute` and `path.index.relative` keys to `git repo info`. This exposes the index file location as a scriptable config-like key using standard format rules, allowing scripts to retrieve it through the same interface as other repository path information. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 12 ++++++++++++ builtin/repo.c | 18 ++++++++++++++++++ t/t1900-repo-info.sh | 23 +++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index 20836cf8f6a40b..08ef47750c5fb1 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -128,6 +128,18 @@ values that they return: The path to the repository's hooks directory relative to the current working directory. Respects the `core.hooksPath` configuration. +`path.index.absolute`:: + The canonical absolute path to the repository's current index file. + Respects the `GIT_INDEX_FILE` environment override. Returns the + configured index path even if the repository is bare or the file does + not exist. + +`path.index.relative`:: + The path to the repository's current index file relative to the current + working directory. Respects the `GIT_INDEX_FILE` environment override. + Returns the configured index path even if the repository is bare or the + file does not exist. + `path.superproject-root.absolute`:: The canonical absolute path to the working tree root of the superproject if the current repository is an initialized submodule. Outputs an empty diff --git a/builtin/repo.c b/builtin/repo.c index 01666dc17c2c47..a4ea2022eb359d 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -142,6 +142,22 @@ static int get_path_hooks_relative(struct repository *repo, struct strbuf *buf) return 0; } +static int get_path_index_absolute(struct repository *repo, struct strbuf *buf) +{ + const char *index_file = repo_get_index_file(repo); + + format_path(buf, index_file, "", PATH_FORMAT_CANONICAL); + return 0; +} + +static int get_path_index_relative(struct repository *repo, struct strbuf *buf) +{ + const char *index_file = repo_get_index_file(repo); + + format_path(buf, index_file, repo->prefix, PATH_FORMAT_RELATIVE); + return 0; +} + static int get_path_superproject_absolute(struct repository *repo, struct strbuf *buf) { struct strbuf superproject = STRBUF_INIT; @@ -210,6 +226,8 @@ static const struct repo_info_field repo_info_field[] = { { "path.gitdir.relative", get_path_gitdir_relative }, { "path.hooks.absolute", get_path_hooks_absolute }, { "path.hooks.relative", get_path_hooks_relative }, + { "path.index.absolute", get_path_index_absolute }, + { "path.index.relative", get_path_index_relative }, { "path.superproject-root.absolute", get_path_superproject_absolute }, { "path.superproject-root.relative", get_path_superproject_relative }, { "path.toplevel.absolute", get_path_toplevel_absolute }, diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index 1da5db4942d389..431a4842d42ebf 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -237,6 +237,29 @@ then 'git config core.hooksPath /dev/null' fi +test_repo_info_path 'index standard' 'index' '.git/index' + +test_repo_info_path 'index with GIT_INDEX_FILE override' 'index' \ + 'custom-index-file' \ + 'GIT_INDEX_FILE="$ROOT/custom-index-file" && export GIT_INDEX_FILE' + +test_expect_success 'path.index in a bare repository returns default index location' ' + test_when_finished "rm -rf bare.git" && + git init --bare bare.git && + ( + cd bare.git && + ROOT="$(test-tool path-utils real_path .)" && + + echo "path.index.absolute=$ROOT/index" >expect.abs && + git repo info path.index.absolute >actual.abs && + test_cmp expect.abs actual.abs && + + echo "path.index.relative=index" >expect.rel && + git repo info path.index.relative >actual.rel && + test_cmp expect.rel actual.rel + ) +' + test_expect_success 'path.superproject-root absolute and relative' ' test_when_finished "rm -rf sub super" && git init sub && From d7a45a67472709dc462f428dc3baa4c126f3967f Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:18 +0530 Subject: [PATCH 051/316] repo: add path.grafts with absolute and relative suffixes The repository grafts file specifies alternate parent relationships for commits and may be used by repository tooling that needs to inspect or manage grafts. Scripts currently retrieve its location by invoking `git rev-parse --git-path info/grafts`. Introduce `path.grafts.absolute` and `path.grafts.relative` keys to `git repo info`. This exposes the grafts file location as a scriptable config-like key using standard format rules, allowing scripts to retrieve it through the same interface as other repository path information. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 11 +++++++++++ builtin/repo.c | 24 ++++++++++++++++++++++++ t/t1900-repo-info.sh | 6 ++++++ 3 files changed, 41 insertions(+) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index 08ef47750c5fb1..868ab0ed9fed40 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -119,6 +119,17 @@ values that they return: `path.gitdir.relative`:: The path to the Git repository directory relative to the current working directory. +`path.grafts.absolute`:: + The canonical absolute path to the repository's graft file. + Respects the `GIT_GRAFT_FILE` environment override. The path is + returned regardless of whether the file currently exists on disk. + +`path.grafts.relative`:: + The path to the repository's graft file relative to the current + working directory. Respects the `GIT_GRAFT_FILE` environment + override. The path is returned regardless of whether the file + currently exists on disk. + `path.hooks.absolute`:: The canonical absolute path to the repository's hooks directory. Respects the `core.hooksPath` configuration. If `core.hooksPath` is diff --git a/builtin/repo.c b/builtin/repo.c index a4ea2022eb359d..8144b74361a114 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -122,6 +122,28 @@ static int get_path_gitdir_relative(struct repository *repo, struct strbuf *buf) return 0; } +static int get_path_grafts_absolute(struct repository *repo, struct strbuf *buf) +{ + const char *graft_file = repo_get_graft_file(repo); + + if (!graft_file) + return error(_("unable to get graft file")); + + format_path(buf, graft_file, "", PATH_FORMAT_CANONICAL); + return 0; +} + +static int get_path_grafts_relative(struct repository *repo, struct strbuf *buf) +{ + const char *graft_file = repo_get_graft_file(repo); + + if (!graft_file) + return error(_("unable to get graft file")); + + format_path(buf, graft_file, repo->prefix, PATH_FORMAT_RELATIVE); + return 0; +} + static int get_path_hooks_absolute(struct repository *repo, struct strbuf *buf) { struct strbuf hooks_path = STRBUF_INIT; @@ -224,6 +246,8 @@ static const struct repo_info_field repo_info_field[] = { { "path.commondir.relative", get_path_commondir_relative }, { "path.gitdir.absolute", get_path_gitdir_absolute }, { "path.gitdir.relative", get_path_gitdir_relative }, + { "path.grafts.absolute", get_path_grafts_absolute }, + { "path.grafts.relative", get_path_grafts_relative }, { "path.hooks.absolute", get_path_hooks_absolute }, { "path.hooks.relative", get_path_hooks_relative }, { "path.index.absolute", get_path_index_absolute }, diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index 431a4842d42ebf..adc4a92487efec 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -221,6 +221,12 @@ test_repo_info_path 'gitdir with explicit GIT_DIR' 'gitdir' \ '.git' \ 'GIT_DIR="../.git" && export GIT_DIR' +test_repo_info_path 'grafts standard' 'grafts' '.git/info/grafts' + +test_repo_info_path 'grafts with GIT_GRAFT_FILE override' 'grafts' \ + 'custom-graft-file' \ + 'GIT_GRAFT_FILE="$ROOT/custom-graft-file" && export GIT_GRAFT_FILE' + test_repo_info_path 'hooks standard' 'hooks' '.git/hooks' test_repo_info_path 'hooks with core.hooksPath override' 'hooks' \ From 42235267e4871257e2ac92c37b7052ce62d7b097 Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:19 +0530 Subject: [PATCH 052/316] repo: add path.git-prefix Scripts sometimes need the path from the repository's working tree root to the current working directory. While this information can be derived through existing Git commands, `git repo info` does not currently expose it as a scriptable key. Introduce the `path.git-prefix` key to `git repo info`. The key returns the path from the working tree root to the current working directory, returning the empty string when invoked from the working tree root. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 5 +++++ builtin/repo.c | 11 +++++++++++ t/t1900-repo-info.sh | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index 868ab0ed9fed40..fb5aceae8f2ce7 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -113,6 +113,11 @@ values that they return: The path to the Git repository's common directory relative to the current working directory. +`path.git-prefix`:: + The path from the root of the working tree to the current working + directory. Returns the empty string when the current working directory + is the root of the working tree. + `path.gitdir.absolute`:: The canonical absolute path to the Git repository directory (the `.git` directory). diff --git a/builtin/repo.c b/builtin/repo.c index 8144b74361a114..ce78cdc44e7ff3 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -100,6 +100,16 @@ static int get_path_commondir_relative(struct repository *repo, struct strbuf *b return 0; } +static int get_path_git_prefix(struct repository *repo, struct strbuf *buf) +{ + /* + * repo->prefix is NULL when the current working directory is + * the worktree root. + */ + strbuf_addstr(buf, repo->prefix ? repo->prefix : ""); + return 0; +} + static int get_path_gitdir_absolute(struct repository *repo, struct strbuf *buf) { const char *git_dir = repo_get_git_dir(repo); @@ -244,6 +254,7 @@ static const struct repo_info_field repo_info_field[] = { { "object.format", get_object_format }, { "path.commondir.absolute", get_path_commondir_absolute }, { "path.commondir.relative", get_path_commondir_relative }, + { "path.git-prefix", get_path_git_prefix }, { "path.gitdir.absolute", get_path_gitdir_absolute }, { "path.gitdir.relative", get_path_gitdir_relative }, { "path.grafts.absolute", get_path_grafts_absolute }, diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index adc4a92487efec..b689445b7a0fb5 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -215,6 +215,29 @@ test_repo_info_path 'commondir with only GIT_DIR' 'commondir' \ '.git' \ 'GIT_DIR="../.git" && export GIT_DIR' +test_expect_success 'path.git-prefix at repository root' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + echo "path.git-prefix=" >expect && + git repo info path.git-prefix >actual && + test_cmp expect actual + ) +' + +test_expect_success 'path.git-prefix in subdirectory' ' + test_when_finished "rm -rf repo" && + git init repo && + mkdir -p repo/sub/dir && + ( + cd repo/sub/dir && + echo "path.git-prefix=sub/dir/" >expect && + git repo info path.git-prefix >actual && + test_cmp expect actual + ) +' + test_repo_info_path 'gitdir standard' 'gitdir' '.git' test_repo_info_path 'gitdir with explicit GIT_DIR' 'gitdir' \ From 2db363c17a96b54c23eae00b1e763bc41c8e83d1 Mon Sep 17 00:00:00 2001 From: K Jayatheerth Date: Sun, 27 Sep 2026 17:14:20 +0530 Subject: [PATCH 053/316] repo: add path.cdup Scripts sometimes need the relative path from the current working directory to the repository's working tree root (cdup). While this information can be retrieved through `git rev-parse --show-cdup`, `git repo info` does not currently expose it as a scriptable key. Introduce the `path.cdup` key to `git repo info`. The key returns the path from the current working directory to the root of the working tree, returning the empty string when invoked from the working tree root. Mentored-by: Justin Tobler Mentored-by: Lucas Seiki Oshiro Signed-off-by: K Jayatheerth Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 5 +++++ builtin/repo.c | 23 +++++++++++++++++++++ t/t1900-repo-info.sh | 40 +++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index fb5aceae8f2ce7..7e14aac244cd7e 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -104,6 +104,11 @@ values that they return: `object.format`:: The object format (hash algorithm) used in the repository. +`path.cdup`:: + When the command is invoked from a subdirectory, show the + path of the top-level directory relative to the current + directory (typically a sequence of "../", or an empty string). + `path.commondir.absolute`:: The canonical absolute path to the Git repository's common directory (the shared `.git` directory containing objects, diff --git a/builtin/repo.c b/builtin/repo.c index ce78cdc44e7ff3..d4083abedc465b 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -78,6 +78,28 @@ static int get_object_format(struct repository *repo, struct strbuf *buf) return 0; } +static int get_path_cdup(struct repository *repo, struct strbuf *buf) +{ + const char *pfx = repo->prefix; + + if (!is_inside_work_tree(repo)) { + const char *worktree = repo_get_work_tree(repo); + + if (worktree) { + strbuf_addstr(buf, worktree); + } + } + + while (pfx) { + pfx = strchr(pfx, '/'); + if (pfx) { + pfx++; + strbuf_addstr(buf, "../"); + } + } + return 0; +} + static int get_path_commondir_absolute(struct repository *repo, struct strbuf *buf) { const char *common_dir = repo_get_common_dir(repo); @@ -252,6 +274,7 @@ static const struct repo_info_field repo_info_field[] = { { "layout.bare", get_layout_bare }, { "layout.shallow", get_layout_shallow }, { "object.format", get_object_format }, + { "path.cdup", get_path_cdup }, { "path.commondir.absolute", get_path_commondir_absolute }, { "path.commondir.relative", get_path_commondir_relative }, { "path.git-prefix", get_path_git_prefix }, diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index b689445b7a0fb5..c600074c2448c8 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -215,6 +215,46 @@ test_repo_info_path 'commondir with only GIT_DIR' 'commondir' \ '.git' \ 'GIT_DIR="../.git" && export GIT_DIR' +test_expect_success 'path.cdup at repository root' ' + test_when_finished "rm -rf repo" && + git init repo && + ( + cd repo && + echo "path.cdup=" >expect && + git repo info path.cdup >actual && + test_cmp expect actual + ) +' + +test_expect_success 'path.cdup in subdirectory' ' + test_when_finished "rm -rf repo" && + git init repo && + mkdir -p repo/sub/dir && + ( + cd repo/sub/dir && + echo "path.cdup=../../" >expect && + git repo info path.cdup >actual && + test_cmp expect actual + ) +' + +test_expect_success 'path.cdup cwd outside the working tree' ' + test_when_finished "rm -rf repo" && + mkdir -p repo/tmp/x && + cd repo && + git init test && + ( + echo path.cdup=$(pwd)/tmp/x >./test/expect && + cd test && + GIT_WORK_TREE=../tmp/x && + export GIT_WORK_TREE && + GIT_DIR=$(pwd)/.git && + export GIT_DIR && + git repo info path.cdup >actual && + test_cmp expect actual + ) +' + test_expect_success 'path.git-prefix at repository root' ' test_when_finished "rm -rf repo" && git init repo && From a7fcda44dc43226dd932d748d2d19c6ae344d1d4 Mon Sep 17 00:00:00 2001 From: Ilia K Date: Sun, 4 Oct 2026 15:19:15 +0000 Subject: [PATCH 054/316] fsmonitor: check the untracked cache after a trivial response When the monitor sends a trivial response ("/"), for example after the daemon restarted or lost events, refresh_fsmonitor() marks every index entry dirty, which is saved in the index. For the untracked cache it only clears the in-memory flag use_fsmonitor. The valid bits of the cached directories are saved unchanged, although nothing checks them anymore: valid_cached_dir() does not lstat() a valid directory while the monitor is trusted. A command that does not scan for untracked files, like "git add" or "git checkout", therefore saves the new token next to stale valid bits, and every later "git status" trusts them. A directory removed in the meantime keeps its untracked files listed, or, when its own entry is invalid, makes every run print ``` warning: could not open directory 'sub/': No such file or directory ``` Check every cached directory against its stat data before the cache is saved next to such a token, as valid_cached_dir() does without a monitor, and invalidate the ones that changed or disappeared. Doing this at write time skips commands that do not write the index, and sparse index writes, which drop the fsmonitor extension anyway. The cost is one lstat() per cached directory, once per trivial response. Signed-off-by: Ilia K Signed-off-by: Junio C Hamano --- dir.c | 56 ++++++++++++++++++++++++++++++++++++ dir.h | 7 +++++ read-cache.c | 16 +++++++++++ t/t7519-status-fsmonitor.sh | 55 +++++++++++++++++++++++++++++++++++ t/t7527-builtin-fsmonitor.sh | 44 ++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+) diff --git a/dir.c b/dir.c index d896e7be4b1fd2..fc41ca969444de 100644 --- a/dir.c +++ b/dir.c @@ -4053,6 +4053,62 @@ void untracked_cache_invalidate_trimmed_path(struct index_state *istate, } } +static int invalidate_stale_dirs(struct untracked_cache *uc, + struct untracked_cache_dir *ucd, + struct index_state *istate, + struct strbuf *path) +{ + struct stat st; + size_t len = path->len; + int nr_invalidated = 0; + unsigned int i; + + if (ucd->valid && + (lstat(path->buf, &st) || + match_stat_data_racy(istate, &ucd->stat_data, &st))) { + invalidate_one_directory(uc, ucd); + nr_invalidated++; + } + + for (i = 0; i < ucd->dirs_nr; i++) { + /* not written to the index, see write_one_dir() */ + if (!ucd->dirs[i]->recurse) + continue; + strbuf_addch(path, '/'); + strbuf_addstr(path, ucd->dirs[i]->name); + nr_invalidated += invalidate_stale_dirs(uc, ucd->dirs[i], + istate, path); + strbuf_setlen(path, len); + } + + return nr_invalidated; +} + +int untracked_cache_invalidate_stale_dirs(struct index_state *istate) +{ + struct strbuf path = STRBUF_INIT; + const char *worktree; + int nr_invalidated; + + if (!istate->untracked || !istate->untracked->root) + return 0; + + /* + * The index is also read and written by commands that do not + * run in the top-level directory of the worktree. + */ + worktree = repo_get_work_tree(istate->repo); + if (!worktree) + return 0; + + strbuf_addstr(&path, worktree); + nr_invalidated = invalidate_stale_dirs(istate->untracked, + istate->untracked->root, + istate, &path); + strbuf_release(&path); + return nr_invalidated; +} + void untracked_cache_remove_from_index(struct index_state *istate, const char *path) { diff --git a/dir.h b/dir.h index 83e0f648a81f36..7af1562b447a18 100644 --- a/dir.h +++ b/dir.h @@ -604,6 +604,13 @@ void untracked_cache_invalidate_path(struct index_state *, const char *, int saf void untracked_cache_invalidate_trimmed_path(struct index_state *, const char *path, int safe_path); +/* + * Invalidate every cached directory that no longer exists or whose + * stat data no longer matches the working tree. valid_cached_dir() + * skips this check while the file system monitor is trusted. + * Returns the number of invalidated directories. + */ +int untracked_cache_invalidate_stale_dirs(struct index_state *); void untracked_cache_remove_from_index(struct index_state *, const char *); void untracked_cache_add_to_index(struct index_state *, const char *); diff --git a/read-cache.c b/read-cache.c index e40f290bb385e9..517996d5c3b3b3 100644 --- a/read-cache.c +++ b/read-cache.c @@ -3037,6 +3037,22 @@ static int do_write_index(struct index_state *istate, struct tempfile *tempfile, istate->untracked) { strbuf_reset(&sb); + /* + * The monitor could not say what changed (see the trivial + * response in refresh_fsmonitor()), so nothing kept the + * valid bits up to date. Check them before they are saved + * next to the new token, which later commands trust even + * when this command did not look for untracked files. + */ + if (write_extensions & WRITE_FSMONITOR_EXTENSION && + istate->fsmonitor_last_update && + !istate->untracked->use_fsmonitor) { + int nr = untracked_cache_invalidate_stale_dirs(istate); + + trace2_data_intmax("index", istate->repo, + "extension/untr/invalidated", nr); + } + write_untracked_extension(&sb, istate->untracked); err = write_index_ext_header(f, eoie_c, CACHE_EXT_UNTRACKED, sb.len) < 0; diff --git a/t/t7519-status-fsmonitor.sh b/t/t7519-status-fsmonitor.sh index 93973ed25a448b..2e1d795a4bf0d8 100755 --- a/t/t7519-status-fsmonitor.sh +++ b/t/t7519-status-fsmonitor.sh @@ -336,6 +336,61 @@ do done done +# After a trivial response ("/") the monitor cannot vouch for the +# untracked cache. Even a command that does not look for untracked +# files must drop the stale entries, or the next "git status" trusts them. +test_expect_success UNTRACKED_CACHE 'untracked cache is checked after a trivial response' ' + test_when_finished "rm -rf trivial err" && + git init trivial && + ( + cd trivial && + mkdir -p dir/sub && + echo tracked >dir/sub/tracked && + git add dir && + git commit -m initial && + git config core.fsmonitor "$TEST_DIRECTORY/t7519/fsmonitor-none" && + # Version 1 only, or the hook prints a version complaint + # on stderr at every query. + git config core.fsmonitorHookVersion 1 && + git config core.untrackedCache true && + # With "normal", invalidating one path also invalidates + # its parents, and the stale parent below is never seen. + git config status.showUntrackedFiles all && + echo untracked >dir/sub/untracked && + echo "?? dir/sub/untracked" >../expect && + git status --porcelain >../actual && + test_cmp ../expect ../actual && + git status --porcelain >../actual && + test_cmp ../expect ../actual && + + # The monitor misses the removal of dir/sub. "git add other" + # gets the trivial response and does not touch the entries + # of dir and dir/sub by itself. + rm -r dir/sub && + echo other >other && + git -c core.fsmonitor="$TEST_DIRECTORY/t7519/fsmonitor-all" \ + add other && + cat >../expect <<-\EOF && + D dir/sub/tracked + A other + EOF + git status --porcelain >../actual 2>../err && + test_must_be_empty ../err && + test_cmp ../expect ../actual && + + # Invalidate the entry of dir/sub. The stale entry of dir then + # makes "git status" open the removed directory and warn. + git update-index --remove dir/sub/tracked && + cat >../expect <<-\EOF && + D dir/sub/tracked + A other + EOF + git status --porcelain >../actual 2>../err && + test_must_be_empty ../err && + test_cmp ../expect ../actual + ) +' + # test that splitting the index doesn't interfere test_expect_success 'splitting the index results in the same state' ' write_integration_script && diff --git a/t/t7527-builtin-fsmonitor.sh b/t/t7527-builtin-fsmonitor.sh index 86195770e97779..46b97ef7849966 100755 --- a/t/t7527-builtin-fsmonitor.sh +++ b/t/t7527-builtin-fsmonitor.sh @@ -1389,4 +1389,48 @@ test_expect_success CASE_INSENSITIVE_FS 'fsmonitor file case wrong on disk' ' test_grep -q " M dir1/dir2/dir4/FILE-4-A" "$PWD/file_case_wrong-try3.out" ' +# After a restart the daemon sends a trivial response ("/"), because it +# cannot know what changed while it was down. Even a command that does +# not look for untracked files must then drop the stale untracked cache +# entries, or the next "git status" trusts them. +test_expect_success UNTRACKED_CACHE 'untracked cache is checked after a trivial response' ' + test_when_finished "stop_daemon_delete_repo test_trivial" && + + git init test_trivial && + mkdir -p test_trivial/dir/sub && + echo tracked >test_trivial/dir/sub/tracked && + git -C test_trivial add dir && + git -C test_trivial commit -m initial && + git -C test_trivial config core.fsmonitor true && + git -C test_trivial config core.untrackedCache true && + echo untracked >test_trivial/dir/sub/untracked && + + # The first status starts the daemon and builds the untracked + # cache, the second one trusts it. + echo "?? dir/sub/untracked" >expect && + git -C test_trivial status --porcelain >actual && + test_cmp expect actual && + git -C test_trivial status --porcelain >actual && + test_cmp expect actual && + + # Remove dir/sub while no daemon is running. "git add" then + # starts a new daemon, receives its trivial response, and does + # not look for untracked files. + git -C test_trivial fsmonitor--daemon stop && + rm -r test_trivial/dir/sub && + echo other >test_trivial/other && + GIT_TRACE2_EVENT="$PWD/trace_trivial" \ + git -C test_trivial add other && + have_t2_data_event fsm_client query/trivial-response expect <<-\EOF && + D dir/sub/tracked + A other + EOF + git -C test_trivial status --porcelain >actual 2>err && + test_must_be_empty err && + test_cmp expect actual +' + test_done From 6e3e7dfc5f35fddafa0ea08c6a8ceed73f6f8e71 Mon Sep 17 00:00:00 2001 From: Patrick Steinhardt Date: Mon, 5 Oct 2026 12:14:55 +0200 Subject: [PATCH 055/316] builtin/repo: rename "references.format" to "references.storageFormat" As part of 2f28db44d5 (Merge branch 'ps/ref-storage-format', 2026-10-01) we have adapt all sites that used to say "reference format" to instead say "reference storage format". One missed spot though was in git-repo(1), where we still print the "references.format" key. Fix that oversight by renaming the key to "references.storageFormat". Signed-off-by: Patrick Steinhardt Signed-off-by: Junio C Hamano --- Documentation/git-repo.adoc | 6 +++--- builtin/repo.c | 2 +- t/t1900-repo-info.sh | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc index ed7d80c690c720..5af454e8ca4b5b 100644 --- a/Documentation/git-repo.adoc +++ b/Documentation/git-repo.adoc @@ -119,7 +119,7 @@ values that they return: `path.gitdir.relative`:: The path to the Git repository directory relative to the current working directory. -`references.format`:: +`references.storageFormat`:: The reference storage format. The valid values are: + include::ref-storage-format.adoc[] @@ -127,10 +127,10 @@ include::ref-storage-format.adoc[] EXAMPLES -------- -* Retrieves the reference format of the current repository: +* Retrieves the reference storage format of the current repository: + ------------ -git repo info references.format +git repo info references.storageFormat ------------ + diff --git a/builtin/repo.c b/builtin/repo.c index 84e012f83f65ae..15267e8d54e5ab 100644 --- a/builtin/repo.c +++ b/builtin/repo.c @@ -137,7 +137,7 @@ static const struct repo_info_field repo_info_field[] = { { "path.commondir.relative", get_path_commondir_relative }, { "path.gitdir.absolute", get_path_gitdir_absolute }, { "path.gitdir.relative", get_path_gitdir_relative }, - { "references.format", get_references_format }, + { "references.storageFormat", get_references_format }, }; static int repo_info_field_cmp(const void *va, const void *vb) diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh index d115d2d9f98e64..ee84f332290839 100755 --- a/t/t1900-repo-info.sh +++ b/t/t1900-repo-info.sh @@ -39,10 +39,10 @@ test_repo_info () { } test_repo_info 'ref format files is retrieved correctly' \ - 'git init --ref-storage-format=files' 'format-files' 'references.format' 'files' + 'git init --ref-storage-format=files' 'format-files' 'references.storageFormat' 'files' test_repo_info 'ref format reftable is retrieved correctly' \ - 'git init --ref-storage-format=reftable' 'format-reftable' 'references.format' 'reftable' + 'git init --ref-storage-format=reftable' 'format-reftable' 'references.storageFormat' 'reftable' test_repo_info 'bare repository = false is retrieved correctly' \ 'git init' 'nonbare' 'layout.bare' 'false' @@ -72,11 +72,11 @@ test_repo_info 'object.format = sha256 is retrieved correctly' \ test_expect_success 'values returned in order requested' ' cat >expect <<-\EOF && layout.bare=false - references.format=files + references.storageFormat=files layout.bare=false EOF git init --ref-storage-format=files ordered && - git -C ordered repo info layout.bare references.format layout.bare >actual && + git -C ordered repo info layout.bare references.storageFormat layout.bare >actual && test_cmp expect actual ' @@ -87,8 +87,8 @@ test_expect_success 'git-repo-info fails if an invalid key is requested' ' ' test_expect_success 'git-repo-info outputs data even if there is an invalid field' ' - echo "references.format=$(test_detect_ref_format)" >expect && - test_must_fail git repo info foo references.format bar >actual && + echo "references.storageFormat=$(test_detect_ref_format)" >expect && + test_must_fail git repo info foo references.storageFormat bar >actual && test_cmp expect actual ' From 1ce26936d17dd8a95365b1194568def378753471 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Mon, 5 Oct 2026 23:52:39 -0400 Subject: [PATCH 056/316] test-lib: allow lazy prerequisite snippets as here-docs Commit 1d133ae91f (test-lib: allow test snippets as here-docs, 2024-07-10) let test_expect_success and test_expect_failure read their snippets from stdin, making it easier to use single quotes within them. I mentioned there that we could extend this to lazy prerequisites, but left it for later. Let's finish that off now. Since test_body_or_stdin() takes the name of the variable to fill, we can use it directly to populate the saved prereq snippet. We read the body when the prereq is declared, but still evaluate it only when the prereq is used. Converting the curl version check in t5551 shows how this can reduce awkward quoting. Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- t/t5551-http-fetch-smart.sh | 8 ++++---- t/test-lib-functions.sh | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/t/t5551-http-fetch-smart.sh b/t/t5551-http-fetch-smart.sh index 1ec971cdbafdb4..130053c57aa141 100755 --- a/t/t5551-http-fetch-smart.sh +++ b/t/t5551-http-fetch-smart.sh @@ -21,14 +21,14 @@ start_httpd # authentication after an early HTTP/2 response. This bug was introduced # in cURL v7.88.0 (8c762f5998 (http2: minor buffer and error path fixes, # 2023-02-08)) and fixed in v8.3.0 (https://github.com/curl/curl/pull/11756). -test_lazy_prereq HAVE_CURL_HTTP2_BUG ' +test_lazy_prereq HAVE_CURL_HTTP2_BUG - <<\EOT test_have_prereq HTTP2 && build_option libcurl | - awk -F. '\'' + awk -F. ' ($1 == 7 && $2 >= 88) || ($1 == 8 && $2 < 3) { broken = 1 } END { exit !broken } - '\'' -' + ' +EOT test_expect_success HTTP2 'enable client-side http/2' ' git config --global http.version HTTP/2 diff --git a/t/test-lib-functions.sh b/t/test-lib-functions.sh index ed9779b0f73310..3056e937e3e957 100644 --- a/t/test-lib-functions.sh +++ b/t/test-lib-functions.sh @@ -760,7 +760,7 @@ lazily_testable_prereq= lazily_tested_prereq= # Usage: test_lazy_prereq PREREQ 'script' test_lazy_prereq () { lazily_testable_prereq="$lazily_testable_prereq$1 " - eval test_prereq_lazily_$1=\$2 + test_body_or_stdin "test_prereq_lazily_$1" "$2" } test_run_lazy_prereq_ () { From 619585d2a48739699daace817f1f3af621ec7ef4 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 6 Oct 2026 07:08:19 +0000 Subject: [PATCH 057/316] status: count push divergence outside the upstream After rebasing onto a newer upstream, "git status" counts every commit the upstream gained since the last push as a difference from the push branch when status.compareBranches includes "@{push}". That can be many commits, even when only a few of your own differ. Keep the full counts, and when they include commits from the upstream, add how many of them are not in it: Your branch and 'origin/topic' have diverged, and have 51 and 1 different commits each (1 and 1 not in 'upstream/main'). Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- remote.c | 81 +++++++++++++++++++++++++++++++++------- t/t6040-tracking-info.sh | 32 ++++++++++++++++ 2 files changed, 100 insertions(+), 13 deletions(-) diff --git a/remote.c b/remote.c index fe620684635620..cbe79275f71eac 100644 --- a/remote.c +++ b/remote.c @@ -2247,11 +2247,12 @@ int resolve_remote_symref(struct ref *ref, struct ref *list) */ static int stat_branch_pair(const char *branch_name, const char *base, + const char *exclude, int *num_ours, int *num_theirs, enum ahead_behind_flags abf) { struct object_id oid; - struct commit *ours, *theirs; + struct commit *ours, *theirs, *excluded = NULL; struct rev_info revs; struct strvec argv = STRVEC_INIT; @@ -2268,6 +2269,14 @@ static int stat_branch_pair(const char *branch_name, const char *base, if (!ours) return -1; + if (exclude) { + if (refs_read_ref(get_main_ref_store(the_repository), exclude, &oid)) + return -1; + excluded = lookup_commit_reference(the_repository, &oid); + if (!excluded) + return -1; + } + *num_theirs = *num_ours = 0; /* are we the same? */ @@ -2284,6 +2293,8 @@ static int stat_branch_pair(const char *branch_name, const char *base, strvec_pushf(&argv, "%s...%s", oid_to_hex(&ours->object.oid), oid_to_hex(&theirs->object.oid)); + if (excluded) + strvec_pushf(&argv, "^%s", oid_to_hex(&excluded->object.oid)); strvec_push(&argv, "--"); repo_init_revisions(the_repository, &revs, NULL); @@ -2305,6 +2316,8 @@ static int stat_branch_pair(const char *branch_name, const char *base, /* clear object flags smudged by the above traversal */ clear_commit_marks(ours, ALL_REV_FLAGS); clear_commit_marks(theirs, ALL_REV_FLAGS); + if (excluded) + clear_commit_marks(excluded, ALL_REV_FLAGS); strvec_clear(&argv); release_revisions(&revs); @@ -2344,7 +2357,23 @@ int stat_tracking_info(struct branch *branch, int *num_ours, int *num_theirs, if (!base) return -1; - return stat_branch_pair(branch->refname, base, num_ours, num_theirs, abf); + return stat_branch_pair(branch->refname, base, NULL, + num_ours, num_theirs, abf); +} + +/* + * Count the commits that differ between branch_name and base but are not + * in upstream. Return false when they cannot be counted or upstream + * accounts for none of the ours and theirs commits. + */ +static bool stat_outside_upstream(const char *branch_name, const char *base, + const char *upstream, int ours, int theirs, + int *ours_unmerged, int *theirs_unmerged) +{ + if (stat_branch_pair(branch_name, base, upstream, ours_unmerged, + theirs_unmerged, AHEAD_BEHIND_FULL) < 0) + return false; + return *ours_unmerged != ours || *theirs_unmerged != theirs; } static char *resolve_compare_branch(struct branch *branch, const char *name) @@ -2376,6 +2405,8 @@ static void format_branch_comparison(struct strbuf *sb, const char *branch_name, const char *push_remote_name, const char *push_branch_name, + const char *upstream_name, + int ours_unmerged, int theirs_unmerged, enum ahead_behind_flags abf, unsigned flags) { @@ -2421,15 +2452,27 @@ static void format_branch_comparison(struct strbuf *sb, _(" (use \"git pull\" to update your local branch)\n")); } } else { - strbuf_addf(sb, - Q_("Your branch and '%s' have diverged,\n" - "and have %d and %d different commit each, " - "respectively.\n", - "Your branch and '%s' have diverged,\n" - "and have %d and %d different commits each, " - "respectively.\n", - ours + theirs), - branch_name, ours, theirs); + if (upstream_name) + strbuf_addf(sb, + Q_("Your branch and '%s' have diverged,\n" + "and have %d and %d different commit each " + "(%d and %d not in '%s').\n", + "Your branch and '%s' have diverged,\n" + "and have %d and %d different commits each " + "(%d and %d not in '%s').\n", + ours + theirs), + branch_name, ours, theirs, + ours_unmerged, theirs_unmerged, upstream_name); + else + strbuf_addf(sb, + Q_("Your branch and '%s' have diverged,\n" + "and have %d and %d different commit each, " + "respectively.\n", + "Your branch and '%s' have diverged,\n" + "and have %d and %d different commits each, " + "respectively.\n", + ours + theirs), + branch_name, ours, theirs); if (use_divergence_advice && advice_enabled(ADVICE_STATUS_HINTS)) { if (push_remote_name && push_branch_name) strbuf_addf(sb, @@ -2473,7 +2516,9 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb, for (i = 0; i < branches.nr; i++) { char *full_ref; char *short_ref; + char *upstream_name = NULL; int ours, theirs, cmp; + int ours_unmerged = 0, theirs_unmerged = 0; int is_upstream, is_push; unsigned flags = 0; const char *push_remote_name = NULL; @@ -2498,9 +2543,17 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb, if (is_upstream && (!push_ref || !strcmp(upstream_ref, push_ref))) is_push = 1; - cmp = stat_branch_pair(branch->refname, full_ref, + cmp = stat_branch_pair(branch->refname, full_ref, NULL, &ours, &theirs, abf); + if (cmp > 0 && ours && theirs && upstream_ref && !is_upstream && + stat_outside_upstream(branch->refname, full_ref, + upstream_ref, ours, theirs, + &ours_unmerged, &theirs_unmerged)) + upstream_name = refs_shorten_unambiguous_ref( + get_main_ref_store(the_repository), + upstream_ref, 0); + if (cmp < 0) { if (is_upstream) { strbuf_addf(sb, @@ -2542,11 +2595,13 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb, } format_branch_comparison(sb, !cmp, ours, theirs, short_ref, push_remote_name, push_branch_name, - abf, flags); + upstream_name, ours_unmerged, + theirs_unmerged, abf, flags); reported = 1; free(full_ref); free(short_ref); + free(upstream_name); } string_list_clear(&branches, 0); diff --git a/t/t6040-tracking-info.sh b/t/t6040-tracking-info.sh index e95d4209726e95..f8df16a5de54fe 100755 --- a/t/t6040-tracking-info.sh +++ b/t/t6040-tracking-info.sh @@ -746,4 +746,36 @@ test_expect_success 'status.compareBranches suppresses advice when push tracking test_cmp expect actual ' +test_expect_success 'status.compareBranches counts push divergence outside upstream' ' + test_config -C test push.default current && + test_config -C test status.compareBranches "@{upstream} @{push}" && + ( + cd test && + git checkout -b feature18 origin/main && + advance work18 && + git push + ) && + git checkout main && + advance main18a && + advance main18b && + git checkout - && + ( + cd test && + echo amended >work18 && + git commit -a --amend --no-edit && + git pull --rebase && + git status >../actual + ) && + cat >expect <<-EOF && + On branch feature18 + Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit. + + Your branch and ${SQ}origin/feature18${SQ} have diverged, + and have 3 and 1 different commits each (1 and 1 not in ${SQ}origin/main${SQ}). + + nothing to commit, working tree clean + EOF + test_cmp expect actual +' + test_done From 4ba8ef0b1371d0b895eb60d9d77c20095adb1ee4 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 6 Oct 2026 07:08:20 +0000 Subject: [PATCH 058/316] status: say when the push branch was rebased cleanly After rebasing pushed work onto a newer upstream without changing it, "git status" reports the commits outside the upstream on both sides, as if the branch and its push branch held different work. When those commits carry the same changes on both sides, say that the branch was rebased cleanly: Your branch and 'origin/topic' have diverged, and have 51 and 1 different commits each (rebased cleanly on 'upstream/main'). Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- remote.c | 49 ++++++++++++++++++++++++++++++++-------- t/t6040-tracking-info.sh | 30 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/remote.c b/remote.c index cbe79275f71eac..89d142cc7ec77f 100644 --- a/remote.c +++ b/remote.c @@ -2248,7 +2248,7 @@ int resolve_remote_symref(struct ref *ref, struct ref *list) static int stat_branch_pair(const char *branch_name, const char *base, const char *exclude, - int *num_ours, int *num_theirs, + int *num_ours, int *num_theirs, int *num_same, enum ahead_behind_flags abf) { struct object_id oid; @@ -2278,6 +2278,8 @@ static int stat_branch_pair(const char *branch_name, const char *base, } *num_theirs = *num_ours = 0; + if (num_same) + *num_same = 0; /* are we the same? */ if (theirs == ours) @@ -2290,6 +2292,8 @@ static int stat_branch_pair(const char *branch_name, const char *base, /* Run "rev-list --left-right ours...theirs" internally... */ strvec_push(&argv, ""); /* ignored */ strvec_push(&argv, "--left-right"); + if (num_same) + strvec_push(&argv, "--cherry-mark"); strvec_pushf(&argv, "%s...%s", oid_to_hex(&ours->object.oid), oid_to_hex(&theirs->object.oid)); @@ -2311,6 +2315,8 @@ static int stat_branch_pair(const char *branch_name, const char *base, (*num_ours)++; else (*num_theirs)++; + if (num_same && (c->object.flags & PATCHSAME)) + (*num_same)++; } /* clear object flags smudged by the above traversal */ @@ -2358,22 +2364,31 @@ int stat_tracking_info(struct branch *branch, int *num_ours, int *num_theirs, return -1; return stat_branch_pair(branch->refname, base, NULL, - num_ours, num_theirs, abf); + num_ours, num_theirs, NULL, abf); } /* * Count the commits that differ between branch_name and base but are not * in upstream. Return false when they cannot be counted or upstream - * accounts for none of the ours and theirs commits. + * accounts for none of the ours and theirs commits. Otherwise set + * *same_changes when the remaining commits carry the same changes on + * both sides. */ static bool stat_outside_upstream(const char *branch_name, const char *base, const char *upstream, int ours, int theirs, - int *ours_unmerged, int *theirs_unmerged) + int *ours_unmerged, int *theirs_unmerged, + bool *same_changes) { + int same; + if (stat_branch_pair(branch_name, base, upstream, ours_unmerged, - theirs_unmerged, AHEAD_BEHIND_FULL) < 0) + theirs_unmerged, &same, AHEAD_BEHIND_FULL) < 0) + return false; + if (*ours_unmerged == ours && *theirs_unmerged == theirs) return false; - return *ours_unmerged != ours || *theirs_unmerged != theirs; + *same_changes = *ours_unmerged && + same == *ours_unmerged + *theirs_unmerged; + return true; } static char *resolve_compare_branch(struct branch *branch, const char *name) @@ -2407,6 +2422,7 @@ static void format_branch_comparison(struct strbuf *sb, const char *push_branch_name, const char *upstream_name, int ours_unmerged, int theirs_unmerged, + bool same_changes, enum ahead_behind_flags abf, unsigned flags) { @@ -2452,7 +2468,17 @@ static void format_branch_comparison(struct strbuf *sb, _(" (use \"git pull\" to update your local branch)\n")); } } else { - if (upstream_name) + if (same_changes) + strbuf_addf(sb, + Q_("Your branch and '%s' have diverged,\n" + "and have %d and %d different commit each " + "(rebased cleanly on '%s').\n", + "Your branch and '%s' have diverged,\n" + "and have %d and %d different commits each " + "(rebased cleanly on '%s').\n", + ours + theirs), + branch_name, ours, theirs, upstream_name); + else if (upstream_name) strbuf_addf(sb, Q_("Your branch and '%s' have diverged,\n" "and have %d and %d different commit each " @@ -2519,6 +2545,7 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb, char *upstream_name = NULL; int ours, theirs, cmp; int ours_unmerged = 0, theirs_unmerged = 0; + bool same_changes = false; int is_upstream, is_push; unsigned flags = 0; const char *push_remote_name = NULL; @@ -2544,12 +2571,13 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb, is_push = 1; cmp = stat_branch_pair(branch->refname, full_ref, NULL, - &ours, &theirs, abf); + &ours, &theirs, NULL, abf); if (cmp > 0 && ours && theirs && upstream_ref && !is_upstream && stat_outside_upstream(branch->refname, full_ref, upstream_ref, ours, theirs, - &ours_unmerged, &theirs_unmerged)) + &ours_unmerged, &theirs_unmerged, + &same_changes)) upstream_name = refs_shorten_unambiguous_ref( get_main_ref_store(the_repository), upstream_ref, 0); @@ -2596,7 +2624,8 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb, format_branch_comparison(sb, !cmp, ours, theirs, short_ref, push_remote_name, push_branch_name, upstream_name, ours_unmerged, - theirs_unmerged, abf, flags); + theirs_unmerged, same_changes, + abf, flags); reported = 1; free(full_ref); diff --git a/t/t6040-tracking-info.sh b/t/t6040-tracking-info.sh index f8df16a5de54fe..2ebc3573da83d8 100755 --- a/t/t6040-tracking-info.sh +++ b/t/t6040-tracking-info.sh @@ -778,4 +778,34 @@ test_expect_success 'status.compareBranches counts push divergence outside upstr test_cmp expect actual ' +test_expect_success 'status.compareBranches after a clean rebase of the push branch' ' + test_config -C test push.default current && + test_config -C test status.compareBranches "@{upstream} @{push}" && + ( + cd test && + git checkout -b feature19 origin/main && + advance work19 && + git push + ) && + git checkout main && + advance main19a && + advance main19b && + git checkout - && + ( + cd test && + git pull --rebase && + git status >../actual + ) && + cat >expect <<-EOF && + On branch feature19 + Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit. + + Your branch and ${SQ}origin/feature19${SQ} have diverged, + and have 3 and 1 different commits each (rebased cleanly on ${SQ}origin/main${SQ}). + + nothing to commit, working tree clean + EOF + test_cmp expect actual +' + test_done From 910e7ee42a3e5407f42e0ce00f6e6b8d2595826c Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 6 Oct 2026 07:08:21 +0000 Subject: [PATCH 059/316] status: suggest a force push after a clean rebase After rebasing pushed work onto a newer upstream without changing it, "git status" says the branch was rebased cleanly but gives no hint on how to publish it. A plain "git push" is rejected, so suggest a force push: Your branch and 'origin/topic' have diverged, and have 51 and 1 different commits each (rebased cleanly on 'upstream/main'). (use "git push --force-with-lease" to publish your local commits) The lease still stops the push if someone else updated the push branch since it was last fetched. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- remote.c | 7 +++++-- t/t6040-tracking-info.sh | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/remote.c b/remote.c index 89d142cc7ec77f..af8d026073a596 100644 --- a/remote.c +++ b/remote.c @@ -2468,7 +2468,7 @@ static void format_branch_comparison(struct strbuf *sb, _(" (use \"git pull\" to update your local branch)\n")); } } else { - if (same_changes) + if (same_changes) { strbuf_addf(sb, Q_("Your branch and '%s' have diverged,\n" "and have %d and %d different commit each " @@ -2478,7 +2478,10 @@ static void format_branch_comparison(struct strbuf *sb, "(rebased cleanly on '%s').\n", ours + theirs), branch_name, ours, theirs, upstream_name); - else if (upstream_name) + if (use_push_advice && advice_enabled(ADVICE_STATUS_HINTS)) + strbuf_addstr(sb, + _(" (use \"git push --force-with-lease\" to publish your local commits)\n")); + } else if (upstream_name) strbuf_addf(sb, Q_("Your branch and '%s' have diverged,\n" "and have %d and %d different commit each " diff --git a/t/t6040-tracking-info.sh b/t/t6040-tracking-info.sh index 2ebc3573da83d8..4074c6663a603d 100755 --- a/t/t6040-tracking-info.sh +++ b/t/t6040-tracking-info.sh @@ -802,6 +802,7 @@ test_expect_success 'status.compareBranches after a clean rebase of the push bra Your branch and ${SQ}origin/feature19${SQ} have diverged, and have 3 and 1 different commits each (rebased cleanly on ${SQ}origin/main${SQ}). + (use "git push --force-with-lease" to publish your local commits) nothing to commit, working tree clean EOF From f64b8d316f9473bda721175586abf86d504caad5 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 6 Oct 2026 07:08:22 +0000 Subject: [PATCH 060/316] push: name the branch to pull from when it is not the upstream When someone else pushed to the branch you push to, such as a topic branch on your fork, "git push" is rejected with a hint to run "git pull" first. If that branch is not your upstream, the pull merges the upstream and leaves the push branch diverged, so the push is rejected again. Name the branch that diverged and suggest pulling from it: hint: Updates were rejected because 'origin/topic' has diverged hint: from your current branch. Use 'git pull origin topic' hint: to integrate the remote changes. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/push.c | 37 +++++++++++++++++++++++--- t/t6040-tracking-info.sh | 57 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/builtin/push.c b/builtin/push.c index 2377b5af554bda..82435beab5da35 100644 --- a/builtin/push.c +++ b/builtin/push.c @@ -12,6 +12,7 @@ #include "environment.h" #include "gettext.h" #include "hex.h" +#include "refs.h" #include "refspec.h" #include "run-command.h" #include "remote.h" @@ -295,6 +296,11 @@ static const char message_advice_pull_before_push[] = "use 'git pull' before pushing again.\n" "See the 'Note about fast-forwards' in 'git push --help' for details."); +static const char message_advice_pull_from_branch_before_push[] = + N_("Updates were rejected because '%s' has diverged\n" + "from your current branch. Use 'git pull %s %s'\n" + "to integrate the remote changes."); + static const char message_advice_checkout_pull_push[] = N_("Updates were rejected because a pushed branch tip is behind its remote\n" "counterpart. If you want to integrate the remote changes, use 'git pull'\n" @@ -322,11 +328,35 @@ static const char message_advice_ref_needs_update[] = "remote changes, use 'git pull' before pushing again.\n" "See the 'Note about fast-forwards' in 'git push --help' for details."); -static void advise_pull_before_push(void) +static void advise_pull_before_push(struct remote *push_remote) { + struct branch *branch = branch_get(NULL); + struct remote *remote = NULL; + const char *upstream = NULL; + char *tracking = NULL; + char *tracking_name = NULL; + if (!advice_enabled(ADVICE_PUSH_NON_FF_CURRENT) || !advice_enabled(ADVICE_PUSH_UPDATE_REJECTED)) return; - advise(_(message_advice_pull_before_push)); + + if (branch) { + remote = repo_remote_for_push_tracking(the_repository, + push_remote); + tracking = apply_refspecs(&remote->fetch, branch->refname); + upstream = branch_get_upstream(branch, NULL); + } + if (tracking) + tracking_name = refs_shorten_unambiguous_ref( + get_main_ref_store(the_repository), tracking, 0); + + if (tracking && (!upstream || strcmp(tracking, upstream))) + advise(_(message_advice_pull_from_branch_before_push), + tracking_name, remote->name, branch->name); + else + advise(_(message_advice_pull_before_push)); + + free(tracking_name); + free(tracking); } static void advise_checkout_pull_push(void) @@ -370,6 +400,7 @@ static int push_with_options(struct transport *transport, struct refspec *rs, int err; unsigned int reject_reasons; char *anon_url = transport_anonymize_url(transport->url); + struct remote *remote = transport->remote; transport_set_verbosity(transport, verbosity, progress); transport->family = family; @@ -404,7 +435,7 @@ static int push_with_options(struct transport *transport, struct refspec *rs, return 0; if (reject_reasons & REJECT_NON_FF_HEAD) { - advise_pull_before_push(); + advise_pull_before_push(remote); } else if (reject_reasons & REJECT_NON_FF_OTHER) { advise_checkout_pull_push(); } else if (reject_reasons & REJECT_ALREADY_EXISTS) { diff --git a/t/t6040-tracking-info.sh b/t/t6040-tracking-info.sh index 4074c6663a603d..9eb810e1584867 100755 --- a/t/t6040-tracking-info.sh +++ b/t/t6040-tracking-info.sh @@ -809,4 +809,61 @@ test_expect_success 'status.compareBranches after a clean rebase of the push bra test_cmp expect actual ' +test_expect_success 'push to a push branch someone else updated suggests pulling from it' ' + ( + cd test && + git checkout -b feature20 origin/main && + advance work20 && + git push origin feature20 + ) && + git checkout feature20 && + advance other20 && + git checkout - && + ( + cd test && + advance mine20 && + git fetch && + test_must_fail git push origin feature20 2>../actual + ) && + url=$(git -C test config remote.origin.url) && + cat >expect <<-EOF && + To $url + ! [rejected] feature20 -> feature20 (non-fast-forward) + error: failed to push some refs to ${SQ}$url${SQ} + hint: Updates were rejected because ${SQ}origin/feature20${SQ} has diverged + hint: from your current branch. Use ${SQ}git pull origin feature20${SQ} + hint: to integrate the remote changes. + EOF + test_cmp expect actual +' + +test_expect_success 'push to the upstream branch' ' + ( + cd test && + git checkout -b feature21 origin/main && + advance work21 && + git push -u origin feature21 + ) && + git checkout feature21 && + advance other21 && + git checkout - && + ( + cd test && + advance mine21 && + git fetch && + test_must_fail git push 2>../actual + ) && + url=$(git -C test config remote.origin.url) && + cat >expect <<-EOF && + To $url + ! [rejected] feature21 -> feature21 (non-fast-forward) + error: failed to push some refs to ${SQ}$url${SQ} + hint: Updates were rejected because the tip of your current branch is behind + hint: its remote counterpart. If you want to integrate the remote changes, + hint: use ${SQ}git pull${SQ} before pushing again. + hint: See the ${SQ}Note about fast-forwards${SQ} in ${SQ}git push --help${SQ} for details. + EOF + test_cmp expect actual +' + test_done From ae66f77bf928ef4acb4ae8795e2c72a7258c3592 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 6 Oct 2026 07:08:23 +0000 Subject: [PATCH 061/316] push: offer a force push after rewriting pushed commits After amending or rebasing commits you already pushed, "git push" is rejected with a hint to run "git pull" first. Pulling merges the old copies of the same work back in. When the rejected remote tip is a commit your branch pointed to before, according to its reflog, the remote carries no work you have not seen. Name the branch and offer a force push next to the pull: hint: Updates were rejected because 'origin/topic' has diverged hint: from your current branch. Use 'git pull origin topic' hint: to integrate the remote changes, or replace them with hint: 'git push --force-with-lease origin topic'. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/push.c | 17 +++++++++++++--- t/t6040-tracking-info.sh | 18 +++++++++++++++++ transport.c | 42 ++++++++++++++++++++++++++++++++++++++-- transport.h | 13 +++++++------ 4 files changed, 79 insertions(+), 11 deletions(-) diff --git a/builtin/push.c b/builtin/push.c index 82435beab5da35..d918723d43eef3 100644 --- a/builtin/push.c +++ b/builtin/push.c @@ -301,6 +301,12 @@ static const char message_advice_pull_from_branch_before_push[] = "from your current branch. Use 'git pull %s %s'\n" "to integrate the remote changes."); +static const char message_advice_pull_or_force_before_push[] = + N_("Updates were rejected because '%s' has diverged\n" + "from your current branch. Use 'git pull %s %s'\n" + "to integrate the remote changes, or replace them with\n" + "'git push --force-with-lease %s %s'."); + static const char message_advice_checkout_pull_push[] = N_("Updates were rejected because a pushed branch tip is behind its remote\n" "counterpart. If you want to integrate the remote changes, use 'git pull'\n" @@ -328,7 +334,8 @@ static const char message_advice_ref_needs_update[] = "remote changes, use 'git pull' before pushing again.\n" "See the 'Note about fast-forwards' in 'git push --help' for details."); -static void advise_pull_before_push(struct remote *push_remote) +static void advise_pull_before_push(struct remote *push_remote, + unsigned int reject_reasons) { struct branch *branch = branch_get(NULL); struct remote *remote = NULL; @@ -349,7 +356,11 @@ static void advise_pull_before_push(struct remote *push_remote) tracking_name = refs_shorten_unambiguous_ref( get_main_ref_store(the_repository), tracking, 0); - if (tracking && (!upstream || strcmp(tracking, upstream))) + if (tracking && (reject_reasons & REJECT_NON_FF_HEAD_REWRITE)) + advise(_(message_advice_pull_or_force_before_push), + tracking_name, remote->name, branch->name, + remote->name, branch->name); + else if (tracking && (!upstream || strcmp(tracking, upstream))) advise(_(message_advice_pull_from_branch_before_push), tracking_name, remote->name, branch->name); else @@ -435,7 +446,7 @@ static int push_with_options(struct transport *transport, struct refspec *rs, return 0; if (reject_reasons & REJECT_NON_FF_HEAD) { - advise_pull_before_push(remote); + advise_pull_before_push(remote, reject_reasons); } else if (reject_reasons & REJECT_NON_FF_OTHER) { advise_checkout_pull_push(); } else if (reject_reasons & REJECT_ALREADY_EXISTS) { diff --git a/t/t6040-tracking-info.sh b/t/t6040-tracking-info.sh index 9eb810e1584867..b53034ba36f44a 100755 --- a/t/t6040-tracking-info.sh +++ b/t/t6040-tracking-info.sh @@ -863,6 +863,24 @@ test_expect_success 'push to the upstream branch' ' hint: use ${SQ}git pull${SQ} before pushing again. hint: See the ${SQ}Note about fast-forwards${SQ} in ${SQ}git push --help${SQ} for details. EOF + test_cmp expect actual && + ( + cd test && + git pull --rebase && + git push && + echo amended >mine21 && + git commit -a --amend --no-edit && + test_must_fail git push 2>../actual + ) && + cat >expect <<-EOF && + To $url + ! [rejected] feature21 -> feature21 (non-fast-forward) + error: failed to push some refs to ${SQ}$url${SQ} + hint: Updates were rejected because ${SQ}origin/feature21${SQ} has diverged + hint: from your current branch. Use ${SQ}git pull origin feature21${SQ} + hint: to integrate the remote changes, or replace them with + hint: ${SQ}git push --force-with-lease origin feature21${SQ}. + EOF test_cmp expect actual ' diff --git a/transport.c b/transport.c index 25e2c14a7bde24..630dd699db281c 100644 --- a/transport.c +++ b/transport.c @@ -891,6 +891,42 @@ int transport_summary_width(const struct ref *refs) return (2 * maxw + 3); } +struct reflog_has_tip_cb_data { + const struct object_id *target; + int found; +}; + +static int reflog_has_tip(const char *refname UNUSED, + struct object_id *old_oid UNUSED, + struct object_id *new_oid, + const char *committer UNUSED, + timestamp_t timestamp UNUSED, + int tz UNUSED, const char *msg UNUSED, + void *cb_data) +{ + struct reflog_has_tip_cb_data *cb = cb_data; + + if (!oideq(new_oid, cb->target)) + return 0; + cb->found = 1; + return 1; +} + +/* + * Was "refname" ever at "oid" according to its reflog? Then a remote + * sitting at "oid" carries no work we have not seen, only commits we + * have since rewritten, for example with 'commit --amend' or 'rebase'. + */ +static int local_ref_used_to_be_at(const char *refname, + const struct object_id *oid) +{ + struct reflog_has_tip_cb_data cb = { .target = oid }; + + refs_for_each_reflog_ent_reverse(get_main_ref_store(the_repository), + refname, reflog_has_tip, &cb); + return cb.found; +} + void transport_print_push_status(const char *dest, struct ref *refs, int verbose, int porcelain, unsigned int *reject_reasons) { @@ -925,9 +961,11 @@ void transport_print_push_status(const char *dest, struct ref *refs, n += print_one_push_status(ref, dest, n, porcelain, summary_width); if (ref->status == REF_STATUS_REJECT_NONFASTFORWARD) { - if (head != NULL && !strcmp(head, ref->name)) + if (head != NULL && !strcmp(head, ref->name)) { *reject_reasons |= REJECT_NON_FF_HEAD; - else + if (local_ref_used_to_be_at(head, &ref->old_oid)) + *reject_reasons |= REJECT_NON_FF_HEAD_REWRITE; + } else *reject_reasons |= REJECT_NON_FF_OTHER; } else if (ref->status == REF_STATUS_REJECT_ALREADY_EXISTS) { *reject_reasons |= REJECT_ALREADY_EXISTS; diff --git a/transport.h b/transport.h index 39193d0077a312..87944fcd924d3d 100644 --- a/transport.h +++ b/transport.h @@ -252,12 +252,13 @@ int transport_set_option(struct transport *transport, const char *name, void transport_set_verbosity(struct transport *transport, int verbosity, int force_progress); -#define REJECT_NON_FF_HEAD 0x01 -#define REJECT_NON_FF_OTHER 0x02 -#define REJECT_ALREADY_EXISTS 0x04 -#define REJECT_FETCH_FIRST 0x08 -#define REJECT_NEEDS_FORCE 0x10 -#define REJECT_REF_NEEDS_UPDATE 0x20 +#define REJECT_NON_FF_HEAD 0x01 +#define REJECT_NON_FF_OTHER 0x02 +#define REJECT_ALREADY_EXISTS 0x04 +#define REJECT_FETCH_FIRST 0x08 +#define REJECT_NEEDS_FORCE 0x10 +#define REJECT_REF_NEEDS_UPDATE 0x20 +#define REJECT_NON_FF_HEAD_REWRITE 0x40 int transport_push(struct repository *repo, struct transport *connection, From 84bbe3b61c57cdc1012abe4ae210f71a815f1aa2 Mon Sep 17 00:00:00 2001 From: Harald Nordgren Date: Tue, 6 Oct 2026 07:08:24 +0000 Subject: [PATCH 062/316] push: suggest a force push after a clean rebase After rebasing pushed work onto a newer upstream without changing it, "git status" says the branch was rebased cleanly, but a rejected "git push" still suggests pulling from the push branch, which would only bring back old copies of the same commits. When the commits outside the upstream carry the same changes on both sides, say what "git status" says and suggest only the force push: hint: Updates were rejected because 'origin/topic' has diverged hint: from your current branch, which was rebased cleanly on 'upstream/main'. hint: Use 'git push --force-with-lease origin topic' to replace it. Signed-off-by: Harald Nordgren Signed-off-by: Junio C Hamano --- builtin/push.c | 20 +++++++++++++++++--- remote.c | 17 +++++++++++++++++ remote.h | 6 ++++++ t/t6040-tracking-info.sh | 25 +++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/builtin/push.c b/builtin/push.c index d918723d43eef3..bf6e5386a0737a 100644 --- a/builtin/push.c +++ b/builtin/push.c @@ -301,6 +301,11 @@ static const char message_advice_pull_from_branch_before_push[] = "from your current branch. Use 'git pull %s %s'\n" "to integrate the remote changes."); +static const char message_advice_force_after_clean_rebase[] = + N_("Updates were rejected because '%s' has diverged\n" + "from your current branch, which was rebased cleanly on '%s'.\n" + "Use 'git push --force-with-lease %s %s' to replace it."); + static const char message_advice_pull_or_force_before_push[] = N_("Updates were rejected because '%s' has diverged\n" "from your current branch. Use 'git pull %s %s'\n" @@ -356,15 +361,24 @@ static void advise_pull_before_push(struct remote *push_remote, tracking_name = refs_shorten_unambiguous_ref( get_main_ref_store(the_repository), tracking, 0); - if (tracking && (reject_reasons & REJECT_NON_FF_HEAD_REWRITE)) + if (tracking && branch_rebased_cleanly(branch, tracking)) { + char *upstream_name = refs_shorten_unambiguous_ref( + get_main_ref_store(the_repository), upstream, 0); + + advise(_(message_advice_force_after_clean_rebase), + tracking_name, upstream_name, + remote->name, branch->name); + free(upstream_name); + } else if (tracking && (reject_reasons & REJECT_NON_FF_HEAD_REWRITE)) { advise(_(message_advice_pull_or_force_before_push), tracking_name, remote->name, branch->name, remote->name, branch->name); - else if (tracking && (!upstream || strcmp(tracking, upstream))) + } else if (tracking && (!upstream || strcmp(tracking, upstream))) { advise(_(message_advice_pull_from_branch_before_push), tracking_name, remote->name, branch->name); - else + } else { advise(_(message_advice_pull_before_push)); + } free(tracking_name); free(tracking); diff --git a/remote.c b/remote.c index af8d026073a596..623ff98a019c47 100644 --- a/remote.c +++ b/remote.c @@ -2391,6 +2391,23 @@ static bool stat_outside_upstream(const char *branch_name, const char *base, return true; } +bool branch_rebased_cleanly(struct branch *branch, const char *base) +{ + const char *upstream = branch_get_upstream(branch, NULL); + int ours, theirs, ours_unmerged, theirs_unmerged; + bool same_changes = false; + + if (!upstream || !strcmp(upstream, base)) + return false; + if (stat_branch_pair(branch->refname, base, NULL, &ours, &theirs, + NULL, AHEAD_BEHIND_FULL) <= 0 || !ours || !theirs) + return false; + return stat_outside_upstream(branch->refname, base, upstream, + ours, theirs, &ours_unmerged, + &theirs_unmerged, &same_changes) && + same_changes; +} + static char *resolve_compare_branch(struct branch *branch, const char *name) { const char *resolved = NULL; diff --git a/remote.h b/remote.h index cca02033b9d73b..dd8ef6c4437bda 100644 --- a/remote.h +++ b/remote.h @@ -408,6 +408,12 @@ int format_tracking_info(struct branch *branch, struct strbuf *sb, enum ahead_behind_flags abf, int show_divergence_advice); +/* + * Return true when the branch has diverged from base only because the + * work on base was rebased cleanly on the upstream of the branch. + */ +bool branch_rebased_cleanly(struct branch *branch, const char *base); + struct ref *get_local_heads(void); /* diff --git a/t/t6040-tracking-info.sh b/t/t6040-tracking-info.sh index b53034ba36f44a..b5339e1452d604 100755 --- a/t/t6040-tracking-info.sh +++ b/t/t6040-tracking-info.sh @@ -804,6 +804,31 @@ test_expect_success 'status.compareBranches after a clean rebase of the push bra and have 3 and 1 different commits each (rebased cleanly on ${SQ}origin/main${SQ}). (use "git push --force-with-lease" to publish your local commits) + nothing to commit, working tree clean + EOF + test_cmp expect actual && + ( + cd test && + test_must_fail git push 2>../push.err && + git push --force-with-lease origin feature19 && + git status >../actual + ) && + url=$(git -C test config remote.origin.url) && + cat >expect <<-EOF && + To $url + ! [rejected] feature19 -> feature19 (non-fast-forward) + error: failed to push some refs to ${SQ}$url${SQ} + hint: Updates were rejected because ${SQ}origin/feature19${SQ} has diverged + hint: from your current branch, which was rebased cleanly on ${SQ}origin/main${SQ}. + hint: Use ${SQ}git push --force-with-lease origin feature19${SQ} to replace it. + EOF + test_cmp expect push.err && + cat >expect <<-EOF && + On branch feature19 + Your branch is ahead of ${SQ}origin/main${SQ} by 1 commit. + + Your branch is up to date with ${SQ}origin/feature19${SQ}. + nothing to commit, working tree clean EOF test_cmp expect actual From 46a2c11f72afe1c335f39228ab19201811b0f62b Mon Sep 17 00:00:00 2001 From: Delilah Ashley Wu Date: Fri, 9 Oct 2026 19:51:45 +1100 Subject: [PATCH 063/316] t1300: test list with missing global config Record behaviour in two new tests: when no global configuration file exists, `git config list` succeeds whereas `git config list --global` fails. Only check the exit code, as we're not interested in the exact error message. This prevents regressions in the next patch, "config: read global scope via config_sequence", which modifies `do_git_config_sequence()` to optionally return a failure if no global configuration files were successfully read (i.e. when the `--global` flag is specified). Signed-off-by: Delilah Ashley Wu Signed-off-by: Junio C Hamano --- t/t1300-config.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/t/t1300-config.sh b/t/t1300-config.sh index e3f8064889210a..df8c14da0d8d00 100755 --- a/t/t1300-config.sh +++ b/t/t1300-config.sh @@ -2425,6 +2425,18 @@ test_expect_success '--show-scope with --default' ' test_cmp expect actual ' +test_expect_success 'list with nonexistent global config gracefully exits' ' + rm -f "$HOME"/.gitconfig "$HOME"/.config/git/config && + git config ${mode_prefix}list && + git config ${mode_prefix}list --show-scope +' + +test_expect_success 'list --global with nonexistent global config fails' ' + rm -f "$HOME"/.gitconfig "$HOME"/.config/git/config && + test_must_fail git config ${mode_prefix}list --global && + test_must_fail git config ${mode_prefix}list --global --show-scope +' + test_expect_success 'override global and system config' ' test_when_finished rm -f \"\$HOME\"/.gitconfig && cat >"$HOME"/.gitconfig <<-EOF && From 844fb6da4517f42a4eca631ab2e7cfaba1d8e2bd Mon Sep 17 00:00:00 2001 From: Delilah Ashley Wu Date: Fri, 9 Oct 2026 19:51:46 +1100 Subject: [PATCH 064/316] config: read global scope via config_sequence When both `$HOME/.gitconfig` and `$XDG_CONFIG_HOME/git/config` exist, `git config list --global` and `git config get --global` read the home configuration file but ignore the XDG file. Bug reporters expected these `--global` scoped commands to read both files [1][2], which would be consistent with the documentation and the behaviour of the unscoped variants. For example, `git config list` and `git config get` (without `--global`) read from both files (in addition to system-wide and repository-specific entries). We should address this inconsistency by respecting both files during `--global` read operations. The implementation assumes that each configuration scope corresponds to a single file. So during `--global` read operations, Git selects one file path to pass to `git_config_from_file_with_options(file)`. Because the global scope can come from more than one file, we should use another method to read the global configuration. Since `git config list --show-scope --show-origin` reads both the home and XDG files, there must be existing code that respects both locations, namely `do_git_config_sequence()` which reads from all scopes. Introduce an `ignore_system` flag and modify `git_config_system()` to respect it. This makes `config_options` the primary way to disable parts of the configuration sequence and allows callers of `do_git_config_sequence()` to ignore all but the global scope (i.e. ignore system, local, worktree, and cmdline). Reuse the function to read only the global scope when `--global` is specified. This was the suggested solution [3] in the original bug report [1]. The previous patch, "t1300: test list with missing global config", recorded existing behaviour: when no global configuration file exists, `git config list` should succeed whereas `git config list --global` should fail. Since the configuration sequence gracefully ignores missing global configuration files, track whether we successfully read at least one of them and adjust the return code accordingly. Keep populating `opts->source.file` in `builtin/config.c` as the destination for write operations. Read operations use the configuration sequence instead, which can be confusing. Add a comment to explain this distinction. Lastly, modify tests to check that both home and XDG configuration files are respected during `--global` read operations. [1] https://lore.kernel.org/git/CAFA9we-QLQRzJdGMMCPatmfrk1oHeiUu9msMRXXk1MLE5HRxBQ@mail.gmail.com/ [2] https://lore.kernel.org/git/CAAdFe9yhBk-WecVzCTsjQ-4Z3AZAbpP+w+B076ouM3qX6d1WAg@mail.gmail.com/ [3] https://lore.kernel.org/git/kl6ly1oze7wb.fsf@chooglen-macbookpro.roam.corp.google.com/ Reported-by: Jade Lovelace Reported-by: Nils Fahldieck Suggested-by: Glen Choo Helped-by: Derrick Stolee Helped-by: Johannes Schindelin Signed-off-by: Delilah Ashley Wu Signed-off-by: Junio C Hamano --- builtin/config.c | 11 +++++ builtin/var.c | 4 +- config.c | 50 +++++++++++++++------ config.h | 3 +- t/t1300-config.sh | 104 +++++++++++++++++++++++++++++++++++++++++++ t/t1306-xdg-files.sh | 5 ++- 6 files changed, 160 insertions(+), 17 deletions(-) diff --git a/builtin/config.c b/builtin/config.c index 0882899c3fbd2a..a7468e86d3e5b7 100644 --- a/builtin/config.c +++ b/builtin/config.c @@ -957,6 +957,17 @@ static void location_options_init(struct config_location_options *opts, } if (opts->use_global_config) { + /* + * Since global config is sourced from more than one location, + * read it using `do_git_config_sequence()` with other scopes + * ignored. However, writing global config should point to a + * single destination, set in `opts->source.file`. + */ + opts->options.ignore_repo = 1; + opts->options.ignore_cmdline = 1; + opts->options.ignore_worktree = 1; + opts->options.ignore_system = 1; + opts->source.file = opts->file_to_free = git_global_config(); if (!opts->source.file) /* diff --git a/builtin/var.c b/builtin/var.c index cc3a43cde25a6c..e999e72a2d563a 100644 --- a/builtin/var.c +++ b/builtin/var.c @@ -82,7 +82,9 @@ static char *git_attr_val_global(int ident_flag UNUSED) static char *git_config_val_system(int ident_flag UNUSED) { - if (git_config_system()) { + const struct config_options opts = { 0 }; + + if (git_config_system(&opts)) { char *file = git_system_config(); normalize_path_copy(file, file); return file; diff --git a/config.c b/config.c index 1bdd702e7a3969..8c7e5232efa589 100644 --- a/config.c +++ b/config.c @@ -1539,16 +1539,18 @@ void git_global_config_paths(char **user_out, char **xdg_out) *xdg_out = xdg_config; } -int git_config_system(void) +int git_config_system(const struct config_options *opts) { - return !git_env_bool("GIT_CONFIG_NOSYSTEM", 0); + return !opts->ignore_system && !git_env_bool("GIT_CONFIG_NOSYSTEM", 0); } static int do_git_config_sequence(const struct config_options *opts, - const struct repository *repo, - config_fn_t fn, void *data) + const struct repository *repo, config_fn_t fn, + void *data, int require_global_scope_success) { + int tmp_ret; int ret = 0; + int global_had_success = 0; char *system_config = git_system_config(); char *xdg_config = NULL; char *user_config = NULL; @@ -1571,7 +1573,7 @@ static int do_git_config_sequence(const struct config_options *opts, worktree_config = NULL; } - if (git_config_system() && system_config && + if (git_config_system(opts) && system_config && !access_or_die(system_config, R_OK, opts->system_gently ? ACCESS_EACCES_OK : 0)) ret += git_config_from_file_with_options(fn, system_config, @@ -1580,13 +1582,25 @@ static int do_git_config_sequence(const struct config_options *opts, git_global_config_paths(&user_config, &xdg_config); - if (xdg_config && !access_or_die(xdg_config, R_OK, ACCESS_EACCES_OK)) - ret += git_config_from_file_with_options(fn, xdg_config, data, - CONFIG_SCOPE_GLOBAL, NULL); + if (xdg_config && + !access_or_die(xdg_config, R_OK, ACCESS_EACCES_OK)) { + tmp_ret = git_config_from_file_with_options(fn, xdg_config, + data, CONFIG_SCOPE_GLOBAL, + NULL); + ret += tmp_ret; + if (!tmp_ret) + global_had_success = 1; + } - if (user_config && !access_or_die(user_config, R_OK, ACCESS_EACCES_OK)) - ret += git_config_from_file_with_options(fn, user_config, data, - CONFIG_SCOPE_GLOBAL, NULL); + if (user_config && + !access_or_die(user_config, R_OK, ACCESS_EACCES_OK)) { + tmp_ret = git_config_from_file_with_options(fn, user_config, + data, CONFIG_SCOPE_GLOBAL, + NULL); + ret += tmp_ret; + if (!tmp_ret) + global_had_success = 1; + } if (!opts->ignore_repo && repo_config && !access_or_die(repo_config, R_OK, 0)) @@ -1609,6 +1623,10 @@ static int do_git_config_sequence(const struct config_options *opts, free(user_config); free(repo_config); free(worktree_config); + + if (require_global_scope_success && !global_had_success && !ret) + ret = -1; + return ret; } @@ -1631,11 +1649,15 @@ int config_with_options(config_fn_t fn, void *data, } /* - * If we have a specific filename, use it. Otherwise, follow the - * regular lookup sequence. + * Use the specified file when provided, except for the global scope, + * which can come from more than one file. With no explicit source, + * follow the regular lookup sequence. */ if (config_source && config_source->use_stdin) { ret = git_config_from_stdin(fn, data, config_source->scope); + } else if (config_source && config_source->file && + config_source->scope == CONFIG_SCOPE_GLOBAL) { + ret = do_git_config_sequence(opts, repo, fn, data, 1); } else if (config_source && config_source->file) { ret = git_config_from_file_with_options(fn, config_source->file, data, config_source->scope, @@ -1644,7 +1666,7 @@ int config_with_options(config_fn_t fn, void *data, ret = git_config_from_blob_ref(fn, repo, config_source->blob, data, config_source->scope); } else { - ret = do_git_config_sequence(opts, repo, fn, data); + ret = do_git_config_sequence(opts, repo, fn, data, 0); } if (inc.remote_urls) { diff --git a/config.h b/config.h index 31fe3e29611e11..448b8b5967d907 100644 --- a/config.h +++ b/config.h @@ -87,6 +87,7 @@ typedef int (*config_parser_event_fn_t)(enum config_event_t type, struct config_options { unsigned int respect_includes : 1; + unsigned int ignore_system : 1; unsigned int ignore_repo : 1; unsigned int ignore_worktree : 1; unsigned int ignore_cmdline : 1; @@ -408,7 +409,7 @@ int repo_config_rename_section(struct repository *, const char *, const char *); int repo_config_rename_section_in_file(struct repository *, const char *, const char *, const char *); int repo_config_copy_section(struct repository *, const char *, const char *); int repo_config_copy_section_in_file(struct repository *, const char *, const char *, const char *); -int git_config_system(void); +int git_config_system(const struct config_options *opts); int config_error_nonbool(const char *); #if defined(__GNUC__) #define config_error_nonbool(s) (config_error_nonbool(s), const_error()) diff --git a/t/t1300-config.sh b/t/t1300-config.sh index df8c14da0d8d00..15b1d713afd86b 100755 --- a/t/t1300-config.sh +++ b/t/t1300-config.sh @@ -2437,6 +2437,110 @@ test_expect_success 'list --global with nonexistent global config fails' ' test_must_fail git config ${mode_prefix}list --global --show-scope ' +test_expect_success 'list and get --global with only home' ' + rm -f "$HOME"/.config/git/config && + + test_when_finished rm -f \"\$HOME\"/.gitconfig && + cat >"$HOME"/.gitconfig <<-EOF && + [home] + config = true + EOF + + cat >expect <<-EOF && + global home.config=true + EOF + git config ${mode_prefix}list --global --show-scope >actual && + test_cmp expect actual && + + echo true >expect && + git config ${mode_get} --global home.config >actual && + test_cmp expect actual +' + +test_expect_success 'list and get --global with only xdg' ' + rm -f "$HOME"/.gitconfig && + + test_when_finished rm -rf \"\$HOME\"/.config/git && + mkdir -p "$HOME"/.config/git && + cat >"$HOME"/.config/git/config <<-EOF && + [xdg] + config = true + EOF + + cat >expect <<-EOF && + global xdg.config=true + EOF + git config ${mode_prefix}list --global --show-scope >actual && + test_cmp expect actual && + + echo true >expect && + git config ${mode_get} --global xdg.config >actual && + test_cmp expect actual +' + +test_expect_success 'list and get --global with both home and xdg' ' + test_when_finished rm -f \"\$HOME\"/.gitconfig && + cat >"$HOME"/.gitconfig <<-EOF && + [home] + config = home + EOF + + test_when_finished rm -rf \"\$HOME\"/.config/git && + mkdir -p "$HOME"/.config/git && + cat >"$HOME"/.config/git/config <<-EOF && + [xdg] + config = xdg + EOF + + cat >expect <<-EOF && + global file:$HOME/.config/git/config xdg.config=xdg + global file:$HOME/.gitconfig home.config=home + EOF + # Git uses HOME and XDG_CONFIG_HOME to determine the location of the + # XDG config file. On Windows, the default values lead to mixed forward + # and backward slashes in the output --show-origin path for XDG config. + # Explicitly set XDG_CONFIG_HOME with only forward slashes, allowing us + # to use the same expected path across all platforms. + XDG_CONFIG_HOME="$HOME/.config" \ + git config ${mode_prefix}list --global \ + --show-scope --show-origin >actual && + test_cmp expect actual && + + echo xdg >expect && + git config ${mode_get} --global xdg.config >actual && + test_cmp expect actual && + + echo home >expect && + git config ${mode_get} --global home.config >actual && + test_cmp expect actual +' + +test_expect_success 'list --global ignores other scopes' ' + test_when_finished rm -f system_config && + cat >system_config <<-EOF && + [system] + config = true + EOF + + test_config core.repositoryformatversion 1 && + test_config extensions.worktreeConfig true && + test_config --worktree worktree.config true && + + test_when_finished rm -f \"\$HOME\"/.gitconfig && + cat >"$HOME"/.gitconfig <<-EOF && + [home] + config = home + EOF + + test_config repo.config true && + + echo "global home.config=home" >expect && + GIT_CONFIG_NOSYSTEM=false GIT_CONFIG_SYSTEM=system_config \ + git -c cmdline.config=true config ${mode_prefix}list \ + --global --show-scope >actual && + test_cmp expect actual +' + test_expect_success 'override global and system config' ' test_when_finished rm -f \"\$HOME\"/.gitconfig && cat >"$HOME"/.gitconfig <<-EOF && diff --git a/t/t1306-xdg-files.sh b/t/t1306-xdg-files.sh index 40d3c42618c04f..3a9a04bcc143fd 100755 --- a/t/t1306-xdg-files.sh +++ b/t/t1306-xdg-files.sh @@ -52,6 +52,8 @@ test_expect_success 'read with --get: xdg file exists and ~/.gitconfig exists' ' echo " name = read_gitconfig" >>.gitconfig && echo read_gitconfig >expected && git config --get user.name >actual && + test_cmp expected actual && + git config --global --get user.name >actual && test_cmp expected actual ' @@ -68,7 +70,8 @@ test_expect_success 'read with --list: xdg file exists and ~/.gitconfig exists' >.gitconfig && echo "[user]" >.gitconfig && echo " name = read_gitconfig" >>.gitconfig && - echo user.name=read_gitconfig >expected && + echo user.name=read_config >expected && + echo user.name=read_gitconfig >>expected && git config --global --list >actual && test_cmp expected actual ' From d8528698f82c684e479afb2edd0e28805aa489ed Mon Sep 17 00:00:00 2001 From: Jon Simons Date: Sun, 11 Oct 2026 23:00:29 -0400 Subject: [PATCH 065/316] SubmittingPatches: avoid add-failing-test-then-fix anti-pattern Document the preference to avoid the 'add a failing test and fix it in a subsequent commit' anti-pattern. Although such a sequence is fine for local development, reviewers instead prefer a single commit that introduces the test together with its code change [1]. The goal of this addition is to capture the preference in writing, with hopes of optimizing future review flows by reducing back-and-forths to the list. [1] A couple examples of feedback, with rationale for avoiding the pattern, from August 2025 here: - https://lore.kernel.org/git/xmqqqzxpzo8b.fsf@gitster.g/ - https://lore.kernel.org/git/xmqqfrdk3aqy.fsf@gitster.g/ Signed-off-by: Jon Simons Signed-off-by: Junio C Hamano --- Documentation/SubmittingPatches | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Documentation/SubmittingPatches b/Documentation/SubmittingPatches index c60855f7069492..3238a339320a9a 100644 --- a/Documentation/SubmittingPatches +++ b/Documentation/SubmittingPatches @@ -271,6 +271,15 @@ fixed by accident to avoid regression. Also, try merging your work to that are still in flight may have unexpected interactions with what you are trying to do in your topic. +A bugfix patch should include tests in test_expect_success() blocks that +demonstrate the breakage when the code change that implements the fix is +omitted. Submit tests as `test_expect_success` in the same commit as +your code change that fixes the bug. A separate commit that only changes +`test_expect_failure` to `test_expect_success` can leave the behavior +being tested out of the diff context, making the fix harder to review. +Reviewers who want to verify that your test reproduces the bug can +temporarily undo the code changes while keeping the test. + Pushing to a fork of https://github.com/git/git will use their CI integration to test your changes on Linux, Mac and Windows. See the <> section for details. From b8604e85aec871221cc6b220f1d423a9f5bdc740 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 12 Oct 2026 09:44:10 +0200 Subject: [PATCH 066/316] parse-options: add parse_options_takes_argument() Whether an option takes a value, and therefore consumes the next argument when that value is not stuck to it with an '=', is decided by its type and its flags. That rule is currently open-coded in show_gitcomp(), which needs it to decide if it should append an '=' to the option it completes. A following commit will need the same rule to find out which options an early scan of the command line has to skip along with their value. So let's factor that rule out into a new parse_options_takes_argument() function, and let's use it in show_gitcomp(). Note that an option with PARSE_OPT_LASTARG_DEFAULT only consumes the next argument when it isn't the last one. It is considered as taking a value by parse_options_takes_argument(), as an early scan can easily check whether there is a next argument, and would otherwise wrongly take that argument for an option. show_gitcomp() keeps not appending an '=' to such an option though, as it already did, since its value is optional. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- parse-options.c | 35 ++++++++++++++++++++++------------- parse-options.h | 11 +++++++++++ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/parse-options.c b/parse-options.c index 4519ead9dc77b8..d7cbb5a343a1ae 100644 --- a/parse-options.c +++ b/parse-options.c @@ -841,6 +841,25 @@ static void show_negated_gitcomp(const struct option *opts, int show_all, } } +int parse_options_takes_argument(const struct option *opt) +{ + switch (opt->type) { + case OPTION_STRING: + case OPTION_FILENAME: + case OPTION_INTEGER: + case OPTION_UNSIGNED: + case OPTION_CALLBACK: + break; + default: + return 0; + } + + if (opt->flags & (PARSE_OPT_NOARG | PARSE_OPT_OPTARG)) + return 0; + + return 1; +} + static int show_gitcomp(const struct option *opts, int show_all) { const struct option *original_opts = opts; @@ -862,20 +881,10 @@ static int show_gitcomp(const struct option *opts, int show_all) break; case OPTION_GROUP: continue; - case OPTION_STRING: - case OPTION_FILENAME: - case OPTION_INTEGER: - case OPTION_UNSIGNED: - case OPTION_CALLBACK: - if (opts->flags & PARSE_OPT_NOARG) - break; - if (opts->flags & PARSE_OPT_OPTARG) - break; - if (opts->flags & PARSE_OPT_LASTARG_DEFAULT) - break; - suffix = "="; - break; default: + if (parse_options_takes_argument(opts) && + !(opts->flags & PARSE_OPT_LASTARG_DEFAULT)) + suffix = "="; break; } if (opts->flags & PARSE_OPT_COMP_ARG) diff --git a/parse-options.h b/parse-options.h index d7f896a9337024..589f8d900837da 100644 --- a/parse-options.h +++ b/parse-options.h @@ -420,6 +420,17 @@ int parse_options(int argc, const char **argv, const char *prefix, const char * const usagestr[], enum parse_opt_flags flags); +/* + * Return non-zero if `opt` takes a value, which means that it consumes + * the next argument when that value is not stuck to it with an '='. + * + * Note that an option with PARSE_OPT_LASTARG_DEFAULT is considered as + * taking a value here, even though it only consumes the next argument + * when it isn't the last one. Callers that care have to check for that + * flag themselves. + */ +int parse_options_takes_argument(const struct option *opt); + NORETURN void usage_with_options(const char * const *usagestr, const struct option *options); From 6e59330766a6331d7bf3fd299956feeb6bfff787 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 12 Oct 2026 09:44:11 +0200 Subject: [PATCH 067/316] parse-options: extract lookup_long_opt() from parse_long_opt() In a following commit, an early scan of the command line will need to look up long options the same way parse_options() does, including unambiguous abbreviations and negated forms, but without any side effect: it must neither set option values nor call die() or error(). parse_long_opt() can't be used for that, as it does both the look up and the actions that follow it: it calls get_value() on the option it finds, die()s when abbreviations are disallowed, and error()s on an ambiguous abbreviation. Let's extract a new lookup_long_opt() helper from parse_long_opt() to perform only the look up part, and let parse_long_opt() act on what lookup_long_opt() returns. There is no behavior change intended. In particular an exact match is still preferred to any abbreviation, abbreviations are still rejected when disallowed only if there is no exact match, and the ambiguity error message is unchanged. Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- parse-options.c | 105 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 24 deletions(-) diff --git a/parse-options.c b/parse-options.c index d7cbb5a343a1ae..a98b9584234efd 100644 --- a/parse-options.c +++ b/parse-options.c @@ -516,16 +516,46 @@ static void register_abbrev(struct parse_opt_ctx_t *p, abbrev->flags = flags; } -static enum parse_opt_result parse_long_opt( - struct parse_opt_ctx_t *p, const char *arg, - const struct option *options) +enum long_opt_lookup { + LONG_OPT_EXACT, /* exact match found */ + LONG_OPT_ABBREV, /* unambiguous abbreviation found */ + LONG_OPT_AMBIGUOUS, /* more than one candidate abbreviation */ + LONG_OPT_UNKNOWN, /* nothing matches */ +}; + +/* + * Look up the long option named by `arg` (without its leading "--") + * without acting on it. (Neither prints nor dies.) + * + * Return value: + * - On LONG_OPT_EXACT, `found` is the exact matching option and its flags + * (including OPT_UNSET for a negated form), and `stuck` points after + * the '=' or is NULL. + * - On LONG_OPT_ABBREV, `found` is the unambiguous abbreviation, same as + * above for its flags and `stuck`. + * - On LONG_OPT_AMBIGUOUS, `found` and `other` are two candidates of an + * ambiguous abbreviation. + * - On LONG_OPT_UNKNOWN, nothing matches, no result. + * + * `other` is only meaningful for LONG_OPT_AMBIGUOUS. + */ +static enum long_opt_lookup lookup_long_opt(struct parse_opt_ctx_t *p, + const char *arg, + const struct option *options, + struct parsed_option *found, + struct parsed_option *other, + const char **stuck) { + enum opt_parsed flags = OPT_LONG; const char *arg_end = strchrnul(arg, '='); const char *arg_start = arg; - enum opt_parsed flags = OPT_LONG; int arg_starts_with_no_no = 0; - struct parsed_option abbrev = { .option = NULL, .flags = OPT_LONG }; - struct parsed_option ambiguous = { .option = NULL, .flags = OPT_LONG }; + + found->option = NULL; + found->flags = OPT_LONG; + other->option = NULL; + other->flags = OPT_LONG; + *stuck = NULL; if (skip_prefix(arg_start, "no-", &arg_start)) { if (skip_prefix(arg_start, "no-", &arg_start)) @@ -554,43 +584,70 @@ static enum parse_opt_result parse_long_opt( if (skip_prefix(arg_start, long_name, &rest)) { if (*rest == '=') - p->opt = rest + 1; + *stuck = rest + 1; else if (*rest) continue; - return get_value(p, options, flags ^ opt_flags); + found->option = options; + found->flags = flags ^ opt_flags; + return LONG_OPT_EXACT; } /* abbreviated? */ if (!strncmp(long_name, arg_start, arg_end - arg_start)) register_abbrev(p, options, flags ^ opt_flags, - &abbrev, &ambiguous); + found, other); /* negated and abbreviated very much? */ if (allow_unset && starts_with("no-", arg)) register_abbrev(p, options, OPT_UNSET ^ opt_flags, - &abbrev, &ambiguous); + found, other); } - if (disallow_abbreviated_options && (ambiguous.option || abbrev.option)) + if (other->option) + return LONG_OPT_AMBIGUOUS; + + if (found->option) { + if (*arg_end) + *stuck = arg_end + 1; + return LONG_OPT_ABBREV; + } + + return LONG_OPT_UNKNOWN; +} + +static enum parse_opt_result parse_long_opt(struct parse_opt_ctx_t *p, + const char *arg, + const struct option *options) +{ + struct parsed_option found; + struct parsed_option other; + const char *stuck; + enum long_opt_lookup result = lookup_long_opt(p, arg, options, + &found, &other, &stuck); + + if (result == LONG_OPT_UNKNOWN) + return PARSE_OPT_UNKNOWN; + + if (disallow_abbreviated_options && + (result == LONG_OPT_ABBREV || result == LONG_OPT_AMBIGUOUS)) { + const char *arg_end = strchrnul(arg, '='); die("disallowed abbreviated or ambiguous option '%.*s'", (int)(arg_end - arg), arg); + } - if (ambiguous.option) { - error(_("ambiguous option: %s " - "(could be --%s%s or --%s%s)"), - arg, - (ambiguous.flags & OPT_UNSET) ? "no-" : "", - ambiguous.option->long_name, - (abbrev.flags & OPT_UNSET) ? "no-" : "", - abbrev.option->long_name); + if (result == LONG_OPT_AMBIGUOUS) { + error(_("ambiguous option: %s (could be --%s%s or --%s%s)"), arg, + (other.flags & OPT_UNSET) ? "no-" : "", other.option->long_name, + (found.flags & OPT_UNSET) ? "no-" : "", found.option->long_name); return PARSE_OPT_HELP_ERROR; } - if (abbrev.option) { - if (*arg_end) - p->opt = arg_end + 1; - return get_value(p, abbrev.option, abbrev.flags); + + if (result == LONG_OPT_ABBREV || result == LONG_OPT_EXACT) { + p->opt = stuck; + return get_value(p, found.option, found.flags); } - return PARSE_OPT_UNKNOWN; + + BUG("unknown enum long_opt_lookup value '%d'", result); } static enum parse_opt_result parse_nodash_opt(struct parse_opt_ctx_t *p, From 09a0a25219a67b9d9536e506acef8b4467064d3b Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 12 Oct 2026 09:44:12 +0200 Subject: [PATCH 068/316] parse-options: add early_scan_options() Some commands need to look at a few of their options before they can parse their command line for real, for example because the result decides whether a repository is needed at all, or how the beginning of their input should be interpreted. Such an early scan has to know which options take their value as a separate argument, or it mistakes such a value for an option. Several commands get this wrong, as they just walk their arguments comparing them to the few option names they care about. Let's add early_scan_options() to help with this. It walks the arguments using the very same `struct option` array that the command already passes to parse_options(). It looks up long options with the lookup_long_opt() helper extracted from parse_long_opt() in a previous commit, so that negated forms, unambiguous abbreviations and aliases are recognized the same way, and it uses the parse_options_takes_argument() helper added in another previous commit, so that the scan and the actual parsing agree on which options take a value. The options the caller wants to be told about are marked with a new PARSE_OPT_EARLY flag, so that nothing has to be spelled out a second time, and so that the mark cannot drift away from the option it refers to. Using a per-option flag for this is not new as that flag space already holds flags that the parsing loop itself ignores, like PARSE_OPT_NOCOMPLETE and PARSE_OPT_COMP_ARG, which only the completion helper looks at, or PARSE_OPT_HIDDEN and PARSE_OPT_LITERAL_ARGHELP, which only the usage output looks at. The scan is deliberately kept much simpler than parse_options(), instead of teaching the latter to perform a side effect free "dry run". Such a dry run would have to avoid writing through `opt->value`, calling option callbacks, dying on an invalid value, handling `--help` and tracking command mode conflicts, so it would be a much larger refactoring. If parse_options() learns to do it in the future though, the commands converted now would keep both their option array and their PARSE_OPT_EARLY marks, so their conversion would not have to be redone. One consequence of staying simple is that the scan doesn't try to handle what it cannot reliably skip, like short options, unknown options, ambiguous abbreviations or options with a low-level callback, which may consume any number of arguments. It stops at them instead, so that it never takes an option's value for an option. This matters, as for example with: git fast-import --quiet --export-pack --allow-unsafe-features parse_options() takes `--allow-unsafe-features` as the value of `--export-pack-edges`, which `--export-pack` abbreviates. A scan that just skipped `--export-pack` as an unknown option would wrongly see `--allow-unsafe-features` there. So the scan can fail to see an option that parse_options() would accept, but it doesn't report an option that parse_options() would not, and its callers can safely err on the side of not having seen an option. The differences with parse_options() are documented in "parse-options.h". In practice, despite these limitations, early scans using early_scan_options() should still be safer and cleaner than the ad-hoc hand-rolled scans they are meant to replace, which don't know about option values at all and therefore disagree with parse_options() in ways that create plain bugs. Helped-by: Kaartic Sivaraam Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- .../technical/api-parse-options.adoc | 5 + parse-options.c | 138 ++++++++ parse-options.h | 91 +++++ t/helper/test-parse-options.c | 96 ++++++ t/helper/test-tool.c | 1 + t/helper/test-tool.h | 1 + t/t0040-parse-options.sh | 321 ++++++++++++++++++ 7 files changed, 653 insertions(+) diff --git a/Documentation/technical/api-parse-options.adoc b/Documentation/technical/api-parse-options.adoc index 95b7924e84e2c8..f59d8e90a5a460 100644 --- a/Documentation/technical/api-parse-options.adoc +++ b/Documentation/technical/api-parse-options.adoc @@ -197,6 +197,11 @@ are the bitwise-or of: Internal flag, set on options that were expanded from a configured alias. It should not be set by callers. +`PARSE_OPT_EARLY`:: + Report this option to `early_scan_options()`, which looks at a + few options before parsing the command line for real. Ignored + by `parse_options()` itself. + `PARSE_OPT_NOCOMPLETE`:: Do not offer this option for completion. diff --git a/parse-options.c b/parse-options.c index a98b9584234efd..c03c396aa6e206 100644 --- a/parse-options.c +++ b/parse-options.c @@ -726,6 +726,8 @@ static void parse_options_check(const struct option *opts) opts->long_name)) optbug(opts, "uses feature " "not supported for dashless options"); + if ((opts->flags & PARSE_OPT_EARLY) && !opts->long_name) + optbug(opts, "uses PARSE_OPT_EARLY, which needs a long name"); if (opts->type == OPTION_SET_INT && !opts->defval && opts->long_name && !(opts->flags & PARSE_OPT_NONEG)) optbug(opts, "OPTION_SET_INT 0 should not be negatable"); @@ -763,6 +765,8 @@ static void parse_options_check(const struct option *opts) case OPTION_SUBCOMMAND: if (!opts->value || !opts->subcommand_fn) optbug(opts, "OPTION_SUBCOMMAND needs a value and a subcommand function"); + if (opts->flags & PARSE_OPT_EARLY) + optbug(opts, "OPTION_SUBCOMMAND does not support PARSE_OPT_EARLY"); if (!subcommand_value) subcommand_value = opts->value; else if (subcommand_value != opts->value) @@ -1310,6 +1314,140 @@ int parse_options(int argc, const char **argv, return parse_options_end(&ctx); } +/* + * Look for `name`, a long option without its leading "--", among + * `option`, the way parse_options() would. Return true if it names an + * option exactly, or as an unambiguous abbreviation that is not + * disallowed, with `found` set to that option and its flags, and + * `stuck` to the value stuck to it, if any, or to NULL. + */ +static bool find_early_scan_option(struct parse_opt_ctx_t *p, + const char *name, + const struct option *option, + bool disallow_abbrev, + struct parsed_option *found, + const char **stuck) +{ + struct parsed_option other; + enum long_opt_lookup result = lookup_long_opt(p, name, option, + found, &other, stuck); + + switch (result) { + case LONG_OPT_EXACT: + return true; + case LONG_OPT_ABBREV: + return !disallow_abbrev; + case LONG_OPT_AMBIGUOUS: + case LONG_OPT_UNKNOWN: + return false; + } + + BUG("unknown enum long_opt_lookup value '%d'", result); +} + +struct early_scan_state { + struct parse_opt_ctx_t ctx; + const struct option *option; + enum early_scan_flags flags; + early_scan_fn *fn; + void *data; + bool disallow_abbrev; +}; + +/* + * Look at argv[*i]. Return 'true' to go on scanning, with *i on the + * last argument consumed. Return 'false' to stop, with *i set to the + * first argument not consumed. + */ +static bool early_scan_one_option(struct early_scan_state *s, + int argc, const char **argv, int *i) +{ + const char *name; + const char *value; + struct parsed_option found; + bool unset; + int pos = *i; + const char *arg = argv[pos]; + + /* + * parse_options() always stops parsing options at these, + * whatever its flags, so nothing after them is an option. + */ + if (!strcmp(arg, "--") || !strcmp(arg, "--end-of-options")) + return false; + + /* non-option */ + if (*arg != '-' || !arg[1]) + return !(s->flags & EARLY_SCAN_STOP_AT_NON_OPTION); + + /* short option */ + if (!skip_prefix(arg, "--", &name)) + return false; + + /* unknown, ambiguous or disallowed abbreviation */ + if (!find_early_scan_option(&s->ctx, name, s->option, + s->disallow_abbrev, &found, &value)) + return false; + + /* A low-level callback may consume any number of arguments. */ + if (found.option->ll_callback) + return false; + + unset = !!(found.flags & OPT_UNSET); + + /* parse_options() rejects these, so don't go further. */ + if (value && (unset || (found.option->flags & PARSE_OPT_NOARG))) + return false; + + /* + * Like get_arg(), take the next argument as the value when it is + * not stuck to the option. When there is no next argument, + * parse_options() uses the default value of an option with + * PARSE_OPT_LASTARG_DEFAULT, but errors out for other options. + */ + if (!value && !unset && parse_options_takes_argument(found.option)) { + if (pos + 1 < argc) + value = argv[++*i]; + else if (!(found.option->flags & PARSE_OPT_LASTARG_DEFAULT)) + return false; + } + + if ((found.option->flags & PARSE_OPT_EARLY) && + s->fn(found.option, value, unset, pos, s->data)) { + /* the option, and its value if any, have been consumed */ + ++*i; + return false; + } + + return true; +} + +int early_scan_options(int argc, const char **argv, + const struct option *option, + enum early_scan_flags flags, + early_scan_fn *fn, void *data) +{ + struct early_scan_state s = { + .flags = flags, + .fn = fn, + .data = data, + .disallow_abbrev = + git_env_bool("GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS", 0), + }; + struct option *real_option = preprocess_options(&s.ctx, option); + int i; + + s.option = real_option ? real_option : option; + + for (i = 0; i < argc; i++) + if (!early_scan_one_option(&s, argc, argv, &i)) + break; + + free_preprocessed_options(real_option); + free(s.ctx.alias_groups); + return i; +} + static int usage_argh(const struct option *opts, FILE *outfile) { const char *s; diff --git a/parse-options.h b/parse-options.h index 589f8d900837da..f39b9a779a8d99 100644 --- a/parse-options.h +++ b/parse-options.h @@ -51,6 +51,7 @@ enum parse_opt_option_flags { PARSE_OPT_NODASH = 1 << 5, PARSE_OPT_LITERAL_ARGHELP = 1 << 6, PARSE_OPT_FROM_ALIAS = 1 << 7, + PARSE_OPT_EARLY = 1 << 8, /* only for early_scan_options() */ PARSE_OPT_NOCOMPLETE = 1 << 9, PARSE_OPT_COMP_ARG = 1 << 10, PARSE_OPT_CMDMODE = 1 << 11, @@ -502,6 +503,96 @@ static inline void die_for_incompatible_opt2(int opt1, const char *opt1_name, BUG("option callback expects an argument"); \ } while(0) +/*----- Early scan: scanning argv before the actual option parsing -----*/ + +/* + * Some commands need to look at a few options before they can parse + * their command line for real, for example because the result decides + * whether a repository is needed at all. + * + * Such an early scan has to know which options take their value as a + * separate argument, or it could mistake such a value for an + * option. The functions below allow performing such early scans + * without being fooled by option values. + */ + +/* + * Called by early_scan_options() for each argument matching a + * `struct option` with PARSE_OPT_EARLY set. + * + * - `option` is the matching option, + * - `value` its value, or NULL if it doesn't take one or if its value + * is optional and wasn't given, as with PARSE_OPT_OPTARG, or with + * PARSE_OPT_LASTARG_DEFAULT when the option is the last argument, + * - `unset` is 'true' for the negated form of the option (like + * for parse_opt_cb()), in which case `value` is always NULL, and + * - `pos` the index of the option in argv. + * + * Returning a non-zero value stops the scan right after the option and + * its value, if any. + */ +typedef int early_scan_fn(const struct option *option, const char *value, + bool unset, int pos, void *data); + +enum early_scan_flags { + EARLY_SCAN_STOP_AT_NON_OPTION = 1 << 0, /* Stop at any non option */ +}; + +/* + * Scan `argv` for the options described by `option`, calling `fn` for + * each of those that have PARSE_OPT_EARLY set. `argv` is not + * modified. + * + * `fn` may be NULL when no option has PARSE_OPT_EARLY set, which is + * useful to only find out where the scan stops. + * + * The scan always stops at "--" and at "--end-of-options", as + * parse_options() always stops parsing options there too, whatever its + * flags. PARSE_OPT_KEEP_DASHDASH and PARSE_OPT_KEEP_UNKNOWN_OPT only + * decide if the terminator is left in argv, not if it terminates. + * + * Returns the index of the first argument not consumed by the scan, + * which is `argc` when the whole array was scanned. + * + * Long options are looked up the same way as parse_options() does, + * so their negated forms, their unambiguous abbreviations, unless + * GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS is set, and the OPTION_ALIAS + * entries standing for them are all recognized, and their value is + * skipped when they take one. + * + * To never misinterpret an option value as an option though, the scan + * stops at any argument it cannot reliably skip, that is: + * + * - a short option, or a bundle of them, + * + * - an unknown option, or an ambiguous or disallowed abbreviation, + * + * - an option with a low-level callback, as such a callback may + * consume any number of arguments, + * + * - an option with a value stuck to it that parse_options() would + * reject, like a negated option or a PARSE_OPT_NOARG one, + * + * - an option requiring a value when it is the last argument, which + * parse_options() would also reject. + * + * In all these cases, the scan can fail to see an option that + * parse_options() would accept, but it doesn't report an option that + * parse_options() would not, so that callers err on the safe side. + * + * This requires the scan to be told about the way the later + * parse_options() call treats its arguments though: callers should + * pass EARLY_SCAN_STOP_AT_NON_OPTION when parse_options() is called + * with PARSE_OPT_STOP_AT_NON_OPTION or with subcommands, and should + * not use the scan when parse_options() is called with + * PARSE_OPT_KEEP_UNKNOWN_OPT, which disables abbreviations there but + * not here. + */ +int early_scan_options(int argc, const char **argv, + const struct option *option, + enum early_scan_flags flags, + early_scan_fn *fn, void *data); + /*----- incremental advanced APIs -----*/ struct parse_opt_cmdmode_list; diff --git a/t/helper/test-parse-options.c b/t/helper/test-parse-options.c index f181f0c02d355a..de3dd5751ca4ff 100644 --- a/t/helper/test-parse-options.c +++ b/t/helper/test-parse-options.c @@ -383,3 +383,99 @@ int cmd__parse_subcommand(int argc, const char **argv) return parse_subcommand__cmd(argc, argv, test_flags); } + +static enum parse_opt_result early_ll_callback(struct parse_opt_ctx_t *ctx UNUSED, + const struct option *opt UNUSED, + const char *arg UNUSED, + int unset UNUSED) +{ + return 0; +} + +static int show_early_option(const struct option *opt, const char *value, + bool unset, int pos, void *data) +{ + int *stop_at_found = data; + + printf("found: %s at %d", opt->long_name, pos); + if (unset) + printf(" unset"); + if (value) + printf(" value: %s", value); + putchar('\n'); + return *stop_at_found; +} + +int cmd__early_scan_options(int argc, const char **argv) +{ + char *a_string = NULL; + int an_int = 0, a_bool = 0, a_short = 0; + + const struct option option[] = { + OPT_GROUP("early scan test options"), + OPT_BOOL_F(0, "wanted", &a_bool, + "wanted option taking no value", + PARSE_OPT_EARLY), + OPT_STRING_F(0, "wanted-value", &a_string, "str", + "wanted option taking a value", + PARSE_OPT_EARLY), + OPT_STRING(0, "skipped-value", &a_string, "str", + "option whose value has to be skipped"), + OPT_ALIAS(0, "alias-of-skipped", "skipped-value"), + OPT_INTEGER(0, "number", &an_int, + "option taking an integer value"), + OPT_STRING_F(0, "optarg", &a_string, "str", + "option with an optional value", + PARSE_OPT_OPTARG), + OPT_STRING_F(0, "lastarg", &a_string, "str", + "option with a last argument default", + PARSE_OPT_LASTARG_DEFAULT), + OPT_STRING_F(0, "early-optarg", &a_string, "str", + "early option with an optional value", + PARSE_OPT_EARLY | PARSE_OPT_OPTARG), + OPT_STRING_F(0, "early-lastarg", &a_string, "str", + "early option with a last argument default", + PARSE_OPT_EARLY | PARSE_OPT_LASTARG_DEFAULT), + OPT_BOOL_F(0, "no-early", &a_bool, + "early option whose name starts with 'no-'", + PARSE_OPT_EARLY), + OPT_BOOL('s', NULL, &a_short, "short only option"), + { + .type = OPTION_LOWLEVEL_CALLBACK, + .long_name = "low-level", + .help = "option with a low-level callback", + .flags = PARSE_OPT_NOARG | PARSE_OPT_NONEG, + .ll_callback = early_ll_callback, + }, + { + .type = OPTION_CALLBACK, + .long_name = "ll-value", + .argh = "str", + .help = "option with a value and a low-level callback", + .flags = PARSE_OPT_NONEG, + .ll_callback = early_ll_callback, + }, + OPT_END() + }; + + enum early_scan_flags flags = 0; + int stop_at_found = 0; + int stopped; + + while (argc > 1 && *argv[1] == '-') { + if (!strcmp(argv[1], "--stop-at-non-option")) + flags |= EARLY_SCAN_STOP_AT_NON_OPTION; + else if (!strcmp(argv[1], "--stop-at-found")) + stop_at_found = 1; + else + break; + argc--; + argv++; + } + + stopped = early_scan_options(argc - 1, argv + 1, option, flags, + show_early_option, &stop_at_found); + printf("stopped at: %d of %d\n", stopped, argc - 1); + + return 0; +} diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c index b71a22b43bbc9e..5d2f5877d925ee 100644 --- a/t/helper/test-tool.c +++ b/t/helper/test-tool.c @@ -50,6 +50,7 @@ static struct test_cmd cmds[] = { { "pack-mtimes", cmd__pack_mtimes }, { "parse-options", cmd__parse_options }, { "parse-options-flags", cmd__parse_options_flags }, + { "early-scan-options", cmd__early_scan_options }, { "parse-pathspec-file", cmd__parse_pathspec_file }, { "parse-subcommand", cmd__parse_subcommand }, { "partial-clone", cmd__partial_clone }, diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h index f2885b33d58aa8..071306d52dabc3 100644 --- a/t/helper/test-tool.h +++ b/t/helper/test-tool.h @@ -43,6 +43,7 @@ int cmd__pack_deltas(int argc, const char **argv); int cmd__pack_mtimes(int argc, const char **argv); int cmd__parse_options(int argc, const char **argv); int cmd__parse_options_flags(int argc, const char **argv); +int cmd__early_scan_options(int argc, const char **argv); int cmd__parse_pathspec_file(int argc, const char** argv); int cmd__parse_subcommand(int argc, const char **argv); int cmd__partial_clone(int argc, const char **argv); diff --git a/t/t0040-parse-options.sh b/t/t0040-parse-options.sh index 449fff4d34b172..c44b12e46d90c7 100755 --- a/t/t0040-parse-options.sh +++ b/t/t0040-parse-options.sh @@ -845,4 +845,325 @@ test_expect_success 'u16 limits range' ' test_grep "value 65536 for option .u16. not in range \[0,65535\]" err ' +test_expect_success 'early_scan_options() finds a wanted option' ' + test-tool early-scan-options --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 0 + stopped at: 1 of 1 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() reads a stuck or separate value' ' + test-tool early-scan-options --wanted-value=one >actual && + cat >expect <<-\EOF && + found: wanted-value at 0 value: one + stopped at: 1 of 1 + EOF + test_cmp expect actual && + test-tool early-scan-options --wanted-value two >actual && + cat >expect <<-\EOF && + found: wanted-value at 0 value: two + stopped at: 2 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() skips the value of other options' ' + test-tool early-scan-options --skipped-value --wanted >actual && + cat >expect <<-\EOF && + stopped at: 2 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --skipped-value one --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 2 + stopped at: 3 of 3 + EOF + test_cmp expect actual && + test-tool early-scan-options --number --wanted >actual && + cat >expect <<-\EOF && + stopped at: 2 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() stops at a last option missing its value' ' + test-tool early-scan-options --wanted-value >actual && + cat >expect <<-\EOF && + stopped at: 0 of 1 + EOF + test_cmp expect actual && + test-tool early-scan-options --wanted --skipped-value >actual && + cat >expect <<-\EOF && + found: wanted at 0 + stopped at: 1 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() always stops at "--"' ' + test-tool early-scan-options -- --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --stop-at-non-option -- --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --skipped-value -- --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 2 + stopped at: 3 of 3 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() always stops at "--end-of-options"' ' + test-tool early-scan-options --end-of-options --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --stop-at-non-option \ + --end-of-options --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() can stop at a non-option' ' + test-tool early-scan-options --stop-at-non-option \ + arg --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --stop-at-non-option \ + --skipped-value arg --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 2 + stopped at: 3 of 3 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() matches unambiguous abbreviations' ' + GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \ + test-tool early-scan-options --wanted-v=x >actual && + cat >expect <<-\EOF && + found: wanted-value at 0 value: x + stopped at: 1 of 1 + EOF + test_cmp expect actual && + GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \ + test-tool early-scan-options --wanted-v x --wanted >actual && + cat >expect <<-\EOF && + found: wanted-value at 0 value: x + found: wanted at 2 + stopped at: 3 of 3 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() skips the value of an abbreviation' ' + GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \ + test-tool early-scan-options --skipped --wanted >actual && + cat >expect <<-\EOF && + stopped at: 2 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() stops at an ambiguous abbreviation' ' + GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \ + test-tool early-scan-options --want --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() stops at disallowed abbreviations' ' + GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=true \ + test-tool early-scan-options --wanted-v=x >actual && + cat >expect <<-\EOF && + stopped at: 0 of 1 + EOF + test_cmp expect actual && + GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=true \ + test-tool early-scan-options --skipped --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() stops at an unknown option' ' + test-tool early-scan-options --bogus --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() resolves aliases' ' + test-tool early-scan-options --alias-of-skipped --wanted >actual && + cat >expect <<-\EOF && + stopped at: 2 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --alias-of-skipped x --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 2 + stopped at: 3 of 3 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() does not skip an optional value' ' + test-tool early-scan-options --optarg --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 1 + stopped at: 2 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --optarg=5 --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 1 + stopped at: 2 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() skips a last argument default value' ' + test-tool early-scan-options --lastarg --wanted >actual && + cat >expect <<-\EOF && + stopped at: 2 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --wanted --lastarg >actual && + cat >expect <<-\EOF && + found: wanted at 0 + stopped at: 2 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() matches a stuck optional value' ' + test-tool early-scan-options --early-optarg=one >actual && + cat >expect <<-\EOF && + found: early-optarg at 0 value: one + stopped at: 1 of 1 + EOF + test_cmp expect actual && + test-tool early-scan-options --early-lastarg=two >actual && + cat >expect <<-\EOF && + found: early-lastarg at 0 value: two + stopped at: 1 of 1 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() does not take a separate optional value' ' + test-tool early-scan-options --early-optarg --wanted >actual && + cat >expect <<-\EOF && + found: early-optarg at 0 + found: wanted at 1 + stopped at: 2 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() takes a last argument default value' ' + test-tool early-scan-options --early-lastarg --wanted >actual && + cat >expect <<-\EOF && + found: early-lastarg at 0 value: --wanted + stopped at: 2 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --early-lastarg >actual && + cat >expect <<-\EOF && + found: early-lastarg at 0 + stopped at: 1 of 1 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() stops at short options' ' + test-tool early-scan-options -s --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() stops at low-level callbacks' ' + test-tool early-scan-options --low-level --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --ll-value x --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 3 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() stops after what the callback consumed' ' + test-tool early-scan-options --stop-at-found --wanted --wanted >actual && + cat >expect <<-\EOF && + found: wanted at 0 + stopped at: 1 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --stop-at-found \ + --wanted-value x --wanted >actual && + cat >expect <<-\EOF && + found: wanted-value at 0 value: x + stopped at: 2 of 3 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() reports negated options' ' + test-tool early-scan-options --no-wanted >actual && + cat >expect <<-\EOF && + found: wanted at 0 unset + stopped at: 1 of 1 + EOF + test_cmp expect actual && + test-tool early-scan-options --early >actual && + cat >expect <<-\EOF && + found: no-early at 0 unset + stopped at: 1 of 1 + EOF + test_cmp expect actual && + test-tool early-scan-options --no-early >actual && + cat >expect <<-\EOF && + found: no-early at 0 + stopped at: 1 of 1 + EOF + test_cmp expect actual +' + +test_expect_success 'early_scan_options() does not take a value for negated options' ' + test-tool early-scan-options --no-wanted-value --wanted >actual && + cat >expect <<-\EOF && + found: wanted-value at 0 unset + found: wanted at 1 + stopped at: 2 of 2 + EOF + test_cmp expect actual && + test-tool early-scan-options --no-wanted-value=x --wanted >actual && + cat >expect <<-\EOF && + stopped at: 0 of 2 + EOF + test_cmp expect actual +' + test_done From dabb68a20dc66b553606d05f447762434b82c403 Mon Sep 17 00:00:00 2001 From: Christian Couder Date: Mon, 12 Oct 2026 09:44:13 +0200 Subject: [PATCH 069/316] fast-import: use early_scan_options() for --allow-unsafe-features The "feature" lines at the start of the stream are processed before the command line options are parsed, so cmd_fast_import() scans its arguments early to find out if `--allow-unsafe-features` was given. That scan doesn't know which options take their value as a separate argument, and it stops at the first argument that doesn't start with a dash. So it disagrees with parse_options(), which accepts values separated from their option by a space, for a command line like "--depth 5 --allow-unsafe-features": the scan stops at "5" and never sees the option, so unsafe "feature" commands from the stream are refused even though the option was given. Let's fix this by using early_scan_options(), which scans the very same `struct option` array that parse_options() uses, so that both agree on which options take a value, and by marking `--allow-unsafe-features` with PARSE_OPT_EARLY so that the scan reports it. As early_scan_options() looks up long options the same way as parse_options(), the two also agree on abbreviations now. So `--allow-unsafe` allows unsafe "feature" commands, while "--quiet --export-pack --allow-unsafe-features" refuses them, as parse_options() takes `--allow-unsafe-features` there as the value of `--export-pack-edges`. (The old scan wrongly allowed them in this case, as all the arguments start with a dash.) The note about having to spell the option in full in the documentation is therefore removed, and so is the NEEDSWORK comment about it. Helped-by: Kaartic Sivaraam Signed-off-by: Christian Couder Signed-off-by: Junio C Hamano --- Documentation/git-fast-import.adoc | 7 ------ builtin/fast-import.c | 37 ++++++++++++++++-------------- t/t9300-fast-import.sh | 29 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/Documentation/git-fast-import.adoc b/Documentation/git-fast-import.adoc index fd165e11d2d259..7c5900e048cefb 100644 --- a/Documentation/git-fast-import.adoc +++ b/Documentation/git-fast-import.adoc @@ -65,13 +65,6 @@ Only enable this option if you trust the program generating the fast-import stream! This option is enabled automatically for remote-helpers that use the `import` capability, as they are already trusted to run their own code. -+ -Note that this option has to be spelled in full, and has to appear -before any option whose value is separated from it by a space, for -the unsafe `feature` commands in the stream to be allowed. So -`--allow-unsafe` or `--depth 5 --allow-unsafe-features` still refuse -them, while `--allow-unsafe-features --depth 5` and -`--depth=5 --allow-unsafe-features` allow them. `--signed-tags=`:: Specify how to handle signed tags. Behaves in the same way as diff --git a/builtin/fast-import.c b/builtin/fast-import.c index fbd919982c956a..2e667af349fceb 100644 --- a/builtin/fast-import.c +++ b/builtin/fast-import.c @@ -4120,6 +4120,18 @@ static int option_parse_quiet(const struct option *opt UNUSED, return 0; } +static int option_parse_early_allow_unsafe(const struct option *option, + const char *value UNUSED, + bool unset, int pos UNUSED, + void *data) +{ + struct fast_import_state *state = data; + + if (!strcmp(option->long_name, "allow-unsafe-features")) + state->allow_unsafe_features = !unset; + return 0; +} + int cmd_fast_import(int argc, const char **argv, const char *prefix, @@ -4184,7 +4196,7 @@ int cmd_fast_import(int argc, OPT_HIDDEN_GROUP(N_("Advanced")), OPT_BOOL_F(0, "allow-unsafe-features", &state.allow_unsafe_features, N_("allow unsafe mark commands from the stream"), - PARSE_OPT_HIDDEN | PARSE_OPT_NONEG), + PARSE_OPT_HIDDEN | PARSE_OPT_NONEG | PARSE_OPT_EARLY), OPT_CALLBACK_F(0, "export-pack-edges", &state, N_("file"), N_("dump edge commits to "), PARSE_OPT_HIDDEN | PARSE_OPT_NONEG, @@ -4218,23 +4230,14 @@ int cmd_fast_import(int argc, * line to override stream data). But we must do an early parse of any * command-line options that impact how we interpret the feature lines. * - * NEEDSWORK: This scan only matches the exact "--allow-unsafe-features" - * spelling and stops at the first argument that doesn't start with a - * dash. As parse_options() below also accepts unambiguous abbreviations - * and values separated by a space from their option, the two disagree - * for command lines like "--allow-unsafe" or "--depth 5 - * --allow-unsafe-features": parse_options() accepts the option, but - * this scan doesn't see it, so unsafe features from the stream are - * still refused. This errs on the safe side, but should be fixed by - * teaching this scan about the options that take a value. + * The scan uses the same options as parse_options() below, so the + * two agree on which options take a value and on abbreviations. In + * case of doubt, the scan stops early, so unsafe features are then + * refused rather than wrongly allowed. */ - for (int i = 1; i < argc; i++) { - const char *arg = argv[i]; - if (*arg != '-' || !strcmp(arg, "--")) - break; - if (!strcmp(arg, "--allow-unsafe-features")) - state.allow_unsafe_features = 1; - } + early_scan_options(argc - 1, argv + 1, fast_import_options, + EARLY_SCAN_STOP_AT_NON_OPTION, + option_parse_early_allow_unsafe, &state); rc_free = mem_pool_alloc(&fi_mem_pool, cmd_save * sizeof(*rc_free)); for (unsigned int i = 0; i < (cmd_save - 1); i++) diff --git a/t/t9300-fast-import.sh b/t/t9300-fast-import.sh index d9de2ef0d88bc8..85059a53852ae1 100755 --- a/t/t9300-fast-import.sh +++ b/t/t9300-fast-import.sh @@ -2344,6 +2344,35 @@ test_expect_success 'R: export-marks options can be overridden by commandline op test_path_is_missing feature-sub ' +test_expect_success 'R: --allow-unsafe-features found after a value' ' + echo "feature import-marks-if-exists=nonexistent.marks" >input && + git fast-import --allow-unsafe-features input && + GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \ + git fast-import --allow-unsafe input && + test_must_fail env GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=true \ + git fast-import --allow-unsafe err && + test_grep "forbidden in input without --allow-unsafe-features" err +' + +test_expect_success 'R: --allow-unsafe-features as the value of an abbreviation' ' + echo "feature import-marks-if-exists=nonexistent.marks" >input && + test_must_fail env GIT_TEST_DISALLOW_ABBREVIATED_OPTIONS=false \ + git fast-import --quiet --export-pack \ + --allow-unsafe-features err && + test_grep "forbidden in input without --allow-unsafe-features" err +' + test_expect_success 'R: catch typo in marks file name' ' test_must_fail git fast-import --import-marks=nonexistent.marks Date: Tue, 13 Oct 2026 12:07:52 +0000 Subject: [PATCH 070/316] rerere: wait for MERGE_RR.lock before giving up setup_rerere() takes MERGE_RR.lock with LOCK_DIE_ON_ERROR, so when two processes want it at the same time, the second one dies. That used to be rare. Since 452b12c2e0 (builtin/maintenance: use "geometric" strategy by default, 2026-02-24) the auto maintenance after a commit runs "git rerere gc" whenever rr-cache has enough stale entries, and the gc holds the lock while it prunes. If a rebase's next pick conflicts while the gc holds the lock, the rebase dies inside repo_rerere(), before the sequencer has written what "git rebase --continue" needs. Every later "git rebase --continue" then fails with "you have staged changes in your working tree". Wait for the lock for up to rerere.lockTimeout milliseconds, 1000 by default, and only then fail as before. Pruning a few thousand entries takes well under a second, so the default covers a rebase that runs into the gc. Assisted-by: Claude Fable 5.1 Signed-off-by: Thomas Bachem Signed-off-by: Junio C Hamano --- Documentation/config/rerere.adoc | 7 +++++ rerere.c | 22 ++++++++++--- t/t4200-rerere.sh | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/Documentation/config/rerere.adoc b/Documentation/config/rerere.adoc index 3a78b5ebb1dc02..ab035e2d7dc13f 100644 --- a/Documentation/config/rerere.adoc +++ b/Documentation/config/rerere.adoc @@ -10,3 +10,10 @@ rerere.enabled:: enabled if there is an `rr-cache` directory under the `$GIT_DIR`, e.g. if "rerere" was previously used in the repository. + +rerere.lockTimeout:: + The length of time, in milliseconds, to wait for the rerere + lock when another process holds it, typically a background + `git rerere gc`. Value 0 means not to wait at all; -1 means + to wait indefinitely. Default is 1000 (i.e., wait for 1 + second). diff --git a/rerere.c b/rerere.c index 1c3745d9e3279a..64fac07c71d9d8 100644 --- a/rerere.c +++ b/rerere.c @@ -33,6 +33,9 @@ static int rerere_enabled = -1; /* automatically update cleanly resolved paths to the index */ static int rerere_autoupdate; +/* how long to wait for MERGE_RR.lock, in milliseconds */ +static int rerere_lock_timeout_ms = 1000; + #define RR_HAS_POSTIMAGE 1 #define RR_HAS_PREIMAGE 2 struct rerere_dir { @@ -850,6 +853,8 @@ static void git_rerere_config(void) { repo_config_get_bool(the_repository, "rerere.enabled", &rerere_enabled); repo_config_get_bool(the_repository, "rerere.autoupdate", &rerere_autoupdate); + repo_config_get_int(the_repository, "rerere.locktimeout", + &rerere_lock_timeout_ms); repo_config(the_repository, git_default_config, NULL); } @@ -882,12 +887,19 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags) if (flags & (RERERE_AUTOUPDATE|RERERE_NOAUTOUPDATE)) rerere_autoupdate = !!(flags & RERERE_AUTOUPDATE); - if (flags & RERERE_READONLY) + if (flags & RERERE_READONLY) { fd = 0; - else - fd = repo_hold_lock_file_for_update(r, &write_lock, - git_path_merge_rr(r), - LOCK_DIE_ON_ERROR); + } else { + /* + * Another process may hold the lock for a while, e.g. + * "git rerere gc" while it prunes rr-cache, so wait for + * it instead of dying right away. + */ + fd = repo_hold_lock_file_for_update_timeout(r, &write_lock, + git_path_merge_rr(r), + LOCK_DIE_ON_ERROR, + rerere_lock_timeout_ms); + } read_rr(r, merge_rr); return fd; } diff --git a/t/t4200-rerere.sh b/t/t4200-rerere.sh index 7bb601e117ec35..94165c8417ffc4 100755 --- a/t/t4200-rerere.sh +++ b/t/t4200-rerere.sh @@ -242,6 +242,59 @@ test_expect_success 'old records rest in peace' ' test_path_is_missing $rr2/preimage ' +test_expect_success 'a held lock is waited out within rerere.lockTimeout' ' + git reset --hard && + rm -rf $rr && + test_when_finished "wait && rm -f .git/MERGE_RR.lock" && + >.git/MERGE_RR.lock && + { + ( sleep 1 && rm -f .git/MERGE_RR.lock ) & + } && + test_must_fail git -c rerere.lockTimeout=5000 merge first 2>err && + test_grep ! "Unable to create.*MERGE_RR\.lock" err && + test_grep "^=======\$" $rr/preimage +' + +test_expect_success 'merge fails on a lock it cannot take' ' + git reset --hard && + rm -rf $rr && + test_when_finished "rm -f .git/MERGE_RR.lock" && + >.git/MERGE_RR.lock && + test_must_fail git -c rerere.lockTimeout=0 merge first 2>err && + test_grep "Unable to create.*MERGE_RR\.lock" err && + test_grep "^=======\$" a1 && + test_path_is_missing $rr/preimage +' + +test_expect_success 'rerere, forget, clear and gc fail on a lock they cannot take' ' + test_when_finished "rm -f .git/MERGE_RR.lock" && + >.git/MERGE_RR.lock && + test_must_fail git -c rerere.lockTimeout=0 rerere 2>err && + test_grep "Unable to create.*MERGE_RR\.lock" err && + test_must_fail git -c rerere.lockTimeout=0 rerere forget a1 2>err && + test_grep "Unable to create.*MERGE_RR\.lock" err && + test_must_fail git -c rerere.lockTimeout=0 rerere clear 2>err && + test_grep "Unable to create.*MERGE_RR\.lock" err && + test_must_fail git -c rerere.lockTimeout=0 rerere gc 2>err && + test_grep "Unable to create.*MERGE_RR\.lock" err +' + +test_expect_success 'rebase --abort fails on a lock it cannot take' ' + git reset --hard && + git checkout -b lock-held-abort third && + test_when_finished "git checkout third && git branch -D lock-held-abort" && + test_must_fail git rebase first && + test_when_finished "test_might_fail git rebase --abort" && + test_when_finished "rm -f .git/MERGE_RR.lock" && + >.git/MERGE_RR.lock && + test_must_fail git -c rerere.lockTimeout=0 rebase --abort 2>err && + test_grep "Unable to create.*MERGE_RR\.lock" err && + test_path_is_dir .git/rebase-merge && + rm .git/MERGE_RR.lock && + git rebase --abort && + test_path_is_missing .git/rebase-merge +' + rerere_gc_custom_expiry_test () { five_days="$1" right_now="$2" test_expect_success "rerere gc with custom expiry ($five_days, $right_now)" ' From 5f6b6aa3ab4ab9f68271055da51da6746cba707e Mon Sep 17 00:00:00 2001 From: Thomas Bachem Date: Tue, 13 Oct 2026 12:07:53 +0000 Subject: [PATCH 071/316] rerere: add "gc --skip-locked" for auto maintenance Starting with the preceding commit, processes that want to acquire the rerere cache's MERGE_RR.lock by default know to wait up to one second until that lock has been released. This is a sensible default for many commands that happen to write rerere entries, as we would otherwise die immediately when the lock is taken by another process. But for repository maintenance it's a bit more complicated, as there are two cases that we have to care about. When the user explicitly asks us to garbage collect rerere entries via `git rerere gc` they probably want us to try our best to perform this operation. It's thus sensible to wait for the lock and then die if we weren't able to acquire it. But we also prune rerere entries as part of auto-maintenance, which is only executed on a best-effort basis anyway. Delaying the whole operation to acquire the lock is somewhat heavy-handed, and neither does it make sense to die in case we haven't been able to garbage collect rerere entries as that would impede other housekeeping tasks. Furthermore, it's totally fine to skip the operation when the rerere cache is locked already, as we will retry during the next run anyway. But we do not have an easy way to tell `git rerere gc` to skip the operation in case the cache is locked already. Add a new "--skip-locked" flag to plug that gap and have auto-maintenance pass that flag. Only auto-maintenance needs that flag, so hide it, like the "--skip-foreground-tasks" flag that `git maintenance run` passes to `git gc`. Helped-by: Patrick Steinhardt Assisted-by: Claude Fable 5.1 Signed-off-by: Thomas Bachem Signed-off-by: Junio C Hamano --- Documentation/config/rerere.adoc | 4 +++- builtin/gc.c | 4 +++- builtin/rerere.c | 10 ++++++++-- rerere.c | 23 +++++++++++++++++------ rerere.h | 4 +++- t/t4200-rerere.sh | 26 ++++++++++++++++++++++++++ t/t7900-maintenance.sh | 26 +++++++++++++++++++++++++- 7 files changed, 85 insertions(+), 12 deletions(-) diff --git a/Documentation/config/rerere.adoc b/Documentation/config/rerere.adoc index ab035e2d7dc13f..60e78e499fbd4d 100644 --- a/Documentation/config/rerere.adoc +++ b/Documentation/config/rerere.adoc @@ -16,4 +16,6 @@ rerere.lockTimeout:: lock when another process holds it, typically a background `git rerere gc`. Value 0 means not to wait at all; -1 means to wait indefinitely. Default is 1000 (i.e., wait for 1 - second). + second). A `git rerere gc` run by `git maintenance run --auto` + or `git gc --auto` does not wait and does nothing while the + lock is held. diff --git a/builtin/gc.c b/builtin/gc.c index 57a3520263d7be..7ad3987b717ff5 100644 --- a/builtin/gc.c +++ b/builtin/gc.c @@ -385,12 +385,14 @@ static int worktree_prune_condition(struct gc_config *cfg) return should_prune; } -static int maintenance_task_rerere_gc(struct maintenance_run_opts *opts UNUSED, +static int maintenance_task_rerere_gc(struct maintenance_run_opts *opts, struct gc_config *cfg UNUSED) { struct child_process rerere_cmd = CHILD_PROCESS_INIT; rerere_cmd.git_cmd = 1; strvec_pushl(&rerere_cmd.args, "rerere", "gc", NULL); + if (opts->auto_flag) + strvec_push(&rerere_cmd.args, "--skip-locked"); return run_command(&rerere_cmd); } diff --git a/builtin/rerere.c b/builtin/rerere.c index a056cb791b4c80..445f1df03211ce 100644 --- a/builtin/rerere.c +++ b/builtin/rerere.c @@ -56,16 +56,21 @@ int cmd_rerere(int argc, struct repository *repo UNUSED) { struct string_list merge_rr = STRING_LIST_INIT_DUP; - int autoupdate = -1, flags = 0; + int autoupdate = -1, skip_locked = 0, flags = 0; struct option options[] = { OPT_SET_INT(0, "rerere-autoupdate", &autoupdate, N_("register clean resolutions in index"), 1), + OPT_HIDDEN_BOOL(0, "skip-locked", &skip_locked, + N_("skip gc while another process holds the lock")), OPT_END(), }; argc = parse_options(argc, argv, prefix, options, rerere_usage, 0); + if (skip_locked && (argc < 1 || strcmp(argv[0], "gc"))) + die(_("the option '%s' requires '%s'"), "--skip-locked", "gc"); + repo_config(the_repository, git_xmerge_config, NULL); if (autoupdate == 1) @@ -94,7 +99,8 @@ int cmd_rerere(int argc, if (!strcmp(argv[0], "clear")) { rerere_clear(the_repository, &merge_rr); } else if (!strcmp(argv[0], "gc")) - rerere_gc(the_repository, &merge_rr); + rerere_gc(the_repository, &merge_rr, + skip_locked ? RERERE_NOWAIT : 0); else if (!strcmp(argv[0], "status")) { if (setup_rerere(the_repository, &merge_rr, flags | RERERE_READONLY) < 0) diff --git a/rerere.c b/rerere.c index 64fac07c71d9d8..75b99f0f818614 100644 --- a/rerere.c +++ b/rerere.c @@ -887,18 +887,29 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags) if (flags & (RERERE_AUTOUPDATE|RERERE_NOAUTOUPDATE)) rerere_autoupdate = !!(flags & RERERE_AUTOUPDATE); + if ((flags & RERERE_READONLY) && (flags & RERERE_NOWAIT)) + BUG("RERERE_NOWAIT does not apply with RERERE_READONLY"); if (flags & RERERE_READONLY) { fd = 0; } else { + int lock_flags = LOCK_DIE_ON_ERROR; + int timeout_ms = rerere_lock_timeout_ms; + + if (flags & RERERE_NOWAIT) { + lock_flags = 0; + timeout_ms = 0; + } + /* * Another process may hold the lock for a while, e.g. - * "git rerere gc" while it prunes rr-cache, so wait for - * it instead of dying right away. + * "git rerere gc" while it prunes rr-cache, so by default + * wait for it instead of dying right away. */ fd = repo_hold_lock_file_for_update_timeout(r, &write_lock, git_path_merge_rr(r), - LOCK_DIE_ON_ERROR, - rerere_lock_timeout_ms); + lock_flags, timeout_ms); + if (fd < 0) + return -1; } read_rr(r, merge_rr); return fd; @@ -1284,7 +1295,7 @@ bool rerere_gc_needed(struct repository *r, size_t limit) return needed; } -void rerere_gc(struct repository *r, struct string_list *rr) +void rerere_gc(struct repository *r, struct string_list *rr, int flags) { struct string_list to_remove = STRING_LIST_INIT_DUP; DIR *dir; @@ -1294,7 +1305,7 @@ void rerere_gc(struct repository *r, struct string_list *rr) timestamp_t cutoff_resolve; struct strbuf buf = STRBUF_INIT; - if (setup_rerere(r, rr, 0) < 0) + if (setup_rerere(r, rr, flags) < 0) return; rerere_gc_cutoffs(r, &cutoff_resolve, &cutoff_noresolve); diff --git a/rerere.h b/rerere.h index feeb0e2c9fe61b..1182ee3fb1d9f8 100644 --- a/rerere.h +++ b/rerere.h @@ -10,6 +10,8 @@ struct repository; #define RERERE_AUTOUPDATE 01 #define RERERE_NOAUTOUPDATE 02 #define RERERE_READONLY 04 +/* If MERGE_RR.lock is taken, return -1 as if rerere were disabled */ +#define RERERE_NOWAIT 010 /* * Marks paths that have been hand-resolved and added to the @@ -37,7 +39,7 @@ const char *rerere_path(struct strbuf *buf, const struct rerere_id *, int rerere_forget(struct repository *, struct pathspec *); int rerere_remaining(struct repository *, struct string_list *); void rerere_clear(struct repository *, struct string_list *); -void rerere_gc(struct repository *, struct string_list *); +void rerere_gc(struct repository *, struct string_list *, int); /* * Check whether garbage collection for rerere entries is needed, which is diff --git a/t/t4200-rerere.sh b/t/t4200-rerere.sh index 94165c8417ffc4..43795fb7a61985 100755 --- a/t/t4200-rerere.sh +++ b/t/t4200-rerere.sh @@ -242,6 +242,32 @@ test_expect_success 'old records rest in peace' ' test_path_is_missing $rr2/preimage ' +test_expect_success 'gc --skip-locked does nothing while MERGE_RR is locked' ' + mkdir -p $rr2 && + echo Hello >$rr2/preimage && + test-tool chmtime =$just_over_15_days_ago $rr2/preimage && + + test_when_finished "rm -f .git/MERGE_RR.lock" && + >.git/MERGE_RR.lock && + git rerere gc --skip-locked 2>err && + test_must_be_empty err && + test_path_is_file $rr2/preimage +' + +test_expect_success 'gc --skip-locked prunes while MERGE_RR is not locked' ' + mkdir -p $rr2 && + echo Hello >$rr2/preimage && + test-tool chmtime =$just_over_15_days_ago $rr2/preimage && + + git rerere gc --skip-locked && + test_path_is_missing $rr2/preimage +' + +test_expect_success '--skip-locked is only accepted by gc' ' + test_must_fail git rerere --skip-locked clear 2>err && + test_grep "option .--skip-locked. requires .gc." err +' + test_expect_success 'a held lock is waited out within rerere.lockTimeout' ' git reset --hard && rm -rf $rr && diff --git a/t/t7900-maintenance.sh b/t/t7900-maintenance.sh index 4f65fa9439c0b8..9d610a29fe25c1 100755 --- a/t/t7900-maintenance.sh +++ b/t/t7900-maintenance.sh @@ -1007,9 +1007,17 @@ test_expect_rerere_gc () { shift fi + # An automatic run passes --skip-locked to "git rerere gc". + skip_locked= + case " $* " in + *" --auto "*) + skip_locked=--skip-locked + ;; + esac + rm -f "rerere-gc.txt" && GIT_TRACE2_EVENT="$(pwd)/rerere-gc.txt" "$@" && - test_subcommand $negate git rerere gc .git/MERGE_RR.lock && + test_expect_rerere_gc git -c maintenance.rerere-gc.auto=-1 maintenance run --auto --task=rerere-gc +' + +test_expect_success 'rerere-gc task without --auto fails while MERGE_RR is locked' ' + test_when_finished "rm -rf .git/rr-cache .git/MERGE_RR.lock" && + mkdir .git/rr-cache && + >.git/MERGE_RR.lock && + test_must_fail git -c rerere.lockTimeout=0 maintenance run --task=rerere-gc 2>err && + test_grep "Unable to create.*MERGE_RR\.lock" err +' + test_expect_success '--auto and --schedule incompatible' ' test_must_fail git maintenance run --auto --schedule=daily 2>err && test_grep "cannot be used together" err From 5ebc33c76caa73a0235b6ff344a5d4de76d67a4f Mon Sep 17 00:00:00 2001 From: Curtis Allen Smith Date: Tue, 13 Oct 2026 12:02:04 -0600 Subject: [PATCH 072/316] read-cache: do not trust a size change when conversion is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "git status" can report a file as modified while "git diff" and "git add" agree that its contents are unchanged: git init t && cd t printf '* text eol=lf\n' >.gitattributes printf 'one\ntwo\nthree\n' >file.txt git add . && git commit -m init printf 'one\r\ntwo\r\nthree\r\n' >file.txt git status --short # -> M file.txt git diff # -> empty git add file.txt # -> nothing to commit ie_modified() takes a change in the file's size since the index entry was last refreshed as proof that the file is modified, without looking at the contents. A size change proves only that the bytes in the working tree changed. When the path is subject to conversion (the "text" and "eol" attributes, core.autocrlf, a clean filter), different bytes can convert to the same blob, as the CRLF rewrite above does. For such a path, fall through to ce_modified_check_fs(), as is already done when only the mtime changed. Paths without conversion return early as before. ie_modified() runs when the index is refreshed, so this reaches every command that refreshes it: "git update-index --refresh" now succeeds instead of reporting "needs update", "git ls-files -m" and "git status" stop reporting the path, and "git rebase" and "git pull --rebase" no longer refuse to run. "git diff" itself is unchanged; it already reads and converts the file and prints nothing. Helped-by: Torsten Bögershausen Signed-off-by: Curtis Allen Smith Signed-off-by: Junio C Hamano --- read-cache.c | 4 +++- t/t0020-crlf.sh | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/read-cache.c b/read-cache.c index c4cf08a3a34156..70f4787de93af4 100644 --- a/read-cache.c +++ b/read-cache.c @@ -9,6 +9,7 @@ #include "git-compat-util.h" #include "config.h" +#include "convert.h" #include "date.h" #include "diff.h" #include "diffcore.h" @@ -480,7 +481,8 @@ int ie_modified(struct index_state *istate, */ (!S_ISLNK(st->st_mode) || ce->ce_stat_data.sd_size != MAX_PATH) && #endif - (S_ISGITLINK(ce->ce_mode) || ce->ce_stat_data.sd_size != 0)) + (S_ISGITLINK(ce->ce_mode) || ce->ce_stat_data.sd_size != 0) && + !(S_ISREG(st->st_mode) && would_convert_to_git(istate, ce->name))) return changed; changed_fs = ce_modified_check_fs(istate, ce, st); diff --git a/t/t0020-crlf.sh b/t/t0020-crlf.sh index fd1cae09edcc26..9c8d07cfc415cf 100755 --- a/t/t0020-crlf.sh +++ b/t/t0020-crlf.sh @@ -397,4 +397,43 @@ test_expect_success 'New CRLF file gets LF in repo' ' test_cmp alllf alllf2 ' +test_expect_success 'status does not report a CRLF-only rewrite as modified' ' + test_when_finished "git reset --hard autocrlf-checkpoint" && + echo "eol.txt text eol=lf" >.gitattributes && + printf "one\ntwo\nthree\n" >eol.txt && + git add .gitattributes eol.txt && + git commit -m "eol.txt" && + + # a generator rewrites the file with CRLF, same content + printf "one\r\ntwo\r\nthree\r\n" >eol.txt && + git status --porcelain -uno >actual && + test_must_be_empty actual && + git diff --exit-code && + + # a real change is still reported + printf "one\r\ntwo\r\nfour\r\n" >eol.txt && + git status --porcelain -uno >actual && + echo " M eol.txt" >expect && + test_cmp expect actual +' + +test_expect_success 'status sees through a CRLF rewrite of a file with a lone CR' ' + test_when_finished "git reset --hard autocrlf-checkpoint" && + echo "cr.txt text eol=lf" >.gitattributes && + printf "one\rtwo\nthree\n" >cr.txt && + git add .gitattributes cr.txt && + git commit -m "cr.txt" && + + # "git add" keeps the lone CR and drops the others + printf "one\rtwo\r\nthree\r\n" >cr.txt && + git status --porcelain -uno >actual && + test_must_be_empty actual && + + # the converted length matches the blob, the content does not + printf "one\rtwo\nthrEE\r\n" >cr.txt && + git status --porcelain -uno >actual && + echo " M cr.txt" >expect && + test_cmp expect actual +' + test_done From 5bc69b522f270772ea2d21727fdd917b96e1d7df Mon Sep 17 00:00:00 2001 From: Mirko Faina Date: Tue, 13 Oct 2026 21:50:49 +0200 Subject: [PATCH 073/316] bash-completions: add --[no-]graph-indent Add missing completion for "git log --graph-indent" and "git log --no-graph-indent". Signed-off-by: Mirko Faina Signed-off-by: Junio C Hamano --- contrib/completion/git-completion.bash | 1 + 1 file changed, 1 insertion(+) diff --git a/contrib/completion/git-completion.bash b/contrib/completion/git-completion.bash index 9f8b9b50ff834c..f3db12fe19ca19 100644 --- a/contrib/completion/git-completion.bash +++ b/contrib/completion/git-completion.bash @@ -2298,6 +2298,7 @@ __git_log_common_options=" --right-only --show-linear-break --show-notes-by-default --show-pulls --since-as-filter --single-worktree + --graph-indent --no-graph-indent " # Options that go well for log and gitk (not shortlog) __git_log_gitk_options=" From 07e794086b9dee08038d24e1277532304bd0e299 Mon Sep 17 00:00:00 2001 From: Jon Simons Date: Tue, 13 Oct 2026 23:13:36 -0400 Subject: [PATCH 074/316] remote: validate --force-with-lease argument Use check_refname_format() to validate the component of 'git push --force-with-lease=[:]'. apply_push_cas() will silently ignore a lease entry that does not match any remote ref. And today "./refs/heads/main" will happen to be matched with "refs/heads/main" due to refname_match() usage of mkpath(), whose cleanup_path() strips leading "./". An upcoming commit removes mkpath() from refname_match(), after which there is no unintentional match in this situation, so that the lease is ignored instead of applied. Reject an invalid refname now to make this situation fail fast both before and after that change. Signed-off-by: Jon Simons Signed-off-by: Junio C Hamano --- remote.c | 2 ++ t/t5533-push-cas.sh | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/remote.c b/remote.c index 71170f36a97826..114d4d983c31de 100644 --- a/remote.c +++ b/remote.c @@ -2740,6 +2740,8 @@ static int parse_push_cas_option(struct push_cas_option *cas, const char *arg, i /* "--