fix(issue-21): PR #29 review R1-R3 - untracked-safe cleanliness checks; a worktree gets its own venv
R1/R2: `git status --porcelain` honours status.showUntrackedFiles, so a `no` setting in a submodule (or the worktree) hid untracked files from the checks that justify worktree_remove.sh's single --force and from the review snapshot guard (_dirty_submodules), which then approved content it never captured. All three checks (lib_py/worktree.py, worktree_remove.sh, runner._dirty_submodules) now pass --untracked-files=all. The source tree's own snapshot was already safe: `git add -A` ignores that setting. R3: worktree_prepare.sh no longer links .venv to the control root's. It creates the worktree's own venv with the control root's interpreter and installs requirements*.txt; a leftover link is refused (exit 1), a control root without an interpreter is a warning (VENV=skipped); VENV= is printed. Tests: w1 (real venv, kept on re-run), w1b (link refused / skipped), w6e parametrised over the hidden setting for the post-stop recheck, w6e2 (hidden untracked file in the submodule and in the worktree blocks the removal), l17 untracked-hidden / during-hidden (guard refuses before the first review; a mid-round hidden file makes the round stale). Each new case fails without the fix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
1 parent
90a289da44
commit
08db1da9d5
9 files changed
+107
-29
No files matched your search
@@ -417,28 +417,37 @@ def _add_submodule(wt: Path, tmp_path: Path) -> None:
|
||||
_git(wt, "commit", "-q", "-m", "add sm")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("when", ["before", "before-ignored", "untracked", "during"])
|
||||
@pytest.mark.parametrize("when", ["before", "before-ignored", "untracked", "untracked-hidden", "during", "during-hidden"])
|
||||
def test_l17_dirty_submodule_is_refused_or_makes_the_round_stale(wt_repo, monkeypatch, tmp_path, capsys, when):
|
||||
"""The -hidden variants (PR #29 review R2): status.showUntrackedFiles=no in
|
||||
the submodule must not let an untracked file past the snapshot guard,
|
||||
neither before the first review nor when it appears mid-round."""
|
||||
main, wt = wt_repo
|
||||
base = _impl(wt)
|
||||
_add_submodule(wt, tmp_path)
|
||||
if when == "before-ignored": # no ignore setting hides the dirty content
|
||||
_git(wt, "config", "submodule.sm.ignore", "all")
|
||||
_git(wt, "config", "diff.ignoreSubmodules", "all")
|
||||
if when.endswith("-hidden"):
|
||||
_git(wt / "sm", "config", "status.showUntrackedFiles", "no")
|
||||
|
||||
def dirty(*_):
|
||||
(wt / "sm" / "x.txt").write_text("unreviewed work\n")
|
||||
|
||||
def untracked(*_):
|
||||
(wt / "sm" / "new.txt").write_text("untracked in the submodule\n")
|
||||
|
||||
if when.startswith("before"):
|
||||
dirty()
|
||||
elif when == "untracked":
|
||||
(wt / "sm" / "new.txt").write_text("untracked in the submodule\n")
|
||||
elif when.startswith("untracked"):
|
||||
untracked()
|
||||
on_review = {"during": dirty, "during-hidden": untracked}.get(when)
|
||||
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"],
|
||||
on_review=dirty if when == "during" else None)
|
||||
on_review=on_review)
|
||||
assert rc == 1 and _sidecars(main) == []
|
||||
err = capsys.readouterr().err
|
||||
assert "submodule(s) with uncommitted or untracked content: sm" in err
|
||||
if when == "during":
|
||||
if when.startswith("during"):
|
||||
assert len(_reviews(calls)) == 1 and r._rounds[0]["stale"] is True
|
||||
else:
|
||||
assert _reviews(calls) == [], "refused before any review"
|
||||
|
||||
@@ -254,13 +254,20 @@ def test_w9_already_running_resume_persists_the_binding(mam_sandbox, mock_herdr,
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_w1_prepare_worktree_venv_exclude_base(ctl_sub):
|
||||
"""R3 (PR #29 review): the worktree gets its own venv from the control
|
||||
root's interpreter, never a link to the control root's .venv (a link
|
||||
aliases the environment and was once committed by `git add -A`)."""
|
||||
main = ctl_sub
|
||||
wt = _wt(main)
|
||||
for _ in range(2):
|
||||
shutil.rmtree(main / ".venv")
|
||||
subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(main / ".venv")], check=True)
|
||||
for expect in ("VENV=created", "VENV=kept"):
|
||||
res = _prepare(main, wt)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert expect in res.stdout
|
||||
assert os.path.realpath(wt) in _worktrees(main)
|
||||
assert os.readlink(wt / ".venv") == str(main / ".venv")
|
||||
assert not os.path.islink(wt / ".venv") and os.access(wt / ".venv" / "bin" / "python", os.X_OK)
|
||||
assert os.path.realpath(wt / ".venv") != os.path.realpath(main / ".venv")
|
||||
excl = (main / ".git" / "info" / "exclude").read_text().splitlines()
|
||||
assert excl.count("/.venv") == 1 and excl.count(".pytest_cache/") == 1
|
||||
assert _git(wt, "status", "--porcelain").stdout == ""
|
||||
@@ -271,6 +278,18 @@ def test_w1_prepare_worktree_venv_exclude_base(ctl_sub):
|
||||
assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0
|
||||
|
||||
|
||||
def test_w1b_prepare_refuses_a_venv_link_and_skips_without_a_control_root_interpreter(ctl):
|
||||
main = ctl # ctl's .venv is an empty directory: no interpreter to create from
|
||||
wt = _wt(main)
|
||||
res = _prepare(main, wt)
|
||||
assert res.returncode == 0 and "VENV=skipped" in res.stdout and "no venv created" in res.stderr
|
||||
assert not (wt / ".venv").exists()
|
||||
os.symlink(main / ".venv", wt / ".venv")
|
||||
res = _prepare(main, wt)
|
||||
assert res.returncode == 1 and "symbolic link" in res.stderr, res.stdout + res.stderr
|
||||
assert os.path.islink(wt / ".venv"), "nothing is changed by the refusal"
|
||||
|
||||
|
||||
def test_w2_prepare_refusals_and_dry_run(ctl):
|
||||
main = ctl
|
||||
res = _run(main, PREP, "--path", str(main / "inside"), "--branch", "x")
|
||||
@@ -439,12 +458,16 @@ def test_w6d_failed_init_trace_outside_prepare_is_removable(ctl_sub):
|
||||
# must leave a usable checkout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herdr, mock_agents):
|
||||
@pytest.mark.parametrize("hidden", [False, True])
|
||||
def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herdr, mock_agents, hidden):
|
||||
"""R1: the checks run again after --stop; a file a stopping session wrote
|
||||
into a submodule keeps the worktree (and the file)."""
|
||||
into a submodule keeps the worktree (and the file) - also when the
|
||||
submodule's status.showUntrackedFiles=no would hide it (PR #29 review R1)."""
|
||||
main = ctl_sub
|
||||
wt = _wt(main)
|
||||
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
||||
if hidden:
|
||||
_git(wt / "sm", "config", "status.showUntrackedFiles", "no")
|
||||
_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"))
|
||||
@@ -455,6 +478,28 @@ def test_w6e_work_written_while_stopping_is_not_force_removed(ctl_sub, mock_herd
|
||||
assert late.is_file() and os.path.realpath(wt) in _worktrees(main)
|
||||
|
||||
|
||||
def test_w6e2_untracked_submodule_file_hidden_by_config_blocks_the_forced_removal(ctl_sub):
|
||||
"""PR #29 review R1: `status --porcelain` honours status.showUntrackedFiles;
|
||||
a display setting must not decide what a forced removal deletes."""
|
||||
main = ctl_sub
|
||||
wt = _wt(main)
|
||||
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
||||
_git(wt / "sm", "config", "status.showUntrackedFiles", "no")
|
||||
keep = wt / "sm" / "valuable-untracked.txt"
|
||||
keep.write_text("not committed anywhere\n")
|
||||
assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "", "git hides it by default"
|
||||
res = _run(main, REM, "--path", str(wt))
|
||||
assert res.returncode == 3 and "'dirty'" in res.stderr, res.stdout + res.stderr
|
||||
assert keep.is_file() and os.path.realpath(wt) in _worktrees(main)
|
||||
_git(wt, "config", "status.showUntrackedFiles", "no") # the worktree's own status check too
|
||||
keep.unlink()
|
||||
top = wt / "valuable-top.txt"
|
||||
top.write_text("x\n")
|
||||
res = _run(main, REM, "--path", str(wt))
|
||||
assert res.returncode == 3 and "uncommitted changes" in res.stderr, res.stdout + res.stderr
|
||||
assert top.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."""
|
||||
|
||||
Reference in new issue
Block a user