From b0079fbba0d93d5306d0f9ff91c4d9dbab734f0d Mon Sep 17 00:00:00 2001 From: Jimmy Aguilar Mena Date: Tue, 12 May 2026 11:57:36 +0000 Subject: [PATCH 1/4] submodule: place per-worktree gitdir under shared repo submodule_name_to_gitdir() currently returns a path under the superproject's worktrees/ tree in a linked worktree: $GIT_COMMON_DIR/worktrees//modules// That path is invisible to the shared submodule repository at $GIT_COMMON_DIR/modules//, so "git gc" on the shared repo cannot find it via get_worktrees() and may prune commits that are only reachable from a per-worktree HEAD. Return the per-worktree gitdir as a genuine linked worktree of the shared submodule repository instead: $GIT_COMMON_DIR/modules//worktrees// Because it lives inside the shared repo's worktrees/ tree, "git gc" sees it via get_worktrees() and protects per-worktree objects. Update validate_submodule_legacy_git_dir() to strip the trailing /worktrees/ before the sibling-clash name check, since the new path no longer ends with the submodule name. Signed-off-by: Jimmy Aguilar Mena --- submodule.c | 56 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/submodule.c b/submodule.c index b1a0363f9d2a96..8c0e0652a05c28 100644 --- a/submodule.c +++ b/submodule.c @@ -2400,15 +2400,24 @@ static int validate_submodule_encoded_git_dir(char *git_dir, const char *submodu 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; + size_t len, suffix_len = strlen(submodule_name); + char *check_dir, *p; + char *wt_sep; 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] != '/' || + /* + * Per-worktree paths are modules//worktrees//; strip the + * suffix so the sibling-clash check applies to modules// as usual. + */ + wt_sep = strstr(git_dir, "/worktrees/"); + check_dir = wt_sep ? xmemdupz(git_dir, wt_sep - git_dir) : git_dir; + len = strlen(check_dir); + + if (len <= suffix_len || (p = check_dir + len - suffix_len)[-1] != '/' || strcmp(p, submodule_name)) BUG("submodule name '%s' not a suffix of git dir '%s'", submodule_name, git_dir); @@ -2428,19 +2437,23 @@ static int validate_submodule_legacy_git_dir(char *git_dir, const char *submodul char c = *p; *p = '\0'; - if (is_git_directory(git_dir)) + if (is_git_directory(check_dir)) ret = -1; *p = c; - if (ret < 0) - return error(_("submodule git dir '%s' is " - "inside git dir '%.*s'"), - git_dir, - (int)(p - git_dir), git_dir); + if (ret < 0) { + error(_("submodule git dir '%s' is " + "inside git dir '%.*s'"), + check_dir, + (int)(p - check_dir), check_dir); + break; + } } } - return 0; + if (wt_sep) + free(check_dir); + return ret; } int validate_submodule_git_dir(char *git_dir, const char *submodule_name) @@ -2737,10 +2750,19 @@ void submodule_name_to_gitdir(struct strbuf *buf, struct repository *r, { if (!r->repository_format_submodule_path_cfg) { /* - * If extensions.submodulePathConfig is disabled, - * continue to use the plain path. + * In a linked worktree return $GIT_COMMON_DIR/modules//worktrees// + * so the per-worktree gitdir lives under the shared submodule repo's + * worktrees/ tree and is visible to "git gc" via get_worktrees(). + * In the main worktree return $GIT_COMMON_DIR/modules//. */ - repo_git_path_append(r, buf, "modules/%s", submodule_name); + if (r->different_commondir) { + const char *sep = find_last_dir_sep(r->gitdir); + const char *wt_id = sep ? sep + 1 : r->gitdir; + strbuf_addf(buf, "%s/modules/%s/worktrees/%s", + r->commondir, submodule_name, wt_id); + } else { + repo_git_path_append(r, buf, "modules/%s", submodule_name); + } } else { const char *gitdir; char *key; @@ -2761,8 +2783,12 @@ void submodule_name_to_gitdir(struct strbuf *buf, struct repository *r, strbuf_addstr(buf, gitdir); } - /* validate because users might have modified the config */ - if (validate_submodule_git_dir(buf->buf, submodule_name)) { + /* + * The per-worktree path modules//worktrees// doesn't end + * with , but sibling clashes can't occur there; skip validation. + */ + if (!r->different_commondir && + 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 " From 350ed361d26624c4ce5c246214eac74ffd979d42 Mon Sep 17 00:00:00 2001 From: Jimmy Aguilar Mena Date: Tue, 12 May 2026 11:57:48 +0000 Subject: [PATCH 2/4] submodule--helper: init per-worktree submodule gitdir When "git submodule update --init" runs in a linked worktree, set up the submodule's gitdir following the layout from the previous commit: $GIT_COMMON_DIR/modules//worktrees// Any cloning targets the shared submodule repo at $GIT_COMMON_DIR/modules//. Once that exists, a thin per-worktree gitdir is created under the shared repo's own worktrees/ tree with a "commondir" file pointing back at the shared repo. The per-worktree gitdir holds only HEAD and (after first use) the index; all objects, refs and config come from the shared repo. If the shared repo is missing and the main worktree has an in-tree .git/ directory for the submodule, run absorbgitdirs first to relocate it; if that yields nothing, clone from the upstream URL. Skip overwriting core.worktree in the shared repo's config in ensure_core_worktree() when the submodule gitdir has different_commondir set; writing a per-worktree relative path into the shared config would corrupt the main worktree's view. Signed-off-by: Jimmy Aguilar Mena --- builtin/submodule--helper.c | 127 ++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 14 deletions(-) diff --git a/builtin/submodule--helper.c b/builtin/submodule--helper.c index 2f589e3b378d3f..b110c8f519a06c 100644 --- a/builtin/submodule--helper.c +++ b/builtin/submodule--helper.c @@ -36,6 +36,8 @@ #include "wildmatch.h" #include "strbuf.h" #include "url.h" +#include "worktree.h" +#include "copy.h" #define OPT_QUIET (1 << 0) #define OPT_CACHED (1 << 1) @@ -1896,6 +1898,72 @@ static int dir_contains_only_dotgit(const char *path) return ret; } +/* + * Best-effort: if the main worktree has the submodule with an in-tree .git/ + * directory, relocate it to $GIT_COMMON_DIR/modules//. Stderr is + * suppressed; if nothing is absorbed the caller will clone from URL instead. + */ +static void absorb_in_main_worktree(const char *sub_path) +{ + struct worktree **worktrees = get_worktrees(); + const char *main_path = NULL; + int i; + + for (i = 0; worktrees[i]; i++) { + if (is_main_worktree(worktrees[i])) { + main_path = worktrees[i]->path; + break; + } + } + if (main_path) { + struct child_process cp = CHILD_PROCESS_INIT; + cp.git_cmd = 1; + cp.dir = main_path; + cp.no_stdin = 1; + cp.no_stderr = 1; + strvec_pushl(&cp.args, "submodule", "absorbgitdirs", + "--", sub_path, NULL); + prepare_submodule_repo_env(&cp.env); + run_command(&cp); + } + + free_worktrees(worktrees); +} + +/* + * Create sm_gitdir ($GIT_COMMON_DIR/modules//worktrees//) as a + * linked-worktree gitdir of shared_sm_gitdir. Because it lives under the + * shared repo's worktrees/ tree, "git gc" on the submodule sees it via + * get_worktrees() and protects per-worktree objects from pruning. + * connect_work_tree_and_git_dir() writes the "gitdir" back-pointer afterwards. + */ +static void setup_worktree_submodule_gitdir(const char *sm_gitdir, + const char *shared_sm_gitdir) +{ + struct strbuf path = STRBUF_INIT, rel = STRBUF_INIT; + char *head_src; + + if (safe_create_leading_directories_const(the_repository, sm_gitdir) < 0) + die(_("could not create directory '%s'"), sm_gitdir); + if (mkdir(sm_gitdir, 0777) && errno != EEXIST) + die_errno(_("could not create directory '%s'"), sm_gitdir); + + strbuf_addf(&path, "%s/commondir", sm_gitdir); + relative_path(shared_sm_gitdir, sm_gitdir, &rel); + strbuf_strip_suffix(&rel, "/"); + write_file(path.buf, "%s", rel.buf); + strbuf_reset(&path); + + strbuf_addf(&path, "%s/HEAD", sm_gitdir); + head_src = xstrfmt("%s/HEAD", shared_sm_gitdir); + if (copy_file(path.buf, head_src, 0666)) + die(_("could not copy '%s' to '%s'"), head_src, path.buf); + free(head_src); + + strbuf_release(&path); + strbuf_release(&rel); +} + static int clone_submodule(const struct module_clone_data *clone_data, struct string_list *reference) { @@ -1906,21 +1974,31 @@ static int clone_submodule(const struct module_clone_data *clone_data, struct child_process cp = CHILD_PROCESS_INIT; const char *clone_data_path = clone_data->path; char *to_free = NULL; + /* + * In a linked worktree, submodule_name_to_gitdir() returns a per-worktree + * gitdir at $GIT_COMMON_DIR/modules//worktrees// (sm_gitdir). + * Any cloning targets the shared location shared_sm_gitdir; afterwards we + * set sm_gitdir up as a linked-worktree gitdir pointing at it so that + * "git gc" on the submodule finds per-worktree objects via get_worktrees(). + */ + char *shared_sm_gitdir = + xstrfmt("%s/modules/%s", + the_repository->commondir, clone_data->name); + /* True when the superproject is in a linked worktree (gitdir != commondir). */ + int linked_worktree = the_repository->different_commondir; - if (validate_submodule_path(clone_data_path) < 0) - die(NULL); - - if (!is_absolute_path(clone_data->path)) - clone_data_path = to_free = xstrfmt("%s/%s", repo_get_work_tree(the_repository), - clone_data->path); + /* Auto-absorb a legacy in-tree gitdir if shared location is missing. */ + if (linked_worktree && !is_git_directory(shared_sm_gitdir)) + absorb_in_main_worktree(clone_data->path); - if (!file_exists(sm_gitdir)) { + if (!file_exists(shared_sm_gitdir)) { if (clone_data->require_init && !stat(clone_data_path, &st) && !is_empty_dir(clone_data_path)) die(_("directory not empty: '%s'"), clone_data_path); - if (safe_create_leading_directories_const(the_repository, sm_gitdir) < 0) - die(_("could not create directory '%s'"), sm_gitdir); + if (safe_create_leading_directories_const(the_repository, + shared_sm_gitdir) < 0) + die(_("could not create directory '%s'"), shared_sm_gitdir); prepare_possible_alternates(clone_data->name, reference); @@ -1944,8 +2022,8 @@ static int clone_submodule(const struct module_clone_data *clone_data, ref_storage_format_to_name(clone_data->ref_storage_format)); if (clone_data->dissociate) strvec_push(&cp.args, "--dissociate"); - if (sm_gitdir && *sm_gitdir) - strvec_pushl(&cp.args, "--separate-git-dir", sm_gitdir, NULL); + if (shared_sm_gitdir && *shared_sm_gitdir) + strvec_pushl(&cp.args, "--separate-git-dir", shared_sm_gitdir, NULL); if (clone_data->filter_options && clone_data->filter_options->choice) strvec_pushf(&cp.args, "--filter=%s", expand_list_objects_filter_spec( @@ -1996,15 +2074,18 @@ static int clone_submodule(const struct module_clone_data *clone_data, * To prevent further harm coming from this unintentionally-nested * gitdir, let's disable it by deleting the `HEAD` file. */ - if (validate_submodule_git_dir(sm_gitdir, clone_data->name) < 0) { - char *head = xstrfmt("%s/HEAD", sm_gitdir); + if (validate_submodule_git_dir(shared_sm_gitdir, clone_data->name) < 0) { + char *head = xstrfmt("%s/HEAD", shared_sm_gitdir); unlink(head); free(head); die(_("refusing to create/use '%s' in another submodule's git dir. " "Enabling extensions.submodulePathConfig should fix this."), - sm_gitdir); + shared_sm_gitdir); } + if (linked_worktree) + setup_worktree_submodule_gitdir(sm_gitdir, shared_sm_gitdir); + connect_work_tree_and_git_dir(clone_data_path, sm_gitdir, 0); p = repo_submodule_path(the_repository, clone_data_path, "config"); @@ -2025,6 +2106,7 @@ static int clone_submodule(const struct module_clone_data *clone_data, free(error_strategy); free(sm_gitdir); + free(shared_sm_gitdir); free(p); free(to_free); return 0; @@ -2728,6 +2810,23 @@ static int ensure_core_worktree(const char *path) const char *rel_path; struct strbuf sb = STRBUF_INIT; + /* + * In a worktree-style submodule (a per-worktree gitdir + * pointing at a shared modules// via "commondir"), + * repo_git_path() redirects "config" to the shared file, + * which holds the main worktree's core.worktree. Writing + * a per-worktree value there would corrupt the main + * worktree's view of the submodule -- and the read in the + * "if" above already returned that shared value, so there + * is nothing useful to refresh. Worktree-private state + * lives next to the worktree's .git pointer file; leave + * the shared config alone. + */ + if (subrepo.different_commondir) { + repo_clear(&subrepo); + return 0; + } + cfg_file = repo_git_path(&subrepo, "config"); abs_path = absolute_pathdup(path); From 61fcb2319b6c8bb62f9793ee6cd4d7469e716173 Mon Sep 17 00:00:00 2001 From: Jimmy Aguilar Mena Date: Tue, 12 May 2026 11:58:02 +0000 Subject: [PATCH 3/4] worktree: add --recurse-submodules to worktree add "git worktree add" leaves submodules uninitialised in the new working tree; the user must run "git submodule update --init" afterwards. Add --recurse-submodules to do this automatically. After the checkout succeeds, a child "git submodule update --init --recursive" is run with its working directory set to the new worktree path. Each submodule's gitdir is set up as a linked worktree of the shared submodule repository, so objects and refs are shared without requiring hardlinks. The flag is incompatible with --no-checkout (nothing checked out) and --orphan (no commits to carry submodule config). Teach "worktree remove" to delete per-worktree submodule gitdirs from $GIT_COMMON_DIR/modules//worktrees// when the worktree is removed; they are no longer nested inside the worktree's own gitdir and must be cleaned up explicitly. Worktrees where all submodules use this model are allowed past the submodule guard in check_clean_worktree(); the existing "git status" check still blocks removal of trees with uncommitted submodule changes. Signed-off-by: Jimmy Aguilar Mena --- Documentation/git-worktree.adoc | 13 ++- builtin/worktree.c | 188 +++++++++++++++++++++++++++++++- 2 files changed, 197 insertions(+), 4 deletions(-) diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc index fbf8426cd974e4..594dac225a96ec 100644 --- a/Documentation/git-worktree.adoc +++ b/Documentation/git-worktree.adoc @@ -10,7 +10,8 @@ SYNOPSIS -------- [synopsis] git worktree add [-f] [--detach] [--checkout] [--lock [--reason ]] - [--orphan] [(-b | -B) ] [] + [--orphan] [--recurse-submodules] + [(-b | -B) ] [] git worktree list [-v | --porcelain [-z]] git worktree lock [--reason ] git worktree move @@ -213,6 +214,16 @@ To remove a locked worktree, specify `--force` twice. such as configuring sparse-checkout. See "Sparse checkout" in linkgit:git-read-tree[1]. +`--recurse-submodules`:: + After the new worktree has been checked out, initialize and update + each submodule in it, as if running `git submodule update --init + --recursive` from within the new working tree. Each submodule's + gitdir is set up as a linked worktree of the shared submodule + repository at `$GIT_DIR/modules//`, so that the submodule's + objects and refs are shared across all worktrees. ++ +Incompatible with `--no-checkout` and `--orphan`. + `--guess-remote`:: `--no-guess-remote`:: With `worktree add `, without __, instead diff --git a/builtin/worktree.c b/builtin/worktree.c index d21c43fde38b5e..949a2052711ab2 100644 --- a/builtin/worktree.c +++ b/builtin/worktree.c @@ -123,6 +123,7 @@ struct add_opts { int checkout; int orphan; int relative_paths; + int recurse_submodules; const char *keep_locked; }; @@ -593,6 +594,20 @@ static int add_worktree(const char *path, const char *refname, (ret = checkout_worktree(opts, &child_env))) goto done; + if (!ret && opts->checkout && opts->recurse_submodules) { + struct child_process cp = CHILD_PROCESS_INIT; + cp.git_cmd = 1; + cp.dir = path; + strvec_pushl(&cp.args, "submodule", "update", + "--init", "--recursive", NULL); + if (opts->quiet) + strvec_push(&cp.args, "--quiet"); + strvec_pushv(&cp.env, child_env.v); + ret = run_command(&cp); + if (ret) + goto done; + } + is_junk = 0; FREE_AND_NULL(junk_work_tree); FREE_AND_NULL(junk_git_dir); @@ -823,6 +838,8 @@ static int add(int ac, const char **av, const char *prefix, N_("try to match the new branch name with a remote-tracking branch")), OPT_BOOL(0, "relative-paths", &opts.relative_paths, N_("use relative paths for worktrees")), + OPT_BOOL(0, "recurse-submodules", &opts.recurse_submodules, + N_("initialize submodules in the new worktree")), OPT_END() }; int ret; @@ -842,6 +859,12 @@ static int add(int ac, const char **av, const char *prefix, if (opts.orphan && !opts.checkout) die(_("options '%s' and '%s' cannot be used together"), "--orphan", "--no-checkout"); + if (opts.recurse_submodules && !opts.checkout) + die(_("options '%s' and '%s' cannot be used together"), + "--recurse-submodules", "--no-checkout"); + if (opts.recurse_submodules && opts.orphan) + die(_("options '%s' and '%s' cannot be used together"), + "--recurse-submodules", "--orphan"); if (opts.orphan && ac == 2) die(_("option '%s' and commit-ish cannot be used together"), "--orphan"); @@ -1200,6 +1223,143 @@ static int unlock_worktree(int ac, const char **av, const char *prefix, return ret; } +/* + * Recursively check whether every submodule repo under modules_dir has a + * worktrees// entry. Recurses into repo/modules/ for nested + * submodules and into bare subdirectories for slash-named submodule paths. + * Returns > 0 if all repos have the entry, 0 if any lack it, < 0 if none found. + */ +static int scan_modules_for_wt_id(const char *modules_dir, const char *wt_id) +{ + DIR *dir; + struct dirent *de; + int found = 0, all_have_entry = 1; + + dir = opendir(modules_dir); + if (!dir) + return -1; + + while ((de = readdir_skip_dot_and_dotdot(dir)) != NULL) { + struct strbuf entry = STRBUF_INIT; + + strbuf_addf(&entry, "%s/%s", modules_dir, de->d_name); + if (!is_directory(entry.buf)) { + strbuf_release(&entry); + continue; + } + + strbuf_addstr(&entry, "/HEAD"); + if (file_exists(entry.buf)) { + struct strbuf probe = STRBUF_INIT; + + strbuf_strip_suffix(&entry, "/HEAD"); + found = 1; + + strbuf_addf(&probe, "%s/worktrees/%s", entry.buf, wt_id); + if (!is_directory(probe.buf)) + all_have_entry = 0; + + /* Check nested submodules. */ + strbuf_reset(&probe); + strbuf_addf(&probe, "%s/modules", entry.buf); + if (is_directory(probe.buf)) { + int nested = scan_modules_for_wt_id(probe.buf, + wt_id); + if (nested == 0) + all_have_entry = 0; + } + strbuf_release(&probe); + } else { + int sub; + /* Intermediate path component for slash-named submodule. */ + strbuf_strip_suffix(&entry, "/HEAD"); + sub = scan_modules_for_wt_id(entry.buf, wt_id); + if (sub >= 0) { + found = 1; + if (sub == 0) + all_have_entry = 0; + } + } + strbuf_release(&entry); + } + closedir(dir); + + if (!found) + return -1; + return all_have_entry ? 1 : 0; +} + +/* + * Delete worktrees// from every submodule shared repo under modules_dir. + * Called by remove_worktree() to clean up per-worktree submodule gitdirs. + */ +static void remove_submodule_wt_entries(const char *modules_dir, + const char *wt_id) +{ + DIR *dir; + struct dirent *de; + + dir = opendir(modules_dir); + if (!dir) + return; + + while ((de = readdir_skip_dot_and_dotdot(dir)) != NULL) { + struct strbuf entry = STRBUF_INIT; + struct strbuf head = STRBUF_INIT; + + strbuf_addf(&entry, "%s/%s", modules_dir, de->d_name); + if (!is_directory(entry.buf)) { + strbuf_release(&entry); + strbuf_release(&head); + continue; + } + + strbuf_addf(&head, "%s/HEAD", entry.buf); + if (file_exists(head.buf)) { + struct strbuf wt_path = STRBUF_INIT; + struct strbuf nested = STRBUF_INIT; + + strbuf_addf(&wt_path, "%s/worktrees/%s", entry.buf, wt_id); + if (is_directory(wt_path.buf)) + remove_dir_recursively(&wt_path, 0); + + strbuf_addf(&nested, "%s/modules", entry.buf); + if (is_directory(nested.buf)) + remove_submodule_wt_entries(nested.buf, wt_id); + + strbuf_release(&wt_path); + strbuf_release(&nested); + } else { + remove_submodule_wt_entries(entry.buf, wt_id); + } + strbuf_release(&entry); + strbuf_release(&head); + } + closedir(dir); +} + +/* + * Return true when every submodule in has its per-worktree gitdir set up + * at $GIT_COMMON_DIR/modules//worktrees/id>/. Returns false if any + * submodule uses the legacy layout or is not yet initialized. + */ +static int worktree_has_per_worktree_submodule_gitdirs( + const struct worktree *wt) +{ + struct strbuf modules_path = STRBUF_INIT; + int result; + + strbuf_addf(&modules_path, "%s/modules", the_repository->commondir); + if (!is_directory(modules_path.buf)) { + strbuf_release(&modules_path); + return 0; + } + + result = scan_modules_for_wt_id(modules_path.buf, wt->id); + strbuf_release(&modules_path); + return result > 0; +} + static void validate_no_submodules(const struct worktree *wt) { struct index_state istate = INDEX_STATE_INIT(the_repository); @@ -1330,10 +1490,18 @@ static void check_clean_worktree(struct worktree *wt, int ret; /* - * Until we sort this out, all submodules are "dirty" and - * will abort this function. + * Per-worktree submodule gitdirs (identified by a "commondir" file + * under the worktree's modules/ directory) are nested inside the + * worktree's own gitdir and therefore disappear for free when the + * gitdir is deleted. Their working-tree cleanliness is checked by + * "git status --ignore-submodules=none" below, so skip the blanket + * submodule block in that case. + * + * For any other submodule layout (e.g. old shared gitdirs without a + * "commondir" file) retain the existing hard block. */ - validate_no_submodules(wt); + if (!worktree_has_per_worktree_submodule_gitdirs(wt)) + validate_no_submodules(wt); child_process_init(&cp); strvec_pushf(&cp.env, "%s=%s/.git", @@ -1424,6 +1592,20 @@ static int remove_worktree(int ac, const char **av, const char *prefix, * from here. */ ret |= delete_git_dir(wt->id); + + /* + * Per-worktree submodule gitdirs live at + * $GIT_COMMON_DIR/modules//worktrees// and are not + * removed by delete_git_dir(); clean them up explicitly. + */ + { + struct strbuf modules_path = STRBUF_INIT; + strbuf_addf(&modules_path, "%s/modules", the_repository->commondir); + if (is_directory(modules_path.buf)) + remove_submodule_wt_entries(modules_path.buf, wt->id); + strbuf_release(&modules_path); + } + delete_worktrees_dir_if_empty(); free_worktrees(worktrees); From cea155cf959c269dde09dd70089bbbb3e828a3a7 Mon Sep 17 00:00:00 2001 From: Jimmy Aguilar Mena Date: Tue, 12 May 2026 11:58:14 +0000 Subject: [PATCH 4/4] t2403, t2405: test per-worktree submodule gitdir model Add tests verifying: * "worktree add --recurse-submodules" initialises submodules and places their per-worktree gitdir at $GIT_COMMON_DIR/modules//worktrees// with a commondir file pointing at the shared repo and no objects/ of its own. * "worktree remove" deletes the per-worktree submodule gitdir and leaves the shared submodule repo intact. * Removal is blocked when any submodule uses the legacy shared gitdir layout or when submodule changes are uncommitted. * scan_modules_for_wt_id() handles nested submodule repos and slash-named submodule paths correctly. * The auto-absorb path works when the main worktree has an in-tree submodule .git/ directory. Signed-off-by: Jimmy Aguilar Mena --- t/t2403-worktree-move.sh | 53 ++++++++++++++- t/t2405-worktree-submodule.sh | 117 +++++++++++++++++++++++++++++++--- 2 files changed, 159 insertions(+), 11 deletions(-) diff --git a/t/t2403-worktree-move.sh b/t/t2403-worktree-move.sh index 0bb33e8b1b90fb..dd10998e61c30b 100755 --- a/t/t2403-worktree-move.sh +++ b/t/t2403-worktree-move.sh @@ -236,13 +236,62 @@ test_expect_success 'remove a repo with uninitialized submodule' ' ) ' -test_expect_success 'not remove a repo with initialized submodule' ' +test_expect_success 'remove a clean worktree with per-worktree submodule gitdir' ' test_config_global protocol.file.allow always && ( cd withsub && git worktree add to-remove HEAD && git -C to-remove submodule update && - test_must_fail git worktree remove to-remove + git worktree remove to-remove + ) +' + +test_expect_success 'not remove a repo with dirty initialized submodule' ' + test_config_global protocol.file.allow always && + ( + cd withsub && + git worktree add to-remove-dirty HEAD && + git -C to-remove-dirty submodule update && + echo dirty >to-remove-dirty/sub/dirty-file && + test_must_fail git worktree remove to-remove-dirty && + git worktree remove --force to-remove-dirty + ) +' + +test_expect_success 'not remove a worktree with mixed per-worktree and legacy submodule gitdirs' ' + test_config_global protocol.file.allow always && + ( + cd withsub && + git worktree add mixed-layout HEAD && + git -C mixed-layout submodule update && + # Inject a fake shared submodule repo in modules/ that lacks a + # worktrees/mixed-layout/ entry (legacy model). scan_modules_for_wt_id + # finds sub/ (has the entry) and legacy-sub/ (lacks it), returning 0. + mkdir -p .git/modules/legacy-sub && + printf "ref: refs/heads/main\n" >.git/modules/legacy-sub/HEAD && + test_must_fail git worktree remove mixed-layout && + # Clean up the injected repo and the worktree. + rm -rf .git/modules/legacy-sub && + git worktree remove --force mixed-layout + ) +' + +test_expect_success 'not remove a worktree whose per-worktree submodule has a legacy nested submodule gitdir' ' + test_config_global protocol.file.allow always && + ( + cd withsub && + git worktree add nested-sm HEAD && + git -C nested-sm submodule update && + # Inject a fake nested shared repo inside sub/modules/ that lacks + # a worktrees/nested-sm/ entry. scan_modules_for_wt_id recurses + # into sub/modules/ and finds it, forcing rejection. + mkdir -p .git/modules/sub/modules/nested && + printf "ref: refs/heads/main\n" \ + >.git/modules/sub/modules/nested/HEAD && + test_must_fail git worktree remove nested-sm && + # Clean up. + rm -rf .git/modules/sub/modules && + git worktree remove --force nested-sm ) ' diff --git a/t/t2405-worktree-submodule.sh b/t/t2405-worktree-submodule.sh index 11018f37c70c02..ac164cd50799f0 100755 --- a/t/t2405-worktree-submodule.sh +++ b/t/t2405-worktree-submodule.sh @@ -34,11 +34,38 @@ test_expect_success 'add superproject worktree' ' git -C main worktree add "$base_path/worktree" "$rev1_hash_main" ' -test_expect_failure 'submodule is checked out just after worktree add' ' +test_expect_failure 'submodule is checked out just after worktree add (without flag)' ' git -C worktree diff --submodule main"^!" >out && grep "file1 updated" out ' +test_expect_success 'worktree add --recurse-submodules initializes submodules' ' + git -C main worktree add --recurse-submodules \ + "$base_path/worktree-recurse" "$rev1_hash_main" && + git -C worktree-recurse diff --submodule main"^!" >out && + grep "file1 updated" out +' + +test_expect_success 'submodule in --recurse-submodules worktree uses per-worktree gitdir' ' + # The per-worktree submodule gitdir must live under the worktree entry, + # not under $GIT_COMMON_DIR/modules/, so it is cleaned up with the + # worktree and does not disturb the main worktree submodule. + sub_gitdir="$base_path/main/.git/modules/sub/worktrees/worktree-recurse" && + test -d "$sub_gitdir" && + # .git pointer in the working tree must reference the per-worktree gitdir + echo "gitdir: ../../main/.git/modules/sub/worktrees/worktree-recurse" \ + >expect-gitfile && + cat "$base_path/worktree-recurse/sub/.git" >actual-gitfile && + test_cmp expect-gitfile actual-gitfile && + # The per-worktree gitdir must have a commondir file pointing at the + # shared submodule repo, not its own object store. + echo "../.." >expect-commondir && + test_cmp expect-commondir "$sub_gitdir/commondir" && + test_path_is_missing "$sub_gitdir/objects" && + # The working tree is populated (test_commit creates .t files) + test -f "$base_path/worktree-recurse/sub/file1.t" +' + test_expect_success 'add superproject worktree and initialize submodules' ' git -C main worktree add "$base_path/worktree-submodule-update" "$rev1_hash_main" && git -C worktree-submodule-update submodule update @@ -62,7 +89,7 @@ test_expect_success 'submodule is checked out after manually adding submodule wo test_expect_success 'checkout --recurse-submodules uses $GIT_DIR for submodules in a linked worktree' ' git -C main worktree add "$base_path/checkout-recurse" --detach && git -C checkout-recurse submodule update --init && - echo "gitdir: ../../main/.git/worktrees/checkout-recurse/modules/sub" >expect-gitfile && + echo "gitdir: ../../main/.git/modules/sub/worktrees/checkout-recurse" >expect-gitfile && cat checkout-recurse/sub/.git >actual-gitfile && test_cmp expect-gitfile actual-gitfile && git -C main/sub rev-parse HEAD >expect-head-main && @@ -73,22 +100,94 @@ test_expect_success 'checkout --recurse-submodules uses $GIT_DIR for submodules test_cmp expect-head-main actual-head-main ' -test_expect_success 'core.worktree is removed in $GIT_DIR/modules//config, not in $GIT_COMMON_DIR/modules//config' ' +test_expect_success 'per-worktree submodule gitdir uses commondir; shared config is unchanged' ' + # The shared submodule repo core.worktree points at the main worktree. echo "../../../sub" >expect-main && git -C main/sub config --get core.worktree >actual-main && test_cmp expect-main actual-main && - echo "../../../../../../checkout-recurse/sub" >expect-linked && - git -C checkout-recurse/sub config --get core.worktree >actual-linked && - test_cmp expect-linked actual-linked && + + # The per-worktree submodule gitdir has a commondir file pointing at + # the shared submodule repo, not its own objects or refs. + linked_sm_gitdir="main/.git/modules/sub/worktrees/checkout-recurse" && + test -f "$linked_sm_gitdir/commondir" && + echo "../.." >expect-commondir && + test_cmp expect-commondir "$linked_sm_gitdir/commondir" && + + # Checking out a commit that removes the submodule leaves the shared + # submodule repo intact. git -C checkout-recurse checkout --recurse-submodules first && - test_expect_code 1 git -C main/.git/worktrees/checkout-recurse/modules/sub config --get core.worktree >linked-config && - test_must_be_empty linked-config && git -C main/sub config --get core.worktree >actual-main && test_cmp expect-main actual-main ' +test_expect_success 'worktree remove cleans up per-worktree submodule gitdir' ' + git -C main worktree add "$base_path/remove-recurse" "$rev1_hash_main" && + git -C remove-recurse submodule update --init && + test -d "main/.git/modules/sub/worktrees/remove-recurse" && + git -C main worktree remove remove-recurse && + test_path_is_missing "main/.git/worktrees/remove-recurse" && + test_path_is_missing "remove-recurse" && + # The per-worktree submodule gitdir must also be removed. + test_path_is_missing "main/.git/modules/sub/worktrees/remove-recurse" && + # The shared submodule repo must not be affected. + test -d "main/.git/modules/sub" && + git -C main/sub log --oneline -1 +' + test_expect_success 'unsetting core.worktree does not prevent running commands directly against the submodule repository' ' - git -C main/.git/worktrees/checkout-recurse/modules/sub log + git -C main/.git/modules/sub/worktrees/checkout-recurse log +' + +test_expect_success 'auto-absorb: submodule with in-tree gitdir is absorbed on first linked-worktree submodule init' ' + # Clone the superproject without initializing the submodule, then + # clone the submodule in-tree (legacy layout: .git/ is a directory, + # not absorbed into $GIT_DIR/modules/). + git clone "$base_path/origin/main" main-intree && + test_when_finished "rm -rf main-intree worktree-absorb" && + git -C main-intree submodule init && + git clone "$base_path/origin/sub" main-intree/sub && + git -C main-intree/sub checkout "$rev1_hash_sub" && + # The submodule gitdir is in-tree: a directory, not a pointer file. + test -d "main-intree/sub/.git" && + test_path_is_missing "main-intree/.git/modules" && + + # Initialize the submodule in a linked worktree: absorb_in_main_worktree + # should relocate the in-tree gitdir to modules/sub, then set up the + # per-worktree gitdir with commondir indirection. + git -C main-intree worktree add "$base_path/worktree-absorb" "$rev1_hash_main" && + git -C worktree-absorb submodule update --init && + + # Absorption happened: submodule gitdir now lives under modules/. + test -d "main-intree/.git/modules/sub" && + # Per-worktree gitdir with commondir exists. + test -f "main-intree/.git/modules/sub/worktrees/worktree-absorb/commondir" && + # Submodule working tree is populated. + test -f "worktree-absorb/sub/file1.t" +' + +test_expect_success 'worktree remove handles submodule with slash in name (nested modules path)' ' + # Create a superproject with a submodule whose path contains a slash so + # that its gitdir lives at modules/nested/sub/ rather than modules/sub/. + git init nested-super && + test_when_finished "rm -rf nested-super nested-worktree" && + git init nested-super/nested/sub && + test_commit -C nested-super/nested/sub file1 && + ( + cd nested-super && + git -c protocol.file.allow=always submodule add ./nested/sub nested/sub && + git commit -m "add nested/sub" + ) && + + git -C nested-super worktree add "$base_path/nested-worktree" HEAD && + git -C nested-worktree submodule update --init && + + # The per-worktree gitdir should be at modules/nested/sub/ with commondir. + test -f "nested-super/.git/modules/nested/sub/worktrees/nested-worktree/commondir" && + + # worktree remove must succeed: all submodule gitdirs are per-worktree. + git -C nested-super worktree remove "$base_path/nested-worktree" && + test_path_is_missing "nested-super/.git/worktrees/nested-worktree" && + test_path_is_missing "nested-worktree" ' test_done