fix(issue-21): review ffb5055e R1-R3 - re-check the tree after --stop, refuse submodule stashes, roll a partial submodule init back to a usable checkout
Review ffb5055e (planner-reviewer-codex-01): - R1 [P1] worktree_remove.sh: the clean-tree and submodule checks ran only before --stop, so work a stopping session wrote (or committed, or stashed) was deleted by the forced removal. The checks are one function now, run before any change and again right before the removal; any finding then refuses with exit 3 (sessions already stopped, worktree kept), and the removal method follows that latest look. - R2 [P1] lib_py/worktree.py: a submodule stash is reachable from no branch and leaves the status clean, and the forced removal deletes the worktree's own submodule repositories. `git stash list` in every submodule is a separate finding (`stashes`), refused like dirty/unpushed; errors fail closed as before. - R3 [P2] worktree_prepare.sh: when one submodule cloned and a later one failed, the rollback deleted modules/ while the cloned checkout's .git file still pointed into it, and `git status` failed in the worktree. The rollback now undoes only this attempt: each submodule whose git dir did not exist before is deinitialised before its git dir is deleted; submodules initialised earlier are kept; shared submodule.* config values are restored exactly (new keys removed, pre-existing values put back - init and deinit both write .git/config). A final `git status` either confirms a usable checkout or prints what to inspect. Tests W6e (a write during stop keeps the worktree and the file), W6f (a submodule stash refuses), W6g (partial failure on a fresh worktree: usable, no modules/, no new config, then prepare and a plain removal work) and W6h (a failing retry keeps the earlier `sm` init and its config value). The skill docs and the design doc describe the recheck, the stash rule and the scoped rollback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
1 parent
7988db9adf
commit
bbbbb95ef6
7 files changed
+181
-42
No files matched your search
@@ -121,9 +121,9 @@ Every check runs first and changes nothing; a refusal exits 3:
|
||||
2. running sessions under it only with `--stop` (stopped normally, conversations kept);
|
||||
3. no open job of those sessions - never cancelled automatically, they may belong to a running loop (the delegate job of a session `--stop` stops is cancelled by stop itself);
|
||||
4. a clean tree, submodules included (`status --porcelain --ignore-submodules=none`);
|
||||
5. inside submodules: no modification and no commit that is on no remote (`HEAD` included - a submodule sits on a detached HEAD).
|
||||
5. inside submodules: no modification, no commit that is on no remote (`HEAD` included - a submodule sits on a detached HEAD) and no stash (`refs/stash` is reachable from no branch and leaves the status clean).
|
||||
|
||||
Then it stops the sessions, re-checks the jobs and removes the worktree (the branch is kept): plain, or - when the worktree holds submodule state, which git refuses to remove otherwise - a **single `--force` justified by checks 4 and 5**. Do not run `git worktree remove --force` by hand on such a worktree: it deletes the worktree's own submodule repositories, unpushed commits included.
|
||||
Then it stops the sessions, re-checks the jobs, runs checks 4 and 5 **again** (a session that is being stopped can still write, commit or stash - any change refuses with exit 3, sessions already stopped) and removes the worktree (the branch is kept): plain, or - when the worktree holds submodule state, which git refuses to remove otherwise - a **single `--force` justified by checks 4 and 5**. Do not run `git worktree remove --force` by hand on such a worktree: it deletes the worktree's own submodule repositories, unpushed commits included.
|
||||
|
||||
Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control root, 3 refused (nothing changed). Sessions created in a removed worktree cannot be resumed until a worktree exists at that path again.
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
# delegate job of a session --stop will stop is cancelled by stop itself);
|
||||
# (4) clean tree, submodules included; (5) submodules: nothing modified and no
|
||||
# commit that is on no remote (HEAD included - a submodule is on a detached
|
||||
# HEAD). Removal is plain, or - when the worktree holds submodule state, which
|
||||
# HEAD) and no stash. (4) and (5) run again right before the removal, after
|
||||
# --stop: a session that is being stopped can still write, commit or stash.
|
||||
# Removal is plain, or - when the worktree holds submodule state, which
|
||||
# git refuses to remove without it - a single --force justified by (4) and (5).
|
||||
# Never run `git worktree remove --force` by hand on such a worktree.
|
||||
#
|
||||
@@ -79,27 +81,37 @@ if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then
|
||||
echo " close one after checking it: python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py --registry-dir <dir> close --job <id> --request cancelled" >&2
|
||||
fi
|
||||
|
||||
# (4) clean, submodules included
|
||||
DIRTY="$(git -C "$WT" status --porcelain --ignore-submodules=none)"
|
||||
if [ -n "$DIRTY" ]; then
|
||||
refuse "the worktree has uncommitted changes:"
|
||||
printf '%s\n' "$DIRTY" | sed 's/^/ /' >&2
|
||||
fi
|
||||
|
||||
# (5) submodules
|
||||
SUB="$(wt_py submodules "$WT")"
|
||||
TRACE="$(printf '%s' "$SUB" | jget 'd["trace"]')"
|
||||
METHOD="plain"
|
||||
if [ "$TRACE" = "True" ]; then
|
||||
METHOD="single --force"
|
||||
for key in error dirty unpushed; do
|
||||
val="$(printf '%s' "$SUB" | jget "d['$key']")"
|
||||
if [ -n "$val" ]; then
|
||||
refuse "submodule check '$key' is not empty (a forced removal would destroy it):"
|
||||
printf '%s\n' "$val" | sed 's/^/ /' >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# (4) clean, submodules included, and (5) submodules. A function: it runs now,
|
||||
# before any change, and again right before the removal (review R1). It sets
|
||||
# METHOD from what it saw and returns 1 on any finding.
|
||||
judge_tree() {
|
||||
local found=0 dirty sub trace key val
|
||||
if ! dirty="$(git -C "$WT" status --porcelain --ignore-submodules=none 2>&1)"; then
|
||||
refuse "git status failed in the worktree: $dirty"
|
||||
return 1
|
||||
fi
|
||||
if [ -n "$dirty" ]; then
|
||||
refuse "the worktree has uncommitted changes:"
|
||||
printf '%s\n' "$dirty" | sed 's/^/ /' >&2
|
||||
found=1
|
||||
fi
|
||||
sub="$(wt_py submodules "$WT")"
|
||||
trace="$(printf '%s' "$sub" | jget 'd["trace"]')"
|
||||
METHOD="plain"
|
||||
if [ "$trace" = "True" ]; then
|
||||
METHOD="single --force"
|
||||
for key in error dirty unpushed stashes; do
|
||||
val="$(printf '%s' "$sub" | jget "d.get('$key', '')")"
|
||||
if [ -n "$val" ]; then
|
||||
refuse "submodule check '$key' is not empty (a forced removal would destroy it):"
|
||||
printf '%s\n' "$val" | sed 's/^/ /' >&2
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return "$found"
|
||||
}
|
||||
judge_tree || true
|
||||
|
||||
echo "checks: locked/sessions/jobs/clean/submodules -> $([ "$REFUSED" = 1 ] && echo refused || echo passed); removal method: $METHOD"
|
||||
if [ "$REFUSED" = "1" ]; then
|
||||
@@ -130,6 +142,14 @@ if [ "$STOP" = "1" ] && [ -n "$RUNNING_AGENTS" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# (6b) the tree again, right before removing it: the stopped sessions may have
|
||||
# written, committed or stashed while they stopped (review R1). The
|
||||
# removal method follows this latest look.
|
||||
if ! judge_tree; then
|
||||
echo "REFUSED: ${RUNNING:+the sessions were stopped, but }the worktree changed after the checks; it was not removed" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# (7) remove; the branch is kept
|
||||
if [ "$METHOD" = "plain" ]; then
|
||||
git -C "$MAIN" worktree remove "$WT" || exit 1
|
||||
|
||||
Reference in new issue
Block a user