# Conflicts: # .gitignore # Dockerfile # agent/onboarding.py # apps/desktop/electron/main.ts # apps/desktop/electron/pool-stop.ts # apps/desktop/src/components/model-picker.test.tsx # apps/desktop/src/store/updates.ts # apps/desktop/vite.config.ts # datagen-config-examples/run_browser_tasks.sh # docs/rca-ssl-cacert-post-git-pull.md # gateway/run.py # hermes_cli/backup.py # hermes_cli/credential_lifecycle.py # hermes_cli/dashboard_procs.py # hermes_cli/doctor_state.py # hermes_cli/env_loader.py # hermes_cli/gateway_windows.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/psutil_android.py # hermes_cli/update_cmd.py # hermes_cli/update_cmd_windows.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_server_config.py # hermes_cli/web_server_cron.py # plugins/memory/hindsight/__init__.py # plugins/memory/holographic/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/mem0/__init__.py # plugins/platforms/google_chat/oauth.py # plugins/platforms/photon/adapter.py # scripts/ci/list_os_marked_tests.py # scripts/run_tests.sh # tests/agent/test_compression_stall_fallback.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/gateway/test_google_chat_oauth_dependencies.py # tests/hermes_cli/conftest.py # tests/hermes_cli/test_cli_init.py # tests/hermes_cli/test_gateway_migrate_multiplex.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_relaunch.py # tests/hermes_cli/test_update_check.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_worktree_gc.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_retry_policy.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_autostash_conflict_recovery.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_commit_pin_rollback.py # tests/scripts/install/test_install_diverged_update.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_macos_launcher.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_ps1_ascii_only.py # tests/scripts/install/test_install_ps1_browser_install.py # tests/scripts/install/test_install_ps1_managed_node_swap.py # tests/scripts/install/test_install_ps1_native_stderr_eap.py # tests/scripts/install/test_install_ps1_node_path_for_npm.py # tests/scripts/install/test_install_ps1_python_fallback_venv.py # tests/scripts/install/test_install_ps1_resolver_strictmode.py # tests/scripts/install/test_install_ps1_uv_install_fallback.py # tests/scripts/install/test_install_ps1_uv_powershell_host.py # tests/scripts/install/test_install_ps1_venv_process_tree.py # tests/scripts/install/test_install_ps1_venv_recreate_safety.py # tests/scripts/install/test_install_ps1_venv_rename_abort.py # tests/scripts/install/test_install_ps1_venv_transaction_boundary.py # tests/scripts/install/test_install_ps1_web_server_syntax_probe.py # tests/scripts/install/test_install_scripts_computer_use.py # tests/scripts/install/test_install_sh_acp_launcher.py # tests/scripts/install/test_install_sh_bootstrap_marker.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_install_method_stamp.py # tests/scripts/install/test_install_sh_node_deps_failure.py # tests/scripts/install/test_install_sh_node_deps_workspaces.py # tests/scripts/install/test_install_sh_node_global_prefix.py # tests/scripts/install/test_install_sh_node_npm_check.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_node_probe.py # tests/scripts/install/test_install_sh_node_tarball_without_xz.py # tests/scripts/install/test_install_sh_pythonpath_sanitization.py # tests/scripts/install/test_install_sh_reuse_supported_python.py # tests/scripts/install/test_install_sh_root_fhs_uv_python_path.py # tests/scripts/install/test_install_sh_setup_wizard_tty_probe.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_termux_network_prereqs.py # tests/scripts/install/test_install_sh_termux_python_bounds.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_run_tests_parallel.py # tests/test_managed_runtime_resolution.py # tests/test_project_metadata.py # tests/tools/test_browser_use_cli.py # tests/tools/test_tts_pythonpath_fallback.py # tests/tui_gateway/test_hosted_room_driver_runtime.py # tests/tui_gateway/test_tui_gateway_server.py # tools/lazy_deps.py # tools/voice_mode.py # uv.lock # website/docs/developer-guide/macos-bundle-updates.md # website/docs/developer-guide/pm-audit-status.md # website/docs/developer-guide/shared-bundle-builds.md # website/docs/developer-guide/source-update-completion.md # website/docs/developer-guide/stable-releases.md
298 lines
11 KiB
Python
298 lines
11 KiB
Python
"""Pushed-branch tier of the worktree pruners (#A, Aug 2026).
|
|
|
|
Behavior contract: a clean worktree whose branch head EXACTLY matches what
|
|
origin holds (open-PR lane on a single-branch-refspec install) gets its TREE
|
|
reaped while its BRANCH ref survives — including surviving the
|
|
orphaned-branch pass. Anything not provably on origin stays preserved.
|
|
|
|
Uses a real local bare repo as `origin` so `git ls-remote`/push are genuine,
|
|
no network and no mocks.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import worktree_ops
|
|
|
|
|
|
def _run(args, cwd):
|
|
return subprocess.run(
|
|
args, cwd=cwd, capture_output=True, text=True, encoding="utf-8"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def repo_with_bare_origin(tmp_path):
|
|
"""A working clone whose origin is a real local bare repo.
|
|
|
|
Mirrors the managed-install fetch config: single-branch refspec for main
|
|
only, so pushed side branches never gain refs/remotes/origin/<branch>
|
|
entries — the exact condition that made the old pruner misread pushed PR
|
|
lanes as 'unpushed'.
|
|
"""
|
|
bare = tmp_path / "origin.git"
|
|
bare.mkdir()
|
|
_run(["git", "init", "--bare", "--initial-branch=main"], bare)
|
|
|
|
repo = tmp_path / "clone"
|
|
repo.mkdir()
|
|
_run(["git", "init", "--initial-branch=main"], repo)
|
|
_run(["git", "config", "user.email", "test@test.com"], repo)
|
|
_run(["git", "config", "user.name", "Test"], repo)
|
|
(repo / "README.md").write_text("# repo\n")
|
|
_run(["git", "add", "."], repo)
|
|
_run(["git", "commit", "-m", "init"], repo)
|
|
_run(["git", "remote", "add", "origin", str(bare)], repo)
|
|
# Managed-install shape: fetch refspec covers main ONLY.
|
|
_run(
|
|
["git", "config", "remote.origin.fetch",
|
|
"+refs/heads/main:refs/remotes/origin/main"],
|
|
repo,
|
|
)
|
|
_run(["git", "push", "-u", "origin", "main"], repo)
|
|
_run(["git", "fetch", "origin"], repo)
|
|
return repo
|
|
|
|
|
|
def _age(path, hours=100):
|
|
t = time.time() - hours * 3600
|
|
os.utime(path, (t, t))
|
|
|
|
|
|
def _mk_worktree(repo, name, branch, commit=True, push=False, extra_commit_after_push=False):
|
|
"""Create .worktrees/<name> on <branch> with an optional pushed commit."""
|
|
(repo / ".worktrees").mkdir(exist_ok=True)
|
|
p = repo / ".worktrees" / name
|
|
_run(["git", "worktree", "add", str(p), "-b", branch, "HEAD"], repo)
|
|
if commit:
|
|
(p / f"{name}.txt").write_text("payload\n")
|
|
_run(["git", "add", f"{name}.txt"], p)
|
|
_run(["git", "commit", "-m", f"work on {name}"], p)
|
|
if push:
|
|
# Plain push — deliberately NO refs/remotes/* tracking ref appears,
|
|
# because the fetch refspec only covers main.
|
|
result = _run(["git", "push", "origin", branch], p)
|
|
assert result.returncode == 0, result.stderr
|
|
if extra_commit_after_push:
|
|
(p / "later.txt").write_text("post-push work\n")
|
|
_run(["git", "add", "later.txt"], p)
|
|
_run(["git", "commit", "-m", "post-push commit"], p)
|
|
_age(p)
|
|
return p
|
|
|
|
|
|
def _branch_exists(repo, branch):
|
|
return _run(
|
|
["git", "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], repo
|
|
).returncode == 0
|
|
|
|
|
|
class TestFetchRemoteBranchHeads:
|
|
def test_lists_pushed_branches(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
_mk_worktree(repo, "hermes-a", "hermes/hermes-a", push=True)
|
|
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
|
|
assert heads is not None
|
|
assert "main" in heads
|
|
assert "hermes/hermes-a" in heads
|
|
|
|
def test_unreachable_remote_returns_none(self, tmp_path):
|
|
import cli
|
|
repo = tmp_path / "r"
|
|
repo.mkdir()
|
|
_run(["git", "init"], repo)
|
|
_run(["git", "remote", "add", "origin", str(tmp_path / "missing.git")], repo)
|
|
assert worktree_ops._fetch_remote_branch_heads(str(repo)) is None
|
|
|
|
|
|
class TestBranchPushedExact:
|
|
def test_exact_match_true(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-x", "hermes/hermes-x", push=True)
|
|
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
|
|
assert worktree_ops._worktree_branch_pushed_exact(str(wt), heads) is True
|
|
|
|
def test_local_ahead_of_push_false(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(
|
|
repo, "hermes-y", "hermes/hermes-y",
|
|
push=True, extra_commit_after_push=True,
|
|
)
|
|
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
|
|
assert worktree_ops._worktree_branch_pushed_exact(str(wt), heads) is False
|
|
|
|
def test_never_pushed_false(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-z", "hermes/hermes-z", push=False)
|
|
heads = worktree_ops._fetch_remote_branch_heads(str(repo))
|
|
assert worktree_ops._worktree_branch_pushed_exact(str(wt), heads) is False
|
|
|
|
def test_none_heads_false(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-n", "hermes/hermes-n", push=True)
|
|
assert worktree_ops._worktree_branch_pushed_exact(str(wt), None) is False
|
|
assert worktree_ops._worktree_branch_pushed_exact(str(wt), {}) is False
|
|
|
|
|
|
class TestStartupPrunerPushedTier:
|
|
"""cli._prune_stale_worktrees against the real repo fixture."""
|
|
|
|
def test_pushed_lane_tree_reaped_branch_kept(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-pushed", "hermes/hermes-pushed", push=True)
|
|
|
|
cli._prune_stale_worktrees(str(repo))
|
|
|
|
assert not wt.exists(), (
|
|
"pushed-as-is open-PR lane must be reaped (checkout is redundant)"
|
|
)
|
|
assert _branch_exists(repo, "hermes/hermes-pushed"), (
|
|
"branch ref must survive the reap AND the orphaned-branch pass"
|
|
)
|
|
|
|
def test_unpushed_lane_still_preserved(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-unpushed", "hermes/hermes-unp", push=False)
|
|
|
|
cli._prune_stale_worktrees(str(repo))
|
|
|
|
assert wt.exists(), "never-pushed unique work must survive, any age"
|
|
|
|
def test_ahead_of_pushed_lane_preserved(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(
|
|
repo, "hermes-ahead", "hermes/hermes-ahead",
|
|
push=True, extra_commit_after_push=True,
|
|
)
|
|
|
|
cli._prune_stale_worktrees(str(repo))
|
|
|
|
assert wt.exists(), (
|
|
"local commits beyond the pushed head are unpushed work — preserve"
|
|
)
|
|
|
|
def test_pushed_but_dirty_preserved(self, repo_with_bare_origin):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-pdirty", "hermes/hermes-pdirty", push=True)
|
|
(wt / "uncommitted.txt").write_text("in-flight\n")
|
|
_age(wt)
|
|
|
|
cli._prune_stale_worktrees(str(repo))
|
|
|
|
assert wt.exists(), "dirty guard outranks the pushed tier"
|
|
|
|
def test_offline_remote_preserves(self, repo_with_bare_origin, tmp_path):
|
|
import cli
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-off", "hermes/hermes-off", push=True)
|
|
# Sever the remote AFTER pushing: ls-remote now fails -> None ->
|
|
# pushed tier must degrade to preserve.
|
|
_run(["git", "remote", "set-url", "origin", str(tmp_path / "gone.git")], repo)
|
|
|
|
cli._prune_stale_worktrees(str(repo))
|
|
|
|
assert wt.exists(), "unverifiable push state must fail toward preserve"
|
|
|
|
|
|
class TestAttendedGcPushedTier:
|
|
"""worktree_gc audit/reclaim behavior for the pushed tier."""
|
|
|
|
def test_audit_verdict_and_reclaim_keeps_branch(self, repo_with_bare_origin):
|
|
import cli # noqa: F401 (worktree_gc lazily imports cli)
|
|
from hermes_cli import worktree_gc
|
|
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "salv-lane", "salv/pushed-lane", push=True)
|
|
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
by_name = {r.name: r for r in records}
|
|
assert by_name["salv-lane"].verdict == "reap-keep-branch"
|
|
assert "pushed to origin" in by_name["salv-lane"].reason
|
|
|
|
actions = worktree_gc.reclaim_worktrees(str(repo), records=records)
|
|
assert any("salv-lane" in a and "kept" in a for a in actions)
|
|
assert not wt.exists()
|
|
assert _branch_exists(repo, "salv/pushed-lane")
|
|
|
|
def test_audit_never_pushed_keeps(self, repo_with_bare_origin):
|
|
import cli # noqa: F401
|
|
from hermes_cli import worktree_gc
|
|
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "salv-keep", "salv/never-pushed", push=False)
|
|
|
|
records = worktree_gc.audit_worktrees(str(repo), with_sizes=False)
|
|
by_name = {r.name: r for r in records}
|
|
assert by_name["salv-keep"].verdict == "keep"
|
|
worktree_gc.reclaim_worktrees(str(repo), records=records)
|
|
assert wt.exists()
|
|
|
|
|
|
class TestCronWorktreeMaintenance:
|
|
def test_throttle_dispatches_once_per_interval(self, monkeypatch):
|
|
import cron.scheduler as sched
|
|
|
|
calls = []
|
|
monkeypatch.setattr(sched, "_last_worktree_maintenance_at", None)
|
|
monkeypatch.setattr(
|
|
sched.threading, "Thread",
|
|
lambda *a, **k: type("T", (), {"start": lambda self: calls.append(k.get("name"))})(),
|
|
)
|
|
|
|
sched._maybe_run_worktree_maintenance()
|
|
sched._maybe_run_worktree_maintenance() # inside interval — no-op
|
|
|
|
assert len(calls) == 1
|
|
|
|
def test_repo_discovery_requires_worktrees_dir(self, monkeypatch, tmp_path):
|
|
import cron.scheduler as sched
|
|
|
|
# A job workdir inside a git repo WITHOUT .worktrees/ is filtered out.
|
|
repo = tmp_path / "jobrepo"
|
|
repo.mkdir()
|
|
_run(["git", "init"], repo)
|
|
monkeypatch.setattr(
|
|
"cron.jobs.load_jobs",
|
|
lambda: [{"workdir": str(repo)}],
|
|
)
|
|
repos = sched._worktree_maintenance_repos()
|
|
assert repo not in [Path(p) for p in repos]
|
|
|
|
# Adding .worktrees/ makes it eligible.
|
|
(repo / ".worktrees").mkdir()
|
|
repos = sched._worktree_maintenance_repos()
|
|
assert repo in [Path(p) for p in repos]
|
|
|
|
def test_maintenance_prunes_via_real_pruner(self, repo_with_bare_origin, monkeypatch):
|
|
import cron.scheduler as sched
|
|
|
|
repo = repo_with_bare_origin
|
|
wt = _mk_worktree(repo, "hermes-cronreap", "hermes/hermes-cronreap", push=True)
|
|
|
|
monkeypatch.setattr(sched, "_last_worktree_maintenance_at", None)
|
|
monkeypatch.setattr(
|
|
sched, "_worktree_maintenance_repos", lambda: [str(repo)]
|
|
)
|
|
|
|
sched._maybe_run_worktree_maintenance()
|
|
# The sweep runs on a daemon thread — wait for it (bounded).
|
|
deadline = time.time() + 30
|
|
while wt.exists() and time.time() < deadline:
|
|
time.sleep(0.2)
|
|
|
|
assert not wt.exists(), "cron-tick maintenance must run the real pruner"
|
|
assert _branch_exists(repo, "hermes/hermes-cronreap")
|