diff --git a/.claude/scripts/submodule-init.sh b/.claude/scripts/submodule-init.sh index 27a39cdb..e3c5d150 100755 --- a/.claude/scripts/submodule-init.sh +++ b/.claude/scripts/submodule-init.sh @@ -12,8 +12,9 @@ # # 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, @@ -21,6 +22,11 @@ # `git worktree prune`, which would delete a sibling session's entry whenever that session's tree is # momentarily unreadable). 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() { @@ -285,6 +291,60 @@ init_repair_probe() { probe "$path" || die "repair did not restore isolation for '$path' — do not edit it" } +# 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" +} + # Stop here when SOURCED, so the self-test can exercise the path-comparison helpers directly. The # case-only false positive `same_dir` fixes needs a case-insensitive volume, so an end-to-end # reproduction cannot run on the filesystem CI uses — unit-testing the comparison itself is what @@ -306,7 +366,7 @@ init_repair_probe() { super_root=$(git rev-parse --show-toplevel) || die 'not inside a git repository' cd "$super_root" -[ $# -gt 0 ] || die 'usage: submodule-init.sh ... | --all | --check' +[ $# -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 @@ -321,6 +381,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 90f36610..469bf001 100755 --- a/.claude/scripts/submodule-init.test.sh +++ b/.claude/scripts/submodule-init.test.sh @@ -371,7 +371,65 @@ report "empty-init: the failure NAMES the empty submodule (fails at init, not la out="$(cd "$c11/super" && "$helper" --check 2>&1)" && rc=0 || rc=$? report "empty-init: --check still SKIPS a legitimately deinitialised submodule" \ "$([[ $rc -eq 0 ]] && echo yes || echo no)" "rc=$rc $out" +# 12. --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. +c12="$tmp/c12" +mk_super "$c12" +( + cd "$c12/remote-sub" + echo next >file.txt + git add file.txt + git commit -q -m next +) +new_sha="$(git -C "$c12/remote-sub" rev-parse HEAD)" +old_sha="$(git -C "$c12/super/sub" rev-parse HEAD)" +( + cd "$c12/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 "$c12/super/sub" rev-parse HEAD)" == "$old_sha" ]] && echo yes || echo no)" +# Make the new object reachable in the submodule (file:// remote). +git -C "$c12/super/sub" fetch -q origin +out="$(cd "$c12/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 "$c12/super/sub" rev-parse HEAD)" == "$new_sha" ]] && echo yes || echo no)" +report "advance: does not leave a shared core.worktree" \ + "$([[ -z "$(git config -f "$c12/super/.git/modules/sub/config" core.worktree 2>/dev/null || true)" ]] && echo yes || echo no)" +out="$(cd "$c12/super" && "$helper" --check 2>&1)" && rc=0 || rc=$? +report "advance: --check passes afterwards" "$([[ $rc -eq 0 ]] && echo yes || echo no)" "$out" +# 13. --advance refuses a dirty working tree. +c13="$tmp/c13" +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" +report "advance dirty: names the dirty-tree refusal" \ + "$(grep -q 'dirty working tree' <<<"$out" && echo yes || echo no)" "$out" + +# 14. --advance refuses a checkout that is ahead of the recorded pin. +c14="$tmp/c14" +mk_super "$c14" +pin="$(git -C "$c14/super/sub" rev-parse HEAD)" +( + cd "$c14/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 "$c14/super" rev-parse HEAD:sub)" == "$pin" ]] && echo yes || echo no)" +out="$(cd "$c14/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/AGENTS.md b/AGENTS.md index ed6d2caf..b922a707 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2219,10 +2219,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 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.