Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions .claude/scripts/submodule-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@
#
# Usage:
# .claude/scripts/submodule-init.sh <submodule-path> [<submodule-path>...]
# .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 <path> # 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 removes ONLY its own admin entry β€” never a repository-wide
# `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() {
Expand Down Expand Up @@ -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"
Comment on lines +301 to +344

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ—„οΈ Data Integrity & Integration | πŸ”΄ Critical | ⚑ Quick win

Repair isolation before Git uses the populated checkout.

Lines 304-341 run git -C "$path" before repair "$path". If a stale shared core.worktree already redirects this submodule, git checkout --detach "$target" can update another session’s worktree before this command repairs the configuration.

Run repair and probe immediately after the populated-checkout check. Do this before status, rev-parse, or checkout. Add a fixture with a pre-existing stale core.worktree and verify that --advance does not modify the redirected worktree.

Proposed fix
   is_populated "$path" ||
     die "'$path' is not checked out here β€” run submodule-init.sh $path to populate it first"

+  repair "$path"
+  probe "$path" || die "repair did not restore isolation for '$path' β€” do not edit it"
+
   if [ -n "$(git -C "$path" status --porcelain 2>/dev/null)" ]; then
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"
is_populated "$path" ||
die "'$path' is not checked out here β€” run submodule-init.sh $path to populate it first"
repair "$path"
probe "$path" || die "repair did not restore isolation for '$path' β€” do not edit it"
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"
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/scripts/submodule-init.sh around lines 301 - 344, Move repair
"$path" and its probe "$path" validation immediately after the is_populated
check in the advance flow, before any git -C "$path" status, rev-parse, fetch,
or checkout operations. Preserve the existing failure message and stop if
isolation cannot be restored; avoid duplicating the later repair/probe block.
Add a fixture covering a pre-existing stale core.worktree and verify --advance
leaves the redirected worktree unchanged.

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
Expand All @@ -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 <submodule-path>... | --all | --check'
[ $# -gt 0 ] || die 'usage: submodule-init.sh <submodule-path>... | --all | --check | --advance <path>'

case "$1" in
# NON-DESTRUCTIVE probe (see the header note): never touches content or other sessions' trees, but
Expand All @@ -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 <submodule-path>'
advance "$2"
;;
*)
for path in "$@"; do init_repair_probe "$path"; done
;;
Expand Down
58 changes: 58 additions & 0 deletions .claude/scripts/submodule-init.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines +396 to +405

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟠 Major | ⚑ Quick win

Exercise the script-side target fetch.

Lines 396-397 make $new_sha available before --advance runs. This makes cat-file succeed and bypasses the target-fetch path in submodule-init.sh.

Remove the fixture-side fetch. Assert that the target object is initially absent, then verify that --advance fetches and checks out the recorded pin. The PR objective requires this workflow to be empirically verified. Based on learnings, executable components under .claude/scripts/** must use fixture-driven, deterministic tests that exercise script logic with controlled inputs and outputs.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/scripts/submodule-init.test.sh around lines 396 - 405, Update the
fixture setup around the `--advance` invocation to remove the explicit `git -C
"$c12/super/sub" fetch -q origin`, first assert that `$new_sha` is absent from
the submodule’s object database, then retain assertions that `--advance`
succeeds and checks out `$new_sha`. Keep the existing worktree and subsequent
`--check` validations unchanged so the test verifies the script-side target
fetch.

Source: Learnings


# 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
Expand Down
21 changes: 21 additions & 0 deletions .claude/worktree-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` will not move it
(populated trees are repair-only). Plain `git submodule update -- <path>` *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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Correct the test-case reference.

The --advance fixtures are cases 12–14 in submodule-init.test.sh, not cases 6–8. Update this reference so readers can verify the documented procedure.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/worktree-isolation.md at line 166, Update the β€œVerified procedure”
reference in .claude/worktree-isolation.md to identify cases 12–14 in
submodule-init.test.sh, replacing the incorrect cases 6–8 reference.


```sh
# From the superproject, after pulling the pin bump. Refuses dirty / ahead-of-pin checkouts.
.claude/scripts/submodule-init.sh --advance <path>
.claude/scripts/submodule-init.sh --check # must pass afterwards
```

`--advance` reads the gitlink at `HEAD:<path>`, 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
Expand Down
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> # init at the pinned commit + repair + probe (fail-closed)
.claude/scripts/submodule-init.sh <path> # init at the pinned commit + repair + probe (fail-closed)
.claude/scripts/submodule-init.sh --advance <path> # after a pin-bump pull: move a populated checkout to HEAD's gitlink
```

Use it instead of a bare `git submodule update --init <path>` (never `--remote`). If you do run a bare
Use it instead of a bare `git submodule update --init <path>` (never `--remote`), and use `--advance`
instead of `git submodule update -- <path>` 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 <wt> rev-parse --show-toplevel` returns the worktree's **own** path, not a `.git/modules/<name>`
path, and repair it in place before editing anything. The diagnosis, the regression watch, and the
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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.

Expand All @@ -36,12 +38,22 @@ Check that each project landed on the right branch. One left on a detached commi
| Add one | `git submodule add -b <branch> <ssh-url> <path>` |
| Move or rename one | `git mv <old-path> <new-path>` |
| Point one at a new URL | `git submodule set-url -- <path> <new-url>` |
| Advance a populated checkout to the new pin | `.claude/scripts/submodule-init.sh --advance <path>` |
| Remove one | `./delete-submodule.sh <path>` |

Each project is pinned to a specific commit, and your checkout stays on that pin. Automated pull
requests here move the pins forward, but **your existing checkout does not follow β€” and re-running
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 <path>
```

That checks out the pin recorded at `HEAD` for `<path>`, 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.
Loading