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
|
||||
|
||||
|
||||
Reference in new issue
Block a user