# 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
722 lines
29 KiB
Python
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"
|
|
)
|