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 <gitster@pobox.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
920fbe4d4ee8d4e191d33dde05a16ee0e74bdd44
3 files changed
+110
-1
builtin/submodule--helper.c
+12
@@ -34,6 +34,7 @@
34
#include "list-objects-filter-options.h"
35
#include "wildmatch.h"
36
#include "strbuf.h"
37
+#include "url.h"
38
39
#define OPT_QUIET (1 << 0)
40
#define OPT_CACHED (1 << 1)
@@ -465,12 +466,23 @@ static void create_default_gitdir_config(const char *submodule_name)
466
{
467
struct strbuf gitdir_path = STRBUF_INIT;
468
469
+ /* Case 1: try the plain module name */
470
repo_git_path_append(the_repository, &gitdir_path, "modules/%s", submodule_name);
471
if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) {
472
strbuf_release(&gitdir_path);
473
return;
474
}
475
476
+ /* Case 2: Try URI-safe (RFC3986) encoding first, this fixes nested gitdirs */
477
+ strbuf_reset(&gitdir_path);
478
+ repo_git_path_append(the_repository, &gitdir_path, "modules/");
479
+ strbuf_addstr_urlencode(&gitdir_path, submodule_name, is_rfc3986_unreserved);
480
+ if (!validate_and_set_submodule_gitdir(&gitdir_path, submodule_name)) {
481
+ strbuf_release(&gitdir_path);
482
+ return;
483
+ }
484
+
485
+ /* Case 3: nothing worked, error out */
486
die(_("failed to set a valid default config for 'submodule.%s.gitdir'. "
487
"Please ensure it is set, for example by running something like: "
488
"'git config submodule.%s.gitdir .git/modules/%s'"),
submodule.c
+41
-1
@@ -32,6 +32,7 @@
32
#include "read-cache-ll.h"
33
#include "setup.h"
34
#include "advice.h"
35
+#include "url.h"
36
37
static int config_update_recurse_submodules = RECURSE_SUBMODULES_OFF;
38
static int initialized_fetch_ref_tips;
@@ -2253,12 +2254,43 @@ out:
2254
return ret;
2255
}
2256
2256
-int validate_submodule_git_dir(char *git_dir, const char *submodule_name)
2257
+/*
2258
+ * Encoded gitdir validation, only used when extensions.submodulePathConfig is enabled.
2259
+ * This does not print errors like the non-encoded version, because encoding is supposed
2260
+ * to mitigate / fix all these.
2261
+ */
2262
+static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodule_name UNUSED)
2263
+{
2264
+ const char *modules_marker = "/modules/";
2265
+ char *p = git_dir, *last_submodule_name = NULL;
2266
+
2267
+ if (!the_repository->repository_format_submodule_path_cfg)
2268
+ BUG("validate_submodule_encoded_git_dir() must be called with "
2269
+ "extensions.submodulePathConfig enabled.");
2270
+
2271
+ /* Find the last submodule name in the gitdir path (modules can be nested). */
2272
+ while ((p = strstr(p, modules_marker))) {
2273
+ last_submodule_name = p + strlen(modules_marker);
2274
+ p++;
2275
+ }
2276
+
2277
+ /* Prevent the use of '/' in encoded names */
2278
+ if (!last_submodule_name || strchr(last_submodule_name, '/'))
2279
+ return -1;
2280
+
2281
+ return 0;
2282
+}
2283
+
2284
+static int validate_submodule_legacy_git_dir(char *git_dir, const char *submodule_name)
2285
{
2286
size_t len = strlen(git_dir), suffix_len = strlen(submodule_name);
2287
char *p;
2288
int ret = 0;
2289
2290
+ if (the_repository->repository_format_submodule_path_cfg)
2291
+ BUG("validate_submodule_git_dir() must be called with "
2292
+ "extensions.submodulePathConfig disabled.");
2293
+
2294
if (len <= suffix_len || (p = git_dir + len - suffix_len)[-1] != '/' ||
2295
strcmp(p, submodule_name))
2296
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)
2326
return 0;
2327
}
2328
2329
+int validate_submodule_git_dir(char *git_dir, const char *submodule_name)
2330
+{
2331
+ if (!the_repository->repository_format_submodule_path_cfg)
2332
+ return validate_submodule_legacy_git_dir(git_dir, submodule_name);
2333
+
2334
+ return validate_submodule_encoded_git_dir(git_dir, submodule_name);
2335
+}
2336
+
2337
int validate_submodule_path(const char *path)
2338
{
2339
char *p = xstrdup(path);
t/t7425-submodule-gitdir-path-extension.sh
+57
@@ -346,4 +346,61 @@ test_expect_success '`git clone --recurse-submodules` works after migration' '
346
)
347
'
348
349
+test_expect_success 'setup submodules with nested git dirs' '
350
+ git init nested &&
351
+ test_commit -C nested nested &&
352
+ (
353
+ cd nested &&
354
+ cat >.gitmodules <<-EOF &&
355
+ [submodule "hippo"]
356
+ url = .
357
+ path = thing1
358
+ [submodule "hippo/hooks"]
359
+ url = .
360
+ path = thing2
361
+ EOF
362
+ git clone . thing1 &&
363
+ git clone . thing2 &&
364
+ git add .gitmodules thing1 thing2 &&
365
+ test_tick &&
366
+ git commit -m nested
367
+ )
368
+'
369
+
370
+test_expect_success 'git dirs of encoded sibling submodules must not be nested' '
371
+ git clone -c extensions.submodulePathConfig=true --recurse-submodules nested clone_nested &&
372
+
373
+ verify_submodule_gitdir_path clone_nested hippo modules/hippo &&
374
+ git -C clone_nested config submodule.hippo.gitdir >actual &&
375
+ test_grep "\.git/modules/hippo$" actual &&
376
+
377
+ verify_submodule_gitdir_path clone_nested hippo/hooks modules/hippo%2fhooks &&
378
+ git -C clone_nested config submodule.hippo/hooks.gitdir >actual &&
379
+ test_grep "\.git/modules/hippo%2fhooks$" actual
380
+'
381
+
382
+test_expect_success 'submodule git dir nesting detection must work with parallel cloning' '
383
+ git clone -c extensions.submodulePathConfig=true --recurse-submodules --jobs=2 nested clone_parallel &&
384
+
385
+ verify_submodule_gitdir_path clone_parallel hippo modules/hippo &&
386
+ git -C clone_nested config submodule.hippo.gitdir >actual &&
387
+ test_grep "\.git/modules/hippo$" actual &&
388
+
389
+ verify_submodule_gitdir_path clone_parallel hippo/hooks modules/hippo%2fhooks &&
390
+ git -C clone_nested config submodule.hippo/hooks.gitdir >actual &&
391
+ test_grep "\.git/modules/hippo%2fhooks$" actual
392
+'
393
+
394
+test_expect_success 'disabling extensions.submodulePathConfig prevents nested submodules' '
395
+ (
396
+ cd clone_nested &&
397
+ # disable extension and verify failure
398
+ git config --replace-all extensions.submodulePathConfig false &&
399
+ test_must_fail git submodule add ./thing2 hippo/foobar &&
400
+ # re-enable extension and verify it works
401
+ git config --replace-all extensions.submodulePathConfig true &&
402
+ git submodule add ./thing2 hippo/foobar
403
+ )
404
+'
405
+
406
test_done