From f9b4979fbab631f51b260e1b439d916cdfcbabb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 21:39:38 +0000 Subject: [PATCH 1/4] chore: claim #2233 Co-authored-by: Nikolai Emil Damm From 21f2f07499783e8eb45838bb1f7604f222cff93f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 21:40:48 +0000 Subject: [PATCH 2/4] feat(scripts): add submodule-init --advance for pin bumps Isolation-safe way to move an already-populated submodule to HEAD's recorded gitlink after a pin bump. Refuses dirty or ahead-of-pin checkouts; never uses git submodule update. Documented and hermetic- tested. Fixes #2233 Co-authored-by: Nikolai Emil Damm --- .claude/scripts/submodule-init.sh | 70 ++++++++++++++++++++++++-- .claude/scripts/submodule-init.test.sh | 60 ++++++++++++++++++++++ .claude/worktree-isolation.md | 21 ++++++++ README.md | 18 +++++-- 4 files changed, 163 insertions(+), 6 deletions(-) diff --git a/.claude/scripts/submodule-init.sh b/.claude/scripts/submodule-init.sh index 0959f0b7..7feabf5a 100755 --- a/.claude/scripts/submodule-init.sh +++ b/.claude/scripts/submodule-init.sh @@ -12,13 +12,19 @@ # # Usage: # .claude/scripts/submodule-init.sh [...] -# .claude/scripts/submodule-init.sh --all # init + repair + probe every submodule (first clone) -# .claude/scripts/submodule-init.sh --check # non-destructive probe of every initialised submodule +# .claude/scripts/submodule-init.sh --all # init + repair + probe every submodule (first clone) +# .claude/scripts/submodule-init.sh --check # non-destructive probe of every initialised submodule +# .claude/scripts/submodule-init.sh --advance # move a populated checkout to HEAD's recorded pin # # `--check` never modifies submodule content, tracked files, or other sessions' worktrees, but it is # NOT strictly read-only: to prove isolation empirically it adds and then removes a throwaway, # uniquely-named probe worktree (and prunes its own admin entry). That empirical add/remove is the # whole point — it catches a dangling `core.worktree` a config read alone would miss. +# +# `--advance` is the isolation-safe way to follow a pin bump after `git pull` on the superproject. +# Plain `git submodule update` (with or without `--init`) rewrites shared `core.worktree`; this mode +# checks out the recorded gitlink directly, then repair + probe. It refuses a dirty tree or a +# checkout that is ahead of the pin, so it cannot discard uncommitted or unpushed work. set -euo pipefail die() { @@ -216,7 +222,61 @@ init_repair_probe() { probe "$path" || die "repair did not restore isolation for '$path' — do not edit it" } -[ $# -gt 0 ] || die 'usage: submodule-init.sh ... | --all | --check' +# Move an already-populated submodule checkout to the gitlink recorded at the superproject's HEAD. +# Never uses `git submodule update` — that command writes shared `core.worktree` (see header). +advance() { + local path=${1%/} + is_registered_submodule "$path" || + die "'$path' is not a registered submodule (see .gitmodules) — refusing to advance" + + is_populated "$path" || + die "'$path' is not checked out here — run submodule-init.sh $path to populate it first" + + if [ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]; then + die "'$path' has a dirty working tree — commit, stash, or discard local changes before advancing" + fi + + local target head ahead + # Superproject HEAD's gitlink for this path — the pin a pin-bump PR just moved. + target=$(git rev-parse "HEAD:$path" 2>/dev/null) || + die "no gitlink recorded for '$path' at HEAD" + head=$(git -C "$path" rev-parse HEAD) || + die "could not read HEAD of '$path'" + + if [ "$head" = "$target" ]; then + warn "$path — already at recorded pin $target; repairing isolation only" + repair "$path" + probe "$path" || die "repair did not restore isolation for '$path' — do not edit it" + return 0 + fi + + # Ensure the pin object exists locally (a fresh pin bump may not have been fetched into the + # submodule yet). Prefer fetching the exact SHA; fall back to a plain fetch. + if ! git -C "$path" cat-file -e "${target}^{commit}" 2>/dev/null; then + git -C "$path" fetch --quiet origin "$target" 2>/dev/null || + git -C "$path" fetch --quiet origin 2>/dev/null || + true + git -C "$path" cat-file -e "${target}^{commit}" 2>/dev/null || + die "recorded pin $target for '$path' is not available locally — fetch the submodule remote first" + fi + + # Refuse when the checkout has commits that are not reachable from the new pin: advancing would + # detach past them and look like a silent discard. Dirty trees are already refused above. + ahead=$(git -C "$path" rev-list --count "${target}..HEAD" 2>/dev/null) || + die "could not compare '$path' HEAD to recorded pin $target" + if [ "$ahead" -gt 0 ]; then + die "'$path' is $ahead commit(s) ahead of the recorded pin — push or otherwise preserve that work before advancing" + fi + + # Detach onto the recorded pin without `git submodule update` (which rewrites shared core.worktree). + git -C "$path" checkout --quiet --detach "$target" || + die "failed to check out recorded pin $target in '$path'" + repair "$path" + probe "$path" || die "advance left '$path' unisolated — do not edit it" + printf 'submodule-init: %s — advanced to %s\n' "$path" "$target" +} + +[ $# -gt 0 ] || die 'usage: submodule-init.sh ... | --all | --check | --advance ' case "$1" in # NON-DESTRUCTIVE probe (see the header note): never touches content or other sessions' trees, but @@ -231,6 +291,10 @@ case "$1" in --all) while read -r path; do init_repair_probe "$path"; done < <(all_paths) ;; + --advance) + [ $# -eq 2 ] || die 'usage: submodule-init.sh --advance ' + advance "$2" + ;; *) for path in "$@"; do init_repair_probe "$path"; done ;; diff --git a/.claude/scripts/submodule-init.test.sh b/.claude/scripts/submodule-init.test.sh index 5d94b6e2..1da785ab 100755 --- a/.claude/scripts/submodule-init.test.sh +++ b/.claude/scripts/submodule-init.test.sh @@ -142,6 +142,66 @@ report "linked worktree: submodule gitdir lives under the worktree admin dir" \ out="$(cd "$c5/super-wt" && "$helper" --check 2>&1)" && rc=0 || rc=$? report "linked worktree: --check passes" "$([[ $rc -eq 0 ]] && echo yes || echo no)" "$out" +# 6. --advance: move a populated checkout to a newer recorded pin WITHOUT +# `git submodule update` (which rewrites shared core.worktree). Hermetic +# fixture: bump the gitlink in the index while leaving the working tree on +# the old SHA, then advance and assert HEAD + isolation. +c6="$tmp/c6" +mk_super "$c6" +( + cd "$c6/remote-sub" + echo next >file.txt + git add file.txt + git commit -q -m next +) +new_sha="$(git -C "$c6/remote-sub" rev-parse HEAD)" +old_sha="$(git -C "$c6/super/sub" rev-parse HEAD)" +( + cd "$c6/super" + # Record the new pin in the superproject without moving the working tree. + git update-index --cacheinfo "160000,$new_sha,sub" + git commit -q -m "bump sub" +) +report "advance fixture: working tree still on old pin before --advance" \ + "$([[ "$(git -C "$c6/super/sub" rev-parse HEAD)" == "$old_sha" ]] && echo yes || echo no)" +# Make the new object reachable in the submodule (file:// remote). +git -C "$c6/super/sub" fetch -q origin +out="$(cd "$c6/super" && "$helper" --advance sub 2>&1)" && rc=0 || rc=$? +report "advance: exits 0" "$([[ $rc -eq 0 ]] && echo yes || echo no)" "$out" +report "advance: checkout moved to the recorded pin" \ + "$([[ "$(git -C "$c6/super/sub" rev-parse HEAD)" == "$new_sha" ]] && echo yes || echo no)" +report "advance: does not leave a shared core.worktree" \ + "$([[ -z "$(git config -f "$c6/super/.git/modules/sub/config" core.worktree 2>/dev/null || true)" ]] && echo yes || echo no)" +out="$(cd "$c6/super" && "$helper" --check 2>&1)" && rc=0 || rc=$? +report "advance: --check passes afterwards" "$([[ $rc -eq 0 ]] && echo yes || echo no)" "$out" + +# 7. --advance refuses a dirty working tree. +c7="$tmp/c7" +mk_super "$c7" +echo dirty >>"$c7/super/sub/file.txt" +out="$(cd "$c7/super" && "$helper" --advance sub 2>&1)" && rc=0 || rc=$? +report "advance dirty: exits non-zero" "$([[ $rc -ne 0 ]] && echo yes || echo no)" "$out" +report "advance dirty: names the dirty-tree refusal" \ + "$(grep -q 'dirty working tree' <<<"$out" && echo yes || echo no)" "$out" + +# 8. --advance refuses a checkout that is ahead of the recorded pin. +c8="$tmp/c8" +mk_super "$c8" +pin="$(git -C "$c8/super/sub" rev-parse HEAD)" +( + cd "$c8/super/sub" + echo local >extra.txt + git add extra.txt + git commit -q -m local-ahead +) +# Superproject gitlink still points at the old pin; checkout is one commit ahead. +report "advance ahead fixture: gitlink still at old pin" \ + "$([[ "$(git -C "$c8/super" rev-parse HEAD:sub)" == "$pin" ]] && echo yes || echo no)" +out="$(cd "$c8/super" && "$helper" --advance sub 2>&1)" && rc=0 || rc=$? +report "advance ahead: exits non-zero" "$([[ $rc -ne 0 ]] && echo yes || echo no)" "$out" +report "advance ahead: names the ahead-of-pin refusal" \ + "$(grep -q 'ahead of the recorded pin' <<<"$out" && echo yes || echo no)" "$out" + if [[ $fail -ne 0 ]]; then echo "submodule-init self-test: FAILURES above" >&2 exit 1 diff --git a/.claude/worktree-isolation.md b/.claude/worktree-isolation.md index 8717e2e3..98deb0d1 100644 --- a/.claude/worktree-isolation.md +++ b/.claude/worktree-isolation.md @@ -156,6 +156,27 @@ not isolated. The probe is non-destructive — it never modifies submodule conte other sessions' worktrees — but not strictly read-only: it adds and removes a throwaway probe worktree to catch a dangling `core.worktree` a config read alone would miss. +### Advancing a populated submodule to a new pin (`--advance`) + +After a pin-bump PR merges and you `git pull` the superproject, an already-populated submodule +checkout stays on the **old** commit. Re-running `submodule-init.sh ` will not move it +(populated trees are repair-only). Plain `git submodule update -- ` *would* move it, but it +is the same family of command that writes shared `core.worktree` — do not use it here. + +**Verified procedure** (hermetic fixture in `submodule-init.test.sh`, cases 6–8): + +```sh +# From the superproject, after pulling the pin bump. Refuses dirty / ahead-of-pin checkouts. +.claude/scripts/submodule-init.sh --advance +.claude/scripts/submodule-init.sh --check # must pass afterwards +``` + +`--advance` reads the gitlink at `HEAD:`, checks out that commit **directly** inside the +submodule (no `git submodule update`), then runs the usual repair + fail-closed probe. It refuses +when the working tree is dirty or when `HEAD` has commits not reachable from the new pin, so it +cannot discard uncommitted or unpushed work. Prefer this guarded flag over documenting the +hazardous plain-Git command. + What made it dangerous is that it fails **silently**: a `git worktree add` still succeeds, and the worktree looks real. Three live linked worktrees — including **two belonging to the parallel sibling agent** — were all resolving into the *shared main checkout*, i.e. actively colliding, with nothing diff --git a/README.md b/README.md index 5cc1b89b..b15f5ca8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ working copies of a project resolve back to the same folder, so parallel session other; the script does the same job and repairs that. It also works after a `git clone --recurse-submodules`, which has the same problem. Run `.claude/scripts/submodule-init.sh --check` any time to confirm things are still separated — see -[worktree isolation](.claude/worktree-isolation.md) for the full story. +[worktree isolation](.claude/worktree-isolation.md) for the full story. After a pin-bump pull, use +`.claude/scripts/submodule-init.sh --advance ` to move an already-populated checkout to the +new pin (never plain `git submodule update`). Check that each project landed on the right branch. One left on a detached commit can lose work. @@ -36,6 +38,7 @@ Check that each project landed on the right branch. One left on a detached commi | Add one | `git submodule add -b ` | | Move or rename one | `git mv ` | | Point one at a new URL | `git submodule set-url -- ` | +| Advance a populated checkout to the new pin | `.claude/scripts/submodule-init.sh --advance ` | | Remove one | `./delete-submodule.sh ` | Each project is pinned to a specific commit, and your checkout stays on that pin. Automated pull @@ -43,5 +46,14 @@ requests here move the pins forward, but **your existing checkout does not follo the init script will not move it either.** That is deliberate: the script only populates projects that are empty, and leaves already-populated -ones alone so it can never discard work you have sitting in one. Moving a populated project to a -newer pin is a manual, per-project decision — commit or push anything you care about there first. +ones alone so it can never discard work you have sitting in one. After you pull a pin bump and want +the checkout to follow, advance it explicitly: + +```bash +.claude/scripts/submodule-init.sh --advance +``` + +That checks out the pin recorded at `HEAD` for ``, repairs isolation, and probes — without +running `git submodule update`, which would rewrite shared `core.worktree`. It refuses a dirty tree +or a checkout that is ahead of the pin, so uncommitted or unpushed work is never discarded. See +[worktree isolation](.claude/worktree-isolation.md) for why that matters. From 69fc65b043974a2ac4e2c04a10aaa992dc9b3d43 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 21 Jul 2026 21:41:03 +0000 Subject: [PATCH 3/4] docs(agents): point at submodule-init --advance for pin bumps Keep AGENTS.md in sync with the new --advance mode so agents do not reach for plain git submodule update after a pin bump. Part of #2233 Co-authored-by: Nikolai Emil Damm --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6fe86463..ff5e95b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1838,10 +1838,14 @@ The init command is *required* to populate a submodule, so **initialising and re operation, never two**: ```sh -.claude/scripts/submodule-init.sh # init at the pinned commit + repair + probe (fail-closed) +.claude/scripts/submodule-init.sh # init at the pinned commit + repair + probe (fail-closed) +.claude/scripts/submodule-init.sh --advance # after a pin-bump pull: move a populated checkout to HEAD's gitlink ``` -Use it instead of a bare `git submodule update --init ` (never `--remote`). If you do run a bare +Use it instead of a bare `git submodule update --init ` (never `--remote`), and use `--advance` +instead of `git submodule update -- ` when a pin bump has landed and the checkout is still on +the old commit — plain `update` rewrites shared `core.worktree`. `--advance` refuses a dirty tree or +a checkout ahead of the pin. If you do run a bare init — or inherit a tree someone else initialised — **probe before you trust it**: confirm `git -C rev-parse --show-toplevel` returns the worktree's **own** path, not a `.git/modules/` path, and repair it in place before editing anything. The diagnosis, the regression watch, and the From 3a1db7116df8452568f6098c5c384434f1a5cdc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 00:38:58 +0000 Subject: [PATCH 4/4] fix(scripts): correct --advance fixture vars after merge renumber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incomplete $cN renumber left mk_super on the previous case's path, so cases 13–14 rebuilt the wrong fixture and aborted under set -e. Co-authored-by: ned --- .claude/scripts/submodule-init.test.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/scripts/submodule-init.test.sh b/.claude/scripts/submodule-init.test.sh index 7e77510d..469bf001 100755 --- a/.claude/scripts/submodule-init.test.sh +++ b/.claude/scripts/submodule-init.test.sh @@ -376,7 +376,7 @@ report "empty-init: --check still SKIPS a legitimately deinitialised submodule" # fixture: bump the gitlink in the index while leaving the working tree on # the old SHA, then advance and assert HEAD + isolation. c12="$tmp/c12" -mk_super "$c11" +mk_super "$c12" ( cd "$c12/remote-sub" echo next >file.txt @@ -406,7 +406,7 @@ report "advance: --check passes afterwards" "$([[ $rc -eq 0 ]] && echo yes || ec # 13. --advance refuses a dirty working tree. c13="$tmp/c13" -mk_super "$c12" +mk_super "$c13" echo dirty >>"$c13/super/sub/file.txt" out="$(cd "$c13/super" && "$helper" --advance sub 2>&1)" && rc=0 || rc=$? report "advance dirty: exits non-zero" "$([[ $rc -ne 0 ]] && echo yes || echo no)" "$out" @@ -415,7 +415,7 @@ report "advance dirty: names the dirty-tree refusal" \ # 14. --advance refuses a checkout that is ahead of the recorded pin. c14="$tmp/c14" -mk_super "$c13" +mk_super "$c14" pin="$(git -C "$c14/super/sub" rev-parse HEAD)" ( cd "$c14/super/sub"