"""Tests for git worktree isolation (CLI --worktree / -w flag). Verifies worktree creation, cleanup, .worktreeinclude handling, .gitignore management, and integration with the CLI. (#652) """ import os import subprocess import pytest from hermes_cli import worktree_ops @pytest.fixture def git_repo(tmp_path): """Create a temporary git repo for testing.""" repo = tmp_path / "test-repo" repo.mkdir() subprocess.run(["git", "init"], cwd=repo, capture_output=True) subprocess.run( ["git", "config", "user.email", "test@test.com"], cwd=repo, capture_output=True, ) subprocess.run( ["git", "config", "user.name", "Test"], cwd=repo, capture_output=True, ) # Create initial commit (worktrees need at least one commit) (repo / "README.md").write_text("# Test Repo\n") subprocess.run(["git", "add", "."], cwd=repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Initial commit"], cwd=repo, capture_output=True, ) subprocess.run( ["git", "remote", "add", "origin", "https://example.com/test-repo.git"], cwd=repo, capture_output=True, ) # Add a fake remote ref so cleanup logic sees the initial commit as # "pushed" when a remote is configured. subprocess.run( ["git", "update-ref", "refs/remotes/origin/main", "HEAD"], cwd=repo, capture_output=True, ) return repo @pytest.fixture def git_repo_no_remote(tmp_path): """Create a temporary git repo with no configured remotes.""" repo = tmp_path / "test-repo-no-remote" repo.mkdir() subprocess.run(["git", "init"], cwd=repo, capture_output=True) subprocess.run( ["git", "config", "user.email", "test@test.com"], cwd=repo, capture_output=True, ) subprocess.run( ["git", "config", "user.name", "Test"], cwd=repo, capture_output=True, ) (repo / "README.md").write_text("# Test Repo\n") subprocess.run(["git", "add", "."], cwd=repo, capture_output=True) subprocess.run( ["git", "commit", "-m", "Initial commit"], cwd=repo, capture_output=True, ) return repo # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- # Should not crash — just skip all lines class TestWorktreeLockReaping: """Exercise the REAL cli._prune_stale_worktrees lock/dirty/unpushed logic. These import the actual production functions so the behavior contract is enforced against the shipped code: - live-locked (owning pid running) -> never reaped, any age - dead-locked clean (owning pid gone) -> unlocked + reaped (fixes the accumulation bug: `git worktree remove --force` refuses a locked tree) - dirty (uncommitted) at >72h -> preserved - unpushed commits at any age -> preserved - clean/unlocked stale -> reaped (aggressive cleanup intact) """ @staticmethod def _age(path, hours): import time t = time.time() - (hours * 3600) os.utime(path, (t, t)) @staticmethod def _mk(cli, repo, name, pid=None, dirty=False, unpushed=False, age_h=100): p = repo / ".worktrees" / name (repo / ".worktrees").mkdir(exist_ok=True) subprocess.run( ["git", "worktree", "add", str(p), "-b", f"hermes/{name}", "HEAD"], cwd=repo, capture_output=True, ) if pid is not None: subprocess.run( ["git", "worktree", "lock", "--reason", f"hermes pid={pid}", str(p)], cwd=repo, capture_output=True, ) if unpushed: (p / "work.txt").write_text("x") subprocess.run(["git", "add", "work.txt"], cwd=p, capture_output=True) subprocess.run(["git", "commit", "-m", "wip"], cwd=p, capture_output=True) if dirty: (p / "dirty.txt").write_text("uncommitted") TestWorktreeLockReaping._age(p, age_h) return p def test_live_locked_survives_at_any_age(self, git_repo): import cli wt = self._mk(cli, git_repo, "hermes-live", pid=os.getpid()) cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "live-locked worktree (this pid) must never be reaped" def test_dead_locked_clean_is_reaped(self, git_repo): import cli wt = self._mk(cli, git_repo, "hermes-dead", pid=999999) # sanity: this is the accumulation bug — remove --force alone can't do it assert worktree_ops._worktree_lock_is_live(str(git_repo), str(wt)) == "dead" cli._prune_stale_worktrees(str(git_repo)) assert not wt.exists(), "dead-locked clean worktree should be unlocked + reaped" def test_dead_locked_dirty_survives(self, git_repo): import cli wt = self._mk(cli, git_repo, "hermes-deaddirty", pid=999999, dirty=True) cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "dead-locked worktree with uncommitted work must survive" def test_dead_locked_unpushed_survives(self, git_repo): import cli wt = self._mk(cli, git_repo, "hermes-deadunp", pid=999999, unpushed=True) cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "dead-locked worktree with unpushed commits must survive" def test_unlocked_clean_stale_is_reaped(self, git_repo): import cli wt = self._mk(cli, git_repo, "hermes-nolock", pid=None) cli._prune_stale_worktrees(str(git_repo)) assert not wt.exists(), "clean unlocked stale worktree should be reaped" def test_dirty_survives_over_72h(self, git_repo): import cli wt = self._mk(cli, git_repo, "hermes-dirty72", pid=None, dirty=True, age_h=100) cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "dirty worktree must survive even past the 72h tier" class TestWorktreeLockPredicate: """_worktree_lock_is_live classification (real cli helper).""" def _mk_locked(self, repo, name, reason): p = repo / ".worktrees" / name (repo / ".worktrees").mkdir(exist_ok=True) subprocess.run( ["git", "worktree", "add", str(p), "-b", f"hermes/{name}", "HEAD"], cwd=repo, capture_output=True, ) subprocess.run( ["git", "worktree", "lock", "--reason", reason, str(p)], cwd=repo, capture_output=True, ) return p def test_unlocked_returns_none(self, git_repo): p = git_repo / ".worktrees" / "hermes-x" (git_repo / ".worktrees").mkdir(exist_ok=True) subprocess.run( ["git", "worktree", "add", str(p), "-b", "hermes/hermes-x", "HEAD"], cwd=git_repo, capture_output=True, ) assert worktree_ops._worktree_lock_is_live(str(git_repo), str(p)) is None def test_foreign_lock_reason_returns_dead(self, git_repo): p = self._mk_locked(git_repo, "hermes-foreign", "some other tool") assert worktree_ops._worktree_lock_is_live(str(git_repo), str(p)) == "dead" def test_bad_repo_root_fails_safe_to_live(self, tmp_path): # Not a git repo -> git query fails -> must report "live" (never delete) assert worktree_ops._worktree_lock_is_live(str(tmp_path), str(tmp_path / "x")) == "live" class TestWidenedPruner: """Behavior contracts for the widened pruner (#all-.worktrees coverage, squash-merge escape hatch, kanban exclusion, preserved-work warning). Previously only ``hermes-*`` directories were considered, so salvage/ review/port lanes created with raw ``git worktree add`` accumulated forever (real incident: 117 dirs / 26 GB). And squash-merged branches' local commits are unreachable from refs/remotes/* forever, so the unpushed guard preserved fully-merged scratch trees indefinitely. """ @staticmethod def _age(path, hours): import time t = time.time() - (hours * 3600) os.utime(path, (t, t)) @staticmethod def _mk(repo, name, commit=False, dirty=False, age_h=100): p = repo / ".worktrees" / name (repo / ".worktrees").mkdir(exist_ok=True) subprocess.run( ["git", "worktree", "add", str(p), "-b", f"wt/{name}", "HEAD"], cwd=repo, capture_output=True, ) sha = None if commit: (p / "work.txt").write_text(f"work for {name}\n") subprocess.run(["git", "add", "work.txt"], cwd=p, capture_output=True) subprocess.run(["git", "commit", "-m", "wip"], cwd=p, capture_output=True) sha = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=p, capture_output=True, text=True, ).stdout.strip() if dirty: (p / "dirty.txt").write_text("uncommitted") TestWidenedPruner._age(p, age_h) return p, sha @staticmethod def _merge_upstream(repo, sha): """Simulate a squash-merge: land a patch-equivalent commit (different SHA) on the branch refs/remotes/origin/main points at.""" # Distinct committer identity forces a distinct SHA even when the # cherry-pick lands in the same second as the original commit. subprocess.run( ["git", "-c", "user.email=merger@test.com", "-c", "user.name=Merger", "cherry-pick", sha], cwd=repo, capture_output=True, ) new_head = subprocess.run( ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True, ).stdout.strip() assert new_head != sha subprocess.run( ["git", "update-ref", "refs/remotes/origin/main", new_head], cwd=repo, capture_output=True, ) # -- named (non hermes-*) directories are now covered ------------------ def test_named_clean_stale_tree_is_reaped(self, git_repo): import cli wt, _ = self._mk(git_repo, "salvage-12345", age_h=80) cli._prune_stale_worktrees(str(git_repo)) assert not wt.exists(), "clean named tree past 72h soft tier should be reaped" def test_named_tree_gets_3x_grace(self, git_repo): import cli wt, _ = self._mk(git_repo, "salvage-fresh", age_h=48) cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "named tree under 72h must be kept (3x scratch timeline)" # -- squash-merge escape hatch ------------------------------------------ def test_squash_merged_tree_is_reaped(self, git_repo): import cli wt, sha = self._mk(git_repo, "hermes-merged", commit=True, age_h=100) self._merge_upstream(git_repo, sha) assert cli._worktree_has_unpushed_commits(str(wt)), ( "precondition: commit unreachable from remotes (the leak this fixes)" ) cli._prune_stale_worktrees(str(git_repo)) assert not wt.exists(), ( "worktree whose commits are all patch-equivalent upstream is merged " "work and should be reaped" ) # -- _worktree_commits_all_merged_upstream unit contracts ---------------- def test_merged_predicate_fails_safe_without_upstream(self, git_repo_no_remote): """No remote: the local trunk is the baseline (a tree at trunk IS merged). No trunk at all — detached main checkout, no main/master — leaves nothing to compare against -> False.""" repo = git_repo_no_remote p = repo / ".worktrees" / "hermes-noremote" (repo / ".worktrees").mkdir(exist_ok=True) subprocess.run( ["git", "worktree", "add", str(p), "-b", "wt/noremote", "HEAD"], cwd=repo, capture_output=True, ) assert worktree_ops._worktree_commits_all_merged_upstream(str(p)) is True trunk = subprocess.run(["git", "branch", "--show-current"], cwd=repo, capture_output=True, text=True).stdout.strip() subprocess.run(["git", "checkout", "-q", "--detach"], cwd=repo, capture_output=True) subprocess.run(["git", "branch", "-m", trunk, "scratch/not-a-trunk"], cwd=repo, capture_output=True) assert worktree_ops._worktree_merge_base_ref(str(p)) is None assert worktree_ops._worktree_commits_all_merged_upstream(str(p)) is False assert worktree_ops._worktree_has_unpushed_commits(str(p)) is True # -- preserved-work warning ---------------------------------------------- class TestMergeVerdictCache: """The ``git cherry`` patch-equivalence probe is memoized on disk because it dominates ``hermes -w`` startup (~0.2-1.0s per worktree, re-run on every launch for every tree preserved as unpushed). The invariant that makes caching safe: the verdict is a pure function of the ``(base_sha, head_sha)`` range, so a cache entry is only reused while BOTH endpoints are unchanged. These tests pin that relationship rather than any specific timing or cache contents. """ _age = staticmethod(TestWidenedPruner._age) _mk = staticmethod(TestWidenedPruner._mk) _merge_upstream = staticmethod(TestWidenedPruner._merge_upstream) def test_cache_hit_matches_uncached_verdict(self, git_repo): """A cached verdict must equal what the real git call returns.""" wt, sha = self._mk(git_repo, "hermes-cachehit", commit=True) self._merge_upstream(git_repo, sha) uncached = worktree_ops._worktree_commits_all_merged_upstream(str(wt)) cache = {} cold = worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache) warm = worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache) assert cache, "verdict should have been memoized" assert uncached is cold is warm is True def test_new_commit_invalidates_cached_verdict(self, git_repo): """Moving HEAD must not reuse the old entry — the key includes head_sha. This is the guard against the cache turning a 'merged, reapable' verdict into a stale approval to delete a tree that has since gained real work. """ wt, sha = self._mk(git_repo, "hermes-moves", commit=True) self._merge_upstream(git_repo, sha) cache = {} assert worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache) is True key_after_merge = set(cache) # New local-only work lands in the worktree. (wt / "more.txt").write_text("unmerged work\n") subprocess.run(["git", "add", "more.txt"], cwd=wt, capture_output=True) subprocess.run(["git", "commit", "-m", "new work"], cwd=wt, capture_output=True) assert worktree_ops._worktree_commits_all_merged_upstream(str(wt), cache=cache) is False assert set(cache) != key_after_merge, "moved HEAD must produce a new key" def test_cache_is_bounded(self, monkeypatch, tmp_path): """The cache file must not grow without limit across sessions.""" from hermes_cli import worktree_ops path = tmp_path / "verdicts.json" monkeypatch.setattr(worktree_ops, "_worktree_merge_cache_path", lambda: path) monkeypatch.setattr(worktree_ops, "_WORKTREE_MERGE_CACHE_MAX", 10) worktree_ops._save_worktree_merge_cache({f"sha{i}..sha{i}:20": True for i in range(50)}) assert len(worktree_ops._load_worktree_merge_cache()) == 10 class TestPruneParallelEquivalence: """Classification runs on a thread pool, so verdicts must not depend on concurrency: the same mixed board must produce the same survivors whether the pool has 1 worker or many.""" _age = staticmethod(TestWidenedPruner._age) _mk = staticmethod(TestWidenedPruner._mk) _merge_upstream = staticmethod(TestWidenedPruner._merge_upstream) def _board(self, git_repo, tag=""): """A board covering every verdict branch: reapable, dirty, unpushed.""" names = {} for i in range(3): n = f"hermes-merged{tag}{i}" wt, sha = self._mk(git_repo, n, commit=True) self._merge_upstream(git_repo, sha) names[n] = wt for i in range(3): n = f"hermes-unpushed{tag}{i}" wt, _ = self._mk(git_repo, n, commit=True) names[n] = wt for i in range(2): n = f"hermes-dirty{tag}{i}" wt, _ = self._mk(git_repo, n, dirty=True) names[n] = wt n = f"hermes-fresh{tag}" wt, _ = self._mk(git_repo, n, commit=True, age_h=1) names[n] = wt # Every tree must really exist, otherwise the survivor comparison below # is vacuous (a failed `git worktree add` would look like a reap). for name, p in names.items(): assert p.exists(), f"fixture worktree {name} was not created" return names @staticmethod def _kinds(survivors): """Reduce survivor names to their kind, dropping the phase tag.""" out = set() for n in survivors: for kind in ("merged", "unpushed", "dirty", "fresh"): if n.startswith(f"hermes-{kind}"): out.add(kind) return out def test_single_and_multi_worker_agree(self, git_repo, monkeypatch): import cli # Phase A — force the serial path (pool sized to 1 worker). board = self._board(git_repo, tag="a") monkeypatch.setattr(cli.os, "cpu_count", lambda: 1) cli._prune_stale_worktrees(str(git_repo)) serial = self._kinds({n for n, p in board.items() if p.exists()}) # Phase B — an independent board (distinct branch names so creation # can't collide with phase A's leftover refs) run through a real pool. try: worktree_ops._worktree_merge_cache_path().unlink() except Exception: pass board2 = self._board(git_repo, tag="b") monkeypatch.setattr(cli.os, "cpu_count", lambda: 8) cli._prune_stale_worktrees(str(git_repo)) parallel = self._kinds({n for n, p in board2.items() if p.exists()}) assert parallel == serial, ( f"pool width changed the outcome: serial={serial} parallel={parallel}" ) # Sanity: the board exercised both outcomes, so the equality above is # not comparing two empty (or two identical-because-nothing-ran) sets. assert serial == {"dirty", "unpushed", "fresh"}, serial def test_pool_failure_falls_back_to_serial(self, git_repo, monkeypatch): """A ThreadPoolExecutor failure must not block startup.""" import cli wt, sha = self._mk(git_repo, "hermes-poolfail", commit=True) self._merge_upstream(git_repo, sha) class _Boom: def __init__(self, *a, **kw): raise RuntimeError("cannot start thread") import concurrent.futures monkeypatch.setattr(concurrent.futures, "ThreadPoolExecutor", _Boom) monkeypatch.setattr(cli.os, "cpu_count", lambda: 8) cli._prune_stale_worktrees(str(git_repo)) assert not wt.exists(), "serial fallback must still reap the merged tree" class TestShallowCloneDeepening: """Shallow installer clones (`git clone --depth 1`) break the unpushed guard: the shallow boundary disconnects an older worktree HEAD from origin/*, so `git log HEAD --not --remotes` misreports already-public commits as unpushed and every aged worktree is preserved forever (real incident: 21 of 25 hermes-* trees stuck on a default install). These build a REAL shallow clone over file:// and verify the pruner deepens it and reaps the false-positive tree. """ @staticmethod def _run(cmd, cwd): return subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, ) @classmethod def _upstream(cls, tmp_path): """Upstream repo with one commit (A). Returns its path.""" up = tmp_path / "upstream" up.mkdir() cls._run(["git", "init", "-b", "main"], up) cls._run(["git", "config", "user.email", "test@test.com"], up) cls._run(["git", "config", "user.name", "Test"], up) (up / "README.md").write_text("# upstream\n") cls._run(["git", "add", "."], up) cls._run(["git", "commit", "-m", "A"], up) return up @classmethod def _advance_upstream(cls, up, name): (up / f"{name}.txt").write_text(f"{name}\n") cls._run(["git", "add", "."], up) cls._run(["git", "commit", "-m", name], up) @classmethod def _shallow_clone(cls, tmp_path, up): clone = tmp_path / "shallow-clone" subprocess.run( ["git", "clone", "--depth", "1", f"file://{up}", str(clone)], capture_output=True, text=True, ) cls._run(["git", "config", "user.email", "test@test.com"], clone) cls._run(["git", "config", "user.name", "Test"], clone) return clone @staticmethod def _age(path, hours=100): import time t = time.time() - (hours * 3600) os.utime(path, (t, t)) def _stuck_worktree(self, tmp_path): """Build the incident shape: shallow clone at A, worktree at A, upstream advances to B, shallow fetch moves origin/main to B. Worktree HEAD (A) is now disconnected from origin/main (B).""" import cli up = self._upstream(tmp_path) clone = self._shallow_clone(tmp_path, up) assert cli._repo_is_shallow(str(clone)), "fixture must start shallow" wt = clone / ".worktrees" / "hermes-shallowstuck" (clone / ".worktrees").mkdir() self._run( ["git", "worktree", "add", str(wt), "-b", "hermes/hermes-shallowstuck", "HEAD"], clone, ) self._advance_upstream(up, "B") # Same shape as the updater: shallow fetch of the new tip only. self._run(["git", "fetch", "--depth", "1", "origin", "main"], clone) self._run( ["git", "update-ref", "refs/remotes/origin/main", "FETCH_HEAD"], clone, ) self._age(wt) return up, clone, wt def test_shallow_disconnect_reproduces_false_unpushed(self, tmp_path): """Sanity: without deepening, the primitive misreports unpushed.""" import cli _, clone, wt = self._stuck_worktree(tmp_path) assert cli._worktree_has_unpushed_commits(str(wt)), ( "expected the shallow disconnect to look like unpushed commits — " "if this stops reproducing, the fixture no longer exercises the bug" ) def test_repo_is_shallow_detection(self, tmp_path, git_repo): import cli up = self._upstream(tmp_path) clone = self._shallow_clone(tmp_path, up) assert cli._repo_is_shallow(str(clone)) is True assert cli._repo_is_shallow(str(git_repo)) is False assert cli._repo_is_shallow(str(tmp_path / "nonexistent")) is False def test_deepen_connects_history_and_clears_false_unpushed(self, tmp_path): import cli _, clone, wt = self._stuck_worktree(tmp_path) assert cli._worktree_has_unpushed_commits(str(wt)) assert worktree_ops._deepen_shallow_repo(str(clone)) is True assert not cli._repo_is_shallow(str(clone)) assert not cli._worktree_has_unpushed_commits(str(wt)), ( "after deepening, the worktree's HEAD is an ancestor of " "origin/main and must no longer count as unpushed" ) def test_pruner_deepens_and_reaps_stuck_worktree(self, tmp_path): """E2E: the startup pruner itself unshallows and reaps the tree.""" import cli _, clone, wt = self._stuck_worktree(tmp_path) cli._prune_stale_worktrees(str(clone)) assert not cli._repo_is_shallow(str(clone)), "pruner should deepen" assert not wt.exists(), ( "deepened history proves the tree is merged/public — reap it" ) def test_deepen_offline_fails_soft_and_preserves(self, tmp_path): """Unreachable remote: deepen fails, verdicts stay conservative.""" import cli _, clone, wt = self._stuck_worktree(tmp_path) # Point origin somewhere that does not exist. self._run( ["git", "remote", "set-url", "origin", f"file://{tmp_path}/gone"], clone, ) assert worktree_ops._deepen_shallow_repo(str(clone), timeout=30) is False cli._prune_stale_worktrees(str(clone)) assert wt.exists(), ( "offline deepen failure must fall back to preserving the tree" ) def test_deepen_noop_on_full_clone(self, git_repo): assert worktree_ops._deepen_shallow_repo(str(git_repo)) is True def test_real_unpushed_work_survives_deepening(self, tmp_path): """Deepening must not turn genuinely unpushed commits reapable.""" import cli _, clone, wt = self._stuck_worktree(tmp_path) (wt / "real-work.txt").write_text("novel\n") self._run(["git", "add", "real-work.txt"], wt) self._run(["git", "commit", "-m", "real unpushed work"], wt) self._age(wt) cli._prune_stale_worktrees(str(clone)) assert wt.exists(), ( "genuinely unpushed commit must survive even after deepening" ) @pytest.mark.platforms("linux") class TestPrMergedEscapeHatch: """Rebase-merged PRs whose diff changed during salvage defeat ``git cherry`` (patch-id mismatch), so the pruner asks GitHub whether the branch's PR is MERGED. These tests stub the ``gh`` binary on PATH — the contract is about how the pruner consumes the answer, not about GitHub. Contract: - gh reports a merged PR + tree is clean -> reaped - gh reports no merged PR -> preserved - gh missing/failing -> preserved (fail safe) - dirty tree -> never reaped regardless of gh """ _age = staticmethod(TestWorktreeLockReaping._age) @staticmethod def _mk_diverged(repo, name, age_h=100): """Worktree with a commit NOT patch-equivalent to anything upstream.""" p = repo / ".worktrees" / name (repo / ".worktrees").mkdir(exist_ok=True) subprocess.run( ["git", "worktree", "add", str(p), "-b", f"hermes/{name}", "HEAD"], cwd=repo, capture_output=True, ) (p / "salvaged.txt").write_text("diff that was reworked during salvage\n") subprocess.run(["git", "add", "salvaged.txt"], cwd=p, capture_output=True) subprocess.run(["git", "commit", "-m", "salvaged work"], cwd=p, capture_output=True) TestPrMergedEscapeHatch._age(p, age_h) return p @staticmethod def _stub_gh(tmp_path, monkeypatch, stdout='[{"number": 1}]', exit_code=0): gh = tmp_path / "bin" / "gh" gh.parent.mkdir(parents=True, exist_ok=True) gh.write_text(f"#!/bin/sh\nprintf '%s' '{stdout}'\nexit {exit_code}\n") gh.chmod(0o755) monkeypatch.setenv("PATH", f"{gh.parent}:{os.environ['PATH']}") def test_merged_pr_tree_is_reaped(self, git_repo, tmp_path, monkeypatch): import cli wt = self._mk_diverged(git_repo, "hermes-rebase-merged") assert worktree_ops._worktree_commits_all_merged_upstream(str(wt)) is False, ( "precondition: cherry must NOT consider this merged — the PR " "check is the only thing that can reap it" ) self._stub_gh(tmp_path, monkeypatch) cli._prune_stale_worktrees(str(git_repo)) assert not wt.exists(), ( "clean tree whose branch has a MERGED PR is merged work — reap it" ) def test_no_merged_pr_preserved(self, git_repo, tmp_path, monkeypatch): import cli wt = self._mk_diverged(git_repo, "hermes-pr-open") self._stub_gh(tmp_path, monkeypatch, stdout="[]") cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "no merged PR -> still unpushed work, preserve" def test_gh_failure_fails_safe(self, git_repo, tmp_path, monkeypatch): import cli wt = self._mk_diverged(git_repo, "hermes-gh-down") self._stub_gh(tmp_path, monkeypatch, stdout="", exit_code=1) cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "gh failure must preserve the tree (fail safe)" def test_dirty_tree_never_reaped_even_with_merged_pr( self, git_repo, tmp_path, monkeypatch ): import cli wt = self._mk_diverged(git_repo, "hermes-dirty-merged") (wt / "uncommitted.txt").write_text("in-flight\n") self._age(wt, 100) self._stub_gh(tmp_path, monkeypatch) cli._prune_stale_worktrees(str(git_repo)) assert wt.exists(), "dirty guard outranks the PR-merged verdict" def test_merged_verdict_memoized_by_branch_and_head( self, git_repo, tmp_path, monkeypatch ): wt = self._mk_diverged(git_repo, "hermes-memo") self._stub_gh(tmp_path, monkeypatch) cache: dict = {} assert worktree_ops._worktree_branch_pr_merged(str(wt), cache=cache) is True keys = [k for k in cache if k.startswith("pr-merged:")] assert len(keys) == 1 and cache[keys[0]] is True # Break gh: a cached True verdict must not re-consult it. self._stub_gh(tmp_path, monkeypatch, stdout="", exit_code=1) assert worktree_ops._worktree_branch_pr_merged(str(wt), cache=cache) is True def test_negative_verdict_not_cached(self, git_repo, tmp_path, monkeypatch): wt = self._mk_diverged(git_repo, "hermes-nocache-neg") self._stub_gh(tmp_path, monkeypatch, stdout="[]") cache: dict = {} assert worktree_ops._worktree_branch_pr_merged(str(wt), cache=cache) is False assert not [k for k in cache if k.startswith("pr-merged:")], ( "False must not be memoized — the PR can merge later with the " "same (branch, head) key" )