- worktree_prepare.sh installs the same files deploy/install.sh does (.agents/skills/multi-agent-mux-delegate-job/requirements.txt: paho-mqtt, PyYAML) plus requirements*.txt. Without paho the loop wrapper, stop_session and the subscriber tests fail in the worktree - seen as 4 suite failures in a worktree without its own venv, all passing with one. - review D1: a failed pip install removes the venv it half made, so the next run recreates it instead of reporting VENV=kept (test w1c). - review D3: the [worktree context] block's uncommitted-changes flag uses --untracked-files=all like the guards. - review D2: the create skill doc says the install needs the package index. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
710 lines
35 KiB
Python
710 lines
35 KiB
Python
"""Issue #21 phase 2, loop A: worktree helpers (item 4), resume only at the
|
|
original cwd, loud control-plane binding failures (recommendation 1), the
|
|
binding persisted on resume's already-running path (recommendation 2), and the
|
|
installer's `/.venv` ignore rule (#27).
|
|
|
|
Every git repository lives under tmp_path / mam_sandbox; local submodules are
|
|
allowed through GIT_CONFIG_* environment only (never global config), and every
|
|
subprocess gets WORKSPACE_ROOT so lib.sh's .mam/shim lands in the sandbox.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
|
if str(SKILLS) not in sys.path:
|
|
sys.path.insert(0, str(SKILLS))
|
|
|
|
GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}
|
|
FILE_OK = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "protocol.file.allow", "GIT_CONFIG_VALUE_0": "always"}
|
|
FILE_NO = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "protocol.file.allow", "GIT_CONFIG_VALUE_0": "never"}
|
|
PREP = ("multi-agent-mux-create", "scripts", "worktree_prepare.sh")
|
|
REM = ("multi-agent-mux-stop", "scripts", "worktree_remove.sh")
|
|
CP_KEYS = ("workspace_root", "agent_sessions_yaml", "mam_jobs_dir", "delegate_job_logs_dir")
|
|
|
|
|
|
def _git(cwd, *args, env=None, check=True):
|
|
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=check,
|
|
env={**os.environ, **GIT_ID, **(env or {})})
|
|
|
|
|
|
def _skill(root: Path, *parts) -> str:
|
|
return str(root / ".agents" / "skills" / Path(*parts))
|
|
|
|
|
|
def _run(root: Path, parts, *args, env=None, timeout=180):
|
|
return subprocess.run(["bash", _skill(root, *parts), *args], capture_output=True, text=True, cwd=str(root),
|
|
env={**os.environ, "WORKSPACE_ROOT": str(root), **(env or {})}, timeout=timeout)
|
|
|
|
|
|
def _rows(root: Path) -> list:
|
|
import yaml
|
|
return (yaml.safe_load((root / ".mam" / "agent-sessions.yaml").read_text()) or {}).get("herdr_sessions") or []
|
|
|
|
|
|
def _mutate(root: Path, code: str) -> None:
|
|
res = subprocess.run(["bash", "-c", f"source {_skill(root, 'lib.sh')} && atomic_dump_yaml {root / '.mam' / 'agent-sessions.yaml'}"],
|
|
input=code, capture_output=True, text=True, env={**os.environ, "WORKSPACE_ROOT": str(root)})
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
|
|
def _calls(mock_state_file, since=0) -> list:
|
|
calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:]
|
|
return [c[2:] if c[:1] == ["--session"] else c for c in calls]
|
|
|
|
|
|
def _spawn_calls(mock_state_file, since=0) -> list:
|
|
return [c for c in _calls(mock_state_file, since)
|
|
if c[:2] in (["pane", "split"], ["workspace", "create"], ["agent", "start"])]
|
|
|
|
|
|
def _worktrees(main: Path) -> str:
|
|
return _git(main, "worktree", "list", "--porcelain").stdout
|
|
|
|
|
|
@pytest.fixture
|
|
def ctl(mam_sandbox, monkeypatch):
|
|
"""The sandbox is the control root: a git checkout with one commit and a .venv."""
|
|
for k, v in GIT_ID.items():
|
|
monkeypatch.setenv(k, v)
|
|
main = mam_sandbox
|
|
_git(main, "init", "-q")
|
|
(main / "README").write_text("r\n")
|
|
_git(main, "add", "README")
|
|
_git(main, "commit", "-q", "-m", "init")
|
|
(main / ".venv").mkdir(exist_ok=True)
|
|
return main
|
|
|
|
|
|
@pytest.fixture
|
|
def ctl_sub(ctl):
|
|
"""Control root with a submodule (bare local remote) left uninitialised in
|
|
the main checkout, like nats-docker."""
|
|
main = ctl
|
|
src, bare = main.parent / f"{main.name}-subsrc", main.parent / f"{main.name}-sub.git"
|
|
src.mkdir()
|
|
_git(src, "init", "-q")
|
|
(src / "s.txt").write_text("s\n")
|
|
_git(src, "add", "s.txt")
|
|
_git(src, "commit", "-q", "-m", "s")
|
|
subprocess.run(["git", "clone", "-q", "--bare", str(src), str(bare)], check=True, capture_output=True)
|
|
_git(main, "submodule", "add", "-q", str(bare), "sm", env=FILE_OK)
|
|
_git(main, "commit", "-q", "-m", "add sm")
|
|
_git(main, "submodule", "deinit", "-q", "-f", "sm")
|
|
assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0
|
|
return main
|
|
|
|
|
|
def _wt(main: Path, name: str = "wt") -> Path:
|
|
return main.parent / f"{main.name}-{name}"
|
|
|
|
|
|
def _prepare(main, wt, *extra, env=None):
|
|
return _run(main, PREP, "--path", str(wt), "--branch", f"b-{wt.name}", *extra, env=env)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #27: the managed .gitignore block ignores .venv as a file, directory or link
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _install_env():
|
|
env = dict(os.environ)
|
|
env["MAM_REPO_URL"] = str(REPO_ROOT)
|
|
env["MAM_SKIP_VENV"] = "1"
|
|
env["MAM_INSTALLER_URL"] = "file://" + str(REPO_ROOT / "deploy" / "install.sh")
|
|
return env
|
|
|
|
|
|
GI_START = "# >>> MAM managed block (managed by install.sh — do not edit) >>>"
|
|
GI_END = "# <<< MAM managed block <<<"
|
|
|
|
|
|
def _block(gi: str) -> list:
|
|
lines = gi.splitlines()
|
|
return lines[lines.index(GI_START) + 1:lines.index(GI_END)]
|
|
|
|
|
|
@pytest.mark.parametrize("installer", ["install.sh", "install_mam.sh"])
|
|
def test_w10_managed_block_ignores_venv_in_every_form(tmp_path, installer):
|
|
tgt = tmp_path / "tgt"
|
|
tgt.mkdir()
|
|
cmd = ["bash", str(REPO_ROOT / "deploy" / installer)] + ([str(tgt)] if installer == "install.sh" else ["--target", str(tgt)])
|
|
res = subprocess.run(cmd, capture_output=True, text=True, env=_install_env(), cwd=str(tmp_path), timeout=300)
|
|
assert res.returncode == 0, res.stdout[-2000:] + res.stderr[-2000:]
|
|
block = _block((tgt / ".gitignore").read_text())
|
|
assert "/.venv" in block and "/.venv/" not in block
|
|
_git(tgt, "init", "-q")
|
|
v = tgt / ".venv"
|
|
|
|
def clear():
|
|
if v.is_symlink() or v.is_file():
|
|
v.unlink()
|
|
elif v.exists():
|
|
shutil.rmtree(v)
|
|
|
|
if v.exists() or v.is_symlink(): # whatever the installer itself left there is ignored too
|
|
assert _git(tgt, "check-ignore", "-q", ".venv", check=False).returncode == 0
|
|
for make in (lambda p: p.symlink_to(tmp_path), lambda p: p.write_text("x"), lambda p: p.mkdir()):
|
|
clear()
|
|
make(v)
|
|
assert _git(tgt, "check-ignore", "-q", ".venv", check=False).returncode == 0, make
|
|
clear()
|
|
|
|
|
|
def test_w11_reinstall_replaces_the_old_rule_and_keeps_user_lines(tmp_path):
|
|
tgt = tmp_path / "tgt"
|
|
tgt.mkdir()
|
|
# an installation made before #27: the same markers, the old directory-only rule
|
|
(tgt / ".gitignore").write_text(f"user-one\n{GI_START}\n/.venv/\n/.mam/\n{GI_END}\nuser-two\n")
|
|
res = subprocess.run(["bash", str(REPO_ROOT / "deploy" / "install.sh"), str(tgt)],
|
|
capture_output=True, text=True, env=_install_env(), cwd=str(tmp_path), timeout=300)
|
|
assert res.returncode == 0, res.stdout[-2000:] + res.stderr[-2000:]
|
|
gi = (tgt / ".gitignore").read_text()
|
|
assert gi.count(GI_START) == 1 and gi.count(GI_END) == 1
|
|
assert "/.venv" in _block(gi) and "/.venv/" not in _block(gi)
|
|
assert "user-one" in gi.splitlines() and "user-two" in gi.splitlines()
|
|
for ledger in list(tgt.rglob("asset_hashes.txt")) + list(tgt.rglob("install_manifest.txt")):
|
|
assert ".gitignore" not in ledger.read_text().split(), ledger
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resume: original cwd only (A11), binding failures (A12), already running (A13)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _resume(root, ws, name, agent="claude", env=None):
|
|
return _run(root, ("multi-agent-mux-resume", "scripts", "resume_session.sh"),
|
|
"--workspace", str(ws), "--agent", agent, "--session", name, env=env)
|
|
|
|
|
|
def test_w7_resume_only_at_the_original_cwd(mam_sandbox, mock_herdr, mock_agents):
|
|
root = mam_sandbox
|
|
here, there, gone = root / "here", root / "there", root / "gone"
|
|
here.mkdir()
|
|
there.mkdir()
|
|
_mutate(root, f"""d['herdr_sessions'] = [
|
|
{{'name': 'x-creator-claude', 'status': 'stopped', 'pane': {{'cwd': {str(here)!r}, 'cmd': 'claude'}},
|
|
'claude_session_id_own': '11111111-2222-4333-8444-555555555555', 'session_id_source': 'assigned'}},
|
|
{{'name': 'x-creator-agy', 'status': 'stopped', 'pane': {{'cwd': {str(here)!r}, 'cmd': 'agy'}}}},
|
|
{{'name': 'y-creator-claude', 'status': 'stopped', 'pane': {{'cwd': {str(gone)!r}, 'cmd': 'claude'}}}}]""")
|
|
since = len(_calls(mock_herdr))
|
|
res = _resume(root, there, "x-creator-claude")
|
|
assert res.returncode == 2 and f"--workspace '{here}'" in res.stderr
|
|
res = _resume(root, there, "x-creator-agy", agent="agy")
|
|
assert res.returncode == 2
|
|
res = _resume(root, gone, "y-creator-claude")
|
|
assert res.returncode == 2 and "no longer exists" in res.stderr
|
|
assert _spawn_calls(mock_herdr, since) == [], "nothing was spawned in the wrong directory"
|
|
|
|
|
|
def test_w8_binding_failure_refused_for_a_worktree_warned_for_the_control_root(mam_sandbox, mock_herdr, mock_agents):
|
|
root = mam_sandbox
|
|
Path(_skill(root, "lib_py", "control_plane.py")).write_text("raise SystemExit(1)\n")
|
|
wt = root.parent / f"{root.name}-wt8"
|
|
wt.mkdir()
|
|
since = len(_calls(mock_herdr))
|
|
create = ("multi-agent-mux-create", "scripts", "create_session.sh")
|
|
res = _run(root, create, "--workspace", str(wt), "--agent", "claude", "--role", "creator", "--no-onboard")
|
|
assert res.returncode == 2 and "cannot compute the control-plane binding" in res.stderr
|
|
assert _rows(root) == [] and _spawn_calls(mock_herdr, since) == []
|
|
res = _run(root, create, "--workspace", str(root), "--agent", "claude", "--role", "creator", "--no-onboard")
|
|
assert res.returncode == 0 and "WARNING: control-plane binding unavailable" in res.stderr, res.stderr
|
|
(row,) = _rows(root)
|
|
assert "control_plane" not in row
|
|
|
|
_mutate(root, f"""d['herdr_sessions'].append({{'name': 'w8-creator-claude', 'status': 'stopped',
|
|
'pane': {{'cwd': {str(wt)!r}, 'cmd': 'claude'}},
|
|
'claude_session_id_own': '11111111-2222-4333-8444-555555555555', 'session_id_source': 'assigned'}})""")
|
|
res = _resume(root, wt, "w8-creator-claude")
|
|
assert res.returncode == 2 and "cannot compute the control-plane binding" in res.stderr
|
|
|
|
|
|
def test_w9_already_running_resume_persists_the_binding(mam_sandbox, mock_herdr, mock_agents):
|
|
root = mam_sandbox
|
|
create = ("multi-agent-mux-create", "scripts", "create_session.sh")
|
|
res = _run(root, create, "--workspace", str(root), "--agent", "claude", "--role", "creator", "--no-onboard")
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
name = _rows(root)[0]["name"]
|
|
_mutate(root, "d['herdr_sessions'][0].pop('control_plane', None)")
|
|
res = _resume(root, root, name)
|
|
assert res.returncode == 0 and "already running" in res.stdout, res.stdout + res.stderr
|
|
row = _rows(root)[0]
|
|
assert set(row["control_plane"]) == set(CP_KEYS)
|
|
assert row["control_plane"]["workspace_root"] == os.path.realpath(root)
|
|
|
|
custom = {**row["control_plane"], "mam_jobs_dir": str(root / "custom-jobs")}
|
|
_mutate(root, f"d['herdr_sessions'][0]['control_plane'] = {custom!r}")
|
|
assert _resume(root, root, name).returncode == 0
|
|
assert _rows(root)[0]["control_plane"] == custom, "an existing row binding is kept"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# prepare
|
|
# ---------------------------------------------------------------------------
|
|
|
|
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)
|
|
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 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 == ""
|
|
assert f"BASE={_git(wt, 'rev-parse', 'HEAD').stdout.strip()}" in res.stdout
|
|
assert "SUBMODULES=skipped" in res.stdout
|
|
gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip()
|
|
assert not os.path.exists(os.path.join(gd, "modules")), "no submodule trace without --submodules"
|
|
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_w1c_failed_requirements_install_removes_the_half_made_venv(ctl):
|
|
"""Review D1 (PR #29 delta): after a failed pip install the next run must
|
|
not report VENV=kept for an environment without the requirements."""
|
|
main = ctl
|
|
shutil.rmtree(main / ".venv")
|
|
subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(main / ".venv")], check=True)
|
|
(main / "requirements.txt").write_text("this-package-does-not-exist-mam-w1c==0.0.0\n")
|
|
_git(main, "add", "requirements.txt")
|
|
_git(main, "commit", "-q", "-m", "req")
|
|
wt = _wt(main)
|
|
res = _prepare(main, wt, env={**os.environ, "PIP_INDEX_URL": "http://127.0.0.1:9/simple", "PIP_RETRIES": "0", "PIP_TIMEOUT": "1"})
|
|
assert res.returncode == 1 and "pip install" in res.stderr, res.stdout + res.stderr
|
|
assert not (wt / ".venv").exists() and os.path.realpath(wt) in _worktrees(main)
|
|
|
|
|
|
def test_w2_prepare_refusals_and_dry_run(ctl):
|
|
main = ctl
|
|
res = _run(main, PREP, "--path", str(main / "inside"), "--branch", "x")
|
|
assert res.returncode == 2 and "inside the control root" in res.stderr
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt).returncode == 0
|
|
res = _run(main, PREP, "--path", str(_wt(main, "other")), "--branch", f"b-{wt.name}")
|
|
assert res.returncode == 1, "a branch checked out in another worktree: git's refusal is passed on"
|
|
dry = _wt(main, "dry")
|
|
res = _run(main, PREP, "--path", str(dry), "--branch", "dry", "--dry-run")
|
|
assert res.returncode == 0 and "[dry-run]" in res.stdout
|
|
assert not dry.exists() and _git(main, "show-ref", "--verify", "--quiet", "refs/heads/dry", check=False).returncode != 0
|
|
|
|
|
|
def test_w3_w4_prepare_creator_binding_no_daemon_control_root_reconcile(ctl, mock_herdr, mock_agents, monkeypatch):
|
|
"""The creator row points at the worktree but belongs to the control plane;
|
|
prepare suppresses the subscriber daemon; create's immediate reconcile runs
|
|
the control root's reconcile.sh, not the worktree's copy (A8)."""
|
|
from conftest import _subscriber_daemon_pids
|
|
main = ctl
|
|
wt = _wt(main)
|
|
marker_a, marker_b = main / "marker-control-root", main / "marker-worktree"
|
|
ctl_reconcile = Path(_skill(main, "multi-agent-mux-monitor", "scripts", "reconcile.sh"))
|
|
ctl_reconcile.write_text(f"#!/usr/bin/env bash\ntouch '{marker_a}'\n")
|
|
monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False)
|
|
assert _prepare(main, wt).returncode == 0
|
|
wt_reconcile = wt / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
|
wt_reconcile.parent.mkdir(parents=True)
|
|
wt_reconcile.write_text(f"#!/usr/bin/env bash\ntouch '{marker_b}'\n")
|
|
_git(wt, "add", ".agents")
|
|
_git(wt, "commit", "-q", "-m", "worktree copy of reconcile")
|
|
res = _prepare(main, wt, "--creator-agent", "claude", "--onboard")
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
(row,) = _rows(main)
|
|
assert os.path.realpath(row["pane"]["cwd"]) == os.path.realpath(wt)
|
|
assert row["control_plane"]["workspace_root"] == os.path.realpath(main)
|
|
deadline = time.time() + 10
|
|
while time.time() < deadline and not marker_a.exists():
|
|
time.sleep(0.2)
|
|
assert marker_a.exists(), "the control root's reconcile.sh ran"
|
|
assert not marker_b.exists(), "the worktree's reconcile.sh must not run"
|
|
assert _subscriber_daemon_pids(main) == [], "prepare passes MAM_NO_SUBSCRIBER_DAEMON=1"
|
|
|
|
|
|
def test_w6c_submodule_init_failure_rolls_back_and_prepare_recovers(ctl_sub):
|
|
main = ctl_sub
|
|
wt = _wt(main)
|
|
res = _prepare(main, wt, "--submodules", "--creator-agent", "claude", env=FILE_NO)
|
|
assert res.returncode == 1 and "submodule initialisation failed" in res.stderr
|
|
gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip()
|
|
assert not os.path.exists(os.path.join(gd, "modules"))
|
|
assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0
|
|
assert _rows(main) == [], "no creator after a failed step"
|
|
assert _prepare(main, wt).returncode == 0, "re-running without --submodules finishes the preparation"
|
|
res = _run(main, REM, "--path", str(wt))
|
|
assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# remove
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _row(main, name, wt, status="running", job=None):
|
|
return (f"{{'name': {name!r}, 'status': {status!r}, 'role': 'creator', 'delegate_job_id': {job!r}, "
|
|
f"'pane': {{'cwd': {str(wt)!r}, 'cmd': 'claude'}}}}")
|
|
|
|
|
|
def test_w5_remove_refusals_change_nothing(ctl, mock_herdr, mock_agents):
|
|
from lib_py.delegation import registry
|
|
main = ctl
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt).returncode == 0
|
|
jobs = main / ".mam" / "jobs"
|
|
own = registry.register_job("own", registry_dir=str(jobs), agent_session="herdr:wt-creator-claude")
|
|
_mutate(main, f"d['herdr_sessions'] = [{_row(main, 'wt-creator-claude', wt, job=own)}]")
|
|
|
|
res = _run(main, REM, "--path", str(wt)) # (a) running, no --stop
|
|
assert res.returncode == 3 and "pass --stop" in res.stderr
|
|
res = _run(main, REM, "--path", str(wt), "--stop", "--dry-run")
|
|
assert res.returncode == 0, "with --stop the row's own delegate job is not a refusal"
|
|
|
|
other = registry.register_job("other", registry_dir=str(jobs), agent_session="herdr:wt-creator-claude")
|
|
res = _run(main, REM, "--path", str(wt), "--stop") # (b) another open job
|
|
assert res.returncode == 3 and other in res.stderr
|
|
rec = json.loads((jobs / f"{other}.json").read_text())
|
|
rec["status"] = "completed"
|
|
(jobs / f"{other}.json").write_text(json.dumps(rec))
|
|
|
|
(wt / "dirty.txt").write_text("x\n") # (c) dirty
|
|
res = _run(main, REM, "--path", str(wt), "--stop")
|
|
assert res.returncode == 3 and "uncommitted changes" in res.stderr
|
|
(wt / "dirty.txt").unlink()
|
|
|
|
_git(main, "worktree", "lock", str(wt)) # (d) locked
|
|
res = _run(main, REM, "--path", str(wt), "--stop")
|
|
assert res.returncode == 3 and "locked" in res.stderr
|
|
_git(main, "worktree", "unlock", str(wt))
|
|
|
|
assert os.path.realpath(wt) in _worktrees(main)
|
|
(row,) = _rows(main)
|
|
assert row["status"] == "running", "no refusal may stop a session first"
|
|
assert json.loads((jobs / f"{own}.json").read_text())["status"] == "pending"
|
|
|
|
|
|
def test_w6_remove_plain_and_with_stop(ctl, mock_herdr, mock_agents):
|
|
main = ctl
|
|
wt1, wt2 = _wt(main, "one"), _wt(main, "two")
|
|
assert _prepare(main, wt1).returncode == 0
|
|
_mutate(main, f"d['herdr_sessions'] = [{_row(main, 'one-creator-claude', wt1, status='stopped')}]")
|
|
res = _run(main, REM, "--path", str(wt1))
|
|
assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr
|
|
assert os.path.realpath(wt1) not in _worktrees(main)
|
|
assert _git(main, "show-ref", "--verify", "--quiet", f"refs/heads/b-{wt1.name}", check=False).returncode == 0
|
|
|
|
res = _prepare(main, wt2, "--creator-agent", "claude")
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
name = [r for r in _rows(main) if os.path.realpath(r["pane"]["cwd"]) == os.path.realpath(wt2)][0]["name"]
|
|
res = _run(main, REM, "--path", str(wt2), "--stop")
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
assert [r for r in _rows(main) if r["name"] == name][0]["status"] == "stopped"
|
|
assert os.path.realpath(wt2) not in _worktrees(main)
|
|
|
|
|
|
def test_w6b_submodule_worktree_single_force_after_checks(ctl_sub):
|
|
main = ctl_sub
|
|
wt = _wt(main)
|
|
res = _prepare(main, wt, "--submodules", env=FILE_OK)
|
|
assert res.returncode == 0 and "SUBMODULES=initialised" in res.stdout, res.stdout + res.stderr
|
|
|
|
(wt / "sm" / "junk.txt").write_text("u\n") # (i) untracked in the submodule
|
|
res = _run(main, REM, "--path", str(wt))
|
|
assert res.returncode == 3 and os.path.realpath(wt) in _worktrees(main)
|
|
(wt / "sm" / "junk.txt").unlink()
|
|
|
|
(wt / "sm" / "new.txt").write_text("n\n") # (ii) detached commit, gitlink committed above
|
|
_git(wt / "sm", "add", "new.txt")
|
|
_git(wt / "sm", "commit", "-q", "-m", "local only")
|
|
local = _git(wt / "sm", "rev-parse", "HEAD").stdout.strip()
|
|
_git(wt, "add", "sm")
|
|
_git(wt, "commit", "-q", "-m", "bump sm")
|
|
assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == ""
|
|
res = _run(main, REM, "--path", str(wt))
|
|
assert res.returncode == 3 and "unpushed" in res.stderr, res.stdout + res.stderr
|
|
assert _git(wt / "sm", "cat-file", "-e", local, check=False).returncode == 0
|
|
|
|
_git(wt / "sm", "push", "-q", "origin", "HEAD:refs/heads/pushed", env=FILE_OK)
|
|
_git(wt / "sm", "fetch", "-q", "origin", env=FILE_OK)
|
|
res = _run(main, REM, "--path", str(wt))
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
assert "single --force" in res.stdout and os.path.realpath(wt) not in _worktrees(main)
|
|
|
|
|
|
def test_w6d_failed_init_trace_outside_prepare_is_removable(ctl_sub):
|
|
main = ctl_sub
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt).returncode == 0
|
|
assert _git(wt, "submodule", "update", "--init", env=FILE_NO, check=False).returncode != 0
|
|
gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip()
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@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) - 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"))
|
|
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_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."""
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reviews 8f141362 (N1) and 275fdc4f (R4, R5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_w6i_deleted_worktree_directory_is_refused_cleanly(ctl):
|
|
"""N1: `git status` fails in a worktree whose directory is gone - the
|
|
helper refuses (exit 3) instead of dying on an unset METHOD."""
|
|
main = ctl
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt).returncode == 0
|
|
shutil.rmtree(wt)
|
|
for extra in ([], ["--dry-run"]):
|
|
res = _run(main, REM, "--path", str(wt), *extra)
|
|
assert res.returncode == 3, res.stdout + res.stderr
|
|
assert "worktree prune" in res.stderr
|
|
assert "nothing was changed" in res.stdout + res.stderr
|
|
assert "unbound variable" not in res.stderr
|
|
assert str(wt) in _worktrees(main), "nothing was pruned or removed"
|
|
|
|
|
|
def _deinit_after(wt: Path, make_state) -> str:
|
|
"""Put state into the initialised `sm`, then deinit it (no force); returns
|
|
the submodule's repository, which stays under the worktree's modules/."""
|
|
make_state(wt / "sm")
|
|
gd = _git(wt / "sm", "rev-parse", "--absolute-git-dir").stdout.strip()
|
|
_git(wt, "submodule", "deinit", "-q", "sm")
|
|
assert not (wt / "sm" / ".git").exists() and os.path.isdir(gd)
|
|
return gd
|
|
|
|
|
|
def test_w6j_stash_in_a_deinitialised_submodule_repository_is_kept(ctl_sub):
|
|
"""R5: foreach no longer visits a deinit'd submodule, but its repository -
|
|
and its stash - is still under modules/ and would be force-removed."""
|
|
main = ctl_sub
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
|
|
|
def stash(sm):
|
|
(sm / "s.txt").write_text("valuable stashed work\n")
|
|
_git(sm, "stash", "push", "-q", "-m", "keep this work")
|
|
|
|
gd = _deinit_after(wt, stash)
|
|
before = _git(gd, "rev-parse", "refs/stash").stdout.strip()
|
|
res = _run(main, REM, "--path", str(wt))
|
|
assert res.returncode == 3 and "'stashes'" in res.stderr, res.stdout + res.stderr
|
|
assert _git(gd, "rev-parse", "refs/stash").stdout.strip() == before
|
|
|
|
|
|
def test_w6j2_unpushed_commit_in_a_deinitialised_submodule_repository_is_kept(ctl_sub):
|
|
main = ctl_sub
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
|
|
|
local = []
|
|
|
|
def commit(sm):
|
|
(sm / "new.txt").write_text("n\n")
|
|
_git(sm, "add", "new.txt")
|
|
_git(sm, "commit", "-q", "-m", "local only")
|
|
local.append(_git(sm, "rev-parse", "HEAD").stdout.strip())
|
|
# record the new gitlink first: deinit refuses a submodule whose HEAD
|
|
# differs from it, and the top level must be clean for the scenario
|
|
_git(wt, "add", "sm")
|
|
_git(wt, "commit", "-q", "-m", "bump sm")
|
|
|
|
gd = _deinit_after(wt, commit)
|
|
assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == ""
|
|
res = _run(main, REM, "--path", str(wt))
|
|
assert res.returncode == 3 and "'unpushed'" in res.stderr, res.stdout + res.stderr
|
|
assert _git(gd, "cat-file", "-e", local[0], check=False).returncode == 0
|
|
|
|
|
|
def test_w6k_files_in_an_uninitialised_submodule_path_refuse_the_init(ctl_sub):
|
|
"""R4: files that predate the attempt in a not-yet-initialised submodule
|
|
path refuse --submodules up front; nothing is changed or deleted."""
|
|
main = ctl_sub
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt).returncode == 0
|
|
valuable = wt / "sm" / "user-work.txt"
|
|
valuable.write_text("existing user work\n")
|
|
res = _prepare(main, wt, "--submodules", env=FILE_OK)
|
|
assert res.returncode == 3 and "not empty" in res.stderr, res.stdout + res.stderr
|
|
assert valuable.read_text() == "existing user work\n"
|
|
gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip()
|
|
assert not os.path.exists(os.path.join(gd, "modules"))
|
|
assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# review ec50fbe7 (R6): a submodule commit kept only by a tag
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _tag_only_commit(sm: Path, annotated: bool = False) -> str:
|
|
"""Commit on the submodule's detached HEAD, keep it only by a tag, go back
|
|
to the recorded commit (the top level stays clean). Returns the commit."""
|
|
orig = _git(sm, "rev-parse", "HEAD").stdout.strip()
|
|
(sm / "tagged.txt").write_text("kept only by a tag\n")
|
|
_git(sm, "add", "tagged.txt")
|
|
_git(sm, "commit", "-q", "-m", "tag-only work")
|
|
local = _git(sm, "rev-parse", "HEAD").stdout.strip()
|
|
_git(sm, "tag", *(["-a", "-m", "keep"] if annotated else []), "saved-work")
|
|
_git(sm, "checkout", "-q", orig)
|
|
return local
|
|
|
|
|
|
def _remove_refuses_tag_only_commit(main: Path, annotated: bool, deinit: bool) -> None:
|
|
wt = _wt(main)
|
|
assert _prepare(main, wt, "--submodules", env=FILE_OK).returncode == 0
|
|
local = _tag_only_commit(wt / "sm", annotated)
|
|
gd = _git(wt / "sm", "rev-parse", "--absolute-git-dir").stdout.strip()
|
|
assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == ""
|
|
if deinit:
|
|
_git(wt, "submodule", "deinit", "-q", "sm")
|
|
res = _run(main, REM, "--path", str(wt))
|
|
assert res.returncode == 3 and "'unpushed'" in res.stderr, res.stdout + res.stderr
|
|
assert os.path.isdir(gd)
|
|
assert _git(gd, "rev-parse", "saved-work^{commit}").stdout.strip() == local
|
|
|
|
|
|
@pytest.mark.parametrize("deinit", [False, True])
|
|
def test_w6l_tag_only_submodule_commit_is_kept(ctl_sub, deinit):
|
|
"""R6: HEAD and --branches miss a commit only a tag reaches; both checks
|
|
(initialised checkout via foreach, retained repository via --git-dir) use
|
|
the same rev list and refuse."""
|
|
_remove_refuses_tag_only_commit(ctl_sub, annotated=False, deinit=deinit)
|
|
|
|
|
|
def test_w6m_annotated_tag_only_submodule_commit_is_kept(ctl_sub):
|
|
_remove_refuses_tag_only_commit(ctl_sub, annotated=True, deinit=False)
|