"""End-to-end bidirectional roundtrip tests for ``muse bridge``. This module creates a realistic, *complex* git repository from scratch — multiple branches, realistic file trees, multiple authors, conventional commit messages, semver tags — then exercises the full bridge cycle: Git → (git-import) → Muse → (git-export) → Git Each direction is verified for content fidelity, commit count, and bridge state consistency. A second pass adds new commits in git and re-imports (incremental), then adds Muse-native commits and re-exports, confirming that the bridge correctly accumulates history on both sides without duplication. Test organisation (eight tiers): Tier 1 — Fixture Sanity the git repo we build is what we think it is Tier 2 — Full Import git→muse, all content arrives correctly Tier 3 — Full Export muse→git, Muse commits visible in git Tier 4 — Bidirectional import then export then re-import cycle Tier 5 — Incremental new git commits → incremental import Tier 6 — Muse-Native Round Muse-only commits survive git-export Tier 7 — Data Integrity file content SHA-256 match end-to-end Tier 8 — Drift Tracking bridge status drift counters are accurate NOTE: ``git`` subprocess calls in this file are INTENTIONAL and necessary — the bridge command translates *from* git repos. All other Muse code paths are git-free. See ``docs/bridge-ci.md`` for CI requirements. """ from __future__ import annotations import hashlib import json import os import pathlib import subprocess import textwrap import time from collections.abc import Mapping import pytest from tests.cli_test_helper import CliRunner from muse.core.paths import git_bridge_state_path type _StrEnv = dict[str, str] type _ShaMap = dict[str, str] runner = CliRunner() # --------------------------------------------------------------------------- # Fixture helpers # --------------------------------------------------------------------------- def _git(*args: str, cwd: pathlib.Path, check: bool = True) -> str: """Run a git command in *cwd* and return decoded stdout.""" result = subprocess.run( ["git", "-C", str(cwd), *args], capture_output=True, check=False, ) if check and result.returncode != 0: raise RuntimeError( f"git {' '.join(args)} failed:\n{result.stderr.decode()}" ) return result.stdout.decode() def _git_env(**extra: str) -> _StrEnv: """Return an environment dict with git author/committer set.""" return { **os.environ, "GIT_AUTHOR_EMAIL": extra.get("email", "test@example.com"), "GIT_AUTHOR_NAME": extra.get("name", "Test User"), "GIT_COMMITTER_EMAIL": extra.get("email", "test@example.com"), "GIT_COMMITTER_NAME": extra.get("name", "Test User"), } def _git_commit(repo: pathlib.Path, message: str, **author_kw: str) -> str: """Stage all changes and commit; return the new SHA.""" subprocess.run( ["git", "-C", str(repo), "add", "--all"], check=True, capture_output=True, ) subprocess.run( ["git", "-C", str(repo), "commit", "-m", message], check=True, capture_output=True, env=_git_env(**author_kw), ) return _git("log", "--format=%H", "-1", cwd=repo).strip() def _write_files(repo: pathlib.Path, files: Mapping[str, str]) -> None: """Write *files* (rel_path → content) into *repo*, creating parents.""" for rel, content in files.items(): full = repo / rel full.parent.mkdir(parents=True, exist_ok=True) full.write_text(content, encoding="utf-8") def _build_complex_git_repo(path: pathlib.Path) -> _ShaMap: """Build a multi-branch git repo that exercises all bridge features. Returns a dict mapping label → git SHA for later assertions. Layout ------ main: sha['init'] — repo skeleton, conventional commit (feat:) sha['v01'] — semver tag v0.1.0 sha['fix1'] — fix: commit (patch bump signal) sha['v10'] — merge of feature branch + tag v1.0.0 feature/auth: sha['auth1'] — feat: add auth module sha['auth2'] — feat!: breaking API change (major bump signal) Authors: two different emails to exercise AttributionMapper fallback. """ path.mkdir(parents=True, exist_ok=True) subprocess.run(["git", "init", str(path)], check=True, capture_output=True) _git("config", "user.email", "gabriel@tellurstori.com", cwd=path) _git("config", "user.name", "Gabriel", cwd=path) shas: dict[str, str] = {} # ── main: initial scaffold ────────────────────────────────────────────── _write_files(path, { "README.md": textwrap.dedent("""\ # My Project A demonstration repository for the Muse bridge roundtrip tests. """), "src/__init__.py": '"""My project."""\n__version__ = "0.1.0"\n', "src/core.py": textwrap.dedent("""\ \"\"\"Core logic.\"\"\" def greet(name: str) -> str: \"\"\"Return a greeting string.\"\"\" return f"Hello, {name}!" """), "tests/test_core.py": textwrap.dedent("""\ from src.core import greet def test_greet() -> None: assert greet("world") == "Hello, world!" """), "pyproject.toml": "[project]\nname = \"my-project\"\nversion = \"0.1.0\"\n", ".gitignore": "__pycache__/\n*.pyc\n.venv/\ndist/\n", }) shas["init"] = _git_commit(path, "feat: initial project scaffold") # ── tag v0.1.0 ───────────────────────────────────────────────────────── subprocess.run( ["git", "-C", str(path), "tag", "-a", "v0.1.0", "-m", "Release v0.1.0"], check=True, capture_output=True, ) shas["v01"] = shas["init"] # ── feature/auth branch ──────────────────────────────────────────────── _git("checkout", "-b", "feature/auth", cwd=path) _write_files(path, { "src/auth.py": textwrap.dedent("""\ \"\"\"Authentication helpers.\"\"\" from typing import Optional def login(username: str, password: str) -> Optional[str]: \"\"\"Return a session token or None on failure.\"\"\" if username == "admin" and password == "secret": return "tok-admin" return None """), "tests/test_auth.py": textwrap.dedent("""\ from src.auth import login def test_login_success() -> None: assert login("admin", "secret") == "tok-admin" def test_login_failure() -> None: assert login("user", "wrong") is None """), }) shas["auth1"] = _git_commit( path, "feat: add authentication module", email="alice@example.com", name="Alice Dev", ) # Breaking API change on feature branch _write_files(path, { "src/auth.py": textwrap.dedent("""\ \"\"\"Authentication helpers — v2 API.\"\"\"\n class AuthError(Exception): pass def login(username: str, password: str) -> str: \"\"\"Return session token; raises AuthError on failure.\"\"\" if username == "admin" and password == "secret": return "tok-admin" raise AuthError(f"Invalid credentials for {username!r}") """), "tests/test_auth.py": textwrap.dedent("""\ import pytest from src.auth import login, AuthError def test_login_success() -> None: assert login("admin", "secret") == "tok-admin" def test_login_failure_raises() -> None: with pytest.raises(AuthError): login("user", "wrong") """), }) shas["auth2"] = _git_commit( path, "feat!: auth login now raises AuthError instead of returning None\n\nBREAKING CHANGE: callers must catch AuthError", email="alice@example.com", name="Alice Dev", ) # ── back to main: fix commit ──────────────────────────────────────────── _git("checkout", "main", cwd=path) _write_files(path, {"src/core.py": textwrap.dedent("""\ \"\"\"Core logic.\"\"\" def greet(name: str) -> str: \"\"\"Return a greeting string.\"\"\" return f"Hello, {name}!" def farewell(name: str) -> str: \"\"\"Return a farewell string.\"\"\" return f"Goodbye, {name}!" """)}) shas["fix1"] = _git_commit(path, "fix: add missing farewell() function") # ── merge feature/auth → main ────────────────────────────────────────── subprocess.run( ["git", "-C", str(path), "merge", "--no-ff", "feature/auth", "-m", "feat: merge auth module into main"], check=True, capture_output=True, env=_git_env(), ) shas["v10"] = _git("log", "--format=%H", "-1", cwd=path).strip() # Tag the merge commit as v1.0.0 subprocess.run( ["git", "-C", str(path), "tag", "-a", "v1.0.0", "-m", "Release v1.0.0"], check=True, capture_output=True, ) return shas def _invoke(*args: str, cwd: pathlib.Path | None = None) -> "CliRunner": """Invoke the muse CLI.""" return runner.invoke(None, list(args), cwd=cwd) def _make_muse_repo(path: pathlib.Path) -> pathlib.Path: """Initialise an empty Muse repo at *path*.""" path.mkdir(parents=True, exist_ok=True) r = _invoke("init", cwd=path) assert r.exit_code == 0, f"muse init failed: {r.stderr}" return path def _muse_checkout(muse_dir: pathlib.Path, branch: str = "main") -> None: """Checkout *branch* in the Muse repo to populate the working tree. ``git-import`` writes commits to the Muse object store but does not populate the working tree. The working tree will show imported files as "deleted" (in snapshot, not on disk), so ``--force`` is required to let checkout overwrite those staged deletions and restore the snapshot. """ r = _invoke("checkout", "--force", branch, cwd=muse_dir) if r.exit_code != 0: # Try master as a fallback (some git repos default to master) _invoke("checkout", "--force", "master", cwd=muse_dir) def _muse_log(muse_dir: pathlib.Path) -> list[dict]: r = _invoke("log", "--json", cwd=muse_dir) if r.exit_code != 0: return [] try: return json.loads(r.output.strip()).get("commits", []) except json.JSONDecodeError: return [] def _muse_branches(muse_dir: pathlib.Path) -> list[str]: r = _invoke("branch", "--json", cwd=muse_dir) if r.exit_code != 0: return [] try: data = json.loads(r.output.strip()) if isinstance(data, list): return [b["name"] for b in data] except (json.JSONDecodeError, KeyError): pass return [] def _git_log_count(git_dir: pathlib.Path, ref: str = "HEAD") -> int: result = subprocess.run( ["git", "-C", str(git_dir), "rev-list", "--count", ref], capture_output=True, ) return int(result.stdout.decode().strip()) if result.returncode == 0 else 0 def _git_file_sha256(git_dir: pathlib.Path, rel: str) -> str: data = (git_dir / rel).read_bytes() return hashlib.sha256(data).hexdigest() # --------------------------------------------------------------------------- # Tier 1 — Fixture Sanity # --------------------------------------------------------------------------- class TestFixtureSanity: """Verify that _build_complex_git_repo produces what we expect.""" def test_main_branch_has_at_least_4_commits(self, tmp_path: pathlib.Path) -> None: repo = tmp_path / "git" _build_complex_git_repo(repo) count = _git_log_count(repo) assert count >= 4, f"Expected ≥4 commits on main, got {count}" def test_feature_auth_branch_exists(self, tmp_path: pathlib.Path) -> None: repo = tmp_path / "git" _build_complex_git_repo(repo) branches_raw = _git("branch", "--list", cwd=repo) assert "feature/auth" in branches_raw def test_v010_tag_exists(self, tmp_path: pathlib.Path) -> None: repo = tmp_path / "git" _build_complex_git_repo(repo) tags = _git("tag", "--list", cwd=repo) assert "v0.1.0" in tags def test_v100_tag_exists(self, tmp_path: pathlib.Path) -> None: repo = tmp_path / "git" _build_complex_git_repo(repo) tags = _git("tag", "--list", cwd=repo) assert "v1.0.0" in tags def test_src_auth_py_exists_on_main(self, tmp_path: pathlib.Path) -> None: repo = tmp_path / "git" _build_complex_git_repo(repo) assert (repo / "src" / "auth.py").exists() def test_two_authors_in_git_history(self, tmp_path: pathlib.Path) -> None: repo = tmp_path / "git" _build_complex_git_repo(repo) log = _git("log", "--format=%ae", "--all", cwd=repo) emails = {e.strip() for e in log.splitlines() if e.strip()} assert len(emails) >= 2, f"Expected ≥2 author emails, got: {emails}" def test_breaking_change_commit_present(self, tmp_path: pathlib.Path) -> None: repo = tmp_path / "git" _build_complex_git_repo(repo) log = _git("log", "--all", "--format=%s", cwd=repo) assert "BREAKING CHANGE" in log or "feat!" in log # --------------------------------------------------------------------------- # Tier 2 — Full Import (git → Muse) # --------------------------------------------------------------------------- class TestFullImport: """Complete git→muse import from the complex fixture.""" def test_import_exit_code_zero(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) r = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) assert r.exit_code == 0, f"import failed:\n{r.output}\n{r.stderr}" def test_import_creates_muse_commits(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) commits = _muse_log(muse_dir) assert len(commits) >= 3, f"Expected ≥3 Muse commits after import, got {len(commits)}" def test_import_all_branches_imports_feature_branch( self, tmp_path: pathlib.Path ) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), "--all", cwd=muse_dir) branches = _muse_branches(muse_dir) # main or master plus feature/auth should exist after --all import assert len(branches) >= 2, f"Expected ≥2 branches after --all import: {branches}" def test_import_commit_messages_preserved(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) commits = _muse_log(muse_dir) messages = [c.get("message", "") for c in commits] assert any("feat" in m for m in messages), ( f"No conventional-commit message found in: {messages}" ) def test_import_src_files_tracked(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) # git-import writes to the object store only; checkout populates the tree _muse_checkout(muse_dir) assert (muse_dir / "src" / "core.py").exists(), ( "src/core.py not found in Muse working tree after import + checkout" ) def test_import_auth_module_tracked(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) _muse_checkout(muse_dir) assert (muse_dir / "src" / "auth.py").exists(), ( "src/auth.py not found in Muse working tree after import + checkout" ) def test_import_readme_content_matches(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) _muse_checkout(muse_dir) muse_readme = (muse_dir / "README.md").read_text(encoding="utf-8") assert "My Project" in muse_readme def test_import_writes_bridge_state(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) state_file = git_bridge_state_path(muse_dir) assert state_file.exists(), "git-bridge.toml not written after import" content = state_file.read_text() assert "git_sha" in content def test_import_git_dir_excluded(self, tmp_path: pathlib.Path) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) # .git directory must never appear in Muse working tree assert not (muse_dir / ".git").exists(), ( ".git directory leaked into Muse working tree" ) # --------------------------------------------------------------------------- # Tier 3 — Full Export (Muse → Git) # --------------------------------------------------------------------------- class TestFullExport: """Import complex git repo into Muse, then export Muse back to a git repo.""" def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]: """Return (git_source, muse_dir, git_target).""" git_source = tmp_path / "git_source" muse_dir = tmp_path / "muse" git_target = tmp_path / "git_target" _build_complex_git_repo(git_source) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir), cwd=muse_dir) # Create empty git target for export git_target.mkdir() subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_target) _git("config", "user.name", "Test", cwd=git_target) (git_target / "README.md").write_text("init") subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_target), "commit", "-m", "init"], check=True, capture_output=True, env=_git_env(), ) return git_source, muse_dir, git_target def test_export_exit_code_zero(self, tmp_path: pathlib.Path) -> None: _, muse_dir, git_target = self._setup(tmp_path) r = _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", "--json", cwd=muse_dir) assert r.exit_code == 0, f"export failed:\n{r.output}\n{r.stderr}" def test_export_produces_git_commit(self, tmp_path: pathlib.Path) -> None: _, muse_dir, git_target = self._setup(tmp_path) initial_count = _git_log_count(git_target) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) new_count = _git_log_count(git_target) assert new_count > initial_count, ( f"git log count did not increase: {initial_count} → {new_count}" ) def test_export_git_message_references_muse_commit( self, tmp_path: pathlib.Path ) -> None: _, muse_dir, git_target = self._setup(tmp_path) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) msg = _git("log", "--format=%s", "-1", cwd=git_target).strip() assert msg.startswith("mirror: muse sha256:"), ( f"Unexpected git commit message: {msg!r}" ) def test_export_src_files_appear_in_git(self, tmp_path: pathlib.Path) -> None: _, muse_dir, git_target = self._setup(tmp_path) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) assert (git_target / "src" / "core.py").exists(), ( "src/core.py missing in git target after export" ) def test_export_auth_module_appears_in_git(self, tmp_path: pathlib.Path) -> None: _, muse_dir, git_target = self._setup(tmp_path) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) assert (git_target / "src" / "auth.py").exists(), ( "src/auth.py missing in git target after export" ) def test_export_muse_dir_not_in_git(self, tmp_path: pathlib.Path) -> None: _, muse_dir, git_target = self._setup(tmp_path) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) tracked = _git("ls-tree", "-r", "--name-only", "HEAD", cwd=git_target) assert ".muse/" not in tracked and not any( line.startswith(".muse/") for line in tracked.splitlines() ), ".muse/ directory leaked into git export" def test_export_updates_bridge_state_last_export( self, tmp_path: pathlib.Path ) -> None: _, muse_dir, git_target = self._setup(tmp_path) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) state_file = git_bridge_state_path(muse_dir) content = state_file.read_text() assert "last_export" in content def test_export_json_contains_git_sha(self, tmp_path: pathlib.Path) -> None: _, muse_dir, git_target = self._setup(tmp_path) r = _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", "--json", cwd=muse_dir) assert r.exit_code == 0, r.stderr data = json.loads(r.output.strip()) assert data.get("git_sha", "") != "", "json output missing git_sha" def test_export_idempotent_no_duplicate_commit( self, tmp_path: pathlib.Path ) -> None: _, muse_dir, git_target = self._setup(tmp_path) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) count_after_first = _git_log_count(git_target) # Second export — same Muse HEAD, no new commits _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) count_after_second = _git_log_count(git_target) assert count_after_second == count_after_first, ( f"Idempotent export produced a new git commit: " f"{count_after_first} → {count_after_second}" ) # --------------------------------------------------------------------------- # Tier 4 — Bidirectional Cycle # --------------------------------------------------------------------------- class TestBidirectionalCycle: """Full cycle: git→muse→git, verifying state at each step.""" def test_full_cycle_state_consistent(self, tmp_path: pathlib.Path) -> None: """State file must reference both last_import and last_export after cycle.""" git_source = tmp_path / "git" muse_dir = tmp_path / "muse" git_target = tmp_path / "git_out" _build_complex_git_repo(git_source) _make_muse_repo(muse_dir) # Import r = _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir), cwd=muse_dir) assert r.exit_code == 0, r.stderr # Set up a target git repo git_target.mkdir() subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_target) _git("config", "user.name", "Test", cwd=git_target) (git_target / "init.txt").write_text("x") subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_target), "commit", "-m", "init"], check=True, capture_output=True, env=_git_env(), ) # Export r = _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) assert r.exit_code == 0, r.stderr state = (git_bridge_state_path(muse_dir)).read_text() assert "last_import" in state assert "last_export" in state assert "git_sha" in state def test_muse_commits_visible_after_import_then_export( self, tmp_path: pathlib.Path ) -> None: """Muse commits created after import must appear in the subsequent git export.""" git_source = tmp_path / "git" muse_dir = tmp_path / "muse" git_target = tmp_path / "git_out" _build_complex_git_repo(git_source) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir), cwd=muse_dir) # Make a Muse-native commit (after import) (muse_dir / "muse_only.txt").write_text("created in muse\n") _invoke("code", "add", ".", cwd=muse_dir) _invoke("commit", "-m", "chore: muse-native file", "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign", cwd=muse_dir) # Export to git git_target.mkdir() subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_target) _git("config", "user.name", "Test", cwd=git_target) (git_target / "init.txt").write_text("x") subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_target), "commit", "-m", "init"], check=True, capture_output=True, env=_git_env(), ) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) # The muse-native file must appear in the git working tree after export assert (git_target / "muse_only.txt").exists(), ( "muse_only.txt not exported to git after Muse-native commit" ) # --------------------------------------------------------------------------- # Tier 5 — Incremental Import # --------------------------------------------------------------------------- class TestIncrementalImport: """New git commits added after initial import → incremental re-import.""" def test_incremental_import_adds_new_commits( self, tmp_path: pathlib.Path ) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) # Initial full import _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) count_before = len(_muse_log(muse_dir)) # Add 2 more commits to git _write_files(git_dir, {"new_feature.py": "def hello(): pass\n"}) _git_commit(git_dir, "feat: add hello stub") _write_files(git_dir, {"new_feature.py": "def hello(): return 'hi'\n"}) _git_commit(git_dir, "fix: implement hello") # Incremental import r = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), "--incremental", cwd=muse_dir) assert r.exit_code == 0, f"incremental import failed:\n{r.output}\n{r.stderr}" count_after = len(_muse_log(muse_dir)) assert count_after == count_before + 2, ( f"Expected {count_before + 2} commits after incremental import, " f"got {count_after}" ) def test_incremental_import_no_duplicates( self, tmp_path: pathlib.Path ) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) count_after_first = len(_muse_log(muse_dir)) # Incremental import with no new git commits — should be a no-op r = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), "--incremental", cwd=muse_dir) assert r.exit_code == 0, r.stderr count_after_noop = len(_muse_log(muse_dir)) assert count_after_noop == count_after_first, ( f"Incremental import on unchanged git repo added commits: " f"{count_after_first} → {count_after_noop}" ) def test_three_round_incremental_accumulates( self, tmp_path: pathlib.Path ) -> None: """Three incremental imports each adding one commit accumulate correctly.""" git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) base = len(_muse_log(muse_dir)) for i in range(3): _write_files(git_dir, {f"round{i}.txt": f"round {i}\n"}) _git_commit(git_dir, f"chore: round {i} file") _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), "--incremental", cwd=muse_dir) final = len(_muse_log(muse_dir)) assert final == base + 3, ( f"Expected {base + 3} commits after 3 incremental rounds, got {final}" ) # --------------------------------------------------------------------------- # Tier 6 — Muse-Native Round-Trip # --------------------------------------------------------------------------- class TestMuseNativeRoundTrip: """Muse-native changes survive the git-export → git-import cycle.""" def test_muse_native_file_survives_export_then_reimport( self, tmp_path: pathlib.Path ) -> None: """A file created in Muse, exported to git, then re-imported must still exist.""" git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" git_target = tmp_path / "git_out" muse_dir2 = tmp_path / "muse2" # Seed git repo git_dir.mkdir() subprocess.run(["git", "init", str(git_dir)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_dir) _git("config", "user.name", "Test", cwd=git_dir) _write_files(git_dir, {"seed.py": "x = 1\n"}) _git_commit(git_dir, "chore: seed") # Import into Muse _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) # Add a Muse-native file (muse_dir / "muse_native.py").write_text("# created in muse\ndef answer(): return 42\n") _invoke("code", "add", ".", cwd=muse_dir) _invoke("commit", "-m", "feat: muse-native module", "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign", cwd=muse_dir) # Export to fresh git target git_target.mkdir() subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_target) _git("config", "user.name", "Test", cwd=git_target) (git_target / "init.txt").write_text("x") subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_target), "commit", "-m", "init"], check=True, capture_output=True, env=_git_env(), ) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--git-branch", "main", "--no-push", cwd=muse_dir) # muse_native.py must exist in the git export assert (git_target / "muse_native.py").exists(), ( "muse_native.py missing from git target after export" ) # Re-import the exported git repo into a fresh Muse repo _make_muse_repo(muse_dir2) _invoke("bridge", "git-import", str(git_target), "--target", str(muse_dir2), cwd=muse_dir2) _muse_checkout(muse_dir2) assert (muse_dir2 / "muse_native.py").exists(), ( "muse_native.py did not survive git-export → git-import cycle" ) def test_muse_modification_overwrites_in_git( self, tmp_path: pathlib.Path ) -> None: """Modifying an imported file in Muse must overwrite it in git export.""" git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" git_target = tmp_path / "git_out" git_dir.mkdir() subprocess.run(["git", "init", str(git_dir)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_dir) _git("config", "user.name", "Test", cwd=git_dir) _write_files(git_dir, {"config.py": "VERSION = '1.0'\n"}) _git_commit(git_dir, "chore: initial config") _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) # Modify config.py in Muse (muse_dir / "config.py").write_text("VERSION = '2.0'\n") _invoke("code", "add", ".", cwd=muse_dir) _invoke("commit", "-m", "chore: bump version to 2.0", "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign", cwd=muse_dir) # Export git_target.mkdir() subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_target) _git("config", "user.name", "Test", cwd=git_target) (git_target / "init.txt").write_text("x") subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_target), "commit", "-m", "init"], check=True, capture_output=True, env=_git_env(), ) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) content = (git_target / "config.py").read_text() assert "2.0" in content, ( f"Expected VERSION = '2.0' in exported git repo, got: {content!r}" ) # --------------------------------------------------------------------------- # Tier 7 — Data Integrity (SHA-256 end-to-end) # --------------------------------------------------------------------------- class TestDataIntegrity: """File content must be byte-for-byte identical through the full bridge cycle.""" def test_binary_ish_file_sha256_preserved_through_import( self, tmp_path: pathlib.Path ) -> None: """A file with non-ASCII bytes must arrive in Muse with identical content.""" git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" git_dir.mkdir() subprocess.run(["git", "init", str(git_dir)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_dir) _git("config", "user.name", "Test", cwd=git_dir) # Write a file with high-byte content payload = bytes(range(256)) * 4 (git_dir / "data.bin").write_bytes(payload) subprocess.run(["git", "-C", str(git_dir), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_dir), "commit", "-m", "chore: binary payload"], check=True, capture_output=True, env=_git_env(), ) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) # Populate working tree before reading disk _muse_checkout(muse_dir) imported = (muse_dir / "data.bin").read_bytes() assert imported == payload, ( f"Binary content mismatch: expected {len(payload)} bytes, " f"got {len(imported)} bytes" ) def test_text_file_sha256_preserved_import_export( self, tmp_path: pathlib.Path ) -> None: """Text file SHA-256 must match through git→muse→git.""" git_source = tmp_path / "git_src" muse_dir = tmp_path / "muse" git_target = tmp_path / "git_dst" _build_complex_git_repo(git_source) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir), cwd=muse_dir) original_sha = _git_file_sha256(git_source, "src/core.py") git_target.mkdir() subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_target) _git("config", "user.name", "Test", cwd=git_target) (git_target / "init.txt").write_text("x") subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_target), "commit", "-m", "init"], check=True, capture_output=True, env=_git_env(), ) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) exported_sha = _git_file_sha256(git_target, "src/core.py") assert exported_sha == original_sha, ( f"src/core.py SHA-256 mismatch: original={original_sha[:16]}… " f"exported={exported_sha[:16]}…" ) def test_readme_content_byte_for_byte(self, tmp_path: pathlib.Path) -> None: """README.md content must survive import untouched.""" git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) original = (git_dir / "README.md").read_bytes() _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) _muse_checkout(muse_dir) imported = (muse_dir / "README.md").read_bytes() assert imported == original, "README.md content changed during import" def test_no_phantom_files_after_import(self, tmp_path: pathlib.Path) -> None: """Muse working tree must not contain files that were never in git. After a checkout, every file on disk (excluding .muse/) must have been present in the source git repo. """ git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) git_files = set() for f in git_dir.rglob("*"): if f.is_file() and ".git" not in f.parts: git_files.add(f.relative_to(git_dir)) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) _muse_checkout(muse_dir) # Allow Muse-generated init files (.museattributes, .museignore) to # be present — these are created by `muse init` and `muse checkout`. muse_generated = {pathlib.Path(".museattributes"), pathlib.Path(".museignore")} for f in muse_dir.rglob("*"): if f.is_file() and ".muse" not in f.parts: rel = f.relative_to(muse_dir) if rel in muse_generated: continue assert rel in git_files, ( f"Phantom file in Muse after import: {rel}" ) # --------------------------------------------------------------------------- # Tier 8 — Drift Tracking # --------------------------------------------------------------------------- class TestDriftTracking: """``muse bridge git-status`` drift counters are accurate after each operation.""" def test_status_shows_zero_drift_after_fresh_import( self, tmp_path: pathlib.Path ) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) r = _invoke("bridge", "git-status", "--git-dir", str(git_dir), "--json", cwd=muse_dir) assert r.exit_code == 0, f"git-status failed: {r.stderr}" data = json.loads(r.output.strip()) # Drift is nested under the "drift" key in the JSON output drift = data.get("drift", {}) assert drift.get("git_commits_since_import") == 0, ( f"Expected 0 git commits since import, got drift={drift}" ) def test_status_detects_new_git_commits_as_drift( self, tmp_path: pathlib.Path ) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) # Add a new commit to git (without importing) _write_files(git_dir, {"drift.txt": "new git commit\n"}) _git_commit(git_dir, "chore: drift commit") r = _invoke("bridge", "git-status", "--git-dir", str(git_dir), "--json", cwd=muse_dir) assert r.exit_code == 0, r.stderr data = json.loads(r.output.strip()) drift = data.get("drift", {}) assert drift.get("git_commits_since_import") == 1, ( f"Expected 1 git commit of drift, got drift={drift}" ) def test_status_detects_muse_only_commits_as_export_drift( self, tmp_path: pathlib.Path ) -> None: """Muse commits after a git-export baseline appear as export drift. We first export to establish the baseline (last_export), then commit a Muse-native change without re-exporting, so the drift counter shows 1. """ git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" git_target = tmp_path / "git_out" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) # Establish export baseline git_target.mkdir() subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True) _git("config", "user.email", "test@example.com", cwd=git_target) _git("config", "user.name", "Test", cwd=git_target) (git_target / "init.txt").write_text("x") subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True) subprocess.run( ["git", "-C", str(git_target), "commit", "-m", "init"], check=True, capture_output=True, env=_git_env(), ) _invoke("bridge", "git-export", "--git-dir", str(git_target), "--no-push", cwd=muse_dir) # Add a Muse commit after the export baseline (muse_dir / "muse_drift.txt").write_text("muse only\n") _invoke("code", "add", ".", cwd=muse_dir) _invoke("commit", "-m", "chore: muse-only drift", "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign", cwd=muse_dir) r = _invoke("bridge", "git-status", "--git-dir", str(git_dir), "--json", cwd=muse_dir) assert r.exit_code == 0, r.stderr data = json.loads(r.output.strip()) drift = data.get("drift", {}) assert (drift.get("muse_commits_since_export") or 0) >= 1, ( f"Expected ≥1 Muse commit of export drift, got drift={drift}" ) def test_status_shows_last_import_sha_in_state( self, tmp_path: pathlib.Path ) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) r = _invoke("bridge", "git-status", "--git-dir", str(git_dir), "--json", cwd=muse_dir) assert r.exit_code == 0, r.stderr data = json.loads(r.output.strip()) # last_import is a top-level key in the git-status JSON output li = data.get("last_import", {}) assert li.get("git_sha", "") != "", ( f"last_import.git_sha empty after import: {data}" ) def test_status_text_mode_shows_drift_section( self, tmp_path: pathlib.Path ) -> None: git_dir = tmp_path / "git" muse_dir = tmp_path / "muse" _build_complex_git_repo(git_dir) _make_muse_repo(muse_dir) _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir) r = _invoke("bridge", "git-status", "--git-dir", str(git_dir), cwd=muse_dir) assert r.exit_code == 0, r.stderr # Human-readable output must mention drift assert "Drift" in r.output or "commit" in r.output.lower(), ( f"Expected Drift section in text output:\n{r.output}" )