From 8cafc305e22a59efb92472d4132616e24d3184c6 Mon Sep 17 00:00:00 2001 From: Abraham Samuel Adekunle Date: Thu, 8 Jan 2026 16:07:04 +0100 Subject: [PATCH 01/29] add -p: show user's hunk decision when selecting hunks When a user is interactively deciding which hunks to use or skip for staging, unstaging, stashing etc, there is no way to know the decision previously chosen for a hunk when navigating through the previous and next hunks using K/J respectively. Improve the UI to explicitly show if a user has previously decided to use a hunk (by pressing 'y') or skip the hunk (by pressing 'n'). This will improve clarity when and aid the navigation process for the user. Reported-by: Junio C Hamano Signed-off-by: Abraham Samuel Adekunle Signed-off-by: Junio C Hamano --- add-patch.c | 81 +++++++++++++++++++++----------------- t/t3701-add-interactive.sh | 18 ++++----- 2 files changed, 53 insertions(+), 46 deletions(-) diff --git a/add-patch.c b/add-patch.c index 173a53241ebf07..df8f2e6d74e135 100644 --- a/add-patch.c +++ b/add-patch.c @@ -42,10 +42,10 @@ static struct patch_mode patch_mode_add = { .apply_args = { "--cached", NULL }, .apply_check_args = { "--cached", NULL }, .prompt_mode = { - N_("Stage mode change [y,n,q,a,d%s,?]? "), - N_("Stage deletion [y,n,q,a,d%s,?]? "), - N_("Stage addition [y,n,q,a,d%s,?]? "), - N_("Stage this hunk [y,n,q,a,d%s,?]? ") + N_("Stage mode change%s [y,n,q,a,d%s,?]? "), + N_("Stage deletion%s [y,n,q,a,d%s,?]? "), + N_("Stage addition%s [y,n,q,a,d%s,?]? "), + N_("Stage this hunk%s [y,n,q,a,d%s,?]? ") }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for staging."), @@ -64,10 +64,10 @@ static struct patch_mode patch_mode_stash = { .apply_args = { "--cached", NULL }, .apply_check_args = { "--cached", NULL }, .prompt_mode = { - N_("Stash mode change [y,n,q,a,d%s,?]? "), - N_("Stash deletion [y,n,q,a,d%s,?]? "), - N_("Stash addition [y,n,q,a,d%s,?]? "), - N_("Stash this hunk [y,n,q,a,d%s,?]? "), + N_("Stash mode change%s [y,n,q,a,d%s,?]? "), + N_("Stash deletion%s [y,n,q,a,d%s,?]? "), + N_("Stash addition%s [y,n,q,a,d%s,?]? "), + N_("Stash this hunk%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for stashing."), @@ -88,10 +88,10 @@ static struct patch_mode patch_mode_reset_head = { .is_reverse = 1, .index_only = 1, .prompt_mode = { - N_("Unstage mode change [y,n,q,a,d%s,?]? "), - N_("Unstage deletion [y,n,q,a,d%s,?]? "), - N_("Unstage addition [y,n,q,a,d%s,?]? "), - N_("Unstage this hunk [y,n,q,a,d%s,?]? "), + N_("Unstage mode change%s [y,n,q,a,d%s,?]? "), + N_("Unstage deletion%s [y,n,q,a,d%s,?]? "), + N_("Unstage addition%s [y,n,q,a,d%s,?]? "), + N_("Unstage this hunk%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for unstaging."), @@ -111,10 +111,10 @@ static struct patch_mode patch_mode_reset_nothead = { .apply_check_args = { "--cached", NULL }, .index_only = 1, .prompt_mode = { - N_("Apply mode change to index [y,n,q,a,d%s,?]? "), - N_("Apply deletion to index [y,n,q,a,d%s,?]? "), - N_("Apply addition to index [y,n,q,a,d%s,?]? "), - N_("Apply this hunk to index [y,n,q,a,d%s,?]? "), + N_("Apply mode change to index%s [y,n,q,a,d%s,?]? "), + N_("Apply deletion to index%s [y,n,q,a,d%s,?]? "), + N_("Apply addition to index%s [y,n,q,a,d%s,?]? "), + N_("Apply this hunk to index%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for applying."), @@ -134,10 +134,10 @@ static struct patch_mode patch_mode_checkout_index = { .apply_check_args = { "-R", NULL }, .is_reverse = 1, .prompt_mode = { - N_("Discard mode change from worktree [y,n,q,a,d%s,?]? "), - N_("Discard deletion from worktree [y,n,q,a,d%s,?]? "), - N_("Discard addition from worktree [y,n,q,a,d%s,?]? "), - N_("Discard this hunk from worktree [y,n,q,a,d%s,?]? "), + N_("Discard mode change from worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard deletion from worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard addition from worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard this hunk from worktree%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for discarding."), @@ -157,10 +157,10 @@ static struct patch_mode patch_mode_checkout_head = { .apply_check_args = { "-R", NULL }, .is_reverse = 1, .prompt_mode = { - N_("Discard mode change from index and worktree [y,n,q,a,d%s,?]? "), - N_("Discard deletion from index and worktree [y,n,q,a,d%s,?]? "), - N_("Discard addition from index and worktree [y,n,q,a,d%s,?]? "), - N_("Discard this hunk from index and worktree [y,n,q,a,d%s,?]? "), + N_("Discard mode change from index and worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard deletion from index and worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard addition from index and worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard this hunk from index and worktree%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for discarding."), @@ -179,10 +179,10 @@ static struct patch_mode patch_mode_checkout_nothead = { .apply_for_checkout = 1, .apply_check_args = { NULL }, .prompt_mode = { - N_("Apply mode change to index and worktree [y,n,q,a,d%s,?]? "), - N_("Apply deletion to index and worktree [y,n,q,a,d%s,?]? "), - N_("Apply addition to index and worktree [y,n,q,a,d%s,?]? "), - N_("Apply this hunk to index and worktree [y,n,q,a,d%s,?]? "), + N_("Apply mode change to index and worktree%s [y,n,q,a,d%s,?]? "), + N_("Apply deletion to index and worktree%s [y,n,q,a,d%s,?]? "), + N_("Apply addition to index and worktree%s [y,n,q,a,d%s,?]? "), + N_("Apply this hunk to index and worktree%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for applying."), @@ -202,10 +202,10 @@ static struct patch_mode patch_mode_worktree_head = { .apply_check_args = { "-R", NULL }, .is_reverse = 1, .prompt_mode = { - N_("Discard mode change from worktree [y,n,q,a,d%s,?]? "), - N_("Discard deletion from worktree [y,n,q,a,d%s,?]? "), - N_("Discard addition from worktree [y,n,q,a,d%s,?]? "), - N_("Discard this hunk from worktree [y,n,q,a,d%s,?]? "), + N_("Discard mode change from worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard deletion from worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard addition from worktree%s [y,n,q,a,d%s,?]? "), + N_("Discard this hunk from worktree%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for discarding."), @@ -224,10 +224,10 @@ static struct patch_mode patch_mode_worktree_nothead = { .apply_args = { NULL }, .apply_check_args = { NULL }, .prompt_mode = { - N_("Apply mode change to worktree [y,n,q,a,d%s,?]? "), - N_("Apply deletion to worktree [y,n,q,a,d%s,?]? "), - N_("Apply addition to worktree [y,n,q,a,d%s,?]? "), - N_("Apply this hunk to worktree [y,n,q,a,d%s,?]? "), + N_("Apply mode change to worktree%s [y,n,q,a,d%s,?]? "), + N_("Apply deletion to worktree%s [y,n,q,a,d%s,?]? "), + N_("Apply addition to worktree%s [y,n,q,a,d%s,?]? "), + N_("Apply this hunk to worktree%s [y,n,q,a,d%s,?]? "), }, .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk " "will immediately be marked for applying."), @@ -1460,6 +1460,7 @@ static int patch_update_file(struct add_p_state *s, render_diff_header(s, file_diff, colored, &s->buf); fputs(s->buf.buf, stdout); for (;;) { + const char *hunk_use_decision = ""; enum { ALLOW_GOTO_PREVIOUS_HUNK = 1 << 0, ALLOW_GOTO_PREVIOUS_UNDECIDED_HUNK = 1 << 1, @@ -1564,8 +1565,14 @@ static int patch_update_file(struct add_p_state *s, (uintmax_t)(file_diff->hunk_nr ? file_diff->hunk_nr : 1)); + if (hunk->use != UNDECIDED_HUNK) { + if (hunk->use == USE_HUNK) + hunk_use_decision = _(" (was: y)"); + else + hunk_use_decision = _(" (was: n)"); + } printf(_(s->mode->prompt_mode[prompt_mode_type]), - s->buf.buf); + hunk_use_decision, s->buf.buf); if (*s->s.reset_color_interactive) fputs(s->s.reset_color_interactive, stdout); fflush(stdout); diff --git a/t/t3701-add-interactive.sh b/t/t3701-add-interactive.sh index 4285314f35f8f2..5ce9c6dd60e9b0 100755 --- a/t/t3701-add-interactive.sh +++ b/t/t3701-add-interactive.sh @@ -527,7 +527,7 @@ test_expect_success 'goto hunk 1 with "g 1"' ' _10 +15 _20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ EOF test_write_lines s y g 1 | git add -p >actual && tail -n 7 actual.trimmed && @@ -540,7 +540,7 @@ test_expect_success 'goto hunk 1 with "g1"' ' _10 +15 _20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ EOF test_write_lines s y g1 | git add -p >actual && tail -n 4 actual.trimmed && @@ -554,7 +554,7 @@ test_expect_success 'navigate to hunk via regex /pattern' ' _10 +15 _20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ EOF test_write_lines s y /1,2 | git add -p >actual && tail -n 5 actual.trimmed && @@ -567,7 +567,7 @@ test_expect_success 'navigate to hunk via regex / pattern' ' _10 +15 _20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ EOF test_write_lines s y / 1,2 | git add -p >actual && tail -n 4 actual.trimmed && @@ -579,11 +579,11 @@ test_expect_success 'print again the hunk' ' tr _ " " >expect <<-EOF && +15 20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? @@ -1,2 +1,3 @@ + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? @@ -1,2 +1,3 @@ 10 +15 20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]?_ EOF test_write_lines s y g 1 p | git add -p >actual && tail -n 7 actual.trimmed && @@ -595,11 +595,11 @@ test_expect_success TTY 'print again the hunk (PAGER)' ' cat >expect <<-EOF && +15 20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? PAGER @@ -1,2 +1,3 @@ + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? PAGER @@ -1,2 +1,3 @@ PAGER 10 PAGER +15 PAGER 20 - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? EOF test_write_lines s y g 1 P | ( @@ -810,7 +810,7 @@ test_expect_success 'colors can be overridden' ' -old +new more-context - (1/2) Stage this hunk [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? + (1/2) Stage this hunk (was: y) [y,n,q,a,d,k,K,j,J,g,/,e,p,P,?]? EOF test_cmp expect actual ' From b60f7d890dee571069f9c3c6d44ecaed5c34fa39 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:22 +0200 Subject: [PATCH 02/29] submodule--helper: use submodule_name_to_gitdir in add_submodule While testing submodule gitdir path encoding, I noticed submodule--helper is still using a hardcoded modules gitdir path leading to test failures. Call the submodule_name_to_gitdir() helper instead, which was invented exactly for this purpose and is already used by all the other locations which work on gitdirs. Also narrow the scope of the submod_gitdir_path variable which is not used anymore in the updated "else" branch. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/submodule--helper.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index fcd73abe5336a9..2873b2780ef941 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -3187,13 +3187,13 @@ static void append_fetch_remotes(struct strbuf *msg, const char *git_dir_path) static int add_submodule(const struct add_data *add_data) { - char *submod_gitdir_path; struct module_clone_data clone_data = MODULE_CLONE_DATA_INIT; struct string_list reference = STRING_LIST_INIT_NODUP; int ret = -1; /* perhaps the path already exists and is already a git repo, else clone it */ if (is_directory(add_data->sm_path)) { + char *submod_gitdir_path; struct strbuf sm_path = STRBUF_INIT; strbuf_addstr(&sm_path, add_data->sm_path); submod_gitdir_path = xstrfmt("%s/.git", add_data->sm_path); @@ -3207,10 +3207,11 @@ static int add_submodule(const struct add_data *add_data) free(submod_gitdir_path); } else { struct child_process cp = CHILD_PROCESS_INIT; + struct strbuf submod_gitdir = STRBUF_INIT; - submod_gitdir_path = xstrfmt(".git/modules/%s", add_data->sm_name); + submodule_name_to_gitdir(&submod_gitdir, the_repository, add_data->sm_name); - if (is_directory(submod_gitdir_path)) { + if (is_directory(submod_gitdir.buf)) { if (!add_data->force) { struct strbuf msg = STRBUF_INIT; char *die_msg; @@ -3219,8 +3220,8 @@ static int add_submodule(const struct add_data *add_data) "locally with remote(s):\n"), add_data->sm_name); - append_fetch_remotes(&msg, submod_gitdir_path); - free(submod_gitdir_path); + append_fetch_remotes(&msg, submod_gitdir.buf); + strbuf_release(&submod_gitdir); strbuf_addf(&msg, _("If you want to reuse this local git " "directory instead of cloning again from\n" @@ -3238,7 +3239,7 @@ static int add_submodule(const struct add_data *add_data) "submodule '%s'\n"), add_data->sm_name); } } - free(submod_gitdir_path); + strbuf_release(&submod_gitdir); clone_data.prefix = add_data->prefix; clone_data.path = add_data->sm_path; From 05a1cdb5255b2b438e66bdaa91b7b2ce75fbe71b Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:23 +0200 Subject: [PATCH 03/29] submodule: always validate gitdirs inside submodule_name_to_gitdir Move the ad-hoc validation checks sprinkled across the source tree, after calling submodule_name_to_gitdir() into the function proper, which now always validates the gitdir before returning it. This simplifies the API and helps to: 1. Avoid redundant validation calls after submodule_name_to_gitdir(). 2. Avoid the risk of callers forgetting to validate. 3. Ensure gitdir paths provided by users via configs are always valid (config gitdir paths are added in a subsequent commit). The validation function can still be called as many times as needed outside submodule_name_to_gitdir(), for example we keep two calls which are still required, to avoid parallel clone races by re-running the validation in builtin/submodule-helper.c. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/submodule--helper.c | 4 ---- submodule.c | 12 ++++-------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index 2873b2780ef941..fc10ace5a847f1 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -1703,10 +1703,6 @@ static int clone_submodule(const struct module_clone_data *clone_data, clone_data_path = to_free = xstrfmt("%s/%s", repo_get_work_tree(the_repository), clone_data->path); - if (validate_submodule_git_dir(sm_gitdir, clone_data->name) < 0) - die(_("refusing to create/use '%s' in another submodule's " - "git dir"), sm_gitdir); - if (!file_exists(sm_gitdir)) { if (clone_data->require_init && !stat(clone_data_path, &st) && !is_empty_dir(clone_data_path)) diff --git a/submodule.c b/submodule.c index 35c55155f7bf83..d937911fbcbd47 100644 --- a/submodule.c +++ b/submodule.c @@ -2172,11 +2172,6 @@ int submodule_move_head(const char *path, const char *super_prefix, struct strbuf gitdir = STRBUF_INIT; submodule_name_to_gitdir(&gitdir, the_repository, sub->name); - if (validate_submodule_git_dir(gitdir.buf, - sub->name) < 0) - die(_("refusing to create/use '%s' in another " - "submodule's git dir"), - gitdir.buf); connect_work_tree_and_git_dir(path, gitdir.buf, 0); strbuf_release(&gitdir); @@ -2355,9 +2350,6 @@ static void relocate_single_git_dir_into_superproject(const char *path, die(_("could not lookup name for submodule '%s'"), path); submodule_name_to_gitdir(&new_gitdir, the_repository, sub->name); - if (validate_submodule_git_dir(new_gitdir.buf, sub->name) < 0) - die(_("refusing to move '%s' into an existing git dir"), - real_old_git_dir); if (safe_create_leading_directories_const(the_repository, new_gitdir.buf) < 0) die(_("could not create directory '%s'"), new_gitdir.buf); real_new_git_dir = real_pathdup(new_gitdir.buf, 1); @@ -2606,4 +2598,8 @@ void submodule_name_to_gitdir(struct strbuf *buf, struct repository *r, */ repo_git_path_append(r, buf, "modules/"); strbuf_addstr(buf, submodule_name); + + if (validate_submodule_git_dir(buf->buf, submodule_name) < 0) + die(_("refusing to create/use '%s' in another submodule's " + "git dir"), buf->buf); } From 34206caaf7c5059ac8480587e31cfc40473002b4 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:24 +0200 Subject: [PATCH 04/29] builtin/submodule--helper: add gitdir command This exposes the gitdir name computed by submodule_name_to_gitdir() internally, to make it easier for users and tests to interact with it. Next commit will add a gitdir configuration, so this helper can also be used to easily query that config or validate any gitdir path the user sets (submodule_name_to_git_dir now runs the validation logic, since our previous commit). Based-on-patch-by: Brandon Williams Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/submodule--helper.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index fc10ace5a847f1..7ea82d7fa243e4 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -1208,6 +1208,22 @@ static int module_summary(int argc, const char **argv, const char *prefix, return ret; } +static int module_gitdir(int argc, const char **argv, const char *prefix UNUSED, + struct repository *repo) +{ + struct strbuf gitdir = STRBUF_INIT; + + if (argc != 2) + usage(_("git submodule--helper gitdir ")); + + submodule_name_to_gitdir(&gitdir, repo, argv[1]); + + printf("%s\n", gitdir.buf); + + strbuf_release(&gitdir); + return 0; +} + struct sync_cb { const char *prefix; const char *super_prefix; @@ -3587,6 +3603,7 @@ int cmd_submodule__helper(int argc, NULL }; struct option options[] = { + OPT_SUBCOMMAND("gitdir", &fn, module_gitdir), OPT_SUBCOMMAND("clone", &fn, module_clone), OPT_SUBCOMMAND("add", &fn, module_add), OPT_SUBCOMMAND("update", &fn, module_update), From 4173df5187c8ba8bc2cc1a215f25b284d70631da Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:25 +0200 Subject: [PATCH 05/29] submodule: introduce extensions.submodulePathConfig The idea of this extension is to abstract away the submodule gitdir path implementation: everyone is expected to use the config and not worry about how the path is computed internally, either in git or other implementations. With this extension enabled, the submodule..gitdir repo config becomes the single source of truth for all submodule gitdir paths. The submodule..gitdir config is added automatically for all new submodules when this extension is enabled. Git will throw an error if the extension is enabled and a config is missing, advising users how to migrate. Migration is manual for now. E.g. to add a missing config entry for an existing "foo" module: git config submodule.foo.gitdir .git/modules/foo Suggested-by: Junio C Hamano Suggested-by: Phillip Wood Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/extensions.adoc | 23 +++ Documentation/config/submodule.adoc | 7 + builtin/submodule--helper.c | 54 ++++++- repository.c | 1 + repository.h | 1 + setup.c | 7 + setup.h | 1 + submodule.c | 61 ++++---- t/lib-verify-submodule-gitdir-path.sh | 24 ++++ t/meson.build | 1 + t/t7425-submodule-gitdir-path-extension.sh | 160 +++++++++++++++++++++ t/t9902-completion.sh | 1 + 12 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 t/lib-verify-submodule-gitdir-path.sh create mode 100755 t/t7425-submodule-gitdir-path-extension.sh diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index 532456644b770e..f4f57c91141ccf 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -73,6 +73,29 @@ relativeWorktrees::: repaired with either the `--relative-paths` option or with the `worktree.useRelativePaths` config set to `true`. +submodulePathConfig::: + This extension is for the minority of users who: ++ +-- +* Encounter errors like `refusing to create ... in another submodule's git dir` + due to a number of reasons, like case-insensitive filesystem conflicts when + creating modules named `foo` and `Foo`. +* Require more flexible submodule layouts, for example due to nested names like + `foo`, `foo/bar` and `foo/baz` not supported by the default gitdir mechanism + which uses `.git/modules/` locations, causing further conflicts. +-- ++ +When `extensions.submodulePathConfig` is enabled, the `submodule..gitdir` +config becomes the single source of truth for all submodule gitdir paths and is +automatically set for all new submodules both during clone and init operations. ++ +Git will error out if a module does not have a corresponding +`submodule..gitdir` set. ++ +Existing (pre-extension) submodules need to be migrated by adding the missing +config entries. This is done manually for now, e.g. for each submodule: +`git config submodule..gitdir .git/modules/`. + worktreeConfig::: If enabled, then worktrees will load config settings from the `$GIT_DIR/config.worktree` file in addition to the diff --git a/Documentation/config/submodule.adoc b/Documentation/config/submodule.adoc index 0672d9911724d1..74f1659a91cfb8 100644 --- a/Documentation/config/submodule.adoc +++ b/Documentation/config/submodule.adoc @@ -52,6 +52,13 @@ submodule..active:: submodule.active config option. See linkgit:gitsubmodules[7] for details. +submodule..gitdir:: + This sets the gitdir path for submodule . This configuration is + respected when `extensions.submodulePathConfig` is enabled, otherwise it + has no effect. When enabled, this config becomes the single source of + truth for submodule gitdir paths and Git will error if it is missing. + See linkgit:git-config[1] for details. + submodule.active:: A repeated field which contains a pathspec used to match against a submodule's path to determine if the submodule is of interest to git diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index 7ea82d7fa243e4..ef373525349c2a 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -435,6 +435,48 @@ struct init_cb { }; #define INIT_CB_INIT { 0 } +static int validate_and_set_submodule_gitdir(struct strbuf *gitdir_path, + const char *submodule_name) +{ + const char *value; + char *key; + + if (validate_submodule_git_dir(gitdir_path->buf, submodule_name)) + return -1; + + key = xstrfmt("submodule.%s.gitdir", submodule_name); + + /* Nothing to do if the config already exists. */ + if (!repo_config_get_string_tmp(the_repository, key, &value)) { + free(key); + return 0; + } + + if (repo_config_set_gently(the_repository, key, gitdir_path->buf)) { + free(key); + return -1; + } + + free(key); + return 0; +} + +static void create_default_gitdir_config(const char *submodule_name) +{ + struct strbuf gitdir_path = STRBUF_INIT; + + repo_git_path_append(the_repository, &gitdir_path, "modules/%s", submodule_name); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) { + strbuf_release(&gitdir_path); + return; + } + + die(_("failed to set a valid default config for 'submodule.%s.gitdir'. " + "Please ensure it is set, for example by running something like: " + "'git config submodule.%s.gitdir .git/modules/%s'"), + submodule_name, submodule_name, submodule_name); +} + static void init_submodule(const char *path, const char *prefix, const char *super_prefix, unsigned int flags) @@ -511,6 +553,10 @@ static void init_submodule(const char *path, const char *prefix, if (repo_config_set_gently(the_repository, sb.buf, upd)) die(_("Failed to register update mode for submodule path '%s'"), displaypath); } + + if (the_repository->repository_format_submodule_path_cfg) + create_default_gitdir_config(sub->name); + strbuf_release(&sb); free(displaypath); free(url); @@ -1805,8 +1851,9 @@ static int clone_submodule(const struct module_clone_data *clone_data, char *head = xstrfmt("%s/HEAD", sm_gitdir); unlink(head); free(head); - die(_("refusing to create/use '%s' in another submodule's " - "git dir"), sm_gitdir); + die(_("refusing to create/use '%s' in another submodule's git dir. " + "Enabling extensions.submodulePathConfig should fix this."), + sm_gitdir); } connect_work_tree_and_git_dir(clone_data_path, sm_gitdir, 0); @@ -3578,6 +3625,9 @@ static int module_add(int argc, const char **argv, const char *prefix, add_data.progress = !!progress; add_data.dissociate = !!dissociate; + if (the_repository->repository_format_submodule_path_cfg) + create_default_gitdir_config(add_data.sm_name); + if (add_submodule(&add_data)) goto cleanup; configure_added_submodule(&add_data); diff --git a/repository.c b/repository.c index 6faf5c73981ebf..35a06e6719a7b5 100644 --- a/repository.c +++ b/repository.c @@ -288,6 +288,7 @@ int repo_init(struct repository *repo, repo->repository_format_worktree_config = format.worktree_config; repo->repository_format_relative_worktrees = format.relative_worktrees; repo->repository_format_precious_objects = format.precious_objects; + repo->repository_format_submodule_path_cfg = format.submodule_path_cfg; /* take ownership of format.partial_clone */ repo->repository_format_partial_clone = format.partial_clone; diff --git a/repository.h b/repository.h index 5808a5d610846a..aa907bd1e4d18c 100644 --- a/repository.h +++ b/repository.h @@ -158,6 +158,7 @@ struct repository { int repository_format_worktree_config; int repository_format_relative_worktrees; int repository_format_precious_objects; + int repository_format_submodule_path_cfg; /* Indicate if a repository has a different 'commondir' from 'gitdir' */ unsigned different_commondir:1; diff --git a/setup.c b/setup.c index 7086741e6c2d1f..207fa36e100b61 100644 --- a/setup.c +++ b/setup.c @@ -687,6 +687,9 @@ static enum extension_result handle_extension(const char *var, } else if (!strcmp(ext, "relativeworktrees")) { data->relative_worktrees = git_config_bool(var, value); return EXTENSION_OK; + } else if (!strcmp(ext, "submodulepathconfig")) { + data->submodule_path_cfg = git_config_bool(var, value); + return EXTENSION_OK; } return EXTENSION_UNKNOWN; } @@ -1865,6 +1868,8 @@ const char *setup_git_directory_gently(int *nongit_ok) repo_fmt.worktree_config; the_repository->repository_format_relative_worktrees = repo_fmt.relative_worktrees; + the_repository->repository_format_submodule_path_cfg = + repo_fmt.submodule_path_cfg; /* take ownership of repo_fmt.partial_clone */ the_repository->repository_format_partial_clone = repo_fmt.partial_clone; @@ -1963,6 +1968,8 @@ void check_repository_format(struct repository_format *fmt) fmt->ref_storage_format); the_repository->repository_format_worktree_config = fmt->worktree_config; + the_repository->repository_format_submodule_path_cfg = + fmt->submodule_path_cfg; the_repository->repository_format_relative_worktrees = fmt->relative_worktrees; the_repository->repository_format_partial_clone = diff --git a/setup.h b/setup.h index 8522fa8575da71..568bb9f1d12c39 100644 --- a/setup.h +++ b/setup.h @@ -130,6 +130,7 @@ struct repository_format { char *partial_clone; /* value of extensions.partialclone */ int worktree_config; int relative_worktrees; + int submodule_path_cfg; int is_bare; int hash_algo; int compat_hash_algo; diff --git a/submodule.c b/submodule.c index d937911fbcbd47..f7af389c79099e 100644 --- a/submodule.c +++ b/submodule.c @@ -31,6 +31,7 @@ #include "commit-reach.h" #include "read-cache-ll.h" #include "setup.h" +#include "advice.h" static int config_update_recurse_submodules = RECURSE_SUBMODULES_OFF; static int initialized_fetch_ref_tips; @@ -2164,8 +2165,9 @@ int submodule_move_head(const char *path, const char *super_prefix, if (validate_submodule_git_dir(git_dir, sub->name) < 0) die(_("refusing to create/use '%s' in " - "another submodule's git dir"), - git_dir); + "another submodule's git dir. " + "Enabling extensions.submodulePathConfig " + "should fix this."), git_dir); free(git_dir); } } else { @@ -2576,30 +2578,37 @@ int submodule_to_gitdir(struct repository *repo, void submodule_name_to_gitdir(struct strbuf *buf, struct repository *r, const char *submodule_name) { - /* - * NEEDSWORK: The current way of mapping a submodule's name to - * its location in .git/modules/ has problems with some naming - * schemes. For example, if a submodule is named "foo" and - * another is named "foo/bar" (whether present in the same - * superproject commit or not - the problem will arise if both - * superproject commits have been checked out at any point in - * time), or if two submodule names only have different cases in - * a case-insensitive filesystem. - * - * There are several solutions, including encoding the path in - * some way, introducing a submodule..gitdir config in - * .git/config (not .gitmodules) that allows overriding what the - * gitdir of a submodule would be (and teach Git, upon noticing - * a clash, to automatically determine a non-clashing name and - * to write such a config), or introducing a - * submodule..gitdir config in .gitmodules that repo - * administrators can explicitly set. Nothing has been decided, - * so for now, just append the name at the end of the path. - */ - repo_git_path_append(r, buf, "modules/"); - strbuf_addstr(buf, submodule_name); + if (!r->repository_format_submodule_path_cfg) { + /* + * If extensions.submodulePathConfig is disabled, + * continue to use the plain path. + */ + repo_git_path_append(r, buf, "modules/%s", submodule_name); + } else { + const char *gitdir; + char *key; + int ret; - if (validate_submodule_git_dir(buf->buf, submodule_name) < 0) + /* Otherwise the extension is enabled, so use the gitdir config. */ + key = xstrfmt("submodule.%s.gitdir", submodule_name); + ret = repo_config_get_string_tmp(r, key, &gitdir); + FREE_AND_NULL(key); + + if (ret) + die(_("the 'submodule.%s.gitdir' config does not exist for module '%s'. " + "Please ensure it is set, for example by running something like: " + "'git config submodule.%s.gitdir .git/modules/%s'. For details " + "see the extensions.submodulePathConfig documentation."), + submodule_name, submodule_name, submodule_name, submodule_name); + + strbuf_addstr(buf, gitdir); + } + + /* validate because users might have modified the config */ + if (validate_submodule_git_dir(buf->buf, submodule_name)) { + advise(_("enabling extensions.submodulePathConfig might fix the " + "following error, if it's not already enabled.")); die(_("refusing to create/use '%s' in another submodule's " - "git dir"), buf->buf); + " git dir."), buf->buf); + } } diff --git a/t/lib-verify-submodule-gitdir-path.sh b/t/lib-verify-submodule-gitdir-path.sh new file mode 100644 index 00000000000000..4e0cfdc605bd56 --- /dev/null +++ b/t/lib-verify-submodule-gitdir-path.sh @@ -0,0 +1,24 @@ +# Helper to verify if repo $1 contains a submodule named $2 with gitdir path $3 + +# This does not check filesystem existence. That is done in submodule.c via the +# submodule_name_to_gitdir() API which this helper ends up calling. The gitdirs +# might or might not exist (e.g. when adding a new submodule), so this only +# checks the expected configuration path, which might be overridden by the user. + +verify_submodule_gitdir_path () { + repo="$1" && + name="$2" && + path="$3" && + ( + cd "$repo" && + # Compute expected absolute path + expected="$(git rev-parse --git-common-dir)/$path" && + expected="$(test-tool path-utils real_path "$expected")" && + # Compute actual absolute path + actual="$(git submodule--helper gitdir "$name")" && + actual="$(test-tool path-utils real_path "$actual")" && + echo "$expected" >expect && + echo "$actual" >actual && + test_cmp expect actual + ) +} diff --git a/t/meson.build b/t/meson.build index a5531df415ffe2..2c565beb8d22e8 100644 --- a/t/meson.build +++ b/t/meson.build @@ -884,6 +884,7 @@ integration_tests = [ 't7422-submodule-output.sh', 't7423-submodule-symlinks.sh', 't7424-submodule-mixed-ref-formats.sh', + 't7425-submodule-gitdir-path-extension.sh', 't7450-bad-git-dotfiles.sh', 't7500-commit-template-squash-signoff.sh', 't7501-commit-basic-functionality.sh', diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh new file mode 100755 index 00000000000000..453183e27c95a2 --- /dev/null +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -0,0 +1,160 @@ +#!/bin/sh + +test_description='submodulePathConfig extension works as expected' + +. ./test-lib.sh +. "$TEST_DIRECTORY"/lib-verify-submodule-gitdir-path.sh + +test_expect_success 'setup: allow file protocol' ' + git config --global protocol.file.allow always +' + +test_expect_success 'create repo with mixed extension submodules' ' + git init -b main legacy-sub && + test_commit -C legacy-sub legacy-initial && + legacy_rev=$(git -C legacy-sub rev-parse HEAD) && + + git init -b main new-sub && + test_commit -C new-sub new-initial && + new_rev=$(git -C new-sub rev-parse HEAD) && + + git init -b main main && + ( + cd main && + git submodule add ../legacy-sub legacy && + test_commit legacy-sub && + + # trigger the "die_path_inside_submodule" check + test_must_fail git submodule add ../new-sub "legacy/nested" && + + git config core.repositoryformatversion 1 && + git config extensions.submodulePathConfig true && + + git submodule add ../new-sub "New Sub" && + test_commit new && + + # retrigger the "die_path_inside_submodule" check with encoding + test_must_fail git submodule add ../new-sub "New Sub/nested2" + ) +' + +test_expect_success 'verify new submodule gitdir config' ' + git -C main config submodule."New Sub".gitdir >actual && + echo ".git/modules/New Sub" >expect && + test_cmp expect actual && + verify_submodule_gitdir_path main "New Sub" "modules/New Sub" +' + +test_expect_success 'manual add and verify legacy submodule gitdir config' ' + # the legacy module should not contain a gitdir config, because it + # was added before the extension was enabled. Add and test it. + test_must_fail git -C main config submodule.legacy.gitdir && + git -C main config submodule.legacy.gitdir .git/modules/legacy && + git -C main config submodule.legacy.gitdir >actual && + echo ".git/modules/legacy" >expect && + test_cmp expect actual && + verify_submodule_gitdir_path main "legacy" "modules/legacy" +' + +test_expect_success 'gitdir config path is relative for both absolute and relative urls' ' + test_when_finished "rm -rf relative-cfg-path-test" && + git init -b main relative-cfg-path-test && + ( + cd relative-cfg-path-test && + git config core.repositoryformatversion 1 && + git config extensions.submodulePathConfig true && + + # Test with absolute URL + git submodule add "$TRASH_DIRECTORY/new-sub" sub-abs && + git config submodule.sub-abs.gitdir >actual && + echo ".git/modules/sub-abs" >expect && + test_cmp expect actual && + + # Test with relative URL + git submodule add ../new-sub sub-rel && + git config submodule.sub-rel.gitdir >actual && + echo ".git/modules/sub-rel" >expect && + test_cmp expect actual + ) +' + +test_expect_success 'clone from repo with both legacy and new-style submodules' ' + git clone --recurse-submodules main cloned-non-extension && + ( + cd cloned-non-extension && + + test_path_is_dir .git/modules/legacy && + test_path_is_dir .git/modules/"New Sub" && + + test_must_fail git config submodule.legacy.gitdir && + test_must_fail git config submodule."New Sub".gitdir && + + git submodule status >list && + test_grep "$legacy_rev legacy" list && + test_grep "$new_rev New Sub" list + ) && + + git clone -c extensions.submodulePathConfig=true --recurse-submodules main cloned-extension && + ( + cd cloned-extension && + + test_path_is_dir .git/modules/legacy && + test_path_is_dir ".git/modules/New Sub" && + + git config submodule.legacy.gitdir && + git config submodule."New Sub".gitdir && + + git submodule status >list && + test_grep "$legacy_rev legacy" list && + test_grep "$new_rev New Sub" list + ) +' + +test_expect_success 'commit and push changes to encoded submodules' ' + git -C legacy-sub config receive.denyCurrentBranch updateInstead && + git -C new-sub config receive.denyCurrentBranch updateInstead && + git -C main config receive.denyCurrentBranch updateInstead && + ( + cd cloned-extension && + + git -C legacy switch --track -C main origin/main && + test_commit -C legacy second-commit && + git -C legacy push && + + git -C "New Sub" switch --track -C main origin/main && + test_commit -C "New Sub" second-commit && + git -C "New Sub" push && + + # Stage and commit submodule changes in superproject + git switch --track -C main origin/main && + git add legacy "New Sub" && + git commit -m "update submodules" && + + # push superproject commit to main repo + git push + ) && + + # update expected legacy & new submodule checksums + legacy_rev=$(git -C legacy-sub rev-parse HEAD) && + new_rev=$(git -C new-sub rev-parse HEAD) +' + +test_expect_success 'fetch mixed submodule changes and verify updates' ' + ( + cd main && + + # only update submodules because superproject was + # pushed into at the end of last test + git submodule update --init --recursive && + + test_path_is_dir .git/modules/legacy && + test_path_is_dir ".git/modules/New Sub" && + + # Verify both submodules are at the expected commits + git submodule status >list && + test_grep "$legacy_rev legacy" list && + test_grep "$new_rev New Sub" list + ) +' + +test_done diff --git a/t/t9902-completion.sh b/t/t9902-completion.sh index 964e1f156932c6..ffb9c8b522e269 100755 --- a/t/t9902-completion.sh +++ b/t/t9902-completion.sh @@ -3053,6 +3053,7 @@ test_expect_success 'git config set - variable name - __git_compute_second_level submodule.sub.fetchRecurseSubmodules Z submodule.sub.ignore Z submodule.sub.active Z + submodule.sub.gitdir Z EOF ' From c349bad72969d59758e1294b4e9964dccd967fa0 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:26 +0200 Subject: [PATCH 06/29] submodule: allow runtime enabling extensions.submodulePathConfig Add a new config `init.defaultSubmodulePathConfig` which allows enabling `extensions.submodulePathConfig` for new submodules by default (those created via git init or clone). Important: setting init.defaultSubmodulePathConfig = true does not globally enable `extensions.submodulePathConfig`. Existing repositories will still have the extension disabled and will require migration (for example via git submodule--helper command added in the next commit). Suggested-by: Patrick Steinhardt Suggested-by: Junio C Hamano Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/extensions.adoc | 4 + Documentation/config/init.adoc | 6 + setup.c | 10 ++ t/t7425-submodule-gitdir-path-extension.sh | 122 +++++++++++++++++++++ 4 files changed, 142 insertions(+) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index f4f57c91141ccf..e8d9d9a19a5fb6 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -95,6 +95,10 @@ Git will error out if a module does not have a corresponding Existing (pre-extension) submodules need to be migrated by adding the missing config entries. This is done manually for now, e.g. for each submodule: `git config submodule..gitdir .git/modules/`. ++ +The extension can be enabled automatically for new repositories by setting +`init.defaultSubmodulePathConfig` to `true`, for example by running +`git config --global init.defaultSubmodulePathConfig true`. worktreeConfig::: If enabled, then worktrees will load config settings from the diff --git a/Documentation/config/init.adoc b/Documentation/config/init.adoc index e45b2a812151dc..7b4abdaf8ba29b 100644 --- a/Documentation/config/init.adoc +++ b/Documentation/config/init.adoc @@ -18,3 +18,9 @@ endif::[] See `--ref-format=` in linkgit:git-init[1]. Both the command line option and the `GIT_DEFAULT_REF_FORMAT` environment variable take precedence over this config. + +init.defaultSubmodulePathConfig:: + A boolean that specifies if `git init` and `git clone` should + automatically set `extensions.submodulePathConfig` to `true`. This + allows all new repositories to automatically use the submodule path + extension. Defaults to `false` when unset. diff --git a/setup.c b/setup.c index 207fa36e100b61..3f91a4aaec174b 100644 --- a/setup.c +++ b/setup.c @@ -2228,6 +2228,7 @@ void initialize_repository_version(int hash_algo, { struct strbuf repo_version = STRBUF_INIT; int target_version = GIT_REPO_VERSION; + int default_submodule_path_config = 0; /* * Note that we initialize the repository version to 1 when the ref @@ -2266,6 +2267,15 @@ void initialize_repository_version(int hash_algo, clear_repository_format(&repo_fmt); } + repo_config_get_bool(the_repository, "init.defaultSubmodulePathConfig", + &default_submodule_path_config); + if (default_submodule_path_config) { + /* extensions.submodulepathconfig requires at least version 1 */ + if (target_version == 0) + target_version = 1; + repo_config_set(the_repository, "extensions.submodulepathconfig", "true"); + } + strbuf_addf(&repo_version, "%d", target_version); repo_config_set(the_repository, "core.repositoryformatversion", repo_version.buf); diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh index 453183e27c95a2..03ac165de96477 100755 --- a/t/t7425-submodule-gitdir-path-extension.sh +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -157,4 +157,126 @@ test_expect_success 'fetch mixed submodule changes and verify updates' ' ) ' +test_expect_success '`git init` respects init.defaultSubmodulePathConfig' ' + test_config_global init.defaultSubmodulePathConfig true && + git init repo-init && + git -C repo-init config extensions.submodulePathConfig >actual && + echo true >expect && + test_cmp expect actual && + # create a submodule and check gitdir + ( + cd repo-init && + git init -b main sub && + test_commit -C sub sub-initial && + git submodule add ./sub sub && + git config submodule.sub.gitdir >actual && + echo ".git/modules/sub" >expect && + test_cmp expect actual + ) +' + +test_expect_success '`git init` does not set extension by default' ' + git init upstream && + test_commit -C upstream initial && + test_must_fail git -C upstream config extensions.submodulePathConfig && + # create a pair of submodules and check gitdir is not created + git init -b main sub && + test_commit -C sub sub-initial && + ( + cd upstream && + git submodule add ../sub sub1 && + test_path_is_dir .git/modules/sub1 && + test_must_fail git config submodule.sub1.gitdir && + git submodule add ../sub sub2 && + test_path_is_dir .git/modules/sub2 && + test_must_fail git config submodule.sub2.gitdir && + git commit -m "Add submodules" + ) +' + +test_expect_success '`git clone` does not set extension by default' ' + test_when_finished "rm -rf repo-clone-no-ext" && + git clone upstream repo-clone-no-ext && + ( + cd repo-clone-no-ext && + + test_must_fail git config extensions.submodulePathConfig && + test_path_is_missing .git/modules/sub1 && + test_path_is_missing .git/modules/sub2 && + + # create a submodule and check gitdir is not created + git submodule add ../sub sub3 && + test_must_fail git config submodule.sub3.gitdir + ) +' + +test_expect_success '`git clone --recurse-submodules` does not set extension by default' ' + test_when_finished "rm -rf repo-clone-no-ext" && + git clone --recurse-submodules upstream repo-clone-no-ext && + ( + cd repo-clone-no-ext && + + # verify that that submodules do not have gitdir set + test_must_fail git config extensions.submodulePathConfig && + test_path_is_dir .git/modules/sub1 && + test_must_fail git config submodule.sub1.gitdir && + test_path_is_dir .git/modules/sub2 && + test_must_fail git config submodule.sub2.gitdir && + + # create another submodule and check that gitdir is not created + git submodule add ../sub sub3 && + test_path_is_dir .git/modules/sub3 && + test_must_fail git config submodule.sub3.gitdir + ) + +' + +test_expect_success '`git clone` respects init.defaultSubmodulePathConfig' ' + test_when_finished "rm -rf repo-clone" && + test_config_global init.defaultSubmodulePathConfig true && + git clone upstream repo-clone && + ( + cd repo-clone && + + # verify new repo extension is inherited from global config + git config extensions.submodulePathConfig >actual && + echo true >expect && + test_cmp expect actual && + + # new submodule has a gitdir config + git submodule add ../sub sub && + test_path_is_dir .git/modules/sub && + git config submodule.sub.gitdir >actual && + echo ".git/modules/sub" >expect && + test_cmp expect actual + ) +' + +test_expect_success '`git clone --recurse-submodules` respects init.defaultSubmodulePathConfig' ' + test_when_finished "rm -rf repo-clone-recursive" && + test_config_global init.defaultSubmodulePathConfig true && + git clone --recurse-submodules upstream repo-clone-recursive && + ( + cd repo-clone-recursive && + + # verify new repo extension is inherited from global config + git config extensions.submodulePathConfig >actual && + echo true >expect && + test_cmp expect actual && + + # previous submodules should exist + git config submodule.sub1.gitdir && + git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 && + + # create another submodule and check that gitdir is created + git submodule add ../sub new-sub && + test_path_is_dir .git/modules/new-sub && + git config submodule.new-sub.gitdir >actual && + echo ".git/modules/new-sub" >expect && + test_cmp expect actual + ) +' + test_done From e14349d58eeae0eac23bf7f740d22f51fc90a49d Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:27 +0200 Subject: [PATCH 07/29] submodule--helper: add gitdir migration command Manually running "git config submodule..gitdir .git/modules/" for each submodule can be impractical, so add a migration command to submodule--helper to automatically create configs for all submodules as required by extensions.submodulePathConfig. The command calls create_default_gitdir_config() which validates the gitdir paths before adding the configs. Suggested-by: Junio C Hamano Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- Documentation/config/extensions.adoc | 6 +- builtin/submodule--helper.c | 61 ++++++++++++++++++++ t/t7425-submodule-gitdir-path-extension.sh | 67 ++++++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) diff --git a/Documentation/config/extensions.adoc b/Documentation/config/extensions.adoc index e8d9d9a19a5fb6..2aef3315b1d275 100644 --- a/Documentation/config/extensions.adoc +++ b/Documentation/config/extensions.adoc @@ -93,8 +93,10 @@ Git will error out if a module does not have a corresponding `submodule..gitdir` set. + Existing (pre-extension) submodules need to be migrated by adding the missing -config entries. This is done manually for now, e.g. for each submodule: -`git config submodule..gitdir .git/modules/`. +config entries. This can be done manually, e.g. for each submodule: +`git config submodule..gitdir .git/modules/`, or via the +`git submodule--helper migrate-gitdir-configs` command which iterates over all +submodules and attempts to migrate them. + The extension can be enabled automatically for new repositories by setting `init.defaultSubmodulePathConfig` to `true`, for example by running diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index ef373525349c2a..fa4f5cc15919f3 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -1270,6 +1270,66 @@ static int module_gitdir(int argc, const char **argv, const char *prefix UNUSED, return 0; } +static int module_migrate(int argc UNUSED, const char **argv UNUSED, + const char *prefix UNUSED, struct repository *repo) +{ + struct strbuf module_dir = STRBUF_INIT; + DIR *dir; + struct dirent *de; + int repo_version = 0; + + repo_git_path_append(repo, &module_dir, "modules/"); + + dir = opendir(module_dir.buf); + if (!dir) + die(_("could not open '%s'"), module_dir.buf); + + while ((de = readdir(dir))) { + struct strbuf gitdir_path = STRBUF_INIT; + char *key; + const char *value; + + if (is_dot_or_dotdot(de->d_name)) + continue; + + strbuf_addf(&gitdir_path, "%s/%s", module_dir.buf, de->d_name); + if (!is_git_directory(gitdir_path.buf)) { + strbuf_release(&gitdir_path); + continue; + } + strbuf_release(&gitdir_path); + + key = xstrfmt("submodule.%s.gitdir", de->d_name); + if (!repo_config_get_string_tmp(repo, key, &value)) { + /* Already has a gitdir config, nothing to do. */ + free(key); + continue; + } + free(key); + + create_default_gitdir_config(de->d_name); + } + + closedir(dir); + strbuf_release(&module_dir); + + repo_config_get_int(the_repository, "core.repositoryformatversion", &repo_version); + if (repo_version == 0 && + repo_config_set_gently(repo, "core.repositoryformatversion", "1")) + die(_("could not set core.repositoryformatversion to 1.\n" + "Please set it for migration to work, for example:\n" + "git config core.repositoryformatversion 1")); + + if (repo_config_set_gently(repo, "extensions.submodulePathConfig", "true")) + die(_("could not enable submodulePathConfig extension. It is required\n" + "for migration to work. Please enable it in the root repo:\n" + "git config extensions.submodulePathConfig true")); + + repo->repository_format_submodule_path_cfg = 1; + + return 0; +} + struct sync_cb { const char *prefix; const char *super_prefix; @@ -3653,6 +3713,7 @@ int cmd_submodule__helper(int argc, NULL }; struct option options[] = { + OPT_SUBCOMMAND("migrate-gitdir-configs", &fn, module_migrate), OPT_SUBCOMMAND("gitdir", &fn, module_gitdir), OPT_SUBCOMMAND("clone", &fn, module_clone), OPT_SUBCOMMAND("add", &fn, module_add), diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh index 03ac165de96477..89e2feab8b7c4b 100755 --- a/t/t7425-submodule-gitdir-path-extension.sh +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -279,4 +279,71 @@ test_expect_success '`git clone --recurse-submodules` respects init.defaultSubmo ) ' +test_expect_success 'submodule--helper migrates legacy modules' ' + ( + cd upstream && + + # previous submodules exist and were not migrated yet + test_must_fail git config submodule.sub1.gitdir && + test_must_fail git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 && + + # run migration + git submodule--helper migrate-gitdir-configs && + + # test that migration worked + git config submodule.sub1.gitdir >actual && + echo ".git/modules/sub1" >expect && + test_cmp expect actual && + git config submodule.sub2.gitdir >actual && + echo ".git/modules/sub2" >expect && + test_cmp expect actual && + + # repository extension is enabled after migration + git config extensions.submodulePathConfig >actual && + echo "true" >expect && + test_cmp expect actual + ) +' + +test_expect_success '`git clone --recurse-submodules` works after migration' ' + test_when_finished "rm -rf repo-clone-recursive" && + + # test with extension disabled after the upstream repo was migrated + git clone --recurse-submodules upstream repo-clone-recursive && + ( + cd repo-clone-recursive && + + # init.defaultSubmodulePathConfig was disabled before clone, so + # the repo extension config should also be off, the migration ignored + test_must_fail git config extensions.submodulePathConfig && + + # modules should look like there was no migration done + test_must_fail git config submodule.sub1.gitdir && + test_must_fail git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 + ) && + rm -rf repo-clone-recursive && + + # enable the extension, then retry the clone + test_config_global init.defaultSubmodulePathConfig true && + git clone --recurse-submodules upstream repo-clone-recursive && + ( + cd repo-clone-recursive && + + # repository extension is enabled + git config extensions.submodulePathConfig >actual && + echo "true" >expect && + test_cmp expect actual && + + # gitdir configs exist for submodules + git config submodule.sub1.gitdir && + git config submodule.sub2.gitdir && + test_path_is_dir .git/modules/sub1 && + test_path_is_dir .git/modules/sub2 + ) +' + test_done From 226694bdf4aaecd18f6cd4df12656cc61b213d16 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:28 +0200 Subject: [PATCH 08/29] builtin/credential-store: move is_rfc3986_unreserved to url.[ch] is_rfc3986_unreserved() was moved to credential-store.c and was made static by f89854362c (credential-store: move related functions to credential-store file, 2023-06-06) under a correct assumption, at the time, that it was the only place using it. However now we need it to apply URL-encoding to submodule names when constructing gitdir paths, to avoid conflicts, so bring it back as a public function exposed via url.h, instead of the old helper path (strbuf), which has nothing to do with 3986 encoding/decoding anymore. This function will be used in subsequent commits which do the encoding. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/credential-store.c | 7 +------ url.c | 6 ++++++ url.h | 7 +++++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/builtin/credential-store.c b/builtin/credential-store.c index b74e06cc93d9cd..bc1453c6b2b1b2 100644 --- a/builtin/credential-store.c +++ b/builtin/credential-store.c @@ -7,6 +7,7 @@ #include "path.h" #include "string-list.h" #include "parse-options.h" +#include "url.h" #include "write-or-die.h" static struct lock_file credential_lock; @@ -76,12 +77,6 @@ static void rewrite_credential_file(const char *fn, struct credential *c, die_errno("unable to write credential store"); } -static int is_rfc3986_unreserved(char ch) -{ - return isalnum(ch) || - ch == '-' || ch == '_' || ch == '.' || ch == '~'; -} - static int is_rfc3986_reserved_or_unreserved(char ch) { if (is_rfc3986_unreserved(ch)) diff --git a/url.c b/url.c index 282b12495ae7d4..adc289229c6491 100644 --- a/url.c +++ b/url.c @@ -3,6 +3,12 @@ #include "strbuf.h" #include "url.h" +int is_rfc3986_unreserved(char ch) +{ + return isalnum(ch) || + ch == '-' || ch == '_' || ch == '.' || ch == '~'; +} + int is_urlschemechar(int first_flag, int ch) { /* diff --git a/url.h b/url.h index 2a27c3427763b2..e644c3c8096028 100644 --- a/url.h +++ b/url.h @@ -21,4 +21,11 @@ char *url_decode_parameter_value(const char **query); void end_url_with_slash(struct strbuf *buf, const char *url); void str_end_url_with_slash(const char *url, char **dest); +/* + * The set of unreserved characters as per STD66 (RFC3986) is + * '[A-Za-z0-9-._~]'. These characters are safe to appear in URI + * components without percent-encoding. + */ +int is_rfc3986_unreserved(char ch); + #endif /* URL_H */ From 920fbe4d4ee8d4e191d33dde05a16ee0e74bdd44 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:29 +0200 Subject: [PATCH 09/29] submodule--helper: fix filesystem collisions by encoding gitdir paths Fix nested filesystem collisions by url-encoding gitdir paths stored in submodule.%s.gitdir, when extensions.submodulePathConfig is enabled. Credit goes to Junio and Patrick for coming up with this design: the encoding is only applied when necessary, to newly added submodules. Existing modules don't need the encoding because git already errors out when detecting nested gitdirs before this patch. This commit adds the basic url-encoding and some tests. Next commits extend the encode -> validate -> retry loop to fix more conflicts. Suggested-by: Junio C Hamano Suggested-by: Patrick Steinhardt Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/submodule--helper.c | 12 +++++ submodule.c | 42 +++++++++++++++- t/t7425-submodule-gitdir-path-extension.sh | 57 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index fa4f5cc15919f3..361542d67805b8 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -34,6 +34,7 @@ #include "list-objects-filter-options.h" #include "wildmatch.h" #include "strbuf.h" +#include "url.h" #define OPT_QUIET (1 << 0) #define OPT_CACHED (1 << 1) @@ -465,12 +466,23 @@ static void create_default_gitdir_config(const char *submodule_name) { struct strbuf gitdir_path = STRBUF_INIT; + /* Case 1: try the plain module name */ repo_git_path_append(the_repository, &gitdir_path, "modules/%s", submodule_name); if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) { strbuf_release(&gitdir_path); return; } + /* Case 2: Try URI-safe (RFC3986) encoding first, this fixes nested gitdirs */ + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_rfc3986_unreserved); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) { + strbuf_release(&gitdir_path); + return; + } + + /* Case 3: nothing worked, error out */ die(_("failed to set a valid default config for 'submodule.%s.gitdir'. " "Please ensure it is set, for example by running something like: " "'git config submodule.%s.gitdir .git/modules/%s'"), diff --git a/submodule.c b/submodule.c index f7af389c79099e..609d9073772ef3 100644 --- a/submodule.c +++ b/submodule.c @@ -32,6 +32,7 @@ #include "read-cache-ll.h" #include "setup.h" #include "advice.h" +#include "url.h" static int config_update_recurse_submodules = RECURSE_SUBMODULES_OFF; static int initialized_fetch_ref_tips; @@ -2253,12 +2254,43 @@ int submodule_move_head(const char *path, const char *super_prefix, return ret; } -int validate_submodule_git_dir(char *git_dir, const char *submodule_name) +/* + * Encoded gitdir validation, only used when extensions.submodulePathConfig is enabled. + * This does not print errors like the non-encoded version, because encoding is supposed + * to mitigate / fix all these. + */ +static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodule_name UNUSED) +{ + const char *modules_marker = "/modules/"; + char *p = git_dir, *last_submodule_name = NULL; + + if (!the_repository->repository_format_submodule_path_cfg) + BUG("validate_submodule_encoded_git_dir() must be called with " + "extensions.submodulePathConfig enabled."); + + /* Find the last submodule name in the gitdir path (modules can be nested). */ + while ((p = strstr(p, modules_marker))) { + last_submodule_name = p + strlen(modules_marker); + p++; + } + + /* Prevent the use of '/' in encoded names */ + if (!last_submodule_name || strchr(last_submodule_name, '/')) + return -1; + + return 0; +} + +static int validate_submodule_legacy_git_dir(char *git_dir, const char *submodule_name) { size_t len = strlen(git_dir), suffix_len = strlen(submodule_name); char *p; int ret = 0; + if (the_repository->repository_format_submodule_path_cfg) + BUG("validate_submodule_git_dir() must be called with " + "extensions.submodulePathConfig disabled."); + if (len <= suffix_len || (p = git_dir + len - suffix_len)[-1] != '/' || strcmp(p, submodule_name)) BUG("submodule name '%s' not a suffix of git dir '%s'", @@ -2294,6 +2326,14 @@ int validate_submodule_git_dir(char *git_dir, const char *submodule_name) return 0; } +int validate_submodule_git_dir(char *git_dir, const char *submodule_name) +{ + if (!the_repository->repository_format_submodule_path_cfg) + return validate_submodule_legacy_git_dir(git_dir, submodule_name); + + return validate_submodule_encoded_git_dir(git_dir, submodule_name); +} + int validate_submodule_path(const char *path) { char *p = xstrdup(path); diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh index 89e2feab8b7c4b..ce1428a2ffa4b9 100755 --- a/t/t7425-submodule-gitdir-path-extension.sh +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -346,4 +346,61 @@ test_expect_success '`git clone --recurse-submodules` works after migration' ' ) ' +test_expect_success 'setup submodules with nested git dirs' ' + git init nested && + test_commit -C nested nested && + ( + cd nested && + cat >.gitmodules <<-EOF && + [submodule "hippo"] + url = . + path = thing1 + [submodule "hippo/hooks"] + url = . + path = thing2 + EOF + git clone . thing1 && + git clone . thing2 && + git add .gitmodules thing1 thing2 && + test_tick && + git commit -m nested + ) +' + +test_expect_success 'git dirs of encoded sibling submodules must not be nested' ' + git clone -c extensions.submodulePathConfig=true --recurse-submodules nested clone_nested && + + verify_submodule_gitdir_path clone_nested hippo modules/hippo && + git -C clone_nested config submodule.hippo.gitdir >actual && + test_grep "\.git/modules/hippo$" actual && + + verify_submodule_gitdir_path clone_nested hippo/hooks modules/hippo%2fhooks && + git -C clone_nested config submodule.hippo/hooks.gitdir >actual && + test_grep "\.git/modules/hippo%2fhooks$" actual +' + +test_expect_success 'submodule git dir nesting detection must work with parallel cloning' ' + git clone -c extensions.submodulePathConfig=true --recurse-submodules --jobs=2 nested clone_parallel && + + verify_submodule_gitdir_path clone_parallel hippo modules/hippo && + git -C clone_nested config submodule.hippo.gitdir >actual && + test_grep "\.git/modules/hippo$" actual && + + verify_submodule_gitdir_path clone_parallel hippo/hooks modules/hippo%2fhooks && + git -C clone_nested config submodule.hippo/hooks.gitdir >actual && + test_grep "\.git/modules/hippo%2fhooks$" actual +' + +test_expect_success 'disabling extensions.submodulePathConfig prevents nested submodules' ' + ( + cd clone_nested && + # disable extension and verify failure + git config --replace-all extensions.submodulePathConfig false && + test_must_fail git submodule add ./thing2 hippo/foobar && + # re-enable extension and verify it works + git config --replace-all extensions.submodulePathConfig true && + git submodule add ./thing2 hippo/foobar + ) +' + test_done From 1685bba838ace8b4e325616ab914a6b01f18547f Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:30 +0200 Subject: [PATCH 10/29] submodule: fix case-folding gitdir filesystem collisions Add a new check when extension.submodulePathConfig is enabled, to detect and prevent case-folding filesystem colisions. When this new check is triggered, a stricter casefolding aware URI encoding is used to percent-encode uppercase characters. By using this check/retry mechanism the uppercase encoding is only applied when necessary, so case-sensitive filesystems are not affected. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/submodule--helper.c | 26 ++++++++++- submodule.c | 53 +++++++++++++++++++++- t/t7425-submodule-gitdir-path-extension.sh | 35 ++++++++++++++ url.c | 7 +++ url.h | 7 +++ 5 files changed, 126 insertions(+), 2 deletions(-) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index 361542d67805b8..746f9fa63c3be9 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -473,7 +473,7 @@ static void create_default_gitdir_config(const char *submodule_name) return; } - /* Case 2: Try URI-safe (RFC3986) encoding first, this fixes nested gitdirs */ + /* Case 2.1: Try URI-safe (RFC3986) encoding first, this fixes nested gitdirs */ strbuf_reset(&gitdir_path); repo_git_path_append(the_repository, &gitdir_path, "modules/"); strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_rfc3986_unreserved); @@ -482,6 +482,30 @@ static void create_default_gitdir_config(const char *submodule_name) return; } + /* Case 2.2: Try extended uppercase URI (RFC3986) encoding, to fix case-folding */ + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_casefolding_rfc3986_unreserved); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) + return; + + /* Case 2.3: Try some derived gitdir names, see if one sticks */ + for (char c = '0'; c <= '9'; c++) { + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_rfc3986_unreserved); + strbuf_addch(&gitdir_path, c); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) + return; + + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/"); + strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_casefolding_rfc3986_unreserved); + strbuf_addch(&gitdir_path, c); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) + return; + } + /* Case 3: nothing worked, error out */ die(_("failed to set a valid default config for 'submodule.%s.gitdir'. " "Please ensure it is set, for example by running something like: " diff --git a/submodule.c b/submodule.c index 609d9073772ef3..a5a47359c71386 100644 --- a/submodule.c +++ b/submodule.c @@ -2254,15 +2254,58 @@ int submodule_move_head(const char *path, const char *super_prefix, return ret; } +static int check_casefolding_conflict(const char *git_dir, + const char *submodule_name, + const bool suffixes_match) +{ + char *p, *modules_dir = xstrdup(git_dir); + struct dirent *de; + DIR *dir = NULL; + int ret = 0; + + if ((p = find_last_dir_sep(modules_dir))) + *p = '\0'; + + /* No conflict is possible if modules_dir doesn't exist (first clone) */ + if (!is_directory(modules_dir)) + goto cleanup; + + dir = opendir(modules_dir); + if (!dir) { + ret = -1; + goto cleanup; + } + + /* Check for another directory under .git/modules that differs only in case. */ + while ((de = readdir(dir))) { + if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) + continue; + + if ((suffixes_match || is_git_directory(git_dir)) && + !strcasecmp(de->d_name, submodule_name) && + strcmp(de->d_name, submodule_name)) { + ret = -1; /* collision found */ + break; + } + } + +cleanup: + if (dir) + closedir(dir); + free(modules_dir); + return ret; +} + /* * Encoded gitdir validation, only used when extensions.submodulePathConfig is enabled. * This does not print errors like the non-encoded version, because encoding is supposed * to mitigate / fix all these. */ -static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodule_name UNUSED) +static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodule_name) { const char *modules_marker = "/modules/"; char *p = git_dir, *last_submodule_name = NULL; + int config_ignorecase = 0; if (!the_repository->repository_format_submodule_path_cfg) BUG("validate_submodule_encoded_git_dir() must be called with " @@ -2278,6 +2321,14 @@ static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodu if (!last_submodule_name || strchr(last_submodule_name, '/')) return -1; + /* Prevent conflicts on case-folding filesystems */ + repo_config_get_bool(the_repository, "core.ignorecase", &config_ignorecase); + if (ignore_case || config_ignorecase) { + bool suffixes_match = !strcmp(last_submodule_name, submodule_name); + return check_casefolding_conflict(git_dir, submodule_name, + suffixes_match); + } + return 0; } diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh index ce1428a2ffa4b9..3cca93c8972b01 100755 --- a/t/t7425-submodule-gitdir-path-extension.sh +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -403,4 +403,39 @@ test_expect_success 'disabling extensions.submodulePathConfig prevents nested su ) ' +test_expect_success CASE_INSENSITIVE_FS 'verify case-folding conflicts are correctly encoded' ' + git clone -c extensions.submodulePathConfig=true main cloned-folding && + ( + cd cloned-folding && + + # conflict: the "folding" gitdir will already be taken + git submodule add ../new-sub "folding" && + test_commit lowercase && + git submodule add ../new-sub "FoldinG" && + test_commit uppercase && + + # conflict: the "foo" gitdir will already be taken + git submodule add ../new-sub "FOO" && + test_commit uppercase-foo && + git submodule add ../new-sub "foo" && + test_commit lowercase-foo && + + # create a multi conflict between foobar, fooBar and foo%42ar + # the "foo" gitdir will already be taken + git submodule add ../new-sub "foobar" && + test_commit lowercase-foobar && + git submodule add ../new-sub "foo%42ar" && + test_commit encoded-foo%42ar && + git submodule add ../new-sub "fooBar" && + test_commit mixed-fooBar + ) && + verify_submodule_gitdir_path cloned-folding "folding" "modules/folding" && + verify_submodule_gitdir_path cloned-folding "FoldinG" "modules/%46oldin%47" && + verify_submodule_gitdir_path cloned-folding "FOO" "modules/FOO" && + verify_submodule_gitdir_path cloned-folding "foo" "modules/foo0" && + verify_submodule_gitdir_path cloned-folding "foobar" "modules/foobar" && + verify_submodule_gitdir_path cloned-folding "foo%42ar" "modules/foo%42ar" && + verify_submodule_gitdir_path cloned-folding "fooBar" "modules/fooBar0" +' + test_done diff --git a/url.c b/url.c index adc289229c6491..3ca5987e905d59 100644 --- a/url.c +++ b/url.c @@ -9,6 +9,13 @@ int is_rfc3986_unreserved(char ch) ch == '-' || ch == '_' || ch == '.' || ch == '~'; } +int is_casefolding_rfc3986_unreserved(char c) +{ + return (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~'; +} + int is_urlschemechar(int first_flag, int ch) { /* diff --git a/url.h b/url.h index e644c3c8096028..cd9140e9946b16 100644 --- a/url.h +++ b/url.h @@ -28,4 +28,11 @@ void str_end_url_with_slash(const char *url, char **dest); */ int is_rfc3986_unreserved(char ch); +/* + * This is a variant of is_rfc3986_unreserved() that treats uppercase + * letters as "reserved". This forces them to be percent-encoded, allowing + * 'Foo' (%46oo) and 'foo' (foo) to be distinct on case-folding filesystems. + */ +int is_casefolding_rfc3986_unreserved(char c); + #endif /* URL_H */ From 82c36fa0a987c9c8617f5ded41834f7487e616e2 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:31 +0200 Subject: [PATCH 11/29] submodule: hash the submodule name for the gitdir path If none of the previous plain-text / encoding / derivation steps work and case 2.4 is reached, then try a hash of the submodule name to see if that can be a valid gitdir before giving up and throwing an error. This is a "last resort" type of measure to avoid conflicts since it loses the human readability of the gitdir path. This logic will be reached in rare cases, as can be seen in the test we added. Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- builtin/submodule--helper.c | 19 +++++++ t/t7425-submodule-gitdir-path-extension.sh | 59 ++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index 746f9fa63c3be9..2fa71c814cb6df 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -465,6 +465,10 @@ static int validate_and_set_submodule_gitdir(struct strbuf *gitdir_path, static void create_default_gitdir_config(const char *submodule_name) { struct strbuf gitdir_path = STRBUF_INIT; + struct git_hash_ctx ctx; + char hex_name_hash[GIT_MAX_HEXSZ + 1], header[128]; + unsigned char raw_name_hash[GIT_MAX_RAWSZ]; + int header_len; /* Case 1: try the plain module name */ repo_git_path_append(the_repository, &gitdir_path, "modules/%s", submodule_name); @@ -506,6 +510,21 @@ static void create_default_gitdir_config(const char *submodule_name) return; } + /* Case 2.4: If all the above failed, try a hash of the name as a last resort */ + header_len = snprintf(header, sizeof(header), "blob %zu", strlen(submodule_name)); + the_hash_algo->init_fn(&ctx); + the_hash_algo->update_fn(&ctx, header, header_len); + the_hash_algo->update_fn(&ctx, "\0", 1); + the_hash_algo->update_fn(&ctx, submodule_name, strlen(submodule_name)); + the_hash_algo->final_fn(raw_name_hash, &ctx); + hash_to_hex_algop_r(hex_name_hash, raw_name_hash, the_hash_algo); + strbuf_reset(&gitdir_path); + repo_git_path_append(the_repository, &gitdir_path, "modules/%s", hex_name_hash); + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) { + strbuf_release(&gitdir_path); + return; + } + /* Case 3: nothing worked, error out */ die(_("failed to set a valid default config for 'submodule.%s.gitdir'. " "Please ensure it is set, for example by running something like: " diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh index 3cca93c8972b01..a76e64a9f7d7d3 100755 --- a/t/t7425-submodule-gitdir-path-extension.sh +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -438,4 +438,63 @@ test_expect_success CASE_INSENSITIVE_FS 'verify case-folding conflicts are corre verify_submodule_gitdir_path cloned-folding "fooBar" "modules/fooBar0" ' +test_expect_success CASE_INSENSITIVE_FS 'verify hashing conflict resolution as a last resort' ' + git clone -c extensions.submodulePathConfig=true main cloned-hash && + ( + cd cloned-hash && + + # conflict: add all submodule conflicting variants until we reach the + # final hashing conflict resolution for submodule "foo" + git submodule add ../new-sub "foo" && + git submodule add ../new-sub "foo0" && + git submodule add ../new-sub "foo1" && + git submodule add ../new-sub "foo2" && + git submodule add ../new-sub "foo3" && + git submodule add ../new-sub "foo4" && + git submodule add ../new-sub "foo5" && + git submodule add ../new-sub "foo6" && + git submodule add ../new-sub "foo7" && + git submodule add ../new-sub "foo8" && + git submodule add ../new-sub "foo9" && + git submodule add ../new-sub "%46oo" && + git submodule add ../new-sub "%46oo0" && + git submodule add ../new-sub "%46oo1" && + git submodule add ../new-sub "%46oo2" && + git submodule add ../new-sub "%46oo3" && + git submodule add ../new-sub "%46oo4" && + git submodule add ../new-sub "%46oo5" && + git submodule add ../new-sub "%46oo6" && + git submodule add ../new-sub "%46oo7" && + git submodule add ../new-sub "%46oo8" && + git submodule add ../new-sub "%46oo9" && + test_commit add-foo-variants && + git submodule add ../new-sub "Foo" && + test_commit add-uppercase-foo + ) && + verify_submodule_gitdir_path cloned-hash "foo" "modules/foo" && + verify_submodule_gitdir_path cloned-hash "foo0" "modules/foo0" && + verify_submodule_gitdir_path cloned-hash "foo1" "modules/foo1" && + verify_submodule_gitdir_path cloned-hash "foo2" "modules/foo2" && + verify_submodule_gitdir_path cloned-hash "foo3" "modules/foo3" && + verify_submodule_gitdir_path cloned-hash "foo4" "modules/foo4" && + verify_submodule_gitdir_path cloned-hash "foo5" "modules/foo5" && + verify_submodule_gitdir_path cloned-hash "foo6" "modules/foo6" && + verify_submodule_gitdir_path cloned-hash "foo7" "modules/foo7" && + verify_submodule_gitdir_path cloned-hash "foo8" "modules/foo8" && + verify_submodule_gitdir_path cloned-hash "foo9" "modules/foo9" && + verify_submodule_gitdir_path cloned-hash "%46oo" "modules/%46oo" && + verify_submodule_gitdir_path cloned-hash "%46oo0" "modules/%46oo0" && + verify_submodule_gitdir_path cloned-hash "%46oo1" "modules/%46oo1" && + verify_submodule_gitdir_path cloned-hash "%46oo2" "modules/%46oo2" && + verify_submodule_gitdir_path cloned-hash "%46oo3" "modules/%46oo3" && + verify_submodule_gitdir_path cloned-hash "%46oo4" "modules/%46oo4" && + verify_submodule_gitdir_path cloned-hash "%46oo5" "modules/%46oo5" && + verify_submodule_gitdir_path cloned-hash "%46oo6" "modules/%46oo6" && + verify_submodule_gitdir_path cloned-hash "%46oo7" "modules/%46oo7" && + verify_submodule_gitdir_path cloned-hash "%46oo8" "modules/%46oo8" && + verify_submodule_gitdir_path cloned-hash "%46oo9" "modules/%46oo9" && + hash=$(printf "Foo" | git hash-object --stdin) && + verify_submodule_gitdir_path cloned-hash "Foo" "modules/${hash}" +' + test_done From e897c9b7f31cf83e93cfefe1f82eb4a18337c9b1 Mon Sep 17 00:00:00 2001 From: Adrian Ratiu Date: Mon, 12 Jan 2026 20:46:32 +0200 Subject: [PATCH 12/29] submodule: detect conflicts with existing gitdir configs Credit goes to Emily and Josh for testing and noticing a corner-case which caused conflicts with existing gitdir configs to silently pass validation, then fail later in add_submodule() with a cryptic error: fatal: A git directory for 'nested%2fsub' is found locally with remote(s): origin /.../trash directory.t7425-submodule-gitdir-path-extension/sub This change ensures the validation step checks existing gitdirs for conflicts. We only have to do this for submodules having gitdirs, because those without submodule.%s.gitdir need to be migrated and will throw an error earlier in the submodule codepath. Quoting Josh: My testing setup has been as follows: * Using our locally-built Git with our downstream patch of [1] included: * create a repo "sub" * create a repo "super" * In "super": * mkdir nested * git submodule add ../sub nested/sub * Verify that the submodule's gitdir is .git/modules/nested%2fsub * Using a build of git from upstream `next` plus this series: * git config set --global extensions.submodulepathconfig true * git clone --recurse-submodules super super2 * create a repo "nested%2fsub" * In "super2": * git submodule add ../nested%2fsub At this point I'd expect the collision detection / encoding to take effect, but instead I get the error listed above. End quote Suggested-by: Josh Steadmon Signed-off-by: Adrian Ratiu Signed-off-by: Junio C Hamano --- submodule.c | 61 ++++++++++++++++++++++ t/t7425-submodule-gitdir-path-extension.sh | 28 ++++++++++ 2 files changed, 89 insertions(+) diff --git a/submodule.c b/submodule.c index a5a47359c71386..4ab3fcb5981c41 100644 --- a/submodule.c +++ b/submodule.c @@ -2296,6 +2296,62 @@ static int check_casefolding_conflict(const char *git_dir, return ret; } +struct submodule_from_gitdir_cb { + const char *gitdir; + const char *submodule_name; + bool conflict_found; +}; + +static int find_conflict_by_gitdir_cb(const char *var, const char *value, + const struct config_context *ctx UNUSED, void *data) +{ + struct submodule_from_gitdir_cb *cb = data; + const char *submodule_name_start; + size_t submodule_name_len; + const char *suffix = ".gitdir"; + size_t suffix_len = strlen(suffix); + + if (!skip_prefix(var, "submodule.", &submodule_name_start)) + return 0; + + /* Check if submodule_name_start ends with ".gitdir" */ + submodule_name_len = strlen(submodule_name_start); + if (submodule_name_len < suffix_len || + strcmp(submodule_name_start + submodule_name_len - suffix_len, suffix) != 0) + return 0; /* Does not end with ".gitdir" */ + + submodule_name_len -= suffix_len; + + /* + * A conflict happens if: + * 1. The submodule names are different and + * 2. The gitdir paths resolve to the same absolute path + */ + if (value && strncmp(cb->submodule_name, submodule_name_start, submodule_name_len)) { + char *abs_path_cb = absolute_pathdup(cb->gitdir); + char *abs_path_value = absolute_pathdup(value); + + cb->conflict_found = !strcmp(abs_path_cb, abs_path_value); + + free(abs_path_cb); + free(abs_path_value); + } + + return cb->conflict_found; +} + +static bool submodule_conflicts_with_existing(const char *gitdir, const char *submodule_name) +{ + struct submodule_from_gitdir_cb cb = { 0 }; + cb.submodule_name = submodule_name; + cb.gitdir = gitdir; + + /* Find conflicts with existing repo gitdir configs */ + repo_config(the_repository, find_conflict_by_gitdir_cb, &cb); + + return cb.conflict_found; +} + /* * Encoded gitdir validation, only used when extensions.submodulePathConfig is enabled. * This does not print errors like the non-encoded version, because encoding is supposed @@ -2321,6 +2377,11 @@ static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodu if (!last_submodule_name || strchr(last_submodule_name, '/')) return -1; + /* Prevent conflicts with existing submodule gitdirs */ + if (is_git_directory(git_dir) && + submodule_conflicts_with_existing(git_dir, submodule_name)) + return -1; + /* Prevent conflicts on case-folding filesystems */ repo_config_get_bool(the_repository, "core.ignorecase", &config_ignorecase); if (ignore_case || config_ignorecase) { diff --git a/t/t7425-submodule-gitdir-path-extension.sh b/t/t7425-submodule-gitdir-path-extension.sh index a76e64a9f7d7d3..ea86ecf7ee5b48 100755 --- a/t/t7425-submodule-gitdir-path-extension.sh +++ b/t/t7425-submodule-gitdir-path-extension.sh @@ -497,4 +497,32 @@ test_expect_success CASE_INSENSITIVE_FS 'verify hashing conflict resolution as a verify_submodule_gitdir_path cloned-hash "Foo" "modules/${hash}" ' +test_expect_success 'submodule gitdir conflicts with previously encoded name (local config)' ' + git init -b main super_with_encoded && + ( + cd super_with_encoded && + + git config core.repositoryformatversion 1 && + git config extensions.submodulePathConfig true && + + # Add a submodule with a nested path + git submodule add --name "nested/sub" ../sub nested/sub && + test_commit add-encoded-gitdir && + + verify_submodule_gitdir_path . "nested/sub" "modules/nested%2fsub" && + test_path_is_dir ".git/modules/nested%2fsub" + ) && + + # create a submodule that will conflict with the encoded gitdir name: + # the existing gitdir is ".git/modules/nested%2fsub", which is used + # by "nested/sub", so the new submod will get another (non-conflicting) + # name: "nested%252fsub". + ( + cd super_with_encoded && + git submodule add ../sub "nested%2fsub" && + verify_submodule_gitdir_path . "nested%2fsub" "modules/nested%252fsub" && + test_path_is_dir ".git/modules/nested%252fsub" + ) +' + test_done From 1954b943227f2455d97e3b35ce106c203471b0ba Mon Sep 17 00:00:00 2001 From: Deveshi Dwivedi Date: Mon, 12 Jan 2026 16:36:42 +0000 Subject: [PATCH 13/29] t5403: introduce check_post_checkout helper function The test file repeatedly uses the same four-line pattern to validate post-checkout hook arguments: read the args file, then test each of the three values individually. Introduce a check_post_checkout helper function that encapsulates this pattern. This patch does not change test behavior; it prepares the code for improvement in the next step. Additionally, the 'post-checkout hook is triggered by clone' test is improved to validate the hook arguments (old ref, new ref, and flag) rather than just checking that the hook file was created. Signed-off-by: Deveshi Dwivedi Signed-off-by: Junio C Hamano --- t/t5403-post-checkout-hook.sh | 49 ++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/t/t5403-post-checkout-hook.sh b/t/t5403-post-checkout-hook.sh index ade9e5087f9f30..d9de4e75297af9 100755 --- a/t/t5403-post-checkout-hook.sh +++ b/t/t5403-post-checkout-hook.sh @@ -10,6 +10,17 @@ export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME TEST_PASSES_SANITIZE_LEAK=true . ./test-lib.sh +# Usage: check_post_checkout +# +# Verify that the post-checkout hook arguments in match the expected +# values: for the previous HEAD, for the new HEAD, and +# indicating whether this was a branch checkout (1) or file checkout (0). +check_post_checkout () { + test "$#" = 4 || BUG "check_post_checkout takes 4 args" + read old new flag <"$1" && + test "$old" = "$2" && test "$new" = "$3" && test "$flag" = "$4" +} + test_expect_success setup ' test_hook --setup post-checkout <<-\EOF && echo "$@" >.git/post-checkout.args @@ -24,29 +35,30 @@ test_expect_success setup ' test_expect_success 'post-checkout receives the right arguments with HEAD unchanged ' ' test_when_finished "rm -f .git/post-checkout.args" && git checkout main && - read old new flag <.git/post-checkout.args && - test $old = $new && test $flag = 1 + check_post_checkout .git/post-checkout.args \ + "$(git rev-parse HEAD)" "$(git rev-parse HEAD)" 1 ' test_expect_success 'post-checkout args are correct with git checkout -b ' ' test_when_finished "rm -f .git/post-checkout.args" && git checkout -b new1 && - read old new flag <.git/post-checkout.args && - test $old = $new && test $flag = 1 + check_post_checkout .git/post-checkout.args \ + "$(git rev-parse HEAD)" "$(git rev-parse HEAD)" 1 ' test_expect_success 'post-checkout receives the right args with HEAD changed ' ' test_when_finished "rm -f .git/post-checkout.args" && + old=$(git rev-parse HEAD) && git checkout two && - read old new flag <.git/post-checkout.args && - test $old != $new && test $flag = 1 + check_post_checkout .git/post-checkout.args \ + "$old" "$(git rev-parse HEAD)" 1 ' test_expect_success 'post-checkout receives the right args when not switching branches ' ' test_when_finished "rm -f .git/post-checkout.args" && git checkout main -- three.t && - read old new flag <.git/post-checkout.args && - test $old = $new && test $flag = 0 + check_post_checkout .git/post-checkout.args \ + "$(git rev-parse HEAD)" "$(git rev-parse HEAD)" 0 ' test_rebase () { @@ -56,10 +68,8 @@ test_rebase () { git checkout -B rebase-test main && rm -f .git/post-checkout.args && git rebase $args rebase-on-me && - read old new flag <.git/post-checkout.args && - test_cmp_rev main $old && - test_cmp_rev rebase-on-me $new && - test $flag = 1 + check_post_checkout .git/post-checkout.args \ + "$(git rev-parse main)" "$(git rev-parse rebase-on-me)" 1 ' test_expect_success "post-checkout is triggered on rebase $args with fast-forward" ' @@ -67,10 +77,8 @@ test_rebase () { git checkout -B ff-rebase-test rebase-on-me^ && rm -f .git/post-checkout.args && git rebase $args rebase-on-me && - read old new flag <.git/post-checkout.args && - test_cmp_rev rebase-on-me^ $old && - test_cmp_rev rebase-on-me $new && - test $flag = 1 + check_post_checkout .git/post-checkout.args \ + "$(git rev-parse rebase-on-me^)" "$(git rev-parse rebase-on-me)" 1 ' test_expect_success "rebase $args fast-forward branch checkout runs post-checkout hook" ' @@ -80,10 +88,8 @@ test_rebase () { git checkout two && rm -f .git/post-checkout.args && git rebase $args HEAD rebase-fast-forward && - read old new flag <.git/post-checkout.args && - test_cmp_rev two $old && - test_cmp_rev three $new && - test $flag = 1 + check_post_checkout .git/post-checkout.args \ + "$(git rev-parse two)" "$(git rev-parse three)" 1 ' test_expect_success "rebase $args checkout does not remove untracked files" ' @@ -110,7 +116,8 @@ test_expect_success 'post-checkout hook is triggered by clone' ' echo "$@" >"$GIT_DIR/post-checkout.args" EOF git clone --template=templates . clone3 && - test_path_is_file clone3/.git/post-checkout.args + check_post_checkout clone3/.git/post-checkout.args \ + "$(test_oid zero)" "$(git -C clone3 rev-parse HEAD)" 1 ' test_done From 7a747f972d73d9419603d8127514cf188ed7a9ab Mon Sep 17 00:00:00 2001 From: Deveshi Dwivedi Date: Mon, 12 Jan 2026 16:36:43 +0000 Subject: [PATCH 14/29] t5403: use test_cmp for post-checkout argument checks Update check_post_checkout and the post-checkout hook implementation to use test_cmp instead of individual test commands. This provides better error messages when tests fail, making it easier to debug which specific argument (old ref, new ref, or flag) was incorrect. The hook now outputs in key=value format which test_cmp can display clearly when there's a mismatch. Signed-off-by: Deveshi Dwivedi Signed-off-by: Junio C Hamano --- t/t5403-post-checkout-hook.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/t/t5403-post-checkout-hook.sh b/t/t5403-post-checkout-hook.sh index d9de4e75297af9..53d97df070ffc1 100755 --- a/t/t5403-post-checkout-hook.sh +++ b/t/t5403-post-checkout-hook.sh @@ -17,13 +17,13 @@ TEST_PASSES_SANITIZE_LEAK=true # indicating whether this was a branch checkout (1) or file checkout (0). check_post_checkout () { test "$#" = 4 || BUG "check_post_checkout takes 4 args" - read old new flag <"$1" && - test "$old" = "$2" && test "$new" = "$3" && test "$flag" = "$4" + echo "old=$2 new=$3 flag=$4" >expect && + test_cmp expect "$1" } test_expect_success setup ' test_hook --setup post-checkout <<-\EOF && - echo "$@" >.git/post-checkout.args + echo "old=$1 new=$2 flag=$3" >.git/post-checkout.args EOF test_commit one && test_commit two && @@ -113,7 +113,7 @@ test_rebase --merge test_expect_success 'post-checkout hook is triggered by clone' ' mkdir -p templates/hooks && write_script templates/hooks/post-checkout <<-\EOF && - echo "$@" >"$GIT_DIR/post-checkout.args" + echo "old=$1 new=$2 flag=$3" >"$GIT_DIR/post-checkout.args" EOF git clone --template=templates . clone3 && check_post_checkout clone3/.git/post-checkout.args \ From 81021871eaa8b16a892b9c8791a0c905ab26e342 Mon Sep 17 00:00:00 2001 From: Shreyansh Paliwal Date: Tue, 13 Jan 2026 01:23:43 +0530 Subject: [PATCH 15/29] doc: MyFirstContribution: fix missing dependencies and clarify build steps Fix issues in the MyFirstContribution guide that can lead to confusion or test failures when following the documented steps. * Add missing header includes in code examples (environment.h and strbuf.h). * Correct manpage synopsis formatting to prevent failing documentation tests. * Specify the use of parallel test execution with -j$(nproc), noting that it runs tests using all available CPUs and may be adjusted. These updates improve documentation accuracy and make the first-time contributor journey smoother. Signed-off-by: Shreyansh Paliwal Signed-off-by: Junio C Hamano --- Documentation/MyFirstContribution.adoc | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Documentation/MyFirstContribution.adoc b/Documentation/MyFirstContribution.adoc index f186dfbc898fd4..7306edab0ff58e 100644 --- a/Documentation/MyFirstContribution.adoc +++ b/Documentation/MyFirstContribution.adoc @@ -331,7 +331,8 @@ on the command line, including the name of our command. (If `prefix` is empty for you, try `cd Documentation/ && ../bin-wrappers/git psuh`). That's not so helpful. So what other context can we get? -Add a line to `#include "config.h"` and `#include "repository.h"`. +Add a line to `#include "config.h"`, `#include "repository.h"` and +`#include "environment.h"`. Then, add the following bits to the function body: function body: @@ -429,6 +430,7 @@ Add the following includes: ---- #include "commit.h" #include "pretty.h" +#include "strbuf.h" ---- Then, add the following lines within your implementation of `cmd_psuh()` near @@ -503,8 +505,8 @@ git-psuh - Delight users' typo with a shy horse SYNOPSIS -------- -[verse] -'git-psuh [...]' +[synopsis] +git psuh [...] DESCRIPTION ----------- @@ -726,9 +728,10 @@ $ prove -j$(nproc) --shuffle t[0-9]*.sh ---- NOTE: You can also do this with `make test` or use any testing harness which can -speak TAP. `prove` can run concurrently. `shuffle` randomizes the order the -tests are run in, which makes them resilient against unwanted inter-test -dependencies. `prove` also makes the output nicer. +speak TAP. `prove` can run concurrently. `-j$(nproc)` runs tests using all +available CPUs in parallel, but the job count can be adjusted as needed. +`shuffle` randomizes the order the tests are run in, which makes them resilient +against unwanted inter-test dependencies. `prove` also makes the output nicer. Go ahead and commit this change, as well. From ed0f7a62f75232896eccb622bfaf9fe56903a261 Mon Sep 17 00:00:00 2001 From: Aaron Plattner Date: Wed, 14 Jan 2026 08:36:19 -0800 Subject: [PATCH 16/29] remote-curl: use auth for probe_rpc() requests too If a large request requires post_rpc() to call probe_rpc(), the latter does not use the authorization credentials used for other requests. If this fails with an HTTP 401 error and http_auth.multistage isn't set, then the whole request just fails. For example, using git-credential-msal [1], the following attempt to clone a large repository fails partway through because the initial request to download the commit history and promisor packs succeeds, but the subsequent request to download the blobs needed to construct the working tree fails with a 401 error and the checkout fails. (lines removed for brevity) git clone --filter=blob:none https://secure-server.example/repo 11:03:26.855369 git.c:502 trace: built-in: git clone --filter=blob:none https://secure-server.example/repo Cloning into 'sw'... warning: templates not found in /home/aaron/share/git-core/templates 11:03:26.857169 run-command.c:673 trace: run_command: git remote-https origin https://secure-server.example/repo 11:03:27.012104 http.c:849 => Send header: GET repo/info/refs?service=git-upload-pack HTTP/1.1 11:03:27.049243 http.c:849 <= Recv header: HTTP/1.1 401 Unauthorized 11:03:27.049270 http.c:849 <= Recv header: WWW-Authenticate: Bearer error="invalid_request", error_description="No bearer token found in the request", msal-tenant-id="", msal-client-id="" 11:03:27.053786 run-command.c:673 trace: run_command: 'git credential-msal get' 11:03:27.952830 http.c:849 => Send header: GET repo/info/refs?service=git-upload-pack HTTP/1.1 11:03:27.952849 http.c:849 => Send header: Authorization: Bearer 11:03:27.995419 http.c:849 <= Recv header: HTTP/1.1 200 OK 11:03:28.230039 http.c:890 == Info: Reusing existing https: connection with host secure-server.example 11:03:28.230208 http.c:849 => Send header: POST repo/git-upload-pack HTTP/1.1 11:03:28.230216 http.c:849 => Send header: Content-Type: application/x-git-upload-pack-request 11:03:28.230221 http.c:849 => Send header: Authorization: Bearer 11:03:28.269085 http.c:849 <= Recv header: HTTP/1.1 200 OK 11:03:28.684163 http.c:890 == Info: Reusing existing https: connection with host secure-server.example 11:03:28.684379 http.c:849 => Send header: POST repo/git-upload-pack HTTP/1.1 11:03:28.684391 http.c:849 => Send header: Accept: application/x-git-upload-pack-result 11:03:28.684393 http.c:849 => Send header: Authorization: Bearer 11:03:28.869546 run-command.c:673 trace: run_command: git index-pack --stdin --fix-thin '--keep=fetch-pack 43856 on dgx-spark' --promisor 11:06:39.861237 run-command.c:673 trace: run_command: git -c fetch.negotiationAlgorithm=noop fetch origin --no-tags --no-write-fetch-head --recurse-submodules=no --filter=blob:none --stdin 11:06:39.865981 run-command.c:673 trace: run_command: git remote-https origin https://secure-server.example/repo 11:06:39.868039 run-command.c:673 trace: run_command: git-remote-https origin https://secure-server.example/repo 11:07:30.412575 http.c:849 => Send header: GET repo/info/refs?service=git-upload-pack HTTP/1.1 11:07:30.456285 http.c:849 <= Recv header: HTTP/1.1 401 Unauthorized 11:07:30.456318 http.c:849 <= Recv header: WWW-Authenticate: Bearer error="invalid_request", error_description="No bearer token found in the request", msal-tenant-id="", msal-client-id="" 11:07:30.456439 run-command.c:673 trace: run_command: 'git credential-cache get' 11:07:30.461266 http.c:849 => Send header: GET repo/info/refs?service=git-upload-pack HTTP/1.1 11:07:30.461282 http.c:849 => Send header: Authorization: Bearer 11:07:30.501628 http.c:849 <= Recv header: HTTP/1.1 200 OK 11:07:34.725262 http.c:849 => Send header: POST repo/git-upload-pack HTTP/1.1 11:07:34.725279 http.c:849 => Send header: Content-Type: application/x-git-upload-pack-request 11:07:34.761407 http.c:849 <= Recv header: HTTP/1.1 401 Unauthorized 11:07:34.761443 http.c:890 == Info: Bearer authentication problem, ignoring. 11:07:34.761453 http.c:849 <= Recv header: WWW-Authenticate: Bearer error="invalid_request", error_description="No bearer token found in the request", msal-tenant-id="", msal-client-id="" 11:07:34.761509 http.c:890 == Info: The requested URL returned error: 401 11:07:34.761530 http.c:890 == Info: closing connection #0 11:07:34.761913 run-command.c:673 trace: run_command: 'git credential-cache erase' 11:07:34.761927 run-command.c:765 trace: start_command: /bin/sh -c 'git credential-cache erase' 'git credential-cache erase' 11:07:34.768069 git.c:502 trace: built-in: git credential-cache erase 11:07:34.768690 run-command.c:673 trace: run_command: 'git credential-msal erase' 11:07:34.768713 run-command.c:765 trace: start_command: /bin/sh -c 'git credential-msal erase' 'git credential-msal erase' 11:07:34.772742 git.c:808 trace: exec: git-credential-msal erase 11:07:34.772783 run-command.c:673 trace: run_command: git-credential-msal erase 11:07:34.772819 run-command.c:765 trace: start_command: /usr/bin/git-credential-msal erase error: RPC failed; HTTP 401 curl 22 The requested URL returned error: 401 fatal: unable to write request to remote: Broken pipe fatal: could not fetch c4fff0229c9be06ecf576356a4d39a8a755b8d81 from promisor remote warning: Clone succeeded, but checkout failed. You can inspect what was checked out with 'git status' and retry with 'git restore --source=HEAD :/' In this case, the HTTP_REAUTH retry logic is not used because the credential helper didn't set the 'continue' flag, so http_auth.multistage is false and handle_curl_result() fails with HTTP_NOAUTH instead. Fix the immediate problem by including the authorization headers in the probe_rpc() request as well. Add a test for this scenario: 1. Create a repository with two thousand refs. 2. Clone that into the web root used by t5563-simple-http-auth.sh. 3. Configure http.postBuffer to be very small in order to trigger the probe_rpc() path that fails. 4. Clone using a valid Bearer token. [1] https://github.com/Binary-Eater/git-credential-msal Tested-by: Lucas De Marchi Signed-off-by: Aaron Plattner Signed-off-by: Junio C Hamano --- remote-curl.c | 1 + t/t5563-simple-http-auth.sh | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/remote-curl.c b/remote-curl.c index 69f919454a4565..92e40bb682d34d 100644 --- a/remote-curl.c +++ b/remote-curl.c @@ -876,6 +876,7 @@ static int probe_rpc(struct rpc_state *rpc, struct slot_results *results) headers = curl_slist_append(headers, rpc->hdr_content_type); headers = curl_slist_append(headers, rpc->hdr_accept); + headers = http_append_auth_header(&http_auth, headers); curl_easy_setopt(slot->curl, CURLOPT_NOBODY, 0L); curl_easy_setopt(slot->curl, CURLOPT_POST, 1L); diff --git a/t/t5563-simple-http-auth.sh b/t/t5563-simple-http-auth.sh index 317f33af5a7e60..0e1465c15755de 100755 --- a/t/t5563-simple-http-auth.sh +++ b/t/t5563-simple-http-auth.sh @@ -605,6 +605,51 @@ test_expect_success 'access using bearer auth with invalid credentials' ' EOF ' +test_expect_success 'clone with bearer auth and probe_rpc' ' + test_when_finished "per_test_cleanup" && + test_when_finished "rm -rf large.git" && + + # Set up a repository large enough to trigger probe_rpc + git init large.git && + ( + cd large.git && + git config set maintenance.auto false && + git commit --allow-empty --message "initial" && + # Create many refs to trigger probe_rpc, which is called when + # the request body is larger than http.postBuffer. + # + # In the test later, http.postBuffer is set to 70000. Each + # "want" line is ~45 bytes, so we need at least 70000/45 = ~1600 + # refs + test_seq -f "create refs/heads/branch-%d @" 2000 | + git update-ref --stdin + ) && + git clone --bare large.git "$HTTPD_DOCUMENT_ROOT_PATH/large.git" && + + # Clone it through HTTP with a Bearer token + set_credential_reply get <<-EOF && + capability[]=authtype + authtype=Bearer + credential=YS1naXQtdG9rZW4= + EOF + + # Bearer token + 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: Bearer authorize_uri="id.example.com" + EOF + + # Set a small buffer to force probe_rpc to be called + # Must be > LARGE_PACKET_MAX (65520) + test_config_global http.postBuffer 70000 && + test_config_global credential.helper test-helper && + git clone "$HTTPD_URL/custom_auth/large.git" partial-auth-clone 2>clone-error +' + test_expect_success 'access using three-legged auth' ' test_when_finished "per_test_cleanup" && From f85b49f3d4af5ee0b428285799ac711d6abe1cfb Mon Sep 17 00:00:00 2001 From: LorenzoPegorari Date: Fri, 16 Jan 2026 01:05:03 +0100 Subject: [PATCH 17/29] diff: improve scaling of filenames in diffstat to handle UTF-8 chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `show_stats()` function tries to scale the filenames in the diffstat to ensure they don't exceed the given `name-width`. It does so by calculating the "display width" of the characters to be dropped, but then advances the filename pointer by that number of bytes. However, the "display width" of a character is not always equal to its byte count. The result is that sometimes, when displaying UTF-8 characters, filenames exceed the given `name-width`, and frequently the bytes of the UTF-8 characters are truncated. The following is an example of the issue, where the 2 files are "HelloHi" and "Hello你好", and `name-width=6`: ...oHi | 0 ...好 | 0 Make the filename pointer move by the actual number of bytes of the characters to drop from the filename, rather than their display width, using the `utf8_width()` function. Force `len` to not be less than 0 (this happens if the given `name-width` is 2 or less), otherwise an infinite loop is entered. Signed-off-by: LorenzoPegorari Signed-off-by: Junio C Hamano --- diff.c | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/diff.c b/diff.c index a1961526c0dab1..86fdf4d8d738fd 100644 --- a/diff.c +++ b/diff.c @@ -2823,17 +2823,12 @@ static void show_stats(struct diffstat_t *data, struct diff_options *options) char *slash; prefix = "..."; len -= 3; - /* - * NEEDSWORK: (name_len - len) counts the display - * width, which would be shorter than the byte - * length of the corresponding substring. - * Advancing "name" by that number of bytes does - * *NOT* skip over that many columns, so it is - * very likely that chomping the pathname at the - * slash we will find starting from "name" will - * leave the resulting string still too long. - */ - name += name_len - len; + if (len < 0) + len = 0; + + while (name_len > len) + name_len -= utf8_width((const char**)&name, NULL); + slash = strchr(name, '/'); if (slash) name = slash; From 04f5d95ef7715e952c93f078e2973c44bb6f3396 Mon Sep 17 00:00:00 2001 From: LorenzoPegorari Date: Fri, 16 Jan 2026 01:05:38 +0100 Subject: [PATCH 18/29] t4073: add test for diffstat paths length when containing UTF-8 chars Add test checking the length of filepaths containing UTF-8 chars when generating a diffstat with various `name-width`s. Signed-off-by: LorenzoPegorari [jc: fixed up t/meson.build to spell the name of the new test file correctly] Signed-off-by: Junio C Hamano --- t/meson.build | 1 + t/t4073-diff-stat-name-width.sh | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100755 t/t4073-diff-stat-name-width.sh diff --git a/t/meson.build b/t/meson.build index a5531df415ffe2..73edae4e3d8a64 100644 --- a/t/meson.build +++ b/t/meson.build @@ -496,6 +496,7 @@ integration_tests = [ 't4070-diff-pairs.sh', 't4071-diff-minimal.sh', 't4072-diff-max-depth.sh', + 't4073-diff-stat-name-width.sh', 't4100-apply-stat.sh', 't4101-apply-nonl.sh', 't4102-apply-rename.sh', diff --git a/t/t4073-diff-stat-name-width.sh b/t/t4073-diff-stat-name-width.sh new file mode 100755 index 00000000000000..ec5d3c3c1ffc9d --- /dev/null +++ b/t/t4073-diff-stat-name-width.sh @@ -0,0 +1,61 @@ +#!/bin/sh + +test_description='git-diff check diffstat filepaths length when containing UTF-8 chars' + +. ./test-lib.sh + + +create_files () { + mkdir -p "d你好" && + touch "d你好/f再见" +} + +test_expect_success 'setup' ' + git init && + git config core.quotepath off && + git commit -m "Initial commit" --allow-empty && + create_files && + git add . && + git commit -m "Added files" +' + +test_expect_success 'test name-width long enough for filepath' ' + git diff HEAD~1 HEAD --stat --stat-name-width=12 >out && + grep "d你好/f再见 |" out && + git diff HEAD~1 HEAD --stat --stat-name-width=11 >out && + grep "d你好/f再见 |" out +' + +test_expect_success 'test name-width not long enough for dir name' ' + git diff HEAD~1 HEAD --stat --stat-name-width=10 >out && + grep ".../f再见 |" out && + git diff HEAD~1 HEAD --stat --stat-name-width=9 >out && + grep ".../f再见 |" out +' + +test_expect_success 'test name-width not long enough for slash' ' + git diff HEAD~1 HEAD --stat --stat-name-width=8 >out && + grep "...f再见 |" out +' + +test_expect_success 'test name-width not long enough for file name' ' + git diff HEAD~1 HEAD --stat --stat-name-width=7 >out && + grep "...再见 |" out && + git diff HEAD~1 HEAD --stat --stat-name-width=6 >out && + grep "...见 |" out && + git diff HEAD~1 HEAD --stat --stat-name-width=5 >out && + grep "...见 |" out && + git diff HEAD~1 HEAD --stat --stat-name-width=4 >out && + grep "... |" out +' + +test_expect_success 'test name-width minimum length' ' + git diff HEAD~1 HEAD --stat --stat-name-width=3 >out && + grep "... |" out && + git diff HEAD~1 HEAD --stat --stat-name-width=2 >out && + grep "... |" out && + git diff HEAD~1 HEAD --stat --stat-name-width=1 >out && + grep "... |" out +' + +test_done From 9500b2131d29960d3fbd35559c063f7a74568875 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Mon, 19 Jan 2026 00:19:45 -0500 Subject: [PATCH 19/29] remote: return non-const pointer from error_buf() We have an error_buf() helper that functions a bit like our error() helper, but returns NULL instead of -1. Its return type is "const char *", but this is overly restrictive. If we use the helper in a function that returns non-const "char *", the compiler will complain about the implicit cast from const to non-const. Meanwhile, the const in the helper is doing nothing useful, as it only ever returns NULL. Let's drop the const, which will let us use it in both types of function. Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- remote.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/remote.c b/remote.c index df9675cd330ed1..246c8b92e2584c 100644 --- a/remote.c +++ b/remote.c @@ -1838,7 +1838,7 @@ int branch_merge_matches(struct branch *branch, } __attribute__((format (printf,2,3))) -static const char *error_buf(struct strbuf *err, const char *fmt, ...) +static char *error_buf(struct strbuf *err, const char *fmt, ...) { if (err) { va_list ap; From 782a719e99b8a66ef7e05481a481ddfc329985b5 Mon Sep 17 00:00:00 2001 From: Jeff King Date: Mon, 19 Jan 2026 00:20:26 -0500 Subject: [PATCH 20/29] remote: drop const return of tracking_for_push_dest() The string returned from tracking_for_push_dest() comes from apply_refspec(), and thus is always an allocated string (or NULL). We should return a non-const pointer so that the caller knows that ownership of the string is being transferred. This goes back to the function's origin in e291c75a95 (remote.c: add branch_get_push, 2015-05-21). It never really mattered because our return is just forwarded through branch_get_push_1(), which returns a const string as part of an intentionally hacky memory management scheme (see that commit for details). As the first step of untangling that hackery, let's drop the extra const from this helper function (and from the variables that store its result). There should be no functional change (yet). Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- remote.c | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/remote.c b/remote.c index 246c8b92e2584c..fc5894e9496c9d 100644 --- a/remote.c +++ b/remote.c @@ -1876,9 +1876,9 @@ const char *branch_get_upstream(struct branch *branch, struct strbuf *err) return branch->merge[0]->dst; } -static const char *tracking_for_push_dest(struct remote *remote, - const char *refname, - struct strbuf *err) +static char *tracking_for_push_dest(struct remote *remote, + const char *refname, + struct strbuf *err) { char *ret; @@ -1906,7 +1906,7 @@ static const char *branch_get_push_1(struct repository *repo, if (remote->push.nr) { char *dst; - const char *ret; + char *ret; dst = apply_refspecs(&remote->push, branch->refname); if (!dst) @@ -1936,7 +1936,8 @@ static const char *branch_get_push_1(struct repository *repo, case PUSH_DEFAULT_UNSPECIFIED: case PUSH_DEFAULT_SIMPLE: { - const char *up, *cur; + const char *up; + char *cur; up = branch_get_upstream(branch, err); if (!up) From 9bf9eed093561f50091014faa7164c0325ea9ced Mon Sep 17 00:00:00 2001 From: Jeff King Date: Mon, 19 Jan 2026 00:22:08 -0500 Subject: [PATCH 21/29] remote: fix leak in branch_get_push_1() with invalid "simple" config Most of the code paths in branch_get_push_1() allocate a string for the @{push} value. We then return the result, which is stored in a "struct branch", so the value is not leaked. But there's one path that does leak: when we are in the "simple" push mode, we have to check that the @{push} value matches what we'd get for @{upstream}. If it doesn't, we return an error, but forget to free the @{push} value we computed. Curiously, the existing tests don't trigger this with LSan, even though they do exercise the code path. As far as I can tell, it should be triggered via: git -c push.default=simple \ -c branch.foo.remote=origin \ -c branch.foo.merge=refs/heads/not-foo \ rev-parse foo@{push} which will complain that the upstream ("not-foo") does not match the push destination ("foo"). We do die() shortly after this, but not until after returning from branch_get_push_1(), which is where the leak happens. So it seems like a false negative in LSan. However, I can trigger it reliably by printing the @{push} value using for-each-ref. This takes a little more setup (because we need "foo" to actually exist to iterate over it with for-each-ref), but we can piggy-back on the existing repo config in t6300. Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- remote.c | 4 +++- t/for-each-ref-tests.sh | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/remote.c b/remote.c index fc5894e9496c9d..041f9ceb527b0e 100644 --- a/remote.c +++ b/remote.c @@ -1945,9 +1945,11 @@ static const char *branch_get_push_1(struct repository *repo, cur = tracking_for_push_dest(remote, branch->refname, err); if (!cur) return NULL; - if (strcmp(cur, up)) + if (strcmp(cur, up)) { + free(cur); return error_buf(err, _("cannot resolve 'simple' push to a single destination")); + } return cur; } } diff --git a/t/for-each-ref-tests.sh b/t/for-each-ref-tests.sh index e3ad19298accde..02fb92e99e5475 100644 --- a/t/for-each-ref-tests.sh +++ b/t/for-each-ref-tests.sh @@ -1744,6 +1744,15 @@ test_expect_success ':remotename and :remoteref' ' ) ' +test_expect_success '%(push) with an invalid push-simple config' ' + echo "refs/heads/main " >expect && + git -c push.default=simple \ + -c remote.pushdefault=myfork \ + for-each-ref \ + --format="%(refname) %(push)" refs/heads/main >actual && + test_cmp expect actual +' + test_expect_success "${git_for_each_ref} --ignore-case ignores case" ' ${git_for_each_ref} --format="%(refname)" refs/heads/MAIN >actual && test_must_be_empty actual && From d79fff4a11a527f57516c62fe00777852bab719a Mon Sep 17 00:00:00 2001 From: Jeff King Date: Mon, 19 Jan 2026 00:23:20 -0500 Subject: [PATCH 22/29] remote: always allocate branch.push_tracking_ref In branch_get_push(), we usually allocate a new string for the @{push} ref, but will not do so in push.default=upstream mode, where we just pass back the result of branch_get_upstream() directly. This led to a hacky memory management scheme in e291c75a95 (remote.c: add branch_get_push, 2015-05-21): we store the result in the push_tracking_ref field of a "struct branch", under the assumption that the branch struct will last until the end of the program. So even though the struct doesn't know if it has an allocated string or not, it doesn't matter because we hold on to it either way. But that assumption was violated by f5ccb535cc (remote: fix leaking config strings, 2024-08-22), which added a function to free branch structs. Any struct which is fed to branch_release() is at risk of leaking its push_tracking_ref member. I don't think this can actually be triggered in practice. We rarely actually free the branch structs, and we only fill in the push_tracking_ref string lazily when it is needed. So triggering the leak would require a code path that does both, and I couldn't find one. Still, this is an ugly trap that may eventually spring on us. Since there is only one code path in branch_get_push() that doesn't allocate, let's just have it copy the string. And then we know that push_tracking_ref is always allocated, and we can free it in branch_release(). Signed-off-by: Jeff King Signed-off-by: Junio C Hamano --- remote.c | 7 ++++--- remote.h | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/remote.c b/remote.c index 041f9ceb527b0e..c61bcc905f900d 100644 --- a/remote.c +++ b/remote.c @@ -272,6 +272,7 @@ static void branch_release(struct branch *branch) free((char *)branch->refname); free(branch->remote_name); free(branch->pushremote_name); + free(branch->push_tracking_ref); merge_clear(branch); } @@ -1890,8 +1891,8 @@ static char *tracking_for_push_dest(struct remote *remote, return ret; } -static const char *branch_get_push_1(struct repository *repo, - struct branch *branch, struct strbuf *err) +static char *branch_get_push_1(struct repository *repo, + struct branch *branch, struct strbuf *err) { struct remote_state *remote_state = repo->remote_state; struct remote *remote; @@ -1931,7 +1932,7 @@ static const char *branch_get_push_1(struct repository *repo, return tracking_for_push_dest(remote, branch->refname, err); case PUSH_DEFAULT_UPSTREAM: - return branch_get_upstream(branch, err); + return xstrdup_or_null(branch_get_upstream(branch, err)); case PUSH_DEFAULT_UNSPECIFIED: case PUSH_DEFAULT_SIMPLE: diff --git a/remote.h b/remote.h index 0ca399e1835bf1..fc052945ee451d 100644 --- a/remote.h +++ b/remote.h @@ -331,7 +331,7 @@ struct branch { int merge_alloc; - const char *push_tracking_ref; + char *push_tracking_ref; }; struct branch *branch_get(const char *name); From b143f0f60816bbb2095eadc15d81b49c131f6a19 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Tue, 20 Jan 2026 22:47:08 +0100 Subject: [PATCH 23/29] last-modified: clarify in the docs the command takes a pathspec The documentation mentions git-last-modified(1) takes `...`, but that argument actually accepts a pathspec. Reword the documentation to reflect that. Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- Documentation/git-last-modified.adoc | 11 ++++++----- builtin/last-modified.c | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc index 602843e09598a5..7c3fd844b8eca5 100644 --- a/Documentation/git-last-modified.adoc +++ b/Documentation/git-last-modified.adoc @@ -9,7 +9,8 @@ git-last-modified - EXPERIMENTAL: Show when files were last modified SYNOPSIS -------- [synopsis] -git last-modified [--recursive] [--show-trees] [] [[--] ...] +git last-modified [--recursive] [--show-trees] + [] [[--] ...] DESCRIPTION ----------- @@ -39,10 +40,10 @@ OPTIONS spell ``, see the 'Specifying Ranges' section of linkgit:gitrevisions[7]. -`[--] ...`:: - For each __ given, the commit which last modified it is returned. - Without an optional path parameter, all files and subdirectories - in path traversal the are included in the output. +`[--] ...`:: + Show the commit that last modified each path matching __. + If no __ is given, all files and subdirectories are included. + See linkgit:gitglossary[7] for details on pathspec syntax. SEE ALSO -------- diff --git a/builtin/last-modified.c b/builtin/last-modified.c index b0ecbdc5400d13..781495f597652e 100644 --- a/builtin/last-modified.c +++ b/builtin/last-modified.c @@ -510,8 +510,8 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix, struct last_modified lm = { 0 }; const char * const last_modified_usage[] = { - N_("git last-modified [--recursive] [--show-trees] " - "[] [[--] ...]"), + N_("git last-modified [--recursive] [--show-trees]\n" + " [] [[--] ...]"), NULL }; From 209574de2d2ab0a264522c8c44c3eebb6d03ec43 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Tue, 20 Jan 2026 22:47:09 +0100 Subject: [PATCH 24/29] last-modified: document option '-z' The command git-last-modified(1) already recognizes the option '-z', and similar to many other commands this will make the output NUL-terminated instead of using newlines. Although, this option is missing from the documentation, so add it. In addition to that, to have '-z' also appear in the help output of `git last-modified -h`, move the handling of '-z' to parse_options() in builtin/last-modified.c itself. Before, the parsing of option '-z' was done by diff_opt_parse(), which is called by setup_revisions(). That would fill in `struct diff_options::line_termination`, but that field was not used by the diff machinery itself. Thus it makes more sense to have the handling of that option completely in builtin/last-modified.c. Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- Documentation/git-last-modified.adoc | 21 ++++++++++++++++++++- builtin/last-modified.c | 11 +++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc index 7c3fd844b8eca5..3760fd33a1826f 100644 --- a/Documentation/git-last-modified.adoc +++ b/Documentation/git-last-modified.adoc @@ -9,7 +9,7 @@ git-last-modified - EXPERIMENTAL: Show when files were last modified SYNOPSIS -------- [synopsis] -git last-modified [--recursive] [--show-trees] +git last-modified [--recursive] [--show-trees] [-z] [] [[--] ...] DESCRIPTION @@ -33,6 +33,9 @@ OPTIONS Show tree entries even when recursing into them. It has no effect without `--recursive`. +`-z`:: + Terminate each line with a _NUL_ character rather than a newline. + ``:: Only traverse commits in the specified revision range. When no `` is specified, it defaults to `HEAD` (i.e. the whole @@ -45,6 +48,22 @@ OPTIONS If no __ is given, all files and subdirectories are included. See linkgit:gitglossary[7] for details on pathspec syntax. +OUTPUT +------ + +The output is in the format: + +------------ + TAB LF +------------ + +If a path contains any special characters, the path is C-style quoted. To +avoid quoting, pass option `-z` to terminate each line with a NUL. + +------------ + TAB NUL +------------ + SEE ALSO -------- linkgit:git-blame[1], diff --git a/builtin/last-modified.c b/builtin/last-modified.c index 781495f597652e..46423b527e44a1 100644 --- a/builtin/last-modified.c +++ b/builtin/last-modified.c @@ -55,6 +55,7 @@ struct last_modified { struct rev_info rev; bool recursive; bool show_trees; + bool nul_termination; const char **all_paths; size_t all_paths_nr; @@ -165,10 +166,10 @@ static void last_modified_emit(struct last_modified *lm, putchar('^'); printf("%s\t", oid_to_hex(&commit->object.oid)); - if (lm->rev.diffopt.line_termination) - write_name_quoted(path, stdout, '\n'); - else + if (lm->nul_termination) printf("%s%c", path, '\0'); + else + write_name_quoted(path, stdout, '\n'); } static void mark_path(const char *path, const struct object_id *oid, @@ -510,7 +511,7 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix, struct last_modified lm = { 0 }; const char * const last_modified_usage[] = { - N_("git last-modified [--recursive] [--show-trees]\n" + N_("git last-modified [--recursive] [--show-trees] [-z]\n" " [] [[--] ...]"), NULL }; @@ -520,6 +521,8 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix, N_("recurse into subtrees")), OPT_BOOL('t', "show-trees", &lm.show_trees, N_("show tree entries when recursing into subtrees")), + OPT_BOOL('z', NULL, &lm.nul_termination, + N_("lines are separated with NUL character")), OPT_END() }; From 9bfaf78cb28b26ab0538e2edd2229547d6be962f Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Tue, 20 Jan 2026 22:47:10 +0100 Subject: [PATCH 25/29] last-modified: document option '--max-depth' Option --max-depth is supported by git-last-modified(1), because it was added to the diff machinery in a1dfa5448d (diff: teach tree-diff a max-depth parameter, 2025-08-07). This option is useful for everyday use of the git-last-modified(1) command, so document it's existence in the man page. To have it also appear in the help output of `git last-modified -h`, move the handling of '--max-depth' to parse_options() in builtin/last-modified.c itself. This prepares for the change in default behavior in the next commit. Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- Documentation/git-last-modified.adoc | 8 +++++++- builtin/last-modified.c | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc index 3760fd33a1826f..6f9b119bb655d0 100644 --- a/Documentation/git-last-modified.adoc +++ b/Documentation/git-last-modified.adoc @@ -9,7 +9,7 @@ git-last-modified - EXPERIMENTAL: Show when files were last modified SYNOPSIS -------- [synopsis] -git last-modified [--recursive] [--show-trees] [-z] +git last-modified [--recursive] [--show-trees] [--max-depth=] [-z] [] [[--] ...] DESCRIPTION @@ -33,6 +33,12 @@ OPTIONS Show tree entries even when recursing into them. It has no effect without `--recursive`. +`--max-depth=`:: + For each pathspec given on the command line, traverse at most `` + levels into subtrees. A negative value means no limit. + The default is 0, which shows all paths matching the pathspec + without descending into subtrees. + `-z`:: Terminate each line with a _NUL_ character rather than a newline. diff --git a/builtin/last-modified.c b/builtin/last-modified.c index 46423b527e44a1..797c1bb88b5a8d 100644 --- a/builtin/last-modified.c +++ b/builtin/last-modified.c @@ -56,6 +56,7 @@ struct last_modified { bool recursive; bool show_trees; bool nul_termination; + int max_depth; const char **all_paths; size_t all_paths_nr; @@ -483,6 +484,12 @@ static int last_modified_init(struct last_modified *lm, struct repository *r, lm->rev.diffopt.flags.recursive = lm->recursive; lm->rev.diffopt.flags.tree_in_recursive = lm->show_trees; + if (lm->max_depth >= 0) { + lm->rev.diffopt.flags.recursive = 1; + lm->rev.diffopt.max_depth = lm->max_depth; + lm->rev.diffopt.max_depth_valid = 1; + } + argc = setup_revisions(argc, argv, &lm->rev, NULL); if (argc > 1) { error(_("unknown last-modified argument: %s"), argv[1]); @@ -511,7 +518,7 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix, struct last_modified lm = { 0 }; const char * const last_modified_usage[] = { - N_("git last-modified [--recursive] [--show-trees] [-z]\n" + N_("git last-modified [--recursive] [--show-trees] [--max-depth=] [-z]\n" " [] [[--] ...]"), NULL }; @@ -521,11 +528,19 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix, N_("recurse into subtrees")), OPT_BOOL('t', "show-trees", &lm.show_trees, N_("show tree entries when recursing into subtrees")), + OPT_INTEGER_F(0, "max-depth", &lm.max_depth, + N_("maximum tree depth to recurse"), PARSE_OPT_NONEG), OPT_BOOL('z', NULL, &lm.nul_termination, N_("lines are separated with NUL character")), OPT_END() }; + /* + * Set the default of a max-depth to "unset". This will change in a + * subsequent commit. + */ + lm.max_depth = -1; + argc = parse_options(argc, argv, prefix, last_modified_options, last_modified_usage, PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT); From 9dcc09bed13aba0dc93d253f18ee2c7da5970c0c Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Tue, 20 Jan 2026 22:47:11 +0100 Subject: [PATCH 26/29] last-modified: change default max-depth to 0 By default git-last-modified(1) doesn't recurse into subtrees. So when the pathspec contained a path in a subtree, the command would only print the commit information about the parent tree of the path, like: $ git last-modified -- path/file aaa0aab1bbb2bcc3ccc4ddd5dde6eee7eff8fff9 path Change the default behavior to give commit information about the exact path instead: $ git last-modified -- path/file aaa0aab1bbb2bcc3ccc4ddd5dde6eee7eff8fff9 path/file To achieve this, the default max-depth is changed to 0 and recursive is always enabled. The handling of option '-r' is modified to disable a max-depth, resulting in the behavior of this option to remain unchanged. No existing tests were modified, because there didn't exist any tests covering the example above. But more tests are added to cover this now. Signed-off-by: Toon Claes Signed-off-by: Junio C Hamano --- Documentation/git-last-modified.adoc | 9 +++---- builtin/last-modified.c | 21 ++++------------- t/t8020-last-modified.sh | 35 ++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/Documentation/git-last-modified.adoc b/Documentation/git-last-modified.adoc index 6f9b119bb655d0..d7d16fc4f73e66 100644 --- a/Documentation/git-last-modified.adoc +++ b/Documentation/git-last-modified.adoc @@ -25,13 +25,14 @@ OPTIONS `-r`:: `--recursive`:: - Instead of showing tree entries, step into subtrees and show all entries - inside them recursively. + Recursively traverse into all subtrees. By default, the command only + shows tree entries matching the ``. With this option, it + descends into subtrees and displays all entries within them. + Equivalent to `--max-depth=-1`. `-t`:: `--show-trees`:: - Show tree entries even when recursing into them. It has no effect - without `--recursive`. + Show tree entries even when recursing into them. `--max-depth=`:: For each pathspec given on the command line, traverse at most `` diff --git a/builtin/last-modified.c b/builtin/last-modified.c index 797c1bb88b5a8d..e27f36b624c60d 100644 --- a/builtin/last-modified.c +++ b/builtin/last-modified.c @@ -53,7 +53,6 @@ define_commit_slab(active_paths_for_commit, struct bitmap *); struct last_modified { struct hashmap paths; struct rev_info rev; - bool recursive; bool show_trees; bool nul_termination; int max_depth; @@ -481,14 +480,10 @@ static int last_modified_init(struct last_modified *lm, struct repository *r, lm->rev.no_commit_id = 1; lm->rev.diff = 1; lm->rev.diffopt.flags.no_recursive_diff_tree_combined = 1; - lm->rev.diffopt.flags.recursive = lm->recursive; + lm->rev.diffopt.flags.recursive = 1; lm->rev.diffopt.flags.tree_in_recursive = lm->show_trees; - - if (lm->max_depth >= 0) { - lm->rev.diffopt.flags.recursive = 1; - lm->rev.diffopt.max_depth = lm->max_depth; - lm->rev.diffopt.max_depth_valid = 1; - } + lm->rev.diffopt.max_depth = lm->max_depth; + lm->rev.diffopt.max_depth_valid = lm->max_depth >= 0; argc = setup_revisions(argc, argv, &lm->rev, NULL); if (argc > 1) { @@ -524,8 +519,8 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix, }; struct option last_modified_options[] = { - OPT_BOOL('r', "recursive", &lm.recursive, - N_("recurse into subtrees")), + OPT_SET_INT('r', "recursive", &lm.max_depth, + N_("recurse into subtrees"), -1), OPT_BOOL('t', "show-trees", &lm.show_trees, N_("show tree entries when recursing into subtrees")), OPT_INTEGER_F(0, "max-depth", &lm.max_depth, @@ -535,12 +530,6 @@ int cmd_last_modified(int argc, const char **argv, const char *prefix, OPT_END() }; - /* - * Set the default of a max-depth to "unset". This will change in a - * subsequent commit. - */ - lm.max_depth = -1; - argc = parse_options(argc, argv, prefix, last_modified_options, last_modified_usage, PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT); diff --git a/t/t8020-last-modified.sh b/t/t8020-last-modified.sh index a4c1114ee28f7f..43f38937baf634 100755 --- a/t/t8020-last-modified.sh +++ b/t/t8020-last-modified.sh @@ -85,6 +85,41 @@ test_expect_success 'last-modified subdir recursive' ' EOF ' +test_expect_success 'last-modified subdir non-recursive' ' + check_last_modified a <<-\EOF + 3 a + EOF +' + +test_expect_success 'last-modified path in subdir non-recursive' ' + check_last_modified a/file <<-\EOF + 2 a/file + EOF +' + +test_expect_success 'last-modified subdir with wildcard non-recursive' ' + check_last_modified a/* <<-\EOF + 3 a/b + 2 a/file + EOF +' + +test_expect_success 'last-modified with negative max-depth' ' + check_last_modified --max-depth=-1 <<-\EOF + 3 a/b/file + 2 a/file + 1 file + EOF +' + +test_expect_success 'last-modified with max-depth of 1' ' + check_last_modified --max-depth=1 <<-\EOF + 3 a/b + 2 a/file + 1 file + EOF +' + test_expect_success 'last-modified from non-HEAD commit' ' check_last_modified HEAD^ <<-\EOF 2 a From 49223593fd743998d13fcd27fceaf1e0095bb08e Mon Sep 17 00:00:00 2001 From: Amisha Chhajed Date: Wed, 21 Jan 2026 18:30:05 +0530 Subject: [PATCH 27/29] sparse-checkout: optimize string_list construction and add tests to verify deduplication. Improve O(n^2) complexity to O(n log n) while building a sorted 'string_list' by constructing it unsorted then sorting it followed by removing duplicates. sparse-checkout deduplicates repeated cone-mode patterns, but this behaviour was previously untested, add tests that verify that sparse-checkout file contain each cone pattern only once and sparse-checkout list reports each pattern only once. Signed-off-by: Amisha Chhajed Acked-by: Derrick Stolee Signed-off-by: Junio C Hamano --- builtin/sparse-checkout.c | 7 +++-- t/t1091-sparse-checkout-builtin.sh | 48 ++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/builtin/sparse-checkout.c b/builtin/sparse-checkout.c index 15d51e60a86533..7dfb276bf01431 100644 --- a/builtin/sparse-checkout.c +++ b/builtin/sparse-checkout.c @@ -91,10 +91,11 @@ static int sparse_checkout_list(int argc, const char **argv, const char *prefix, hashmap_for_each_entry(&pl.recursive_hashmap, &iter, pe, ent) { /* pe->pattern starts with "/", skip it */ - string_list_insert(&sl, pe->pattern + 1); + string_list_append(&sl, pe->pattern + 1); } string_list_sort(&sl); + string_list_remove_duplicates(&sl, 0); for (i = 0; i < sl.nr; i++) { quote_c_style(sl.items[i].string, NULL, stdout, 0); @@ -289,7 +290,7 @@ static void write_cone_to_file(FILE *fp, struct pattern_list *pl) if (!hashmap_contains_parent(&pl->recursive_hashmap, pe->pattern, &parent_pattern)) - string_list_insert(&sl, pe->pattern); + string_list_append(&sl, pe->pattern); } string_list_sort(&sl); @@ -311,7 +312,7 @@ static void write_cone_to_file(FILE *fp, struct pattern_list *pl) if (!hashmap_contains_parent(&pl->recursive_hashmap, pe->pattern, &parent_pattern)) - string_list_insert(&sl, pe->pattern); + string_list_append(&sl, pe->pattern); } strbuf_release(&parent_pattern); diff --git a/t/t1091-sparse-checkout-builtin.sh b/t/t1091-sparse-checkout-builtin.sh index b2da4feaeff9ec..cd0aed9975fe24 100755 --- a/t/t1091-sparse-checkout-builtin.sh +++ b/t/t1091-sparse-checkout-builtin.sh @@ -817,6 +817,54 @@ test_expect_success 'cone mode clears ignored subdirectories' ' test_cmp expect out ' +test_expect_success 'sparse-checkout deduplicates repeated cone patterns' ' + rm -f repo/.git/info/sparse-checkout && + git -C repo sparse-checkout init --cone && + git -C repo sparse-checkout add --stdin <<-\EOF && + foo/bar/baz + a/b/c + foo/bar/baz + a/b + EOF + cat >expect <<-\EOF && + /* + !/*/ + /a/ + !/a/*/ + /foo/ + !/foo/*/ + /foo/bar/ + !/foo/bar/*/ + /a/b/ + /foo/bar/baz/ + EOF + test_cmp expect repo/.git/info/sparse-checkout +' + +test_expect_success 'sparse-checkout list deduplicates repeated cone patterns' ' + rm -f repo/.git/info/sparse-checkout && + git -C repo sparse-checkout init --cone && + cat <<-\EOF >repo/.git/info/sparse-checkout && + /* + !/*/ + /a/ + !/a/*/ + /foo/ + !/foo/*/ + /foo/bar/ + !/foo/bar/*/ + /a/b/ + /foo/bar/baz/ + /foo/bar/baz/ + EOF + git -C repo sparse-checkout list >actual && + cat <<-\EOF >expect && + a/b + foo/bar/baz + EOF + test_cmp expect actual +' + test_expect_success 'malformed cone-mode patterns' ' git -C repo sparse-checkout init --cone && mkdir -p repo/foo/bar && From a824421d3644f39bfa8dfc75876db8ed1c7bcdbf Mon Sep 17 00:00:00 2001 From: Shreyansh Paliwal Date: Wed, 21 Jan 2026 18:24:11 +0530 Subject: [PATCH 28/29] t5500: simplify test implementation and fix git exit code suppression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'shallow since with commit graph and already-seen commit” test uses a convoluted here-doc that combines manual input construction with packetize, echo and embedded Git commands. This structure hides failures from the git commands, as their exit codes are suppressed inside echo command substitution and being on the upstream side of pipes. Instead of using here-doc to construct the pack protocol that is directly sent to the 'git upload-pack' command being tested, capture the outputs of the git commands upfront and use the 'test-tool pkt-line pack' tool to construct the input in a temporary file, and then feed it to the command. This has a few advantages: * Executing the git commands outside the here-doc avoids suppressing their exit codes and makes debugging easier. * It removes the need to manually count and manage pkt-line lengths to keep in line with the v2 protocol, as the tool handles this internally. Signed-off-by: Shreyansh Paliwal Signed-off-by: Junio C Hamano --- t/t5500-fetch-pack.sh | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/t/t5500-fetch-pack.sh b/t/t5500-fetch-pack.sh index 2677cd5faa8253..4bb56c167a52ec 100755 --- a/t/t5500-fetch-pack.sh +++ b/t/t5500-fetch-pack.sh @@ -892,15 +892,20 @@ test_expect_success 'shallow since with commit graph and already-seen commit' ' test_commit other && git commit-graph write --reachable && git config core.commitGraph true && - - GIT_PROTOCOL=version=2 git upload-pack . <<-EOF >/dev/null - 0012command=fetch - $(echo "object-format=$(test_oid algo)" | packetize) - 00010013deepen-since 1 - $(echo "want $(git rev-parse other)" | packetize) - $(echo "have $(git rev-parse main)" | packetize) + oid_algo=$(test_oid algo) && + oid_other=$(git rev-parse other) && + oid_main=$(git rev-parse main) && + + test-tool pkt-line pack >input <<-EOF && + command=fetch + object-format=$oid_algo + 0001 + deepen-since 1 + want $oid_other + have $oid_main 0000 EOF + GIT_PROTOCOL=version=2 git upload-pack . /dev/null ) ' From 3e0db84c88c57e70ac8be8c196dfa92c5d656fbc Mon Sep 17 00:00:00 2001 From: Junio C Hamano Date: Thu, 5 Feb 2026 15:07:22 -0800 Subject: [PATCH 29/29] Start 2.54 cycle Signed-off-by: Junio C Hamano --- Documentation/RelNotes/2.54.0.adoc | 39 ++++++++++++++++++++++++++++++ GIT-VERSION-GEN | 2 +- RelNotes | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 Documentation/RelNotes/2.54.0.adoc diff --git a/Documentation/RelNotes/2.54.0.adoc b/Documentation/RelNotes/2.54.0.adoc new file mode 100644 index 00000000000000..20c660d82a40cf --- /dev/null +++ b/Documentation/RelNotes/2.54.0.adoc @@ -0,0 +1,39 @@ +Git v2.54 Release Notes +======================= + +UI, Workflows & Features +------------------------ + + * "git add -p" and friends note what the current status of the hunk + being shown is. + + +Performance, Internal Implementation, Development Support etc. +-------------------------------------------------------------- + + * Avoid local submodule repository directory paths overlapping with + each other by encoding submodule names before using them as path + components. + + +Fixes since v2.53 +----------------- + + * HTTP transport failed to authenticate in some code paths, which has + been corrected. + (merge ed0f7a62f7 ap/http-probe-rpc-use-auth later to maint). + + * The computation of column width made by "git diff --stat" was + confused when pathnames contain non-ASCII characters. + (merge 04f5d95ef7 lp/diff-stat-utf8-display-width-fix later to maint). + + * The "-z" and "--max-depth" documentation (and implementation of + "-z") in the "git last-modified" command have been updated. + (merge 9dcc09bed1 tc/last-modified-options-cleanup later to maint). + + * Other code cleanup, docfix, build fix, etc. + (merge d79fff4a11 jk/remote-tracking-ref-leakfix later to maint). + (merge 7a747f972d dd/t5403-modernise later to maint). + (merge 81021871ea sp/myfirstcontribution-include-update later to maint). + (merge 49223593fd ac/sparse-checkout-string-list-cleanup later to maint). + (merge a824421d36 sp/t5500-cleanup later to maint). diff --git a/GIT-VERSION-GEN b/GIT-VERSION-GEN index 86730d0da77724..44240e07b881f0 100755 --- a/GIT-VERSION-GEN +++ b/GIT-VERSION-GEN @@ -1,6 +1,6 @@ #!/bin/sh -DEF_VER=v2.53.0 +DEF_VER=v2.53.GIT LF=' ' diff --git a/RelNotes b/RelNotes index 6dfd3d1bcf2f33..84ef7387adb35c 120000 --- a/RelNotes +++ b/RelNotes @@ -1 +1 @@ -Documentation/RelNotes/2.53.0.adoc \ No newline at end of file +Documentation/RelNotes/2.54.0.adoc \ No newline at end of file