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:
GodopuandClaude Opus 5 committed 2026-09-14 18:14:13 +00:00
1 parent bbbbb95ef6
commit c7c559b7aa
7 files changed
+168 -8

No files matched your search

+46
View File
@@ -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