Files
hermes-agent/tests/hermes_cli/test_worktree.py
ethernet 890bbbda1f Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts
#	apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts
#	apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts
#	apps/desktop/e2e/bot-mode-roster-localized.spec.ts
#	apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts
#	apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts
#	apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts
#	apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts
#	apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts
#	apps/desktop/e2e/bot-roster-user-sections.spec.ts
#	apps/desktop/e2e/bot-routines-pane-narrow.spec.ts
#	apps/desktop/e2e/bot-row-open-recent-session.spec.ts
#	apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts
#	apps/desktop/e2e/group-composer-auto-grow.spec.ts
#	apps/desktop/e2e/group-create-gate-remote-roster.spec.ts
#	apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts
#	apps/desktop/e2e/hosted-room-backend-continuity.spec.ts
#	apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts
#	apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts
#	apps/desktop/e2e/worktree-branch-status.spec.ts
#	apps/desktop/electron/backend-probes.test.ts
#	apps/desktop/electron/connection-apply.test.ts
#	apps/desktop/electron/desktop-electron-pin.test.ts
#	apps/desktop/electron/desktop-uninstall.test.ts
#	apps/desktop/electron/gateway-file-download-transport.test.ts
#	apps/desktop/electron/gateway-stop-before-update.test.ts
#	apps/desktop/electron/github-api-auth.test.ts
#	apps/desktop/electron/registry-primary-profile-scope.test.ts
#	apps/desktop/electron/update-api-check.test.ts
#	apps/desktop/electron/update-handoff-marker.test.ts
#	apps/desktop/electron/venv-blocker-scan.test.ts
#	apps/desktop/scripts/after-extract.test.mjs
#	apps/desktop/scripts/local-pack-publish.test.mjs
#	apps/desktop/scripts/tasks-scroll.test.mjs
#	apps/desktop/src/app/settings/model-settings.test.tsx
#	apps/desktop/src/app/updates-overlay.blockers.test.tsx
#	apps/desktop/src/components/desktop-install-overlay.test.tsx
#	apps/desktop/src/lib/update-copy.test.ts
#	scripts/ci/check_os_marker_fakes.py
#	tests-js/desktop-mac-usage-descriptions.test.ts
#	tests-js/node-engine-alignment.test.ts
#	tests/agent/lsp/test_install_and_lint_fixes.py
#	tests/agent/test_command_token_source.py
#	tests/agent/test_compression_boundary_hook.py
#	tests/agent/test_create_openai_client_ssl_verify.py
#	tests/agent/test_custom_provider_ca_probes.py
#	tests/agent/test_endpoint_blackhole.py
#	tests/agent/test_estimator_parity.py
#	tests/agent/test_in_place_compaction.py
#	tests/agent/test_moa_loop_mode.py
#	tests/agent/test_model_metadata.py
#	tests/agent/test_skill_session_platform_gate.py
#	tests/agent/test_skill_utils.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/computer_use/test_doctor.py
#	tests/cron/test_codex_execution_paths.py
#	tests/cron/test_cron_bot_chat_delivery.py
#	tests/cron/test_cron_script.py
#	tests/cron/test_media_delivery_parity.py
#	tests/cron/test_misfire_catchup.py
#	tests/cron/test_parallel_pool.py
#	tests/cron/test_recurring_eagain_redispatch.py
#	tests/gateway/test_choice_picker.py
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_dingtalk.py
#	tests/gateway/test_feishu.py
#	tests/gateway/test_feishu_onboard.py
#	tests/gateway/test_gateway_shutdown.py
#	tests/gateway/test_matrix.py
#	tests/gateway/test_model_command_custom_providers.py
#	tests/gateway/test_reasoning_command.py
#	tests/gateway/test_runtime_footer.py
#	tests/gateway/test_session.py
#	tests/gateway/test_session_hygiene.py
#	tests/gateway/test_status.py
#	tests/gateway/test_teams.py
#	tests/gateway/test_turn_lease.py
#	tests/gateway/test_whatsapp_connect.py
#	tests/hermes_cli/test_approvals_command.py
#	tests/hermes_cli/test_auth_store_lock_concurrent.py
#	tests/hermes_cli/test_backup.py
#	tests/hermes_cli/test_banner_git_state.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_compat_manifest_targets.py
#	tests/hermes_cli/test_computer_use_cli.py
#	tests/hermes_cli/test_cpr_local_leak.py
#	tests/hermes_cli/test_dashboard_auth_gate.py
#	tests/hermes_cli/test_dashboard_procs_kill_grace.py
#	tests/hermes_cli/test_desktop_lifecycle_windows_live.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_command_install.py
#	tests/hermes_cli/test_fleet_config_migration_windows_live.py
#	tests/hermes_cli/test_gateway.py
#	tests/hermes_cli/test_gateway_platform_gating.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	tests/hermes_cli/test_gateway_task_probe.py
#	tests/hermes_cli/test_gateway_wsl.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_install_cua_driver.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_command_exports.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_linux_desktop_entry.py
#	tests/hermes_cli/test_local_runtime.py
#	tests/hermes_cli/test_local_runtime_updates.py
#	tests/hermes_cli/test_managed_uv.py
#	tests/hermes_cli/test_mcp_reload_confirm_gate.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_npm_engine.py
#	tests/hermes_cli/test_personality_none.py
#	tests/hermes_cli/test_pet_toggle.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_plugin_event_bus.py
#	tests/hermes_cli/test_plugin_manifest_v2.py
#	tests/hermes_cli/test_plugin_packs.py
#	tests/hermes_cli/test_plugins_cmd.py
#	tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
#	tests/hermes_cli/test_process_identity.py
#	tests/hermes_cli/test_profiles.py
#	tests/hermes_cli/test_profiles_sidebar_cache.py
#	tests/hermes_cli/test_pty_bridge.py
#	tests/hermes_cli/test_resolve_turn_limit.py
#	tests/hermes_cli/test_serve_runtime_inventory.py
#	tests/hermes_cli/test_session_vacuum_config.py
#	tests/hermes_cli/test_set_config_value.py
#	tests/hermes_cli/test_signal_handler_kanban_worker.py
#	tests/hermes_cli/test_slash_confirm_windows.py
#	tests/hermes_cli/test_stale_pid_guard.py
#	tests/hermes_cli/test_startup_fast_guards.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_telegram_managed_bot.py
#	tests/hermes_cli/test_tools_config.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_autostash.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_fetch_failure_classifier.py
#	tests/hermes_cli/test_update_fleet_probe_resume_token.py
#	tests/hermes_cli/test_update_handoff_backend_reap.py
#	tests/hermes_cli/test_update_handoff_desktop_rebuild.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_host_obligation.py
#	tests/hermes_cli/test_update_import_guard.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_update_inventory.py
#	tests/hermes_cli/test_update_launchd_unloaded_gateway.py
#	tests/hermes_cli/test_update_missing_configured_deps.py
#	tests/hermes_cli/test_update_modified_notice.py
#	tests/hermes_cli/test_update_multiplex_migration_hook.py
#	tests/hermes_cli/test_update_no_gateway_restart.py
#	tests/hermes_cli/test_update_orphan_backend_reap.py
#	tests/hermes_cli/test_update_parked_branch_guard.py
#	tests/hermes_cli/test_update_post_pull_syntax_guard.py
#	tests/hermes_cli/test_update_receipt.py
#	tests/hermes_cli/test_update_self_lock.py
#	tests/hermes_cli/test_update_shim_fail_closed.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_update_sqlite_remediation.py
#	tests/hermes_cli/test_update_stale_dashboard.py
#	tests/hermes_cli/test_update_stale_virtualenv.py
#	tests/hermes_cli/test_update_venv_health.py
#	tests/hermes_cli/test_update_venv_ownership_preflight.py
#	tests/hermes_cli/test_update_wedged_gateway.py
#	tests/hermes_cli/test_update_yes_flag.py
#	tests/hermes_cli/test_update_zip_two_phase.py
#	tests/hermes_cli/test_urllib_security.py
#	tests/hermes_cli/test_ux_messages_auth_config.py
#	tests/hermes_cli/test_ux_messages_startup.py
#	tests/hermes_cli/test_venv_holder_classifier.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/hermes_cli/test_verify_core_dependencies.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_server_console_ws.py
#	tests/hermes_cli/test_web_server_ws_ping.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/hermes_state/test_fts_rebuild_admission.py
#	tests/hermes_state/test_hermes_state.py
#	tests/plugins/memory/test_memory_lazy_install.py
#	tests/plugins/test_google_meet_plugin.py
#	tests/plugins/test_langfuse_plugin.py
#	tests/plugins/test_security_guidance_plugin.py
#	tests/plugins/test_transform_llm_output_hook.py
#	tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py
#	tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py
#	tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py
#	tests/scripts/install/test_install_clone_throttle_fallback.py
#	tests/scripts/install/test_install_lockfile_churn.py
#	tests/scripts/install/test_install_no_initial_commit.py
#	tests/scripts/install/test_install_sh_browser_install.py
#	tests/scripts/install/test_install_sh_node_prerelease.py
#	tests/scripts/install/test_install_sh_symlink_stomp.py
#	tests/scripts/install/test_install_sh_uv_lock_config.py
#	tests/scripts/install/test_install_unmerged_index.py
#	tests/scripts/test_contributor_map.py
#	tests/scripts/test_run_tests_parallel.py
#	tests/skills/test_competitor_news_monitor_skill.py
#	tests/skills/test_document_to_action_items_skill.py
#	tests/skills/test_google_workspace_setup.py
#	tests/skills/test_google_workspace_setup_deps.py
#	tests/skills/test_grounded_citations_skill.py
#	tests/skills/test_ip_as_logo_skill.py
#	tests/skills/test_live_dashboard_skill.py
#	tests/skills/test_mcp_oauth_remote_gateway_skill.py
#	tests/skills/test_office_document_skills.py
#	tests/skills/test_openclaw_migration.py
#	tests/skills/test_product_price_monitor_skill.py
#	tests/skills/test_scrollcraft_skill.py
#	tests/skills/test_setup_wizard_generator_skill.py
#	tests/skills/test_weekly_review_planning_skill.py
#	tests/test_engines_satisfiable.py
#	tests/test_fast_safe_load.py
#	tests/test_hermes_bootstrap.py
#	tests/test_hermes_constants.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/test_model_tools_async_bridge.py
#	tests/test_packaging_build_guard.py
#	tests/test_packaging_metadata.py
#	tests/test_yaml_indent_consistency.py
#	tests/tools/test_approval_timeout_overflow.py
#	tests/tools/test_base_environment.py
#	tests/tools/test_bot_mode_dm.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_hardening.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_use_cli.py
#	tests/tools/test_clipboard.py
#	tests/tools/test_code_execution.py
#	tests/tools/test_code_execution_modes.py
#	tests/tools/test_code_execution_windows_env.py
#	tests/tools/test_computer_use.py
#	tests/tools/test_delegate_liveness_timeout.py
#	tests/tools/test_execute_code_approval_cluster.py
#	tests/tools/test_execution_flag_detection.py
#	tests/tools/test_fal_common.py
#	tests/tools/test_file_operations.py
#	tests/tools/test_file_tools.py
#	tests/tools/test_file_tools_cwd_resolution.py
#	tests/tools/test_file_tools_live.py
#	tests/tools/test_lazy_deps.py
#	tests/tools/test_lazy_deps_durable_target.py
#	tests/tools/test_lazy_deps_managed.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_local_tempdir.py
#	tests/tools/test_macos_protected_search.py
#	tests/tools/test_mcp_npx_cached_bin.py
#	tests/tools/test_oneshot_completion_linger.py
#	tests/tools/test_process_registry.py
#	tests/tools/test_read_file_schema_gating.py
#	tests/tools/test_skill_improvements.py
#	tests/tools/test_skills_sync.py
#	tests/tools/test_termux_api_detection.py
#	tests/tools/test_tirith_security.py
#	tests/tools/test_transcription_tools.py
#	tests/tools/test_tts_streaming.py
#	tests/tools/test_wake_word.py
#	tests/tui_gateway/test_compute_host_borrowed_lease.py
#	tests/tui_gateway/test_compute_host_turn_protocol.py
#	tests/tui_gateway/test_isolated_orphan_activity.py
#	tests/tui_gateway/test_protocol.py
#	tests/tui_gateway/test_slash_worker_profile_home.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tests/tui_gateway/test_tui_gateway_server.py
#	ui-tui/src/__tests__/terminalParity.test.ts
#	ui-tui/src/__tests__/termuxComposerLayout.test.ts
#	ui-tui/src/__tests__/textInputFastEcho.test.ts
2026-09-23 07:02:44 -04:00

722 lines
29 KiB
Python

"""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"
)