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
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -431,3 +432,87 @@ def test_w6d_failed_init_trace_outside_prepare_is_removable(ctl_sub):
|
||||
assert os.path.isdir(os.path.join(gd, "modules")), "git leaves modules/ behind after a failed init"
|
||||
res = _run(main, REM, "--path", str(wt))
|
||||
assert res.returncode == 0 and "single --force" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# review ffb5055e: work that a forced removal must not destroy; rollback that
|
||||
# must leave a usable checkout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herdr, mock_agents):
|
||||
"""R1: the checks run again after --stop; a file a stopping session wrote
|
||||
into a submodule keeps the worktree (and the file)."""
|
||||
main = ctl_sub
|
||||
wt = _wt(main)
|
||||
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
||||
_mutate(main, f"d['herdr_sessions'] = [{_row(main, 'late-creator-claude', wt)}]")
|
||||
late = wt / "sm" / "late-user-work.txt"
|
||||
stop = Path(_skill(main, "multi-agent-mux-stop", "scripts", "stop_session.sh"))
|
||||
stop.write_text("#!/usr/bin/env bash\nprintf 'unsaved work\\n' > " + shlex.quote(str(late)) + "\n" + stop.read_text())
|
||||
res = _run(main, REM, "--path", str(wt), "--stop")
|
||||
assert res.returncode == 3, res.stdout + res.stderr
|
||||
assert "changed after the checks" in res.stderr
|
||||
assert late.is_file() and os.path.realpath(wt) in _worktrees(main)
|
||||
|
||||
|
||||
def test_w6f_submodule_stash_blocks_the_forced_removal(ctl_sub):
|
||||
"""R2: a stash in a submodule is reachable from no branch and leaves the
|
||||
status clean; it must still refuse the forced removal."""
|
||||
main = ctl_sub
|
||||
wt = _wt(main)
|
||||
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
||||
(wt / "sm" / "s.txt").write_text("valuable stashed work\n")
|
||||
_git(wt / "sm", "stash", "push", "-q", "-m", "keep this work")
|
||||
assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == ""
|
||||
res = _run(main, REM, "--path", str(wt))
|
||||
assert res.returncode == 3 and "'stashes'" in res.stderr, res.stdout + res.stderr
|
||||
assert os.path.realpath(wt) in _worktrees(main)
|
||||
assert _git(wt / "sm", "rev-parse", "--verify", "-q", "refs/stash", check=False).returncode == 0
|
||||
|
||||
|
||||
def _add_unreachable_submodule(repo: Path, missing: Path) -> None:
|
||||
"""A second submodule `zbad` (sorted after `sm`) whose remote does not exist."""
|
||||
sm_sha = _git(repo, "ls-tree", "HEAD", "sm").stdout.split()[2]
|
||||
_git(repo, "config", "-f", ".gitmodules", "submodule.zbad.path", "zbad")
|
||||
_git(repo, "config", "-f", ".gitmodules", "submodule.zbad.url", str(missing))
|
||||
_git(repo, "add", ".gitmodules")
|
||||
_git(repo, "update-index", "--add", "--cacheinfo", f"160000,{sm_sha},zbad")
|
||||
_git(repo, "commit", "-q", "-m", "second, unreachable submodule")
|
||||
|
||||
|
||||
def test_w6g_partial_submodule_failure_rolls_back_to_a_usable_worktree(ctl_sub):
|
||||
"""R3: `sm` clones, `zbad` fails. The rollback deinits the new checkout
|
||||
before deleting its git dir, so the worktree stays a usable checkout."""
|
||||
main = ctl_sub
|
||||
_add_unreachable_submodule(main, main.parent / "missing-remote.git")
|
||||
wt = _wt(main)
|
||||
res = _prepare(main, wt, "--submodules", env=FILE_OK)
|
||||
assert res.returncode == 1 and "submodule initialisation failed" in res.stderr
|
||||
assert _git(wt, "status", "--porcelain", check=False).returncode == 0
|
||||
gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip()
|
||||
assert not os.path.exists(os.path.join(gd, "modules"))
|
||||
assert not (wt / "sm" / ".git").exists()
|
||||
for name in ("sm", "zbad"):
|
||||
assert _git(main, "config", "--get", f"submodule.{name}.url", check=False).returncode != 0
|
||||
assert _prepare(main, wt).returncode == 0
|
||||
res = _run(main, REM, "--path", str(wt))
|
||||
assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_w6h_failed_retry_keeps_the_earlier_submodule_state(ctl_sub):
|
||||
"""R3: a submodule initialised by an earlier run (and its shared config)
|
||||
survives the rollback of a later, failing run."""
|
||||
main = ctl_sub
|
||||
wt = _wt(main)
|
||||
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
||||
url_before = _git(main, "config", "--get", "submodule.sm.url").stdout.strip()
|
||||
_add_unreachable_submodule(wt, main.parent / "missing-remote.git")
|
||||
res = _prepare(main, wt, "--submodules", env=FILE_OK)
|
||||
assert res.returncode == 1, res.stdout + res.stderr
|
||||
assert _git(wt, "status", "--porcelain", check=False).returncode == 0
|
||||
gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip()
|
||||
assert os.path.isdir(os.path.join(gd, "modules", "sm"))
|
||||
assert not os.path.exists(os.path.join(gd, "modules", "zbad"))
|
||||
assert (wt / "sm" / ".git").exists() and (wt / "sm" / "s.txt").exists()
|
||||
assert _git(main, "config", "--get", "submodule.sm.url").stdout.strip() == url_before
|
||||
assert _git(main, "config", "--get", "submodule.zbad.url", check=False).returncode != 0
|
||||
Reference in new issue
Block a user