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.<name>.gitdir repo config becomes the single source of truth for all submodule gitdir paths. The submodule.<name>.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 <gitster@pobox.com> Suggested-by: Phillip Wood <phillip.wood123@gmail.com> Suggested-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Adrian Ratiu <adrian.ratiu@collabora.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Adrian Ratiu committed Jan 12, 2026 at 20:46 UTC 4173df5187c8ba8bc2cc1a215f25b284d70631da
12 files changed +313 -28
Documentation/config/extensions.adoc
+23
@@ -73,6 +73,29 @@ relativeWorktrees:::
73 repaired with either the `--relative-paths` option or with the
74 `worktree.useRelativePaths` config set to `true`.
75
76 +submodulePathConfig:::
77 + This extension is for the minority of users who:
78 ++
79 +--
80 +* Encounter errors like `refusing to create ... in another submodule's git dir`
81 + due to a number of reasons, like case-insensitive filesystem conflicts when
82 + creating modules named `foo` and `Foo`.
83 +* Require more flexible submodule layouts, for example due to nested names like
84 + `foo`, `foo/bar` and `foo/baz` not supported by the default gitdir mechanism
85 + which uses `.git/modules/<plain-name>` locations, causing further conflicts.
86 +--
87 ++
88 +When `extensions.submodulePathConfig` is enabled, the `submodule.<name>.gitdir`
89 +config becomes the single source of truth for all submodule gitdir paths and is
90 +automatically set for all new submodules both during clone and init operations.
91 ++
92 +Git will error out if a module does not have a corresponding
93 +`submodule.<name>.gitdir` set.
94 ++
95 +Existing (pre-extension) submodules need to be migrated by adding the missing
96 +config entries. This is done manually for now, e.g. for each submodule:
97 +`git config submodule.<name>.gitdir .git/modules/<name>`.
98 +
99 worktreeConfig:::
100 If enabled, then worktrees will load config settings from the
101 `$GIT_DIR/config.worktree` file in addition to the
Documentation/config/submodule.adoc
+7
@@ -52,6 +52,13 @@ submodule.<name>.active::
52 submodule.active config option. See linkgit:gitsubmodules[7] for
53 details.
54
55 +submodule.<name>.gitdir::
56 + This sets the gitdir path for submodule <name>. This configuration is
57 + respected when `extensions.submodulePathConfig` is enabled, otherwise it
58 + has no effect. When enabled, this config becomes the single source of
59 + truth for submodule gitdir paths and Git will error if it is missing.
60 + See linkgit:git-config[1] for details.
61 +
62 submodule.active::
63 A repeated field which contains a pathspec used to match against a
64 submodule's path to determine if the submodule is of interest to git
builtin/submodule--helper.c
+52 -2
@@ -435,6 +435,48 @@ struct init_cb {
435 };
436 #define INIT_CB_INIT { 0 }
437
438 +static int validate_and_set_submodule_gitdir(struct strbuf *gitdir_path,
439 + const char *submodule_name)
440 +{
441 + const char *value;
442 + char *key;
443 +
444 + if (validate_submodule_git_dir(gitdir_path->buf, submodule_name))
445 + return -1;
446 +
447 + key = xstrfmt("submodule.%s.gitdir", submodule_name);
448 +
449 + /* Nothing to do if the config already exists. */
450 + if (!repo_config_get_string_tmp(the_repository, key, &value)) {
451 + free(key);
452 + return 0;
453 + }
454 +
455 + if (repo_config_set_gently(the_repository, key, gitdir_path->buf)) {
456 + free(key);
457 + return -1;
458 + }
459 +
460 + free(key);
461 + return 0;
462 +}
463 +
464 +static void create_default_gitdir_config(const char *submodule_name)
465 +{
466 + struct strbuf gitdir_path = STRBUF_INIT;
467 +
468 + repo_git_path_append(the_repository, &gitdir_path, "modules/%s", submodule_name);
469 + if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) {
470 + strbuf_release(&gitdir_path);
471 + return;
472 + }
473 +
474 + die(_("failed to set a valid default config for 'submodule.%s.gitdir'. "
475 + "Please ensure it is set, for example by running something like: "
476 + "'git config submodule.%s.gitdir .git/modules/%s'"),
477 + submodule_name, submodule_name, submodule_name);
478 +}
479 +
480 static void init_submodule(const char *path, const char *prefix,
481 const char *super_prefix,
482 unsigned int flags)
@@ -511,6 +553,10 @@ static void init_submodule(const char *path, const char *prefix,
553 if (repo_config_set_gently(the_repository, sb.buf, upd))
554 die(_("Failed to register update mode for submodule path '%s'"), displaypath);
555 }
556 +
557 + if (the_repository->repository_format_submodule_path_cfg)
558 + create_default_gitdir_config(sub->name);
559 +
560 strbuf_release(&sb);
561 free(displaypath);
562 free(url);
@@ -1805,8 +1851,9 @@ static int clone_submodule(const struct module_clone_data *clone_data,
1851 char *head = xstrfmt("%s/HEAD", sm_gitdir);
1852 unlink(head);
1853 free(head);
1808 - die(_("refusing to create/use '%s' in another submodule's "
1809 - "git dir"), sm_gitdir);
1854 + die(_("refusing to create/use '%s' in another submodule's git dir. "
1855 + "Enabling extensions.submodulePathConfig should fix this."),
1856 + sm_gitdir);
1857 }
1858
1859 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,
3625 add_data.progress = !!progress;
3626 add_data.dissociate = !!dissociate;
3627
3628 + if (the_repository->repository_format_submodule_path_cfg)
3629 + create_default_gitdir_config(add_data.sm_name);
3630 +
3631 if (add_submodule(&add_data))
3632 goto cleanup;
3633 configure_added_submodule(&add_data);
repository.c
+1
@@ -288,6 +288,7 @@ int repo_init(struct repository *repo,
288 repo->repository_format_worktree_config = format.worktree_config;
289 repo->repository_format_relative_worktrees = format.relative_worktrees;
290 repo->repository_format_precious_objects = format.precious_objects;
291 + repo->repository_format_submodule_path_cfg = format.submodule_path_cfg;
292
293 /* take ownership of format.partial_clone */
294 repo->repository_format_partial_clone = format.partial_clone;
repository.h
+1
@@ -158,6 +158,7 @@ struct repository {
158 int repository_format_worktree_config;
159 int repository_format_relative_worktrees;
160 int repository_format_precious_objects;
161 + int repository_format_submodule_path_cfg;
162
163 /* Indicate if a repository has a different 'commondir' from 'gitdir' */
164 unsigned different_commondir:1;
setup.c
+7
@@ -687,6 +687,9 @@ static enum extension_result handle_extension(const char *var,
687 } else if (!strcmp(ext, "relativeworktrees")) {
688 data->relative_worktrees = git_config_bool(var, value);
689 return EXTENSION_OK;
690 + } else if (!strcmp(ext, "submodulepathconfig")) {
691 + data->submodule_path_cfg = git_config_bool(var, value);
692 + return EXTENSION_OK;
693 }
694 return EXTENSION_UNKNOWN;
695 }
@@ -1865,6 +1868,8 @@ const char *setup_git_directory_gently(int *nongit_ok)
1868 repo_fmt.worktree_config;
1869 the_repository->repository_format_relative_worktrees =
1870 repo_fmt.relative_worktrees;
1871 + the_repository->repository_format_submodule_path_cfg =
1872 + repo_fmt.submodule_path_cfg;
1873 /* take ownership of repo_fmt.partial_clone */
1874 the_repository->repository_format_partial_clone =
1875 repo_fmt.partial_clone;
@@ -1963,6 +1968,8 @@ void check_repository_format(struct repository_format *fmt)
1968 fmt->ref_storage_format);
1969 the_repository->repository_format_worktree_config =
1970 fmt->worktree_config;
1971 + the_repository->repository_format_submodule_path_cfg =
1972 + fmt->submodule_path_cfg;
1973 the_repository->repository_format_relative_worktrees =
1974 fmt->relative_worktrees;
1975 the_repository->repository_format_partial_clone =
setup.h
+1
@@ -130,6 +130,7 @@ struct repository_format {
130 char *partial_clone; /* value of extensions.partialclone */
131 int worktree_config;
132 int relative_worktrees;
133 + int submodule_path_cfg;
134 int is_bare;
135 int hash_algo;
136 int compat_hash_algo;
submodule.c
+35 -26
@@ -31,6 +31,7 @@
31 #include "commit-reach.h"
32 #include "read-cache-ll.h"
33 #include "setup.h"
34 +#include "advice.h"
35
36 static int config_update_recurse_submodules = RECURSE_SUBMODULES_OFF;
37 static int initialized_fetch_ref_tips;
@@ -2164,8 +2165,9 @@ int submodule_move_head(const char *path, const char *super_prefix,
2165 if (validate_submodule_git_dir(git_dir,
2166 sub->name) < 0)
2167 die(_("refusing to create/use '%s' in "
2167 - "another submodule's git dir"),
2168 - git_dir);
2168 + "another submodule's git dir. "
2169 + "Enabling extensions.submodulePathConfig "
2170 + "should fix this."), git_dir);
2171 free(git_dir);
2172 }
2173 } else {
@@ -2576,30 +2578,37 @@ cleanup:
2578 void submodule_name_to_gitdir(struct strbuf *buf, struct repository *r,
2579 const char *submodule_name)
2580 {
2579 - /*
2580 - * NEEDSWORK: The current way of mapping a submodule's name to
2581 - * its location in .git/modules/ has problems with some naming
2582 - * schemes. For example, if a submodule is named "foo" and
2583 - * another is named "foo/bar" (whether present in the same
2584 - * superproject commit or not - the problem will arise if both
2585 - * superproject commits have been checked out at any point in
2586 - * time), or if two submodule names only have different cases in
2587 - * a case-insensitive filesystem.
2588 - *
2589 - * There are several solutions, including encoding the path in
2590 - * some way, introducing a submodule.<name>.gitdir config in
2591 - * .git/config (not .gitmodules) that allows overriding what the
2592 - * gitdir of a submodule would be (and teach Git, upon noticing
2593 - * a clash, to automatically determine a non-clashing name and
2594 - * to write such a config), or introducing a
2595 - * submodule.<name>.gitdir config in .gitmodules that repo
2596 - * administrators can explicitly set. Nothing has been decided,
2597 - * so for now, just append the name at the end of the path.
2598 - */
2599 - repo_git_path_append(r, buf, "modules/");
2600 - strbuf_addstr(buf, submodule_name);
2581 + if (!r->repository_format_submodule_path_cfg) {
2582 + /*
2583 + * If extensions.submodulePathConfig is disabled,
2584 + * continue to use the plain path.
2585 + */
2586 + repo_git_path_append(r, buf, "modules/%s", submodule_name);
2587 + } else {
2588 + const char *gitdir;
2589 + char *key;
2590 + int ret;
2591
2602 - if (validate_submodule_git_dir(buf->buf, submodule_name) < 0)
2592 + /* Otherwise the extension is enabled, so use the gitdir config. */
2593 + key = xstrfmt("submodule.%s.gitdir", submodule_name);
2594 + ret = repo_config_get_string_tmp(r, key, &gitdir);
2595 + FREE_AND_NULL(key);
2596 +
2597 + if (ret)
2598 + die(_("the 'submodule.%s.gitdir' config does not exist for module '%s'. "
2599 + "Please ensure it is set, for example by running something like: "
2600 + "'git config submodule.%s.gitdir .git/modules/%s'. For details "
2601 + "see the extensions.submodulePathConfig documentation."),
2602 + submodule_name, submodule_name, submodule_name, submodule_name);
2603 +
2604 + strbuf_addstr(buf, gitdir);
2605 + }
2606 +
2607 + /* validate because users might have modified the config */
2608 + if (validate_submodule_git_dir(buf->buf, submodule_name)) {
2609 + advise(_("enabling extensions.submodulePathConfig might fix the "
2610 + "following error, if it's not already enabled."));
2611 die(_("refusing to create/use '%s' in another submodule's "
2604 - "git dir"), buf->buf);
2612 + " git dir."), buf->buf);
2613 + }
2614 }
t/lib-verify-submodule-gitdir-path.sh new
+24
@@ -0,0 +1,24 @@
1 +# Helper to verify if repo $1 contains a submodule named $2 with gitdir path $3
2 +
3 +# This does not check filesystem existence. That is done in submodule.c via the
4 +# submodule_name_to_gitdir() API which this helper ends up calling. The gitdirs
5 +# might or might not exist (e.g. when adding a new submodule), so this only
6 +# checks the expected configuration path, which might be overridden by the user.
7 +
8 +verify_submodule_gitdir_path () {
9 + repo="$1" &&
10 + name="$2" &&
11 + path="$3" &&
12 + (
13 + cd "$repo" &&
14 + # Compute expected absolute path
15 + expected="$(git rev-parse --git-common-dir)/$path" &&
16 + expected="$(test-tool path-utils real_path "$expected")" &&
17 + # Compute actual absolute path
18 + actual="$(git submodule--helper gitdir "$name")" &&
19 + actual="$(test-tool path-utils real_path "$actual")" &&
20 + echo "$expected" >expect &&
21 + echo "$actual" >actual &&
22 + test_cmp expect actual
23 + )
24 +}
t/meson.build
+1
@@ -884,6 +884,7 @@ integration_tests = [
884 't7422-submodule-output.sh',
885 't7423-submodule-symlinks.sh',
886 't7424-submodule-mixed-ref-formats.sh',
887 + 't7425-submodule-gitdir-path-extension.sh',
888 't7450-bad-git-dotfiles.sh',
889 't7500-commit-template-squash-signoff.sh',
890 't7501-commit-basic-functionality.sh',
t/t7425-submodule-gitdir-path-extension.sh new
+160
@@ -0,0 +1,160 @@
1 +#!/bin/sh
2 +
3 +test_description='submodulePathConfig extension works as expected'
4 +
5 +. ./test-lib.sh
6 +. "$TEST_DIRECTORY"/lib-verify-submodule-gitdir-path.sh
7 +
8 +test_expect_success 'setup: allow file protocol' '
9 + git config --global protocol.file.allow always
10 +'
11 +
12 +test_expect_success 'create repo with mixed extension submodules' '
13 + git init -b main legacy-sub &&
14 + test_commit -C legacy-sub legacy-initial &&
15 + legacy_rev=$(git -C legacy-sub rev-parse HEAD) &&
16 +
17 + git init -b main new-sub &&
18 + test_commit -C new-sub new-initial &&
19 + new_rev=$(git -C new-sub rev-parse HEAD) &&
20 +
21 + git init -b main main &&
22 + (
23 + cd main &&
24 + git submodule add ../legacy-sub legacy &&
25 + test_commit legacy-sub &&
26 +
27 + # trigger the "die_path_inside_submodule" check
28 + test_must_fail git submodule add ../new-sub "legacy/nested" &&
29 +
30 + git config core.repositoryformatversion 1 &&
31 + git config extensions.submodulePathConfig true &&
32 +
33 + git submodule add ../new-sub "New Sub" &&
34 + test_commit new &&
35 +
36 + # retrigger the "die_path_inside_submodule" check with encoding
37 + test_must_fail git submodule add ../new-sub "New Sub/nested2"
38 + )
39 +'
40 +
41 +test_expect_success 'verify new submodule gitdir config' '
42 + git -C main config submodule."New Sub".gitdir >actual &&
43 + echo ".git/modules/New Sub" >expect &&
44 + test_cmp expect actual &&
45 + verify_submodule_gitdir_path main "New Sub" "modules/New Sub"
46 +'
47 +
48 +test_expect_success 'manual add and verify legacy submodule gitdir config' '
49 + # the legacy module should not contain a gitdir config, because it
50 + # was added before the extension was enabled. Add and test it.
51 + test_must_fail git -C main config submodule.legacy.gitdir &&
52 + git -C main config submodule.legacy.gitdir .git/modules/legacy &&
53 + git -C main config submodule.legacy.gitdir >actual &&
54 + echo ".git/modules/legacy" >expect &&
55 + test_cmp expect actual &&
56 + verify_submodule_gitdir_path main "legacy" "modules/legacy"
57 +'
58 +
59 +test_expect_success 'gitdir config path is relative for both absolute and relative urls' '
60 + test_when_finished "rm -rf relative-cfg-path-test" &&
61 + git init -b main relative-cfg-path-test &&
62 + (
63 + cd relative-cfg-path-test &&
64 + git config core.repositoryformatversion 1 &&
65 + git config extensions.submodulePathConfig true &&
66 +
67 + # Test with absolute URL
68 + git submodule add "$TRASH_DIRECTORY/new-sub" sub-abs &&
69 + git config submodule.sub-abs.gitdir >actual &&
70 + echo ".git/modules/sub-abs" >expect &&
71 + test_cmp expect actual &&
72 +
73 + # Test with relative URL
74 + git submodule add ../new-sub sub-rel &&
75 + git config submodule.sub-rel.gitdir >actual &&
76 + echo ".git/modules/sub-rel" >expect &&
77 + test_cmp expect actual
78 + )
79 +'
80 +
81 +test_expect_success 'clone from repo with both legacy and new-style submodules' '
82 + git clone --recurse-submodules main cloned-non-extension &&
83 + (
84 + cd cloned-non-extension &&
85 +
86 + test_path_is_dir .git/modules/legacy &&
87 + test_path_is_dir .git/modules/"New Sub" &&
88 +
89 + test_must_fail git config submodule.legacy.gitdir &&
90 + test_must_fail git config submodule."New Sub".gitdir &&
91 +
92 + git submodule status >list &&
93 + test_grep "$legacy_rev legacy" list &&
94 + test_grep "$new_rev New Sub" list
95 + ) &&
96 +
97 + git clone -c extensions.submodulePathConfig=true --recurse-submodules main cloned-extension &&
98 + (
99 + cd cloned-extension &&
100 +
101 + test_path_is_dir .git/modules/legacy &&
102 + test_path_is_dir ".git/modules/New Sub" &&
103 +
104 + git config submodule.legacy.gitdir &&
105 + git config submodule."New Sub".gitdir &&
106 +
107 + git submodule status >list &&
108 + test_grep "$legacy_rev legacy" list &&
109 + test_grep "$new_rev New Sub" list
110 + )
111 +'
112 +
113 +test_expect_success 'commit and push changes to encoded submodules' '
114 + git -C legacy-sub config receive.denyCurrentBranch updateInstead &&
115 + git -C new-sub config receive.denyCurrentBranch updateInstead &&
116 + git -C main config receive.denyCurrentBranch updateInstead &&
117 + (
118 + cd cloned-extension &&
119 +
120 + git -C legacy switch --track -C main origin/main &&
121 + test_commit -C legacy second-commit &&
122 + git -C legacy push &&
123 +
124 + git -C "New Sub" switch --track -C main origin/main &&
125 + test_commit -C "New Sub" second-commit &&
126 + git -C "New Sub" push &&
127 +
128 + # Stage and commit submodule changes in superproject
129 + git switch --track -C main origin/main &&
130 + git add legacy "New Sub" &&
131 + git commit -m "update submodules" &&
132 +
133 + # push superproject commit to main repo
134 + git push
135 + ) &&
136 +
137 + # update expected legacy & new submodule checksums
138 + legacy_rev=$(git -C legacy-sub rev-parse HEAD) &&
139 + new_rev=$(git -C new-sub rev-parse HEAD)
140 +'
141 +
142 +test_expect_success 'fetch mixed submodule changes and verify updates' '
143 + (
144 + cd main &&
145 +
146 + # only update submodules because superproject was
147 + # pushed into at the end of last test
148 + git submodule update --init --recursive &&
149 +
150 + test_path_is_dir .git/modules/legacy &&
151 + test_path_is_dir ".git/modules/New Sub" &&
152 +
153 + # Verify both submodules are at the expected commits
154 + git submodule status >list &&
155 + test_grep "$legacy_rev legacy" list &&
156 + test_grep "$new_rev New Sub" list
157 + )
158 +'
159 +
160 +test_done
t/t9902-completion.sh
+1
@@ -3053,6 +3053,7 @@ test_expect_success 'git config set - variable name - __git_compute_second_level
3053 submodule.sub.fetchRecurseSubmodules Z
3054 submodule.sub.ignore Z
3055 submodule.sub.active Z
3056 + submodule.sub.gitdir Z
3057 EOF
3058 '
3059