gabriel / muse public
test_bridge_roundtrip.py python
1,202 lines 46.4 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago
1 """End-to-end bidirectional roundtrip tests for ``muse bridge``.
2
3 This module creates a realistic, *complex* git repository from scratch —
4 multiple branches, realistic file trees, multiple authors, conventional commit
5 messages, semver tags — then exercises the full bridge cycle:
6
7 Git → (git-import) → Muse → (git-export) → Git
8
9 Each direction is verified for content fidelity, commit count, and bridge
10 state consistency. A second pass adds new commits in git and re-imports
11 (incremental), then adds Muse-native commits and re-exports, confirming that
12 the bridge correctly accumulates history on both sides without duplication.
13
14 Test organisation (eight tiers):
15
16 Tier 1 — Fixture Sanity the git repo we build is what we think it is
17 Tier 2 — Full Import git→muse, all content arrives correctly
18 Tier 3 — Full Export muse→git, Muse commits visible in git
19 Tier 4 — Bidirectional import then export then re-import cycle
20 Tier 5 — Incremental new git commits → incremental import
21 Tier 6 — Muse-Native Round Muse-only commits survive git-export
22 Tier 7 — Data Integrity file content SHA-256 match end-to-end
23 Tier 8 — Drift Tracking bridge status drift counters are accurate
24
25 NOTE: ``git`` subprocess calls in this file are INTENTIONAL and necessary — the
26 bridge command translates *from* git repos. All other Muse code paths are
27 git-free. See ``docs/bridge-ci.md`` for CI requirements.
28 """
29
30 from __future__ import annotations
31
32 import hashlib
33 import json
34 import os
35 import pathlib
36 import subprocess
37 import textwrap
38 import time
39 from collections.abc import Mapping
40
41 import pytest
42
43 from tests.cli_test_helper import CliRunner
44 from muse.core.paths import git_bridge_state_path
45
46 type _StrEnv = dict[str, str]
47 type _ShaMap = dict[str, str]
48
49 runner = CliRunner()
50
51 # ---------------------------------------------------------------------------
52 # Fixture helpers
53 # ---------------------------------------------------------------------------
54
55
56 def _git(*args: str, cwd: pathlib.Path, check: bool = True) -> str:
57 """Run a git command in *cwd* and return decoded stdout."""
58 result = subprocess.run(
59 ["git", "-C", str(cwd), *args],
60 capture_output=True,
61 check=False,
62 )
63 if check and result.returncode != 0:
64 raise RuntimeError(
65 f"git {' '.join(args)} failed:\n{result.stderr.decode()}"
66 )
67 return result.stdout.decode()
68
69
70 def _git_env(**extra: str) -> _StrEnv:
71 """Return an environment dict with git author/committer set."""
72 return {
73 **os.environ,
74 "GIT_AUTHOR_EMAIL": extra.get("email", "[email protected]"),
75 "GIT_AUTHOR_NAME": extra.get("name", "Test User"),
76 "GIT_COMMITTER_EMAIL": extra.get("email", "[email protected]"),
77 "GIT_COMMITTER_NAME": extra.get("name", "Test User"),
78 }
79
80
81 def _git_commit(repo: pathlib.Path, message: str, **author_kw: str) -> str:
82 """Stage all changes and commit; return the new SHA."""
83 subprocess.run(
84 ["git", "-C", str(repo), "add", "--all"],
85 check=True, capture_output=True,
86 )
87 subprocess.run(
88 ["git", "-C", str(repo), "commit", "-m", message],
89 check=True, capture_output=True, env=_git_env(**author_kw),
90 )
91 return _git("log", "--format=%H", "-1", cwd=repo).strip()
92
93
94 def _write_files(repo: pathlib.Path, files: Mapping[str, str]) -> None:
95 """Write *files* (rel_path → content) into *repo*, creating parents."""
96 for rel, content in files.items():
97 full = repo / rel
98 full.parent.mkdir(parents=True, exist_ok=True)
99 full.write_text(content, encoding="utf-8")
100
101
102 def _build_complex_git_repo(path: pathlib.Path) -> _ShaMap:
103 """Build a multi-branch git repo that exercises all bridge features.
104
105 Returns a dict mapping label → git SHA for later assertions.
106
107 Layout
108 ------
109 main:
110 sha['init'] — repo skeleton, conventional commit (feat:)
111 sha['v01'] — semver tag v0.1.0
112 sha['fix1'] — fix: commit (patch bump signal)
113 sha['v10'] — merge of feature branch + tag v1.0.0
114 feature/auth:
115 sha['auth1'] — feat: add auth module
116 sha['auth2'] — feat!: breaking API change (major bump signal)
117
118 Authors: two different emails to exercise AttributionMapper fallback.
119 """
120 path.mkdir(parents=True, exist_ok=True)
121 subprocess.run(["git", "init", str(path)], check=True, capture_output=True)
122 _git("config", "user.email", "[email protected]", cwd=path)
123 _git("config", "user.name", "Gabriel", cwd=path)
124
125 shas: dict[str, str] = {}
126
127 # ── main: initial scaffold ──────────────────────────────────────────────
128 _write_files(path, {
129 "README.md": textwrap.dedent("""\
130 # My Project
131
132 A demonstration repository for the Muse bridge roundtrip tests.
133 """),
134 "src/__init__.py": '"""My project."""\n__version__ = "0.1.0"\n',
135 "src/core.py": textwrap.dedent("""\
136 \"\"\"Core logic.\"\"\"
137
138
139 def greet(name: str) -> str:
140 \"\"\"Return a greeting string.\"\"\"
141 return f"Hello, {name}!"
142 """),
143 "tests/test_core.py": textwrap.dedent("""\
144 from src.core import greet
145
146
147 def test_greet() -> None:
148 assert greet("world") == "Hello, world!"
149 """),
150 "pyproject.toml": "[project]\nname = \"my-project\"\nversion = \"0.1.0\"\n",
151 ".gitignore": "__pycache__/\n*.pyc\n.venv/\ndist/\n",
152 })
153 shas["init"] = _git_commit(path, "feat: initial project scaffold")
154
155 # ── tag v0.1.0 ─────────────────────────────────────────────────────────
156 subprocess.run(
157 ["git", "-C", str(path), "tag", "-a", "v0.1.0", "-m", "Release v0.1.0"],
158 check=True, capture_output=True,
159 )
160 shas["v01"] = shas["init"]
161
162 # ── feature/auth branch ────────────────────────────────────────────────
163 _git("checkout", "-b", "feature/auth", cwd=path)
164
165 _write_files(path, {
166 "src/auth.py": textwrap.dedent("""\
167 \"\"\"Authentication helpers.\"\"\"
168 from typing import Optional
169
170
171 def login(username: str, password: str) -> Optional[str]:
172 \"\"\"Return a session token or None on failure.\"\"\"
173 if username == "admin" and password == "secret":
174 return "tok-admin"
175 return None
176 """),
177 "tests/test_auth.py": textwrap.dedent("""\
178 from src.auth import login
179
180
181 def test_login_success() -> None:
182 assert login("admin", "secret") == "tok-admin"
183
184
185 def test_login_failure() -> None:
186 assert login("user", "wrong") is None
187 """),
188 })
189 shas["auth1"] = _git_commit(
190 path, "feat: add authentication module",
191 email="[email protected]", name="Alice Dev",
192 )
193
194 # Breaking API change on feature branch
195 _write_files(path, {
196 "src/auth.py": textwrap.dedent("""\
197 \"\"\"Authentication helpers — v2 API.\"\"\"\n
198 class AuthError(Exception):
199 pass
200
201
202 def login(username: str, password: str) -> str:
203 \"\"\"Return session token; raises AuthError on failure.\"\"\"
204 if username == "admin" and password == "secret":
205 return "tok-admin"
206 raise AuthError(f"Invalid credentials for {username!r}")
207 """),
208 "tests/test_auth.py": textwrap.dedent("""\
209 import pytest
210 from src.auth import login, AuthError
211
212
213 def test_login_success() -> None:
214 assert login("admin", "secret") == "tok-admin"
215
216
217 def test_login_failure_raises() -> None:
218 with pytest.raises(AuthError):
219 login("user", "wrong")
220 """),
221 })
222 shas["auth2"] = _git_commit(
223 path,
224 "feat!: auth login now raises AuthError instead of returning None\n\nBREAKING CHANGE: callers must catch AuthError",
225 email="[email protected]", name="Alice Dev",
226 )
227
228 # ── back to main: fix commit ────────────────────────────────────────────
229 _git("checkout", "main", cwd=path)
230 _write_files(path, {"src/core.py": textwrap.dedent("""\
231 \"\"\"Core logic.\"\"\"
232
233
234 def greet(name: str) -> str:
235 \"\"\"Return a greeting string.\"\"\"
236 return f"Hello, {name}!"
237
238
239 def farewell(name: str) -> str:
240 \"\"\"Return a farewell string.\"\"\"
241 return f"Goodbye, {name}!"
242 """)})
243 shas["fix1"] = _git_commit(path, "fix: add missing farewell() function")
244
245 # ── merge feature/auth → main ──────────────────────────────────────────
246 subprocess.run(
247 ["git", "-C", str(path), "merge", "--no-ff", "feature/auth", "-m",
248 "feat: merge auth module into main"],
249 check=True, capture_output=True, env=_git_env(),
250 )
251 shas["v10"] = _git("log", "--format=%H", "-1", cwd=path).strip()
252
253 # Tag the merge commit as v1.0.0
254 subprocess.run(
255 ["git", "-C", str(path), "tag", "-a", "v1.0.0", "-m", "Release v1.0.0"],
256 check=True, capture_output=True,
257 )
258
259 return shas
260
261
262 def _invoke(*args: str, cwd: pathlib.Path | None = None) -> "CliRunner":
263 """Invoke the muse CLI."""
264 return runner.invoke(None, list(args), cwd=cwd)
265
266
267 def _make_muse_repo(path: pathlib.Path) -> pathlib.Path:
268 """Initialise an empty Muse repo at *path*."""
269 path.mkdir(parents=True, exist_ok=True)
270 r = _invoke("init", cwd=path)
271 assert r.exit_code == 0, f"muse init failed: {r.stderr}"
272 return path
273
274
275 def _muse_checkout(muse_dir: pathlib.Path, branch: str = "main") -> None:
276 """Checkout *branch* in the Muse repo to populate the working tree.
277
278 ``git-import`` writes commits to the Muse object store but does not
279 populate the working tree. The working tree will show imported files as
280 "deleted" (in snapshot, not on disk), so ``--force`` is required to let
281 checkout overwrite those staged deletions and restore the snapshot.
282 """
283 r = _invoke("checkout", "--force", branch, cwd=muse_dir)
284 if r.exit_code != 0:
285 # Try master as a fallback (some git repos default to master)
286 _invoke("checkout", "--force", "master", cwd=muse_dir)
287
288
289 def _muse_log(muse_dir: pathlib.Path) -> list[dict]:
290 r = _invoke("log", "--json", cwd=muse_dir)
291 if r.exit_code != 0:
292 return []
293 try:
294 return json.loads(r.output.strip()).get("commits", [])
295 except json.JSONDecodeError:
296 return []
297
298
299 def _muse_branches(muse_dir: pathlib.Path) -> list[str]:
300 r = _invoke("branch", "--json", cwd=muse_dir)
301 if r.exit_code != 0:
302 return []
303 try:
304 data = json.loads(r.output.strip())
305 if isinstance(data, list):
306 return [b["name"] for b in data]
307 except (json.JSONDecodeError, KeyError):
308 pass
309 return []
310
311
312 def _git_log_count(git_dir: pathlib.Path, ref: str = "HEAD") -> int:
313 result = subprocess.run(
314 ["git", "-C", str(git_dir), "rev-list", "--count", ref],
315 capture_output=True,
316 )
317 return int(result.stdout.decode().strip()) if result.returncode == 0 else 0
318
319
320 def _git_file_sha256(git_dir: pathlib.Path, rel: str) -> str:
321 data = (git_dir / rel).read_bytes()
322 return hashlib.sha256(data).hexdigest()
323
324
325 # ---------------------------------------------------------------------------
326 # Tier 1 — Fixture Sanity
327 # ---------------------------------------------------------------------------
328
329
330 class TestFixtureSanity:
331 """Verify that _build_complex_git_repo produces what we expect."""
332
333 def test_main_branch_has_at_least_4_commits(self, tmp_path: pathlib.Path) -> None:
334 repo = tmp_path / "git"
335 _build_complex_git_repo(repo)
336 count = _git_log_count(repo)
337 assert count >= 4, f"Expected ≥4 commits on main, got {count}"
338
339 def test_feature_auth_branch_exists(self, tmp_path: pathlib.Path) -> None:
340 repo = tmp_path / "git"
341 _build_complex_git_repo(repo)
342 branches_raw = _git("branch", "--list", cwd=repo)
343 assert "feature/auth" in branches_raw
344
345 def test_v010_tag_exists(self, tmp_path: pathlib.Path) -> None:
346 repo = tmp_path / "git"
347 _build_complex_git_repo(repo)
348 tags = _git("tag", "--list", cwd=repo)
349 assert "v0.1.0" in tags
350
351 def test_v100_tag_exists(self, tmp_path: pathlib.Path) -> None:
352 repo = tmp_path / "git"
353 _build_complex_git_repo(repo)
354 tags = _git("tag", "--list", cwd=repo)
355 assert "v1.0.0" in tags
356
357 def test_src_auth_py_exists_on_main(self, tmp_path: pathlib.Path) -> None:
358 repo = tmp_path / "git"
359 _build_complex_git_repo(repo)
360 assert (repo / "src" / "auth.py").exists()
361
362 def test_two_authors_in_git_history(self, tmp_path: pathlib.Path) -> None:
363 repo = tmp_path / "git"
364 _build_complex_git_repo(repo)
365 log = _git("log", "--format=%ae", "--all", cwd=repo)
366 emails = {e.strip() for e in log.splitlines() if e.strip()}
367 assert len(emails) >= 2, f"Expected ≥2 author emails, got: {emails}"
368
369 def test_breaking_change_commit_present(self, tmp_path: pathlib.Path) -> None:
370 repo = tmp_path / "git"
371 _build_complex_git_repo(repo)
372 log = _git("log", "--all", "--format=%s", cwd=repo)
373 assert "BREAKING CHANGE" in log or "feat!" in log
374
375
376 # ---------------------------------------------------------------------------
377 # Tier 2 — Full Import (git → Muse)
378 # ---------------------------------------------------------------------------
379
380
381 class TestFullImport:
382 """Complete git→muse import from the complex fixture."""
383
384 def test_import_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
385 git_dir = tmp_path / "git"
386 muse_dir = tmp_path / "muse"
387 _build_complex_git_repo(git_dir)
388 _make_muse_repo(muse_dir)
389
390 r = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
391 cwd=muse_dir)
392 assert r.exit_code == 0, f"import failed:\n{r.output}\n{r.stderr}"
393
394 def test_import_creates_muse_commits(self, tmp_path: pathlib.Path) -> None:
395 git_dir = tmp_path / "git"
396 muse_dir = tmp_path / "muse"
397 _build_complex_git_repo(git_dir)
398 _make_muse_repo(muse_dir)
399
400 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
401 cwd=muse_dir)
402
403 commits = _muse_log(muse_dir)
404 assert len(commits) >= 3, f"Expected ≥3 Muse commits after import, got {len(commits)}"
405
406 def test_import_all_branches_imports_feature_branch(
407 self, tmp_path: pathlib.Path
408 ) -> None:
409 git_dir = tmp_path / "git"
410 muse_dir = tmp_path / "muse"
411 _build_complex_git_repo(git_dir)
412 _make_muse_repo(muse_dir)
413
414 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
415 "--all", cwd=muse_dir)
416
417 branches = _muse_branches(muse_dir)
418 # main or master plus feature/auth should exist after --all import
419 assert len(branches) >= 2, f"Expected ≥2 branches after --all import: {branches}"
420
421 def test_import_commit_messages_preserved(self, tmp_path: pathlib.Path) -> None:
422 git_dir = tmp_path / "git"
423 muse_dir = tmp_path / "muse"
424 _build_complex_git_repo(git_dir)
425 _make_muse_repo(muse_dir)
426
427 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
428 cwd=muse_dir)
429
430 commits = _muse_log(muse_dir)
431 messages = [c.get("message", "") for c in commits]
432 assert any("feat" in m for m in messages), (
433 f"No conventional-commit message found in: {messages}"
434 )
435
436 def test_import_src_files_tracked(self, tmp_path: pathlib.Path) -> None:
437 git_dir = tmp_path / "git"
438 muse_dir = tmp_path / "muse"
439 _build_complex_git_repo(git_dir)
440 _make_muse_repo(muse_dir)
441
442 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
443 cwd=muse_dir)
444 # git-import writes to the object store only; checkout populates the tree
445 _muse_checkout(muse_dir)
446
447 assert (muse_dir / "src" / "core.py").exists(), (
448 "src/core.py not found in Muse working tree after import + checkout"
449 )
450
451 def test_import_auth_module_tracked(self, tmp_path: pathlib.Path) -> None:
452 git_dir = tmp_path / "git"
453 muse_dir = tmp_path / "muse"
454 _build_complex_git_repo(git_dir)
455 _make_muse_repo(muse_dir)
456
457 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
458 cwd=muse_dir)
459 _muse_checkout(muse_dir)
460
461 assert (muse_dir / "src" / "auth.py").exists(), (
462 "src/auth.py not found in Muse working tree after import + checkout"
463 )
464
465 def test_import_readme_content_matches(self, tmp_path: pathlib.Path) -> None:
466 git_dir = tmp_path / "git"
467 muse_dir = tmp_path / "muse"
468 _build_complex_git_repo(git_dir)
469 _make_muse_repo(muse_dir)
470
471 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
472 cwd=muse_dir)
473 _muse_checkout(muse_dir)
474
475 muse_readme = (muse_dir / "README.md").read_text(encoding="utf-8")
476 assert "My Project" in muse_readme
477
478 def test_import_writes_bridge_state(self, tmp_path: pathlib.Path) -> None:
479 git_dir = tmp_path / "git"
480 muse_dir = tmp_path / "muse"
481 _build_complex_git_repo(git_dir)
482 _make_muse_repo(muse_dir)
483
484 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
485 cwd=muse_dir)
486
487 state_file = git_bridge_state_path(muse_dir)
488 assert state_file.exists(), "git-bridge.toml not written after import"
489 content = state_file.read_text()
490 assert "git_sha" in content
491
492 def test_import_git_dir_excluded(self, tmp_path: pathlib.Path) -> None:
493 git_dir = tmp_path / "git"
494 muse_dir = tmp_path / "muse"
495 _build_complex_git_repo(git_dir)
496 _make_muse_repo(muse_dir)
497
498 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
499 cwd=muse_dir)
500
501 # .git directory must never appear in Muse working tree
502 assert not (muse_dir / ".git").exists(), (
503 ".git directory leaked into Muse working tree"
504 )
505
506
507 # ---------------------------------------------------------------------------
508 # Tier 3 — Full Export (Muse → Git)
509 # ---------------------------------------------------------------------------
510
511
512 class TestFullExport:
513 """Import complex git repo into Muse, then export Muse back to a git repo."""
514
515 def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path, pathlib.Path]:
516 """Return (git_source, muse_dir, git_target)."""
517 git_source = tmp_path / "git_source"
518 muse_dir = tmp_path / "muse"
519 git_target = tmp_path / "git_target"
520
521 _build_complex_git_repo(git_source)
522 _make_muse_repo(muse_dir)
523 _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir),
524 cwd=muse_dir)
525
526 # Create empty git target for export
527 git_target.mkdir()
528 subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True)
529 _git("config", "user.email", "[email protected]", cwd=git_target)
530 _git("config", "user.name", "Test", cwd=git_target)
531 (git_target / "README.md").write_text("init")
532 subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True)
533 subprocess.run(
534 ["git", "-C", str(git_target), "commit", "-m", "init"],
535 check=True, capture_output=True, env=_git_env(),
536 )
537
538 return git_source, muse_dir, git_target
539
540 def test_export_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
541 _, muse_dir, git_target = self._setup(tmp_path)
542
543 r = _invoke("bridge", "git-export",
544 "--git-dir", str(git_target), "--no-push", "--json",
545 cwd=muse_dir)
546 assert r.exit_code == 0, f"export failed:\n{r.output}\n{r.stderr}"
547
548 def test_export_produces_git_commit(self, tmp_path: pathlib.Path) -> None:
549 _, muse_dir, git_target = self._setup(tmp_path)
550
551 initial_count = _git_log_count(git_target)
552 _invoke("bridge", "git-export",
553 "--git-dir", str(git_target), "--no-push",
554 cwd=muse_dir)
555
556 new_count = _git_log_count(git_target)
557 assert new_count > initial_count, (
558 f"git log count did not increase: {initial_count} → {new_count}"
559 )
560
561 def test_export_git_message_references_muse_commit(
562 self, tmp_path: pathlib.Path
563 ) -> None:
564 _, muse_dir, git_target = self._setup(tmp_path)
565
566 _invoke("bridge", "git-export",
567 "--git-dir", str(git_target), "--no-push",
568 cwd=muse_dir)
569
570 msg = _git("log", "--format=%s", "-1", cwd=git_target).strip()
571 assert msg.startswith("mirror: muse sha256:"), (
572 f"Unexpected git commit message: {msg!r}"
573 )
574
575 def test_export_src_files_appear_in_git(self, tmp_path: pathlib.Path) -> None:
576 _, muse_dir, git_target = self._setup(tmp_path)
577
578 _invoke("bridge", "git-export",
579 "--git-dir", str(git_target), "--no-push",
580 cwd=muse_dir)
581
582 assert (git_target / "src" / "core.py").exists(), (
583 "src/core.py missing in git target after export"
584 )
585
586 def test_export_auth_module_appears_in_git(self, tmp_path: pathlib.Path) -> None:
587 _, muse_dir, git_target = self._setup(tmp_path)
588
589 _invoke("bridge", "git-export",
590 "--git-dir", str(git_target), "--no-push",
591 cwd=muse_dir)
592
593 assert (git_target / "src" / "auth.py").exists(), (
594 "src/auth.py missing in git target after export"
595 )
596
597 def test_export_muse_dir_not_in_git(self, tmp_path: pathlib.Path) -> None:
598 _, muse_dir, git_target = self._setup(tmp_path)
599
600 _invoke("bridge", "git-export",
601 "--git-dir", str(git_target), "--no-push",
602 cwd=muse_dir)
603
604 tracked = _git("ls-tree", "-r", "--name-only", "HEAD", cwd=git_target)
605 assert ".muse/" not in tracked and not any(
606 line.startswith(".muse/") for line in tracked.splitlines()
607 ), ".muse/ directory leaked into git export"
608
609 def test_export_updates_bridge_state_last_export(
610 self, tmp_path: pathlib.Path
611 ) -> None:
612 _, muse_dir, git_target = self._setup(tmp_path)
613
614 _invoke("bridge", "git-export",
615 "--git-dir", str(git_target), "--no-push",
616 cwd=muse_dir)
617
618 state_file = git_bridge_state_path(muse_dir)
619 content = state_file.read_text()
620 assert "last_export" in content
621
622 def test_export_json_contains_git_sha(self, tmp_path: pathlib.Path) -> None:
623 _, muse_dir, git_target = self._setup(tmp_path)
624
625 r = _invoke("bridge", "git-export",
626 "--git-dir", str(git_target), "--no-push", "--json",
627 cwd=muse_dir)
628 assert r.exit_code == 0, r.stderr
629 data = json.loads(r.output.strip())
630 assert data.get("git_sha", "") != "", "json output missing git_sha"
631
632 def test_export_idempotent_no_duplicate_commit(
633 self, tmp_path: pathlib.Path
634 ) -> None:
635 _, muse_dir, git_target = self._setup(tmp_path)
636
637 _invoke("bridge", "git-export",
638 "--git-dir", str(git_target), "--no-push", cwd=muse_dir)
639 count_after_first = _git_log_count(git_target)
640
641 # Second export — same Muse HEAD, no new commits
642 _invoke("bridge", "git-export",
643 "--git-dir", str(git_target), "--no-push", cwd=muse_dir)
644 count_after_second = _git_log_count(git_target)
645
646 assert count_after_second == count_after_first, (
647 f"Idempotent export produced a new git commit: "
648 f"{count_after_first} → {count_after_second}"
649 )
650
651
652 # ---------------------------------------------------------------------------
653 # Tier 4 — Bidirectional Cycle
654 # ---------------------------------------------------------------------------
655
656
657 class TestBidirectionalCycle:
658 """Full cycle: git→muse→git, verifying state at each step."""
659
660 def test_full_cycle_state_consistent(self, tmp_path: pathlib.Path) -> None:
661 """State file must reference both last_import and last_export after cycle."""
662 git_source = tmp_path / "git"
663 muse_dir = tmp_path / "muse"
664 git_target = tmp_path / "git_out"
665
666 _build_complex_git_repo(git_source)
667 _make_muse_repo(muse_dir)
668
669 # Import
670 r = _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir),
671 cwd=muse_dir)
672 assert r.exit_code == 0, r.stderr
673
674 # Set up a target git repo
675 git_target.mkdir()
676 subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True)
677 _git("config", "user.email", "[email protected]", cwd=git_target)
678 _git("config", "user.name", "Test", cwd=git_target)
679 (git_target / "init.txt").write_text("x")
680 subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True)
681 subprocess.run(
682 ["git", "-C", str(git_target), "commit", "-m", "init"],
683 check=True, capture_output=True, env=_git_env(),
684 )
685
686 # Export
687 r = _invoke("bridge", "git-export",
688 "--git-dir", str(git_target), "--no-push", cwd=muse_dir)
689 assert r.exit_code == 0, r.stderr
690
691 state = (git_bridge_state_path(muse_dir)).read_text()
692 assert "last_import" in state
693 assert "last_export" in state
694 assert "git_sha" in state
695
696 def test_muse_commits_visible_after_import_then_export(
697 self, tmp_path: pathlib.Path
698 ) -> None:
699 """Muse commits created after import must appear in the subsequent git export."""
700 git_source = tmp_path / "git"
701 muse_dir = tmp_path / "muse"
702 git_target = tmp_path / "git_out"
703
704 _build_complex_git_repo(git_source)
705 _make_muse_repo(muse_dir)
706
707 _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir),
708 cwd=muse_dir)
709
710 # Make a Muse-native commit (after import)
711 (muse_dir / "muse_only.txt").write_text("created in muse\n")
712 _invoke("code", "add", ".", cwd=muse_dir)
713 _invoke("commit", "-m", "chore: muse-native file",
714 "--agent-id", "claude-code",
715 "--model-id", "claude-sonnet-4-6",
716 "--sign",
717 cwd=muse_dir)
718
719 # Export to git
720 git_target.mkdir()
721 subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True)
722 _git("config", "user.email", "[email protected]", cwd=git_target)
723 _git("config", "user.name", "Test", cwd=git_target)
724 (git_target / "init.txt").write_text("x")
725 subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True)
726 subprocess.run(
727 ["git", "-C", str(git_target), "commit", "-m", "init"],
728 check=True, capture_output=True, env=_git_env(),
729 )
730 _invoke("bridge", "git-export",
731 "--git-dir", str(git_target), "--no-push", cwd=muse_dir)
732
733 # The muse-native file must appear in the git working tree after export
734 assert (git_target / "muse_only.txt").exists(), (
735 "muse_only.txt not exported to git after Muse-native commit"
736 )
737
738
739 # ---------------------------------------------------------------------------
740 # Tier 5 — Incremental Import
741 # ---------------------------------------------------------------------------
742
743
744 class TestIncrementalImport:
745 """New git commits added after initial import → incremental re-import."""
746
747 def test_incremental_import_adds_new_commits(
748 self, tmp_path: pathlib.Path
749 ) -> None:
750 git_dir = tmp_path / "git"
751 muse_dir = tmp_path / "muse"
752
753 _build_complex_git_repo(git_dir)
754 _make_muse_repo(muse_dir)
755
756 # Initial full import
757 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
758 cwd=muse_dir)
759 count_before = len(_muse_log(muse_dir))
760
761 # Add 2 more commits to git
762 _write_files(git_dir, {"new_feature.py": "def hello(): pass\n"})
763 _git_commit(git_dir, "feat: add hello stub")
764 _write_files(git_dir, {"new_feature.py": "def hello(): return 'hi'\n"})
765 _git_commit(git_dir, "fix: implement hello")
766
767 # Incremental import
768 r = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
769 "--incremental", cwd=muse_dir)
770 assert r.exit_code == 0, f"incremental import failed:\n{r.output}\n{r.stderr}"
771
772 count_after = len(_muse_log(muse_dir))
773 assert count_after == count_before + 2, (
774 f"Expected {count_before + 2} commits after incremental import, "
775 f"got {count_after}"
776 )
777
778 def test_incremental_import_no_duplicates(
779 self, tmp_path: pathlib.Path
780 ) -> None:
781 git_dir = tmp_path / "git"
782 muse_dir = tmp_path / "muse"
783
784 _build_complex_git_repo(git_dir)
785 _make_muse_repo(muse_dir)
786
787 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
788 cwd=muse_dir)
789 count_after_first = len(_muse_log(muse_dir))
790
791 # Incremental import with no new git commits — should be a no-op
792 r = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
793 "--incremental", cwd=muse_dir)
794 assert r.exit_code == 0, r.stderr
795
796 count_after_noop = len(_muse_log(muse_dir))
797 assert count_after_noop == count_after_first, (
798 f"Incremental import on unchanged git repo added commits: "
799 f"{count_after_first} → {count_after_noop}"
800 )
801
802 def test_three_round_incremental_accumulates(
803 self, tmp_path: pathlib.Path
804 ) -> None:
805 """Three incremental imports each adding one commit accumulate correctly."""
806 git_dir = tmp_path / "git"
807 muse_dir = tmp_path / "muse"
808
809 _build_complex_git_repo(git_dir)
810 _make_muse_repo(muse_dir)
811 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
812 cwd=muse_dir)
813 base = len(_muse_log(muse_dir))
814
815 for i in range(3):
816 _write_files(git_dir, {f"round{i}.txt": f"round {i}\n"})
817 _git_commit(git_dir, f"chore: round {i} file")
818 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
819 "--incremental", cwd=muse_dir)
820
821 final = len(_muse_log(muse_dir))
822 assert final == base + 3, (
823 f"Expected {base + 3} commits after 3 incremental rounds, got {final}"
824 )
825
826
827 # ---------------------------------------------------------------------------
828 # Tier 6 — Muse-Native Round-Trip
829 # ---------------------------------------------------------------------------
830
831
832 class TestMuseNativeRoundTrip:
833 """Muse-native changes survive the git-export → git-import cycle."""
834
835 def test_muse_native_file_survives_export_then_reimport(
836 self, tmp_path: pathlib.Path
837 ) -> None:
838 """A file created in Muse, exported to git, then re-imported must still exist."""
839 git_dir = tmp_path / "git"
840 muse_dir = tmp_path / "muse"
841 git_target = tmp_path / "git_out"
842 muse_dir2 = tmp_path / "muse2"
843
844 # Seed git repo
845 git_dir.mkdir()
846 subprocess.run(["git", "init", str(git_dir)], check=True, capture_output=True)
847 _git("config", "user.email", "[email protected]", cwd=git_dir)
848 _git("config", "user.name", "Test", cwd=git_dir)
849 _write_files(git_dir, {"seed.py": "x = 1\n"})
850 _git_commit(git_dir, "chore: seed")
851
852 # Import into Muse
853 _make_muse_repo(muse_dir)
854 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
855 cwd=muse_dir)
856
857 # Add a Muse-native file
858 (muse_dir / "muse_native.py").write_text("# created in muse\ndef answer(): return 42\n")
859 _invoke("code", "add", ".", cwd=muse_dir)
860 _invoke("commit", "-m", "feat: muse-native module",
861 "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign",
862 cwd=muse_dir)
863
864 # Export to fresh git target
865 git_target.mkdir()
866 subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True)
867 _git("config", "user.email", "[email protected]", cwd=git_target)
868 _git("config", "user.name", "Test", cwd=git_target)
869 (git_target / "init.txt").write_text("x")
870 subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True)
871 subprocess.run(
872 ["git", "-C", str(git_target), "commit", "-m", "init"],
873 check=True, capture_output=True, env=_git_env(),
874 )
875 _invoke("bridge", "git-export",
876 "--git-dir", str(git_target), "--git-branch", "main",
877 "--no-push", cwd=muse_dir)
878
879 # muse_native.py must exist in the git export
880 assert (git_target / "muse_native.py").exists(), (
881 "muse_native.py missing from git target after export"
882 )
883
884 # Re-import the exported git repo into a fresh Muse repo
885 _make_muse_repo(muse_dir2)
886 _invoke("bridge", "git-import", str(git_target), "--target", str(muse_dir2),
887 cwd=muse_dir2)
888 _muse_checkout(muse_dir2)
889
890 assert (muse_dir2 / "muse_native.py").exists(), (
891 "muse_native.py did not survive git-export → git-import cycle"
892 )
893
894 def test_muse_modification_overwrites_in_git(
895 self, tmp_path: pathlib.Path
896 ) -> None:
897 """Modifying an imported file in Muse must overwrite it in git export."""
898 git_dir = tmp_path / "git"
899 muse_dir = tmp_path / "muse"
900 git_target = tmp_path / "git_out"
901
902 git_dir.mkdir()
903 subprocess.run(["git", "init", str(git_dir)], check=True, capture_output=True)
904 _git("config", "user.email", "[email protected]", cwd=git_dir)
905 _git("config", "user.name", "Test", cwd=git_dir)
906 _write_files(git_dir, {"config.py": "VERSION = '1.0'\n"})
907 _git_commit(git_dir, "chore: initial config")
908
909 _make_muse_repo(muse_dir)
910 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
911 cwd=muse_dir)
912
913 # Modify config.py in Muse
914 (muse_dir / "config.py").write_text("VERSION = '2.0'\n")
915 _invoke("code", "add", ".", cwd=muse_dir)
916 _invoke("commit", "-m", "chore: bump version to 2.0",
917 "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign",
918 cwd=muse_dir)
919
920 # Export
921 git_target.mkdir()
922 subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True)
923 _git("config", "user.email", "[email protected]", cwd=git_target)
924 _git("config", "user.name", "Test", cwd=git_target)
925 (git_target / "init.txt").write_text("x")
926 subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True)
927 subprocess.run(
928 ["git", "-C", str(git_target), "commit", "-m", "init"],
929 check=True, capture_output=True, env=_git_env(),
930 )
931 _invoke("bridge", "git-export",
932 "--git-dir", str(git_target), "--no-push", cwd=muse_dir)
933
934 content = (git_target / "config.py").read_text()
935 assert "2.0" in content, (
936 f"Expected VERSION = '2.0' in exported git repo, got: {content!r}"
937 )
938
939
940 # ---------------------------------------------------------------------------
941 # Tier 7 — Data Integrity (SHA-256 end-to-end)
942 # ---------------------------------------------------------------------------
943
944
945 class TestDataIntegrity:
946 """File content must be byte-for-byte identical through the full bridge cycle."""
947
948 def test_binary_ish_file_sha256_preserved_through_import(
949 self, tmp_path: pathlib.Path
950 ) -> None:
951 """A file with non-ASCII bytes must arrive in Muse with identical content."""
952 git_dir = tmp_path / "git"
953 muse_dir = tmp_path / "muse"
954
955 git_dir.mkdir()
956 subprocess.run(["git", "init", str(git_dir)], check=True, capture_output=True)
957 _git("config", "user.email", "[email protected]", cwd=git_dir)
958 _git("config", "user.name", "Test", cwd=git_dir)
959
960 # Write a file with high-byte content
961 payload = bytes(range(256)) * 4
962 (git_dir / "data.bin").write_bytes(payload)
963 subprocess.run(["git", "-C", str(git_dir), "add", "."], check=True, capture_output=True)
964 subprocess.run(
965 ["git", "-C", str(git_dir), "commit", "-m", "chore: binary payload"],
966 check=True, capture_output=True, env=_git_env(),
967 )
968
969 _make_muse_repo(muse_dir)
970 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
971 cwd=muse_dir)
972 # Populate working tree before reading disk
973 _muse_checkout(muse_dir)
974
975 imported = (muse_dir / "data.bin").read_bytes()
976 assert imported == payload, (
977 f"Binary content mismatch: expected {len(payload)} bytes, "
978 f"got {len(imported)} bytes"
979 )
980
981 def test_text_file_sha256_preserved_import_export(
982 self, tmp_path: pathlib.Path
983 ) -> None:
984 """Text file SHA-256 must match through git→muse→git."""
985 git_source = tmp_path / "git_src"
986 muse_dir = tmp_path / "muse"
987 git_target = tmp_path / "git_dst"
988
989 _build_complex_git_repo(git_source)
990 _make_muse_repo(muse_dir)
991 _invoke("bridge", "git-import", str(git_source), "--target", str(muse_dir),
992 cwd=muse_dir)
993
994 original_sha = _git_file_sha256(git_source, "src/core.py")
995
996 git_target.mkdir()
997 subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True)
998 _git("config", "user.email", "[email protected]", cwd=git_target)
999 _git("config", "user.name", "Test", cwd=git_target)
1000 (git_target / "init.txt").write_text("x")
1001 subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True)
1002 subprocess.run(
1003 ["git", "-C", str(git_target), "commit", "-m", "init"],
1004 check=True, capture_output=True, env=_git_env(),
1005 )
1006 _invoke("bridge", "git-export",
1007 "--git-dir", str(git_target), "--no-push", cwd=muse_dir)
1008
1009 exported_sha = _git_file_sha256(git_target, "src/core.py")
1010 assert exported_sha == original_sha, (
1011 f"src/core.py SHA-256 mismatch: original={original_sha[:16]}… "
1012 f"exported={exported_sha[:16]}…"
1013 )
1014
1015 def test_readme_content_byte_for_byte(self, tmp_path: pathlib.Path) -> None:
1016 """README.md content must survive import untouched."""
1017 git_dir = tmp_path / "git"
1018 muse_dir = tmp_path / "muse"
1019
1020 _build_complex_git_repo(git_dir)
1021 original = (git_dir / "README.md").read_bytes()
1022
1023 _make_muse_repo(muse_dir)
1024 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
1025 cwd=muse_dir)
1026 _muse_checkout(muse_dir)
1027
1028 imported = (muse_dir / "README.md").read_bytes()
1029 assert imported == original, "README.md content changed during import"
1030
1031 def test_no_phantom_files_after_import(self, tmp_path: pathlib.Path) -> None:
1032 """Muse working tree must not contain files that were never in git.
1033
1034 After a checkout, every file on disk (excluding .muse/) must have been
1035 present in the source git repo.
1036 """
1037 git_dir = tmp_path / "git"
1038 muse_dir = tmp_path / "muse"
1039
1040 _build_complex_git_repo(git_dir)
1041 git_files = set()
1042 for f in git_dir.rglob("*"):
1043 if f.is_file() and ".git" not in f.parts:
1044 git_files.add(f.relative_to(git_dir))
1045
1046 _make_muse_repo(muse_dir)
1047 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
1048 cwd=muse_dir)
1049 _muse_checkout(muse_dir)
1050
1051 # Allow Muse-generated init files (.museattributes, .museignore) to
1052 # be present — these are created by `muse init` and `muse checkout`.
1053 muse_generated = {pathlib.Path(".museattributes"), pathlib.Path(".museignore")}
1054 for f in muse_dir.rglob("*"):
1055 if f.is_file() and ".muse" not in f.parts:
1056 rel = f.relative_to(muse_dir)
1057 if rel in muse_generated:
1058 continue
1059 assert rel in git_files, (
1060 f"Phantom file in Muse after import: {rel}"
1061 )
1062
1063
1064 # ---------------------------------------------------------------------------
1065 # Tier 8 — Drift Tracking
1066 # ---------------------------------------------------------------------------
1067
1068
1069 class TestDriftTracking:
1070 """``muse bridge git-status`` drift counters are accurate after each operation."""
1071
1072 def test_status_shows_zero_drift_after_fresh_import(
1073 self, tmp_path: pathlib.Path
1074 ) -> None:
1075 git_dir = tmp_path / "git"
1076 muse_dir = tmp_path / "muse"
1077
1078 _build_complex_git_repo(git_dir)
1079 _make_muse_repo(muse_dir)
1080 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
1081 cwd=muse_dir)
1082
1083 r = _invoke("bridge", "git-status",
1084 "--git-dir", str(git_dir), "--json", cwd=muse_dir)
1085 assert r.exit_code == 0, f"git-status failed: {r.stderr}"
1086 data = json.loads(r.output.strip())
1087 # Drift is nested under the "drift" key in the JSON output
1088 drift = data.get("drift", {})
1089 assert drift.get("git_commits_since_import") == 0, (
1090 f"Expected 0 git commits since import, got drift={drift}"
1091 )
1092
1093 def test_status_detects_new_git_commits_as_drift(
1094 self, tmp_path: pathlib.Path
1095 ) -> None:
1096 git_dir = tmp_path / "git"
1097 muse_dir = tmp_path / "muse"
1098
1099 _build_complex_git_repo(git_dir)
1100 _make_muse_repo(muse_dir)
1101 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
1102 cwd=muse_dir)
1103
1104 # Add a new commit to git (without importing)
1105 _write_files(git_dir, {"drift.txt": "new git commit\n"})
1106 _git_commit(git_dir, "chore: drift commit")
1107
1108 r = _invoke("bridge", "git-status",
1109 "--git-dir", str(git_dir), "--json", cwd=muse_dir)
1110 assert r.exit_code == 0, r.stderr
1111 data = json.loads(r.output.strip())
1112 drift = data.get("drift", {})
1113 assert drift.get("git_commits_since_import") == 1, (
1114 f"Expected 1 git commit of drift, got drift={drift}"
1115 )
1116
1117 def test_status_detects_muse_only_commits_as_export_drift(
1118 self, tmp_path: pathlib.Path
1119 ) -> None:
1120 """Muse commits after a git-export baseline appear as export drift.
1121
1122 We first export to establish the baseline (last_export), then commit
1123 a Muse-native change without re-exporting, so the drift counter shows 1.
1124 """
1125 git_dir = tmp_path / "git"
1126 muse_dir = tmp_path / "muse"
1127 git_target = tmp_path / "git_out"
1128
1129 _build_complex_git_repo(git_dir)
1130 _make_muse_repo(muse_dir)
1131 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
1132 cwd=muse_dir)
1133
1134 # Establish export baseline
1135 git_target.mkdir()
1136 subprocess.run(["git", "init", str(git_target)], check=True, capture_output=True)
1137 _git("config", "user.email", "[email protected]", cwd=git_target)
1138 _git("config", "user.name", "Test", cwd=git_target)
1139 (git_target / "init.txt").write_text("x")
1140 subprocess.run(["git", "-C", str(git_target), "add", "."], check=True, capture_output=True)
1141 subprocess.run(
1142 ["git", "-C", str(git_target), "commit", "-m", "init"],
1143 check=True, capture_output=True, env=_git_env(),
1144 )
1145 _invoke("bridge", "git-export",
1146 "--git-dir", str(git_target), "--no-push", cwd=muse_dir)
1147
1148 # Add a Muse commit after the export baseline
1149 (muse_dir / "muse_drift.txt").write_text("muse only\n")
1150 _invoke("code", "add", ".", cwd=muse_dir)
1151 _invoke("commit", "-m", "chore: muse-only drift",
1152 "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--sign",
1153 cwd=muse_dir)
1154
1155 r = _invoke("bridge", "git-status",
1156 "--git-dir", str(git_dir), "--json", cwd=muse_dir)
1157 assert r.exit_code == 0, r.stderr
1158 data = json.loads(r.output.strip())
1159 drift = data.get("drift", {})
1160 assert (drift.get("muse_commits_since_export") or 0) >= 1, (
1161 f"Expected ≥1 Muse commit of export drift, got drift={drift}"
1162 )
1163
1164 def test_status_shows_last_import_sha_in_state(
1165 self, tmp_path: pathlib.Path
1166 ) -> None:
1167 git_dir = tmp_path / "git"
1168 muse_dir = tmp_path / "muse"
1169
1170 _build_complex_git_repo(git_dir)
1171 _make_muse_repo(muse_dir)
1172 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
1173 cwd=muse_dir)
1174
1175 r = _invoke("bridge", "git-status",
1176 "--git-dir", str(git_dir), "--json", cwd=muse_dir)
1177 assert r.exit_code == 0, r.stderr
1178 data = json.loads(r.output.strip())
1179 # last_import is a top-level key in the git-status JSON output
1180 li = data.get("last_import", {})
1181 assert li.get("git_sha", "") != "", (
1182 f"last_import.git_sha empty after import: {data}"
1183 )
1184
1185 def test_status_text_mode_shows_drift_section(
1186 self, tmp_path: pathlib.Path
1187 ) -> None:
1188 git_dir = tmp_path / "git"
1189 muse_dir = tmp_path / "muse"
1190
1191 _build_complex_git_repo(git_dir)
1192 _make_muse_repo(muse_dir)
1193 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir),
1194 cwd=muse_dir)
1195
1196 r = _invoke("bridge", "git-status",
1197 "--git-dir", str(git_dir), cwd=muse_dir)
1198 assert r.exit_code == 0, r.stderr
1199 # Human-readable output must mention drift
1200 assert "Drift" in r.output or "commit" in r.output.lower(), (
1201 f"Expected Drift section in text output:\n{r.output}"
1202 )
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago