test_cmd_commit_hooks_acceptance.py
file-level
1
files
1
commits
0
hotspots
0
🧊 dead
0
💥 blast risk
| 1 | """HK_ACCEPT_01 — musehub#192 Phase 5 acceptance test. |
| 2 | |
| 3 | Reproduces this session's real incident end-to-end against a real |
| 4 | disposable repo: .museagent.md drifts from its compiled adapter (edited but |
| 5 | never re-synced), and confirms the dogfood hook |
| 6 | (`muse agent-config status --fail-if-out-of-sync`) catches it at commit |
| 7 | time, blocking the commit — and that --no-verify still gets it through, |
| 8 | visibly. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import json |
| 14 | import os |
| 15 | import pathlib |
| 16 | import stat |
| 17 | import sys |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 22 | |
| 23 | runner = CliRunner() |
| 24 | |
| 25 | |
| 26 | def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: |
| 27 | saved = os.getcwd() |
| 28 | try: |
| 29 | os.chdir(repo) |
| 30 | return runner.invoke(None, args) |
| 31 | finally: |
| 32 | os.chdir(saved) |
| 33 | |
| 34 | |
| 35 | @pytest.fixture(autouse=True) |
| 36 | def _muse_shim_on_path(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None: |
| 37 | """Make the ``muse`` name on PATH resolve to *this checkout's* source. |
| 38 | |
| 39 | The pre-commit hook mechanism (``run_hook_point``) shells out to |
| 40 | whatever ``muse`` the environment's PATH resolves — by design, since |
| 41 | hooks call the real user-facing tool. In this repo specifically, plain |
| 42 | ``muse`` is the separately installer-managed stable venv (see |
| 43 | docs/agent-guide.md's "muse vs muse-dev"), which does not yet have the |
| 44 | ``--fail-if-out-of-sync`` flag this acceptance test exercises. Without |
| 45 | this shim, the test would silently exercise stale installed code |
| 46 | instead of the change under test. |
| 47 | """ |
| 48 | shim_dir = tmp_path_factory.mktemp("muse-shim") |
| 49 | shim = shim_dir / "muse" |
| 50 | shim.write_text( |
| 51 | f"#!{sys.executable}\n" |
| 52 | "import sys\n" |
| 53 | "from muse.cli.app import main\n" |
| 54 | "main(sys.argv[1:])\n", |
| 55 | encoding="utf-8", |
| 56 | ) |
| 57 | shim.chmod(shim.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) |
| 58 | monkeypatch.setenv("PATH", f"{shim_dir}{os.pathsep}{os.environ['PATH']}") |
| 59 | |
| 60 | repo_root = pathlib.Path(__file__).resolve().parents[1] |
| 61 | existing_pythonpath = os.environ.get("PYTHONPATH", "") |
| 62 | monkeypatch.setenv( |
| 63 | "PYTHONPATH", |
| 64 | f"{repo_root}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(repo_root), |
| 65 | ) |
| 66 | |
| 67 | |
| 68 | class TestAgentConfigDriftAcceptance: |
| 69 | def test_reproduces_agent_config_drift_incident(self, tmp_path: pathlib.Path) -> None: |
| 70 | assert _invoke(tmp_path, ["init", "--domain", "code"]).exit_code == 0 |
| 71 | |
| 72 | assert _invoke(tmp_path, ["agent-config", "init"]).exit_code == 0 |
| 73 | assert _invoke( |
| 74 | tmp_path, ["agent-config", "set", "--adapters", "claude,codex"] |
| 75 | ).exit_code == 0 |
| 76 | assert _invoke(tmp_path, ["agent-config", "sync"]).exit_code == 0 |
| 77 | |
| 78 | assert _invoke(tmp_path, ["code", "add", "."]).exit_code == 0 |
| 79 | assert _invoke(tmp_path, ["commit", "-m", "initial agent config"]).exit_code == 0 |
| 80 | |
| 81 | # Wire up the exact dogfood hook from #192's plan. |
| 82 | (tmp_path / ".musehooks.toml").write_text( |
| 83 | '[pre-commit]\ncommands = ["muse agent-config status --fail-if-out-of-sync"]\n', |
| 84 | encoding="utf-8", |
| 85 | ) |
| 86 | assert _invoke(tmp_path, ["code", "add", ".musehooks.toml"]).exit_code == 0 |
| 87 | assert _invoke(tmp_path, ["commit", "-m", "add pre-commit hook"]).exit_code == 0 |
| 88 | assert _invoke(tmp_path, ["hooks", "install"]).exit_code == 0 |
| 89 | |
| 90 | # Reproduce the actual incident: edit .museagent.md, do NOT re-sync |
| 91 | # the embed adapter (codex/AGENTS.md) — exactly what happened in |
| 92 | # ~/ecosystem/musehub this session. |
| 93 | agent_md = tmp_path / ".museagent.md" |
| 94 | agent_md.write_text(agent_md.read_text() + "\n## New rule\nSomething new.\n") |
| 95 | (tmp_path / "unrelated.py").write_text("x = 1\n") |
| 96 | assert _invoke(tmp_path, ["code", "add", "unrelated.py"]).exit_code == 0 |
| 97 | |
| 98 | r = _invoke(tmp_path, ["commit", "-m", "edit agent config without syncing", "--json"]) |
| 99 | assert r.exit_code == 1, r.output |
| 100 | data = json.loads(r.output) |
| 101 | assert "agent-config" in data["failed_command"] |
| 102 | assert data["error"] == "pre_commit_hook_failed" |
| 103 | |
| 104 | # --no-verify still gets it through, and never silently. |
| 105 | r2 = _invoke( |
| 106 | tmp_path, |
| 107 | ["commit", "-m", "edit agent config without syncing", "--no-verify", "--json"], |
| 108 | ) |
| 109 | assert r2.exit_code == 0, r2.output |
| 110 | data2 = json.loads(r2.output) |
| 111 | assert any("no-verify" in w.lower() for w in data2["warnings"]) |
| 112 | |
| 113 | # Prove the fix actually was "re-sync", not just "commit anyway" — |
| 114 | # after syncing, the same edit commits clean without --no-verify. |
| 115 | (tmp_path / "unrelated2.py").write_text("y = 2\n") |
| 116 | assert _invoke(tmp_path, ["code", "add", "unrelated2.py"]).exit_code == 0 |
| 117 | assert _invoke(tmp_path, ["agent-config", "sync"]).exit_code == 0 |
| 118 | assert _invoke(tmp_path, ["code", "add", "AGENTS.md"]).exit_code == 0 |
| 119 | r3 = _invoke(tmp_path, ["commit", "-m", "re-sync then commit", "--json"]) |
| 120 | assert r3.exit_code == 0, r3.output |