fix(issue-21): reviews 8f141362 N1 and 275fdc4f R4/R5 - clean refusal for a broken worktree, no init over existing files, check retained submodule repositories
- N1 (planner-reviewer-claude-01) worktree_remove.sh: when `git status` fails in the worktree (e.g. its directory was deleted), judge_tree returned before METHOD was set and the summary line died under set -u with exit 1. METHOD now has its default before the function; the refusal is exit 3 with "nothing was changed", plus a `git worktree prune` hint when the directory is gone. Same for --dry-run. - R4 (planner-reviewer-codex-01) worktree_prepare.sh: a submodule path that is not initialised yet but already holds files made the clone fail, and the rollback's `deinit -f` then deleted those files. --submodules now refuses such a path up front (new exit 3, nothing changed), which also makes the rollback's premise true: after a failed attempt, everything under a not-yet-initialised path was created by that attempt. - R5 (planner-reviewer-codex-01) lib_py/worktree.py: `submodule foreach` visits initialised checkouts only, but a deinit'd submodule keeps its repository - stash and unpushed commits included - under the worktree's modules/, which the forced removal deletes. Every repository under modules/ (nested ones included) is now checked directly (`--git-dir`): commits on no remote (HEAD included) and refs/stash with its reflog. A directory that looks like a partial repository cannot be checked and refuses. - Also: the rollback loops are C-style (`seq 0 -1` counts down on BSD), and prepare warns that --base is ignored for an existing branch. Tests W6i (deleted worktree directory, with and without --dry-run), W6j and W6j2 (stash / unpushed commit in a deinit'd submodule's repository) and W6k (existing files in an uninitialised submodule path). Skill docs and the design doc describe the new refusal and the wider repository check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
1 parent
bbbbb95ef6
commit
c7c559b7aa
7 files changed
+168
-8
No files matched your search
@@ -122,6 +122,26 @@ def open_jobs(rows: List[dict], dirs: Iterable[str], exclude_ids: Iterable[str]
|
||||
return out
|
||||
|
||||
|
||||
def _module_gitdirs(root: str):
|
||||
"""Repositories under a worktree's modules/ (nested ones included), and
|
||||
directories that look like partial repositories (a repository marker but
|
||||
not a checkable repository)."""
|
||||
repos: List[str] = []
|
||||
partial: List[str] = []
|
||||
if not os.path.isdir(root):
|
||||
return repos, partial
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
if dirpath == root:
|
||||
continue
|
||||
if "HEAD" in filenames and "objects" in dirnames:
|
||||
repos.append(dirpath)
|
||||
dirnames[:] = [d for d in dirnames if d == "modules"] # nested submodules only
|
||||
elif "HEAD" in filenames or "objects" in dirnames or "refs" in dirnames:
|
||||
partial.append(dirpath)
|
||||
dirnames[:] = []
|
||||
return repos, partial
|
||||
|
||||
|
||||
def submodule_findings(wt: str) -> Dict[str, object]:
|
||||
"""trace: the worktree git dir holds modules/ (git then refuses a plain
|
||||
`worktree remove`, even after a failed init); dirty / unpushed / stashes:
|
||||
@@ -148,6 +168,32 @@ def submodule_findings(wt: str) -> Dict[str, object]:
|
||||
errors.append(r.stderr.strip() or f"{key} check failed (rc {r.returncode})")
|
||||
else:
|
||||
res[key] = r.stdout.strip()
|
||||
# Every repository under modules/, whether its checkout is initialised or
|
||||
# not (review R5): `submodule foreach` visits initialised checkouts only,
|
||||
# but a deinit'd submodule keeps its repository - stash included - here,
|
||||
# and the forced removal deletes it. A repository that cannot be checked
|
||||
# is an error, never assumed safe.
|
||||
repos, partial = _module_gitdirs(os.path.join(gd.stdout.strip(), "modules"))
|
||||
extra: Dict[str, List[str]] = {"unpushed": [], "stashes": []}
|
||||
for d in repos:
|
||||
git = ["git", "--git-dir", d, "--work-tree", d]
|
||||
u = subprocess.run(git + ["log", "--oneline", "HEAD", "--branches", "--not", "--remotes"],
|
||||
capture_output=True, text=True)
|
||||
if u.returncode != 0:
|
||||
errors.append(f"{d}: cannot list its unpushed commits ({u.stderr.strip() or u.returncode})")
|
||||
elif u.stdout.strip():
|
||||
extra["unpushed"] += [f"{d}: {line}" for line in u.stdout.strip().splitlines()]
|
||||
s = subprocess.run(git + ["for-each-ref", "--format=%(refname)", "refs/stash"], capture_output=True, text=True)
|
||||
if s.returncode != 0:
|
||||
errors.append(f"{d}: cannot read refs/stash ({s.stderr.strip() or s.returncode})")
|
||||
elif s.stdout.strip():
|
||||
g = subprocess.run(git + ["log", "-g", "--format=%gd %s", "refs/stash"], capture_output=True, text=True)
|
||||
lines = g.stdout.strip().splitlines() if g.returncode == 0 and g.stdout.strip() else ["refs/stash"]
|
||||
extra["stashes"] += [f"{d}: {line}" for line in lines]
|
||||
errors += [f"{p}: looks like a partial repository and cannot be checked" for p in partial]
|
||||
for key, lines in extra.items():
|
||||
if lines:
|
||||
res[key] = "\n".join(x for x in [str(res[key]).strip(), *lines] if x)
|
||||
res["error"] = "; ".join(errors)
|
||||
return res
|
||||
|
||||
|
||||
@@ -247,7 +247,8 @@ bash .agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh \
|
||||
- **Submodules are opt-in** (`--submodules`). Initialisation writes `submodule.<name>.url` to the shared `.git/config`, and any attempt - even a failed one - leaves a `modules/` directory that makes a plain `git worktree remove` refuse. A failed `--submodules` undoes only what that attempt created - new submodule checkouts are deinitialised before their git dirs are deleted, so the worktree stays a usable checkout; submodules initialised earlier and pre-existing `submodule.*` values are kept - and exits 1; re-run without `--submodules` to finish.
|
||||
- `--creator-agent` creates the creator through this skill with the control root's binding and `MAM_NO_SUBSCRIBER_DAEMON=1`; the row's `pane.cwd` is the worktree.
|
||||
- Prints `BASE=<sha>`: record it **before** delegating the implementation, then review with `run_loop.sh --worktree <WT> --base "$BASE" --review-only`.
|
||||
- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule.
|
||||
- A submodule path that is not initialised yet but already holds files refuses `--submodules` up front (exit 3, nothing changed): the clone would fail on it and a rollback could not tell those files from its own.
|
||||
- Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule, 3 refused to protect existing content (nothing changed).
|
||||
- create itself: a control-plane binding that cannot be computed is refused (exit 2) for a `--workspace` other than the control root and warned about for the control root; the immediate reconcile and the watchdog run the control root's code.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
# modules/ trace that makes a plain `git worktree remove` refuse) -> optional
|
||||
# creator session registered in the control root's .mam -> summary with BASE=.
|
||||
#
|
||||
# Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule.
|
||||
# Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule,
|
||||
# 3 refused to protect existing content (nothing changed).
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -98,6 +99,9 @@ else
|
||||
fi
|
||||
mkdir -p "$(dirname "$WT")"
|
||||
if git -C "$MAIN" show-ref --verify --quiet "refs/heads/$BRANCH"; then
|
||||
if [ -n "$BASE_REF" ]; then
|
||||
echo "WARNING: branch '$BRANCH' already exists; --base '$BASE_REF' is ignored (the worktree checks the branch out as it is)" >&2
|
||||
fi
|
||||
git -C "$MAIN" worktree add "$WT" "$BRANCH" || exit 1
|
||||
else
|
||||
git -C "$MAIN" worktree add -b "$BRANCH" "$WT" "${BASE_REF:-HEAD}" || exit 1
|
||||
@@ -151,18 +155,34 @@ if [ -f "$WT/.gitmodules" ]; then
|
||||
fi
|
||||
done
|
||||
done < <(git config --file "$WT/.gitmodules" --get-regexp '^submodule\..*\.path$' 2>/dev/null || true)
|
||||
# A submodule path that is not initialised yet but already holds files
|
||||
# (review R4): git refuses to clone into it, and a rollback could not tell
|
||||
# those files from its own - refuse before changing anything. With this,
|
||||
# everything under a not-yet-initialised path after a failed attempt was
|
||||
# created by that attempt, which is what the rollback relies on.
|
||||
blocked=()
|
||||
for ((i = 0; i < ${#sm_names[@]}; i++)); do
|
||||
p="$WT/${sm_paths[$i]}"
|
||||
if [ "${sm_pre[$i]}" != "1" ] && [ -e "$p" ] && [ -n "$(ls -A "$p" 2>/dev/null)" ]; then
|
||||
blocked+=("${sm_paths[$i]}")
|
||||
fi
|
||||
done
|
||||
if [ "${#blocked[@]}" -gt 0 ]; then
|
||||
echo "REFUSED: submodule path(s) not initialised yet but not empty: ${blocked[*]}. Initialising would fail on them and a rollback could not tell their files from its own; move the files away (or drop --submodules). Nothing was changed." >&2
|
||||
exit 3
|
||||
fi
|
||||
if ! git -C "$WT" submodule update --init --recursive; then
|
||||
# A submodule cloned by this attempt leaves a checkout whose .git file
|
||||
# points into modules/<name>: deinit it before its git dir goes, or the
|
||||
# worktree stops being a usable git checkout.
|
||||
for i in $(seq 0 $(( ${#sm_names[@]} - 1 ))); do
|
||||
for ((i = 0; i < ${#sm_names[@]}; i++)); do
|
||||
if [ "${sm_pre[$i]}" = "1" ]; then continue; fi
|
||||
git -C "$WT" submodule deinit -q -f -- "${sm_paths[$i]}" >/dev/null 2>&1 || true
|
||||
if [ -f "$WT/${sm_paths[$i]}/.git" ]; then rm -f "$WT/${sm_paths[$i]}/.git"; fi
|
||||
rm -rf "$gd/modules/${sm_names[$i]}"
|
||||
done
|
||||
if [ "$had_modules" != "1" ]; then rm -rf "$gd/modules"; fi
|
||||
for i in $(seq 0 $(( ${#cfg_keys[@]} - 1 ))); do
|
||||
for ((i = 0; i < ${#cfg_keys[@]}; i++)); do
|
||||
if [ "${cfg_had[$i]}" = "1" ]; then
|
||||
git -C "$MAIN" config "${cfg_keys[$i]}" "${cfg_vals[$i]}"
|
||||
else
|
||||
|
||||
@@ -121,7 +121,7 @@ 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, 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).
|
||||
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) - checked in every repository under the worktree's `modules/`, including deinitialised submodules (their repository stays there) and nested ones; a repository that cannot be checked refuses.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
# 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) and no stash. (4) and (5) run again right before the removal, after
|
||||
# HEAD) and no stash - in every repository under the worktree's modules/,
|
||||
# initialised or not. (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).
|
||||
@@ -84,10 +85,14 @@ 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.
|
||||
METHOD="plain" # set before any early return below: the summary line reads it under set -u
|
||||
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"
|
||||
if [ ! -d "$WT" ]; then
|
||||
echo " the directory no longer exists; if that is intended, run: git -C '$MAIN' worktree prune" >&2
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
if [ -n "$dirty" ]; then
|
||||
|
||||
Reference in new issue
Block a user