65a6b6831e
omo's omo-agent-toolkit worktree-sweep (their PR #7151) added three capabilities our hermes worktree command lacked: - --json on list and prune: machine-readable audit/result payloads so scripts and agents can consume verdicts without scraping table output. - --older-than DAYS: an age floor that only ever RESTRICTS reaping (young-but-reapable trees are kept); it never widens eligibility, so the existing safety invariants are untouched. - External-tree visibility: linked worktrees registered outside .worktrees/ are now reported read-only in the audit (branch, locked, missing) instead of being invisible, and registrations whose directory has vanished are dropped via git worktree prune (metadata only, no files touched) during prune. Not ported: omo's ancestor-of-default-branch merge test (our git cherry patch-equivalence is strictly stronger under rebase/squash merges), and their hardcoded external-root exclusion list (we exclude by location: everything outside .worktrees/ is hands-off). Tests: 9 new contracts in tests/hermes_cli/test_worktree_gc.py (age gate restrict-only, external trees never reaped, stale-registration prune dry-run/real, JSON shapes, negative --older-than rejected). Live E2E on a scratch repo verified all three flags end to end.
385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""Behavior contracts for hermes_cli.worktree_gc (attended reclaim).
|
|
|
|
Each guard gets its own contract against a REAL git repo fixture (no mocks —
|
|
the entire value of these tests is exercising actual git verdicts):
|
|
|
|
- clean + fully merged tree → reap
|
|
- untracked-only dirt → reap-archive (files archived, then removed)
|
|
- tracked modifications → keep, any age
|
|
- unique unpushed commits → keep
|
|
- patch-equivalent commits (rebase/squash-merge leak) → reap
|
|
- live-locked tree → keep
|
|
- kanban t_<hex> tree → keep (owned by kanban gc)
|
|
- branch GC: merged branch deleted, unique-commit branch kept,
|
|
checked-out branch kept, protected names kept
|
|
- reclaim operates ONLY on the frozen audit list (concurrent-session trap)
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import worktree_gc
|
|
|
|
|
|
def _git(args, cwd, env=None):
|
|
e = dict(os.environ)
|
|
e.update({
|
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
|
})
|
|
if env:
|
|
e.update(env)
|
|
result = subprocess.run(
|
|
["git", *args], capture_output=True, text=True, cwd=str(cwd), env=e,
|
|
)
|
|
assert result.returncode == 0, f"git {args} failed: {result.stderr}"
|
|
return result.stdout.strip()
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(tmp_path, monkeypatch):
|
|
"""origin (bare) + clone with .worktrees/, HOME redirected for archives."""
|
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
(tmp_path / "home").mkdir()
|
|
|
|
origin = tmp_path / "origin.git"
|
|
origin.mkdir()
|
|
_git(["init", "--bare", "-b", "main"], origin)
|
|
|
|
clone = tmp_path / "repo"
|
|
_git(["clone", str(origin), str(clone)], tmp_path)
|
|
(clone / "README.md").write_text("hello\n")
|
|
_git(["add", "."], clone)
|
|
_git(["commit", "-m", "init"], clone)
|
|
_git(["push", "origin", "main"], clone)
|
|
# origin/HEAD so upstream resolution works like a real clone.
|
|
_git(["remote", "set-head", "origin", "main"], clone)
|
|
(clone / ".worktrees").mkdir()
|
|
return clone
|
|
|
|
|
|
def _add_worktree(repo_path, name, branch=None):
|
|
tree = repo_path / ".worktrees" / name
|
|
branch = branch or f"hermes/{name}"
|
|
_git(["worktree", "add", str(tree), "-b", branch], repo_path)
|
|
return tree, branch
|
|
|
|
|
|
def _verdict(records, name):
|
|
match = [record for record in records if record.name == name]
|
|
assert match, f"no record for {name}"
|
|
return match[0]
|
|
|
|
|
|
class TestAuditVerdicts:
|
|
def test_clean_merged_tree_reaps(self, repo):
|
|
_add_worktree(repo, "hermes-clean")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
assert _verdict(records, "hermes-clean").verdict == "reap"
|
|
|
|
def test_tracked_modifications_keep(self, repo):
|
|
tree, _ = _add_worktree(repo, "hermes-dirty")
|
|
(tree / "README.md").write_text("edited\n")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
record = _verdict(records, "hermes-dirty")
|
|
assert record.verdict == "keep"
|
|
assert "tracked" in record.reason
|
|
|
|
def test_untracked_only_is_reap_archive(self, repo):
|
|
tree, _ = _add_worktree(repo, "hermes-scratch")
|
|
(tree / "PR_BODY_DRAFT.md").write_text("draft\n")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
record = _verdict(records, "hermes-scratch")
|
|
assert record.verdict == "reap-archive"
|
|
assert record.untracked == ["PR_BODY_DRAFT.md"]
|
|
|
|
def test_unique_unpushed_commits_keep(self, repo):
|
|
tree, _ = _add_worktree(repo, "hermes-work")
|
|
(tree / "new.py").write_text("x = 1\n")
|
|
_git(["add", "."], tree)
|
|
_git(["commit", "-m", "unique work"], tree)
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
record = _verdict(records, "hermes-work")
|
|
assert record.verdict == "keep"
|
|
assert "unpushed" in record.reason
|
|
|
|
def test_patch_equivalent_commits_reap(self, repo):
|
|
"""The squash/rebase-merge leak: local commit unreachable from any
|
|
remote ref but patch-equivalent to an upstream commit → merged work."""
|
|
tree, _ = _add_worktree(repo, "hermes-merged")
|
|
(tree / "feat.py").write_text("y = 2\n")
|
|
_git(["add", "."], tree)
|
|
_git(["commit", "-m", "feat"], tree)
|
|
sha = _git(["rev-parse", "HEAD"], tree)
|
|
# "Merge" it to main with a DIFFERENT committer so the cherry-pick
|
|
# produces a distinct sha (same-second identical-committer cherry
|
|
# picks can produce the identical sha — pitfall from the skill).
|
|
_git(["cherry-pick", sha], repo,
|
|
env={"GIT_COMMITTER_NAME": "other", "GIT_COMMITTER_EMAIL": "o@o"})
|
|
_git(["push", "origin", "main"], repo)
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
assert _verdict(records, "hermes-merged").verdict == "reap"
|
|
|
|
def test_live_locked_tree_keeps(self, repo):
|
|
tree, _ = _add_worktree(repo, "hermes-live")
|
|
_git(["worktree", "lock", str(tree),
|
|
"--reason", f"hermes pid={os.getpid()}"], repo)
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
record = _verdict(records, "hermes-live")
|
|
assert record.verdict == "keep"
|
|
assert "in use" in record.reason
|
|
|
|
def test_kanban_tree_untouched(self, repo):
|
|
_add_worktree(repo, "t_deadbeef", branch="kanban/t_deadbeef")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
record = _verdict(records, "t_deadbeef")
|
|
assert record.verdict == "keep"
|
|
assert "kanban" in record.reason
|
|
|
|
|
|
class TestReclaim:
|
|
def test_reap_removes_tree_and_branch(self, repo):
|
|
tree, branch = _add_worktree(repo, "hermes-clean")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
actions = worktree_gc.reclaim_worktrees(str(repo), records=records)
|
|
assert any("removed hermes-clean" in a for a in actions)
|
|
assert not tree.exists()
|
|
probe = subprocess.run(
|
|
["git", "rev-parse", "--verify", "--quiet", branch],
|
|
capture_output=True, text=True, cwd=str(repo),
|
|
)
|
|
assert probe.returncode != 0, "branch should be gone with its tree"
|
|
|
|
def test_untracked_files_archived_under_the_active_profile_home(self, repo, tmp_path, monkeypatch):
|
|
"""The archive follows the active Hermes home (a named profile here), never ~/.hermes."""
|
|
native_home = tmp_path / "native"
|
|
profile_home = tmp_path / "root" / "profiles" / "work"
|
|
profile_home.mkdir(parents=True)
|
|
monkeypatch.setattr(Path, "home", lambda: native_home)
|
|
monkeypatch.setenv("HERMES_HOME", str(profile_home))
|
|
tree, _ = _add_worktree(repo, "hermes-scratch")
|
|
(tree / "NOTES.md").write_text("important scribbles\n")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
worktree_gc.reclaim_worktrees(str(repo), records=records)
|
|
assert not tree.exists()
|
|
archived = list((profile_home / "archive" / "worktree-prune").rglob("NOTES.md"))
|
|
assert archived, "untracked file must be archived under the profile home, not destroyed"
|
|
assert archived[0].read_text() == "important scribbles\n"
|
|
assert not (native_home / ".hermes").exists()
|
|
|
|
def test_dry_run_changes_nothing(self, repo):
|
|
tree, _ = _add_worktree(repo, "hermes-clean")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
actions = worktree_gc.reclaim_worktrees(
|
|
str(repo), dry_run=True, records=records
|
|
)
|
|
assert any("would remove" in a for a in actions)
|
|
assert tree.exists()
|
|
|
|
def test_frozen_list_ignores_trees_created_after_audit(self, repo):
|
|
"""Concurrent-session trap: a tree created between audit and reclaim
|
|
must be out of scope by construction."""
|
|
_add_worktree(repo, "hermes-old")
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
late_tree, _ = _add_worktree(repo, "hermes-late")
|
|
worktree_gc.reclaim_worktrees(str(repo), records=records)
|
|
assert late_tree.exists(), "tree created after the audit must survive"
|
|
|
|
def test_dead_locked_tree_is_unlocked_and_reaped(self, repo):
|
|
tree, _ = _add_worktree(repo, "hermes-zombie")
|
|
_git(["worktree", "lock", str(tree),
|
|
"--reason", "hermes pid=999999999"], repo)
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
assert _verdict(records, "hermes-zombie").verdict == "reap"
|
|
worktree_gc.reclaim_worktrees(str(repo), records=records)
|
|
assert not tree.exists()
|
|
|
|
|
|
class TestBranchGC:
|
|
def test_merged_branch_deleted_any_name(self, repo):
|
|
"""Branch GC is content-gated, not name-gated: any fully-merged local
|
|
branch is safe to delete regardless of prefix."""
|
|
_git(["branch", "salv-12345", "main"], repo)
|
|
_git(["branch", "feat/some-old-thing", "main"], repo)
|
|
records = worktree_gc.audit_branches(str(repo))
|
|
by_name = {record.name: record for record in records}
|
|
assert by_name["salv-12345"].verdict == "delete"
|
|
assert by_name["feat/some-old-thing"].verdict == "delete"
|
|
worktree_gc.reclaim_branches(str(repo), records=records)
|
|
out = _git(["branch", "--format=%(refname:short)"], repo)
|
|
assert "salv-12345" not in out
|
|
assert "feat/some-old-thing" not in out
|
|
|
|
def test_unique_commit_branch_kept(self, repo):
|
|
_git(["checkout", "-b", "feat/real-work"], repo)
|
|
(repo / "wip.py").write_text("z = 3\n")
|
|
_git(["add", "."], repo)
|
|
_git(["commit", "-m", "wip"], repo)
|
|
_git(["checkout", "main"], repo)
|
|
records = worktree_gc.audit_branches(str(repo))
|
|
by_name = {record.name: record for record in records}
|
|
assert by_name["feat/real-work"].verdict == "keep"
|
|
assert "unique" in by_name["feat/real-work"].reason
|
|
|
|
def test_patch_equivalent_branch_deleted(self, repo):
|
|
"""Rebase-merged PR branch: SHAs differ from main but every commit is
|
|
patch-equivalent — the dominant branch leak."""
|
|
_git(["checkout", "-b", "fix/landed"], repo)
|
|
(repo / "fix.py").write_text("a = 4\n")
|
|
_git(["add", "."], repo)
|
|
_git(["commit", "-m", "fix"], repo)
|
|
sha = _git(["rev-parse", "HEAD"], repo)
|
|
_git(["checkout", "main"], repo)
|
|
_git(["cherry-pick", sha], repo,
|
|
env={"GIT_COMMITTER_NAME": "other", "GIT_COMMITTER_EMAIL": "o@o"})
|
|
_git(["push", "origin", "main"], repo)
|
|
records = worktree_gc.audit_branches(str(repo))
|
|
by_name = {record.name: record for record in records}
|
|
assert by_name["fix/landed"].verdict == "delete"
|
|
assert "patch-equivalent" in by_name["fix/landed"].reason
|
|
|
|
def test_checked_out_and_protected_kept(self, repo):
|
|
_tree, branch = _add_worktree(repo, "hermes-active")
|
|
records = worktree_gc.audit_branches(str(repo))
|
|
by_name = {record.name: record for record in records}
|
|
assert by_name["main"].verdict == "keep"
|
|
assert by_name[branch].verdict == "keep"
|
|
|
|
|
|
class TestOlderThanGate:
|
|
def test_young_reapable_tree_kept_under_older_than(self, repo):
|
|
_add_worktree(repo, "hermes-young")
|
|
records = worktree_gc.audit_worktrees(
|
|
str(repo), with_sizes=False, older_than_days=7,
|
|
)
|
|
record = _verdict(records, "hermes-young")
|
|
assert record.verdict == "keep"
|
|
assert "older-than" in record.reason
|
|
|
|
def test_aged_reapable_tree_still_reaps(self, repo):
|
|
import os as _os
|
|
import time as _time
|
|
|
|
tree, _ = _add_worktree(repo, "hermes-old")
|
|
old = _time.time() - 10 * 86400
|
|
_os.utime(tree, (old, old))
|
|
records = worktree_gc.audit_worktrees(
|
|
str(repo), with_sizes=False, older_than_days=7,
|
|
)
|
|
assert _verdict(records, "hermes-old").verdict == "reap"
|
|
|
|
def test_older_than_never_widens_eligibility(self, repo):
|
|
"""A tree with real work stays keep at ANY age — the age gate only
|
|
restricts, it can never doom unmerged/dirty work."""
|
|
import os as _os
|
|
import time as _time
|
|
|
|
tree, _ = _add_worktree(repo, "hermes-old-work")
|
|
(tree / "README.md").write_text("edited\n")
|
|
old = _time.time() - 30 * 86400
|
|
_os.utime(tree, (old, old))
|
|
records = worktree_gc.audit_worktrees(
|
|
str(repo), with_sizes=False, older_than_days=7,
|
|
)
|
|
record = _verdict(records, "hermes-old-work")
|
|
assert record.verdict == "keep"
|
|
assert "tracked" in record.reason
|
|
|
|
|
|
class TestExternalTrees:
|
|
def test_external_tree_reported_never_reaped(self, repo, tmp_path):
|
|
ext = tmp_path / "elsewhere-tree"
|
|
_git(["worktree", "add", str(ext), "-b", "ext/branch"], repo)
|
|
(ext / "WIP.txt").write_text("outside work\n")
|
|
|
|
external = worktree_gc.audit_external_trees(str(repo))
|
|
paths = [record.path for record in external]
|
|
assert any("elsewhere-tree" in p for p in paths)
|
|
record = [r for r in external if "elsewhere-tree" in r.path][0]
|
|
assert record.branch == "ext/branch"
|
|
assert not record.missing
|
|
|
|
# The managed audit + reclaim never see or touch it.
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
assert all("elsewhere-tree" not in r.name for r in records)
|
|
worktree_gc.reclaim_worktrees(str(repo), records=records)
|
|
assert ext.exists() and (ext / "WIP.txt").exists()
|
|
|
|
def test_managed_trees_not_reported_as_external(self, repo):
|
|
_add_worktree(repo, "hermes-managed")
|
|
external = worktree_gc.audit_external_trees(str(repo))
|
|
assert all("hermes-managed" not in r.path for r in external)
|
|
|
|
def test_missing_registration_flagged_and_pruned(self, repo, tmp_path):
|
|
import shutil as _shutil
|
|
|
|
ext = tmp_path / "vanished-tree"
|
|
_git(["worktree", "add", str(ext), "-b", "ext/vanished"], repo)
|
|
_shutil.rmtree(ext)
|
|
|
|
external = worktree_gc.audit_external_trees(str(repo))
|
|
record = [r for r in external if "vanished-tree" in r.path][0]
|
|
assert record.missing
|
|
|
|
planned = worktree_gc.prune_missing_registrations(str(repo), dry_run=True)
|
|
assert any("vanished-tree" in line for line in planned)
|
|
# Dry-run changed nothing.
|
|
assert any(
|
|
r.missing for r in worktree_gc.audit_external_trees(str(repo))
|
|
)
|
|
|
|
done = worktree_gc.prune_missing_registrations(str(repo))
|
|
assert any("pruned" in line for line in done)
|
|
assert all(
|
|
"vanished-tree" not in r.path
|
|
for r in worktree_gc.audit_external_trees(str(repo))
|
|
)
|
|
|
|
|
|
class TestCmdWorktreeJson:
|
|
def _ns(self, repo, action, **kw):
|
|
import argparse
|
|
|
|
return argparse.Namespace(
|
|
repo=str(repo), worktree_action=action,
|
|
json=True, older_than=kw.get("older_than"),
|
|
dry_run=kw.get("dry_run", False),
|
|
trees_only=kw.get("trees_only", False),
|
|
branches_only=kw.get("branches_only", False),
|
|
)
|
|
|
|
def test_list_json_shape(self, repo, capsys):
|
|
import json
|
|
|
|
from hermes_cli.worktree_cmd import cmd_worktree
|
|
|
|
_add_worktree(repo, "hermes-json")
|
|
assert cmd_worktree(self._ns(repo, "list")) == 0
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert set(payload) == {"repo", "trees", "external_trees", "branches"}
|
|
names = [t["name"] for t in payload["trees"]]
|
|
assert "hermes-json" in names
|
|
tree = [t for t in payload["trees"] if t["name"] == "hermes-json"][0]
|
|
assert {"verdict", "reason", "age_days", "branch"} <= set(tree)
|
|
|
|
def test_prune_dry_run_json(self, repo, capsys):
|
|
import json
|
|
|
|
from hermes_cli.worktree_cmd import cmd_worktree
|
|
|
|
_add_worktree(repo, "hermes-json-prune")
|
|
assert cmd_worktree(self._ns(repo, "prune", dry_run=True)) == 0
|
|
payload = json.loads(capsys.readouterr().out)
|
|
assert payload["dry_run"] is True
|
|
assert any("hermes-json-prune" in a for a in payload["actions"])
|
|
# dry-run: tree still present
|
|
assert (repo / ".worktrees" / "hermes-json-prune").exists()
|
|
|
|
def test_negative_older_than_rejected(self, repo, capsys):
|
|
from hermes_cli.worktree_cmd import cmd_worktree
|
|
|
|
assert cmd_worktree(self._ns(repo, "prune", older_than=-1)) == 1
|