"""TDD contract for .muse/bridge-hooks.toml — pre/post bridge hook system. Issue #55 (musehub): AaronRene needs hooks to enforce security audits and auto-open PRs on every git-export without relying on individual memory. Config format (.muse/bridge-hooks.toml): [pre_bridge] hooks = [ { run = "npm audit fix", on_fail = "block" }, ] [post_bridge] hooks = [ { run = "gh pr create --base main --head muse-mirror", on_fail = "warn" }, ] Hook execution contract: - pre_bridge hooks run in the Muse repo root (before sync_to_git). - post_bridge hooks run in the git mirror directory (after git_commit). - on_fail = "block" → non-zero exit aborts the bridge run (SystemExit). - on_fail = "warn" → non-zero exit prints a warning and continues. - MUSE_BRIDGE_GIT_DIR, MUSE_BRIDGE_GIT_BRANCH, MUSE_BRIDGE_COMMIT_ID are injected as environment variables for every hook invocation. Phases ------ BH-1 load_bridge_hooks — reads and validates .muse/bridge-hooks.toml BH-2 run_hook — executes a single hook with correct cwd / env BH-3 run_hooks — runs a list of hooks in order, honouring on_fail BH-4 git-export integration — pre/post hooks fire at the right moments """ from __future__ import annotations import argparse import pathlib import subprocess import sys import textwrap from typing import TypedDict from unittest.mock import MagicMock, patch, call import pytest from muse.core.bridge.hooks import BridgeHook _Env = dict[str, str] # process environment map _Manifest = dict[str, str] # snapshot manifest: path → object_id _ArgVal = str | bool | int | None | list[str] # argparse Namespace field values class _RunCapture(TypedDict): cmd: str | list[str] cwd: pathlib.Path | None # --------------------------------------------------------------------------- # Phase BH-1: load_bridge_hooks # --------------------------------------------------------------------------- class TestLoadBridgeHooks: """BH-1: load_bridge_hooks reads .muse/bridge-hooks.toml.""" def test_returns_empty_hooks_when_file_missing(self, tmp_path: pathlib.Path) -> None: """No .muse/bridge-hooks.toml → empty pre and post lists, no error.""" from muse.core.bridge.hooks import load_bridge_hooks hooks = load_bridge_hooks(tmp_path) assert hooks.pre_bridge == [], "pre_bridge must be empty when file is missing" assert hooks.post_bridge == [], "post_bridge must be empty when file is missing" def test_loads_pre_bridge_hooks(self, tmp_path: pathlib.Path) -> None: """pre_bridge section is parsed into a list of BridgeHook objects.""" from muse.core.bridge.hooks import load_bridge_hooks, BridgeHook hooks_file = tmp_path / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True, exist_ok=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [ { run = "npm audit fix", on_fail = "block" }, { run = "echo ready", on_fail = "warn" }, ] """)) result = load_bridge_hooks(tmp_path) assert len(result.pre_bridge) == 2 assert result.pre_bridge[0] == BridgeHook(run="npm audit fix", on_fail="block") assert result.pre_bridge[1] == BridgeHook(run="echo ready", on_fail="warn") def test_loads_post_bridge_hooks(self, tmp_path: pathlib.Path) -> None: """post_bridge section is parsed correctly.""" from muse.core.bridge.hooks import load_bridge_hooks, BridgeHook hooks_file = tmp_path / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True, exist_ok=True) hooks_file.write_text(textwrap.dedent("""\ [post_bridge] hooks = [ { run = "gh pr create --base main --head muse-mirror", on_fail = "warn" }, ] """)) result = load_bridge_hooks(tmp_path) assert len(result.post_bridge) == 1 assert result.post_bridge[0] == BridgeHook( run="gh pr create --base main --head muse-mirror", on_fail="warn" ) assert result.pre_bridge == [] def test_returns_empty_sections_when_section_missing(self, tmp_path: pathlib.Path) -> None: """File exists but a section is absent → empty list for that section.""" from muse.core.bridge.hooks import load_bridge_hooks hooks_file = tmp_path / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True, exist_ok=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [{ run = "echo hi", on_fail = "block" }] """)) result = load_bridge_hooks(tmp_path) assert len(result.pre_bridge) == 1 assert result.post_bridge == [], "absent post_bridge section must yield empty list" def test_invalid_toml_raises_user_error(self, tmp_path: pathlib.Path) -> None: """Malformed TOML prints a clear error and raises SystemExit.""" from muse.core.bridge.hooks import load_bridge_hooks hooks_file = tmp_path / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True, exist_ok=True) hooks_file.write_text("[[[ invalid toml") with pytest.raises(SystemExit): load_bridge_hooks(tmp_path) def test_invalid_on_fail_value_raises_user_error(self, tmp_path: pathlib.Path) -> None: """on_fail must be 'block' or 'warn' — anything else raises SystemExit.""" from muse.core.bridge.hooks import load_bridge_hooks hooks_file = tmp_path / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True, exist_ok=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [{ run = "echo hi", on_fail = "explode" }] """)) with pytest.raises(SystemExit): load_bridge_hooks(tmp_path) def test_missing_run_field_raises_user_error(self, tmp_path: pathlib.Path) -> None: """A hook entry without 'run' raises SystemExit.""" from muse.core.bridge.hooks import load_bridge_hooks hooks_file = tmp_path / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True, exist_ok=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [{ on_fail = "block" }] """)) with pytest.raises(SystemExit): load_bridge_hooks(tmp_path) # --------------------------------------------------------------------------- # Phase BH-2/3: run_hook / run_hooks # --------------------------------------------------------------------------- class TestRunHook: """BH-2: run_hook executes a single hook with correct cwd and env.""" def test_hook_runs_in_given_cwd(self, tmp_path: pathlib.Path) -> None: """run_hook must execute the command in the specified directory.""" from muse.core.bridge.hooks import run_hook, BridgeHook hook = BridgeHook(run="pwd", on_fail="block") captured: list[_RunCapture] = [] def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: captured.append({"cmd": cmd, "cwd": cwd}) return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") with patch("subprocess.run", side_effect=_fake_run): run_hook(hook, cwd=tmp_path, env={}) assert captured, "subprocess.run was not called" assert captured[0]["cwd"] == tmp_path def test_hook_injects_env_vars(self, tmp_path: pathlib.Path) -> None: """MUSE_BRIDGE_* env vars must be present in the subprocess environment.""" from muse.core.bridge.hooks import run_hook, BridgeHook hook = BridgeHook(run="echo $MUSE_BRIDGE_GIT_DIR", on_fail="block") captured_env: _Env = {} def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: captured_env.update(env or {}) return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") extra_env = { "MUSE_BRIDGE_GIT_DIR": "/tmp/git-mirror", "MUSE_BRIDGE_GIT_BRANCH": "muse-mirror", "MUSE_BRIDGE_COMMIT_ID": "sha256:abc123", } with patch("subprocess.run", side_effect=_fake_run): run_hook(hook, cwd=tmp_path, env=extra_env) assert captured_env.get("MUSE_BRIDGE_GIT_DIR") == "/tmp/git-mirror" assert captured_env.get("MUSE_BRIDGE_GIT_BRANCH") == "muse-mirror" assert captured_env.get("MUSE_BRIDGE_COMMIT_ID") == "sha256:abc123" def test_block_hook_raises_on_nonzero_exit(self, tmp_path: pathlib.Path) -> None: """on_fail='block' + non-zero exit → SystemExit.""" from muse.core.bridge.hooks import run_hook, BridgeHook hook = BridgeHook(run="exit 1", on_fail="block") def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="audit failed") with patch("subprocess.run", side_effect=_fake_run): with pytest.raises(SystemExit): run_hook(hook, cwd=tmp_path, env={}) def test_warn_hook_does_not_raise_on_nonzero_exit( self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: """on_fail='warn' + non-zero exit → prints warning, does NOT raise.""" from muse.core.bridge.hooks import run_hook, BridgeHook hook = BridgeHook(run="exit 1", on_fail="warn") def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="pr already exists") with patch("subprocess.run", side_effect=_fake_run): run_hook(hook, cwd=tmp_path, env={}) # must not raise captured = capsys.readouterr() assert "warn" in (captured.err + captured.out).lower(), ( "a warning message must be printed for on_fail='warn' failures" ) def test_successful_hook_does_not_raise(self, tmp_path: pathlib.Path) -> None: """Exit code 0 → no exception for either on_fail value.""" from muse.core.bridge.hooks import run_hook, BridgeHook for on_fail in ("block", "warn"): hook = BridgeHook(run="echo ok", on_fail=on_fail) def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") with patch("subprocess.run", side_effect=_fake_run): run_hook(hook, cwd=tmp_path, env={}) # must not raise class TestRunHooks: """BH-3: run_hooks runs a list in order and stops on block failure.""" def test_runs_all_hooks_in_order(self, tmp_path: pathlib.Path) -> None: """All hooks in the list are executed in order.""" from muse.core.bridge.hooks import run_hooks, BridgeHook order: list[str] = [] def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: order.append(cmd) # type: ignore[arg-type] return subprocess.CompletedProcess(cmd, 0) hooks = [ BridgeHook(run="first", on_fail="block"), BridgeHook(run="second", on_fail="warn"), BridgeHook(run="third", on_fail="block"), ] with patch("subprocess.run", side_effect=_fake_run): run_hooks(hooks, cwd=tmp_path, env={}) assert order == [["first"], ["second"], ["third"]] def test_block_failure_stops_subsequent_hooks(self, tmp_path: pathlib.Path) -> None: """A block failure must prevent later hooks from running.""" from muse.core.bridge.hooks import run_hooks, BridgeHook ran: list[str] = [] def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: ran.append(cmd) # type: ignore[arg-type] rc = 1 if cmd == ["fail"] else 0 return subprocess.CompletedProcess(cmd, rc) hooks = [ BridgeHook(run="first", on_fail="warn"), BridgeHook(run="fail", on_fail="block"), BridgeHook(run="should-not-run", on_fail="warn"), ] with patch("subprocess.run", side_effect=_fake_run): with pytest.raises(SystemExit): run_hooks(hooks, cwd=tmp_path, env={}) assert ["should-not-run"] not in ran, ( "hooks after a blocking failure must not be executed" ) def test_warn_failure_continues_to_next_hook(self, tmp_path: pathlib.Path) -> None: """A warn failure must not stop the hook chain.""" from muse.core.bridge.hooks import run_hooks, BridgeHook ran: list[str] = [] def _fake_run( cmd: str | list[str], shell: bool = False, cwd: pathlib.Path | None = None, env: _Env | None = None, ) -> subprocess.CompletedProcess[str]: ran.append(cmd) # type: ignore[arg-type] rc = 1 if cmd == ["fails-but-warns"] else 0 return subprocess.CompletedProcess(cmd, rc) hooks = [ BridgeHook(run="fails-but-warns", on_fail="warn"), BridgeHook(run="still-runs", on_fail="block"), ] with patch("subprocess.run", side_effect=_fake_run): run_hooks(hooks, cwd=tmp_path, env={}) assert ["still-runs"] in ran, "hook chain must continue after a warn failure" def test_empty_hook_list_is_a_noop(self, tmp_path: pathlib.Path) -> None: """Empty list → no subprocess calls, no exceptions.""" from muse.core.bridge.hooks import run_hooks with patch("subprocess.run") as mock_run: run_hooks([], cwd=tmp_path, env={}) mock_run.assert_not_called() # --------------------------------------------------------------------------- # Phase BH-4: git-export integration # --------------------------------------------------------------------------- class TestGitExportHookIntegration: """BH-4: pre/post hooks fire at the correct points in run_git_export.""" def _make_args(self, git_dir: pathlib.Path, **overrides: _ArgVal) -> argparse.Namespace: """Minimal argparse.Namespace for run_git_export.""" defaults = dict( git_dir=str(git_dir), json_out=False, dry_run=False, no_push=True, force_push=False, git_branch="muse-mirror", git_remote="origin", muse_ref=None, excludes=[], strip_muse_metadata=True, fix_modes=False, allow_empty=True, commit_message="mirror: muse {commit_id}", watch=None, export_rerere=False, export_shelves=False, ) defaults.update(overrides) return argparse.Namespace(**defaults) def test_pre_bridge_hooks_run_before_sync( self, tmp_path: pathlib.Path ) -> None: """pre_bridge hooks must execute before sync_to_git is called.""" from muse.core.bridge.hooks import load_bridge_hooks, BridgeHooks, BridgeHook muse_root = tmp_path / "muse_repo" muse_root.mkdir() git_dir = tmp_path / "git_repo" git_dir.mkdir() (git_dir / ".git").mkdir() hooks_file = muse_root / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [{ run = "echo pre", on_fail = "block" }] """)) call_order: list[str] = [] def _fake_pre_hook(hook: BridgeHook, *, cwd: pathlib.Path, env: _Env) -> None: call_order.append("pre_hook") def _fake_sync( manifest: _Manifest, *, excludes: list[str] | None = None, strip_muse: bool = True, fix_modes: bool = False, ) -> int: call_order.append("sync_to_git") return 0 with ( patch("muse.core.bridge.exporter.find_repo_root", return_value=muse_root), patch("muse.core.bridge.exporter.GitExporter") as MockExporter, patch("muse.core.bridge.exporter.run_hook", side_effect=_fake_pre_hook), patch("muse.core.bridge.exporter._ensure_git_branch"), patch("muse.core.bridge.exporter.read_bridge_state", return_value={}), patch("muse.core.bridge.exporter.write_bridge_state"), ): instance = MockExporter.return_value instance.resolve_muse_ref.return_value = ("sha256:" + "a" * 64, "sha256:" + "b" * 64) instance.read_snapshot.return_value = {} instance.sync_to_git.side_effect = _fake_sync instance.git_commit.return_value = "abc123" instance.muse_branch = "main" from muse.core.bridge.exporter import run_git_export run_git_export(self._make_args(git_dir)) pre_idx = next((i for i, v in enumerate(call_order) if v == "pre_hook"), None) sync_idx = next((i for i, v in enumerate(call_order) if v == "sync_to_git"), None) assert pre_idx is not None, "pre_bridge hook was not called" assert sync_idx is not None, "sync_to_git was not called" assert pre_idx < sync_idx, ( f"pre_bridge hook must run before sync_to_git " f"(pre_hook at {pre_idx}, sync_to_git at {sync_idx})" ) def test_post_bridge_hooks_run_after_commit( self, tmp_path: pathlib.Path ) -> None: """post_bridge hooks must execute after git_commit.""" from muse.core.bridge.hooks import BridgeHook muse_root = tmp_path / "muse_repo" muse_root.mkdir() git_dir = tmp_path / "git_repo" git_dir.mkdir() (git_dir / ".git").mkdir() hooks_file = muse_root / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True) hooks_file.write_text(textwrap.dedent("""\ [post_bridge] hooks = [{ run = "gh pr create", on_fail = "warn" }] """)) call_order: list[str] = [] def _fake_post_hook(hook: BridgeHook, *, cwd: pathlib.Path, env: _Env) -> None: call_order.append(("post_hook", str(cwd))) def _fake_commit( commit_id: str, commit_message: str, *, allow_empty: bool = True, ) -> str: call_order.append("git_commit") return "deadbeef" with ( patch("muse.core.bridge.exporter.find_repo_root", return_value=muse_root), patch("muse.core.bridge.exporter.GitExporter") as MockExporter, patch("muse.core.bridge.exporter.run_hook", side_effect=_fake_post_hook), patch("muse.core.bridge.exporter._ensure_git_branch"), patch("muse.core.bridge.exporter.read_bridge_state", return_value={}), patch("muse.core.bridge.exporter.write_bridge_state"), ): instance = MockExporter.return_value instance.resolve_muse_ref.return_value = ("sha256:" + "a" * 64, "sha256:" + "b" * 64) instance.read_snapshot.return_value = {} instance.sync_to_git.return_value = 5 instance.git_commit.side_effect = _fake_commit instance.muse_branch = "main" from muse.core.bridge.exporter import run_git_export run_git_export(self._make_args(git_dir)) commit_idx = next((i for i, v in enumerate(call_order) if v == "git_commit"), None) post_idx = next( (i for i, v in enumerate(call_order) if isinstance(v, tuple) and v[0] == "post_hook"), None, ) assert commit_idx is not None, "git_commit was not called" assert post_idx is not None, "post_bridge hook was not called" assert post_idx > commit_idx, ( f"post_bridge hook must run after git_commit " f"(commit at {commit_idx}, post_hook at {post_idx})" ) def test_post_bridge_hooks_run_in_git_dir( self, tmp_path: pathlib.Path ) -> None: """post_bridge hooks must use git_dir as cwd (for gh pr create etc.).""" muse_root = tmp_path / "muse_repo" muse_root.mkdir() git_dir = tmp_path / "git_repo" git_dir.mkdir() (git_dir / ".git").mkdir() hooks_file = muse_root / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True) hooks_file.write_text(textwrap.dedent("""\ [post_bridge] hooks = [{ run = "gh pr create", on_fail = "warn" }] """)) post_cwds: list[pathlib.Path] = [] def _capture_post_hook(hook: BridgeHook, *, cwd: pathlib.Path, env: _Env) -> None: post_cwds.append(cwd) with ( patch("muse.core.bridge.exporter.find_repo_root", return_value=muse_root), patch("muse.core.bridge.exporter.GitExporter") as MockExporter, patch("muse.core.bridge.exporter.run_hook", side_effect=_capture_post_hook), patch("muse.core.bridge.exporter._ensure_git_branch"), patch("muse.core.bridge.exporter.read_bridge_state", return_value={}), patch("muse.core.bridge.exporter.write_bridge_state"), ): instance = MockExporter.return_value instance.resolve_muse_ref.return_value = ("sha256:" + "a" * 64, "sha256:" + "b" * 64) instance.read_snapshot.return_value = {} instance.sync_to_git.return_value = 5 instance.git_commit.return_value = "deadbeef" instance.muse_branch = "main" from muse.core.bridge.exporter import run_git_export run_git_export(self._make_args(git_dir)) assert post_cwds, "post_bridge hook cwd was not captured" assert post_cwds[0] == git_dir, ( f"post_bridge hooks must run in git_dir={git_dir}, got {post_cwds[0]}" ) def test_pre_bridge_hooks_run_in_muse_root( self, tmp_path: pathlib.Path ) -> None: """pre_bridge hooks must use the muse repo root as cwd.""" muse_root = tmp_path / "muse_repo" muse_root.mkdir() git_dir = tmp_path / "git_repo" git_dir.mkdir() (git_dir / ".git").mkdir() hooks_file = muse_root / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [{ run = "npm audit fix", on_fail = "block" }] """)) pre_cwds: list[pathlib.Path] = [] def _capture_pre_hook(hook: BridgeHook, *, cwd: pathlib.Path, env: _Env) -> None: pre_cwds.append(cwd) with ( patch("muse.core.bridge.exporter.find_repo_root", return_value=muse_root), patch("muse.core.bridge.exporter.GitExporter") as MockExporter, patch("muse.core.bridge.exporter.run_hook", side_effect=_capture_pre_hook), patch("muse.core.bridge.exporter._ensure_git_branch"), patch("muse.core.bridge.exporter.read_bridge_state", return_value={}), patch("muse.core.bridge.exporter.write_bridge_state"), ): instance = MockExporter.return_value instance.resolve_muse_ref.return_value = ("sha256:" + "a" * 64, "sha256:" + "b" * 64) instance.read_snapshot.return_value = {} instance.sync_to_git.return_value = 5 instance.git_commit.return_value = "deadbeef" instance.muse_branch = "main" from muse.core.bridge.exporter import run_git_export run_git_export(self._make_args(git_dir)) assert pre_cwds, "pre_bridge hook cwd was not captured" assert pre_cwds[0] == muse_root, ( f"pre_bridge hooks must run in muse_root={muse_root}, got {pre_cwds[0]}" ) def test_blocking_pre_hook_failure_aborts_export( self, tmp_path: pathlib.Path ) -> None: """A blocking pre_bridge failure must abort before sync_to_git.""" muse_root = tmp_path / "muse_repo" muse_root.mkdir() git_dir = tmp_path / "git_repo" git_dir.mkdir() (git_dir / ".git").mkdir() hooks_file = muse_root / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [{ run = "npm audit", on_fail = "block" }] """)) def _fail_pre_hook(hook: BridgeHook, *, cwd: pathlib.Path, env: _Env) -> None: raise SystemExit(1) with ( patch("muse.core.bridge.exporter.find_repo_root", return_value=muse_root), patch("muse.core.bridge.exporter.GitExporter") as MockExporter, patch("muse.core.bridge.exporter.run_hook", side_effect=_fail_pre_hook), patch("muse.core.bridge.exporter._ensure_git_branch"), ): instance = MockExporter.return_value instance.resolve_muse_ref.return_value = ("sha256:" + "a" * 64, "sha256:" + "b" * 64) instance.read_snapshot.return_value = {} from muse.core.bridge.exporter import run_git_export with pytest.raises(SystemExit): run_git_export(self._make_args(git_dir)) instance.sync_to_git.assert_not_called() def test_env_vars_injected_into_hooks(self, tmp_path: pathlib.Path) -> None: """MUSE_BRIDGE_* env vars are passed to every hook invocation.""" muse_root = tmp_path / "muse_repo" muse_root.mkdir() git_dir = tmp_path / "git_repo" git_dir.mkdir() (git_dir / ".git").mkdir() hooks_file = muse_root / ".muse" / "bridge-hooks.toml" hooks_file.parent.mkdir(parents=True) hooks_file.write_text(textwrap.dedent("""\ [pre_bridge] hooks = [{ run = "echo $MUSE_BRIDGE_COMMIT_ID", on_fail = "warn" }] """)) captured_envs: list[_Env] = [] def _capture_hook(hook: BridgeHook, *, cwd: pathlib.Path, env: _Env) -> None: captured_envs.append(dict(env)) commit_id = "sha256:" + "a" * 64 with ( patch("muse.core.bridge.exporter.find_repo_root", return_value=muse_root), patch("muse.core.bridge.exporter.GitExporter") as MockExporter, patch("muse.core.bridge.exporter.run_hook", side_effect=_capture_hook), patch("muse.core.bridge.exporter._ensure_git_branch"), patch("muse.core.bridge.exporter.read_bridge_state", return_value={}), patch("muse.core.bridge.exporter.write_bridge_state"), ): instance = MockExporter.return_value instance.resolve_muse_ref.return_value = (commit_id, "sha256:" + "b" * 64) instance.read_snapshot.return_value = {} instance.sync_to_git.return_value = 3 instance.git_commit.return_value = "deadbeef" instance.muse_branch = "main" from muse.core.bridge.exporter import run_git_export run_git_export(self._make_args(git_dir)) assert captured_envs, "run_hook was not called" env = captured_envs[0] assert env.get("MUSE_BRIDGE_COMMIT_ID") == commit_id, ( f"MUSE_BRIDGE_COMMIT_ID not injected; got env keys: {list(env.keys())}" ) assert "MUSE_BRIDGE_GIT_DIR" in env assert "MUSE_BRIDGE_GIT_BRANCH" in env def test_no_hooks_file_export_runs_normally(self, tmp_path: pathlib.Path) -> None: """Absence of bridge-hooks.toml must not affect a normal export.""" muse_root = tmp_path / "muse_repo" muse_root.mkdir() (muse_root / ".muse").mkdir() # no bridge-hooks.toml git_dir = tmp_path / "git_repo" git_dir.mkdir() (git_dir / ".git").mkdir() with ( patch("muse.core.bridge.exporter.find_repo_root", return_value=muse_root), patch("muse.core.bridge.exporter.GitExporter") as MockExporter, patch("muse.core.bridge.exporter._ensure_git_branch"), patch("muse.core.bridge.exporter.read_bridge_state", return_value={}), patch("muse.core.bridge.exporter.write_bridge_state"), ): instance = MockExporter.return_value instance.resolve_muse_ref.return_value = ("sha256:" + "a" * 64, "sha256:" + "b" * 64) instance.read_snapshot.return_value = {} instance.sync_to_git.return_value = 2 instance.git_commit.return_value = "abc" instance.muse_branch = "main" from muse.core.bridge.exporter import run_git_export run_git_export(self._make_args(git_dir)) # must not raise instance.sync_to_git.assert_called_once()