gabriel / muse public
test_bridge_git_import.py python
921 lines 34.8 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Phase 2 TDD tests for ``muse bridge git-import``.
2
3 Tests are organised into eight tiers:
4
5 Tier 1 — Shape/Schema flag presence, dry-run, exclude defaults
6 Tier 2 — Round-Trip full import integration tests
7 Tier 3 — Edge Cases empty repos, bad refs, LFS, conventional commits
8 Tier 4 — Stress 100-commit import
9 Tier 5 — Data Integrity SHA-256 correctness, determinism, deduplication
10 Tier 6 — Performance time gates
11 Tier 7 — Security ANSI stripping, path traversal, bad handles
12 Tier 8 — Docstrings implementation docstrings present
13
14 NOTE: git subprocess calls in this file are INTENTIONAL — they create real
15 git repositories used as import sources. The bridge command itself converts
16 those into Muse commits. The muse codebase otherwise never uses git.
17 """
18
19 from __future__ import annotations
20
21 import json
22 import os
23 import pathlib
24 import subprocess
25 import time
26
27 import pytest
28
29 from tests.cli_test_helper import CliRunner
30 from muse.core.paths import commits_dir, git_bridge_state_path, logs_dir, objects_dir
31
32 runner = CliRunner()
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39 def _invoke(*args: str, cwd: pathlib.Path | None = None) -> "CliRunner":
40 """Invoke the muse CLI from *cwd* (or CWD if None)."""
41 return runner.invoke(None, list(args), cwd=cwd)
42
43
44 def _make_git_repo(path: pathlib.Path, commits: list[dict]) -> pathlib.Path:
45 """Create a real git repo with the given commits.
46
47 Each commit dict:
48 files: {relative_path: content_str}
49 message: commit message string
50 author_email: (optional) author email
51 author_name: (optional) author name
52 """
53 subprocess.run(["git", "init", str(path)], check=True, capture_output=True)
54 subprocess.run(
55 ["git", "-C", str(path), "config", "user.email", "[email protected]"],
56 check=True, capture_output=True,
57 )
58 subprocess.run(
59 ["git", "-C", str(path), "config", "user.name", "Test User"],
60 check=True, capture_output=True,
61 )
62 for commit in commits:
63 for filepath, content in commit["files"].items():
64 full = path / filepath
65 full.parent.mkdir(parents=True, exist_ok=True)
66 full.write_text(content)
67 subprocess.run(["git", "-C", str(path), "add", "."], check=True, capture_output=True)
68 email = commit.get("author_email", "[email protected]")
69 name = commit.get("author_name", "Test User")
70 env = {
71 **os.environ,
72 "GIT_AUTHOR_EMAIL": email,
73 "GIT_AUTHOR_NAME": name,
74 "GIT_COMMITTER_EMAIL": email,
75 "GIT_COMMITTER_NAME": name,
76 }
77 subprocess.run(
78 ["git", "-C", str(path), "commit", "-m", commit["message"]],
79 check=True, capture_output=True, env=env,
80 )
81 return path
82
83
84 def _make_muse_repo(path: pathlib.Path) -> pathlib.Path:
85 """Initialise a Muse repository at *path* using the CLI."""
86 path.mkdir(parents=True, exist_ok=True)
87 result = _invoke("init", cwd=path)
88 assert result.exit_code == 0, f"muse init failed: {result.stderr}"
89 return path
90
91
92 def _get_muse_log(muse_root: pathlib.Path) -> list[dict]:
93 """Return the muse log as a list of commit dicts."""
94 result = _invoke("log", "--json", cwd=muse_root)
95 if result.exit_code != 0:
96 return []
97 try:
98 data = json.loads(result.output.strip())
99 return data.get("commits", [])
100 except json.JSONDecodeError:
101 return []
102
103
104 def _get_muse_branches(muse_root: pathlib.Path) -> list[str]:
105 """Return list of branch names in the muse repo."""
106 result = _invoke("branch", "--json", cwd=muse_root)
107 if result.exit_code != 0:
108 return []
109 try:
110 data = json.loads(result.output.strip())
111 if isinstance(data, list):
112 return [b["name"] for b in data]
113 return []
114 except (json.JSONDecodeError, KeyError):
115 return []
116
117
118 # ---------------------------------------------------------------------------
119 # Tier 1 — Shape/Schema
120 # ---------------------------------------------------------------------------
121
122 class TestSchemaFlags:
123 """Flag presence and output shape validation."""
124
125 def test_help_contains_incremental_flag(self) -> None:
126 result = _invoke("bridge", "git-import", "--help")
127 assert "--incremental" in result.output
128
129 def test_help_contains_attribution_map(self) -> None:
130 result = _invoke("bridge", "git-import", "--help")
131 assert "--attribution-map" in result.output
132
133 def test_help_contains_import_tags(self) -> None:
134 result = _invoke("bridge", "git-import", "--help")
135 assert "--import-tags" in result.output
136
137 def test_dry_run_writes_nothing(self, tmp_path: pathlib.Path) -> None:
138 git_dir = tmp_path / "git_repo"
139 muse_dir = tmp_path / "muse_repo"
140 _make_git_repo(git_dir, [{"files": {"a.txt": "hello"}, "message": "init"}])
141 _make_muse_repo(muse_dir)
142
143 result = _invoke(
144 "bridge", "git-import", str(git_dir),
145 "--target", str(muse_dir),
146 "--dry-run",
147 cwd=muse_dir,
148 )
149 assert result.exit_code == 0
150
151 # No commit objects should have been written
152 c_dir = commits_dir(muse_dir)
153 commit_files = list(c_dir.glob("**/*.msgpack")) if c_dir.exists() else []
154 assert len(commit_files) == 0, f"dry-run wrote {len(commit_files)} commit files"
155
156 def test_json_output_valid_ndjson(self, tmp_path: pathlib.Path) -> None:
157 git_dir = tmp_path / "git_repo"
158 muse_dir = tmp_path / "muse_repo"
159 _make_git_repo(git_dir, [{"files": {"a.txt": "hello"}, "message": "init"}])
160 _make_muse_repo(muse_dir)
161
162 result = _invoke(
163 "bridge", "git-import", str(git_dir),
164 "--target", str(muse_dir),
165 "--json",
166 cwd=muse_dir,
167 )
168 assert result.exit_code == 0
169 for line in result.output.strip().splitlines():
170 if line.strip():
171 json.loads(line) # raises if invalid JSON
172
173 def test_default_excludes_cover_git_dir(self) -> None:
174 from muse.cli.commands.bridge import _should_exclude
175 assert _should_exclude(".git/config") is True
176 assert _should_exclude(".git/COMMIT_EDITMSG") is True
177
178 def test_default_excludes_cover_node_modules(self) -> None:
179 from muse.cli.commands.bridge import _should_exclude
180 assert _should_exclude("node_modules/lodash/index.js") is True
181
182 def test_default_excludes_cover_pyc(self) -> None:
183 from muse.cli.commands.bridge import _should_exclude
184 assert _should_exclude("src/__pycache__/foo.cpython-312.pyc") is True
185
186 def test_default_excludes_cover_venv(self) -> None:
187 from muse.cli.commands.bridge import _should_exclude
188 assert _should_exclude(".venv/lib/python3.12/site-packages/pip/__init__.py") is True
189
190 def test_non_excluded_path(self) -> None:
191 from muse.cli.commands.bridge import _should_exclude
192 assert _should_exclude("src/main.py") is False
193 assert _should_exclude("README.md") is False
194
195
196 # ---------------------------------------------------------------------------
197 # Tier 2 — Round-Trip / Integration
198 # ---------------------------------------------------------------------------
199
200 class TestRoundTrip:
201 """Full git → muse import round trips."""
202
203 def test_import_3_commits_creates_3_muse_commits(self, tmp_path: pathlib.Path) -> None:
204 git_dir = tmp_path / "git_repo"
205 muse_dir = tmp_path / "muse_repo"
206 _make_git_repo(git_dir, [
207 {"files": {"a.py": "x=1"}, "message": "first"},
208 {"files": {"b.py": "y=2"}, "message": "second"},
209 {"files": {"c.py": "z=3"}, "message": "third"},
210 ])
211 _make_muse_repo(muse_dir)
212
213 result = _invoke(
214 "bridge", "git-import", str(git_dir),
215 "--target", str(muse_dir),
216 cwd=muse_dir,
217 )
218 assert result.exit_code == 0
219
220 commits = _get_muse_log(muse_dir)
221 assert len(commits) == 3, f"Expected 3 commits, got {len(commits)}: {commits}"
222
223 def test_import_creates_expected_branch(self, tmp_path: pathlib.Path) -> None:
224 git_dir = tmp_path / "git_repo"
225 muse_dir = tmp_path / "muse_repo"
226 _make_git_repo(git_dir, [{"files": {"a.py": "x=1"}, "message": "init"}])
227 _make_muse_repo(muse_dir)
228
229 result = _invoke(
230 "bridge", "git-import", str(git_dir),
231 "--target", str(muse_dir),
232 cwd=muse_dir,
233 )
234 assert result.exit_code == 0
235 branches = _get_muse_branches(muse_dir)
236 # Should have imported to main or master
237 assert any(b in ("main", "master") for b in branches), f"branches: {branches}"
238
239 def test_import_2_branches(self, tmp_path: pathlib.Path) -> None:
240 git_dir = tmp_path / "git_repo"
241 muse_dir = tmp_path / "muse_repo"
242 _make_git_repo(git_dir, [{"files": {"a.py": "x=1"}, "message": "init"}])
243
244 # Create a second branch in git
245 subprocess.run(["git", "-C", str(git_dir), "checkout", "-b", "develop"], check=True, capture_output=True)
246 (git_dir / "b.py").write_text("y=2")
247 subprocess.run(["git", "-C", str(git_dir), "add", "."], check=True, capture_output=True)
248 subprocess.run(
249 ["git", "-C", str(git_dir), "commit", "-m", "dev commit"],
250 check=True, capture_output=True,
251 env={**os.environ, "GIT_AUTHOR_EMAIL": "[email protected]", "GIT_AUTHOR_NAME": "T",
252 "GIT_COMMITTER_EMAIL": "[email protected]", "GIT_COMMITTER_NAME": "T"},
253 )
254
255 _make_muse_repo(muse_dir)
256 result = _invoke(
257 "bridge", "git-import", str(git_dir),
258 "--target", str(muse_dir),
259 "--all",
260 cwd=muse_dir,
261 )
262 assert result.exit_code == 0
263 branches = _get_muse_branches(muse_dir)
264 assert len(branches) >= 2, f"Expected >= 2 branches, got {branches}"
265
266 def test_incremental_import_only_imports_new(self, tmp_path: pathlib.Path) -> None:
267 git_dir = tmp_path / "git_repo"
268 muse_dir = tmp_path / "muse_repo"
269 _make_git_repo(git_dir, [
270 {"files": {"a.py": "x=1"}, "message": "first"},
271 {"files": {"b.py": "y=2"}, "message": "second"},
272 {"files": {"c.py": "z=3"}, "message": "third"},
273 ])
274 _make_muse_repo(muse_dir)
275
276 # First import
277 result = _invoke(
278 "bridge", "git-import", str(git_dir),
279 "--target", str(muse_dir),
280 cwd=muse_dir,
281 )
282 assert result.exit_code == 0
283 commits_after_first = _get_muse_log(muse_dir)
284 assert len(commits_after_first) == 3
285
286 # Add 2 more commits to git
287 for filepath, content, msg in [("d.py", "d=4", "fourth"), ("e.py", "e=5", "fifth")]:
288 (git_dir / filepath).write_text(content)
289 subprocess.run(["git", "-C", str(git_dir), "add", "."], check=True, capture_output=True)
290 subprocess.run(
291 ["git", "-C", str(git_dir), "commit", "-m", msg],
292 check=True, capture_output=True,
293 env={**os.environ, "GIT_AUTHOR_EMAIL": "[email protected]", "GIT_AUTHOR_NAME": "T",
294 "GIT_COMMITTER_EMAIL": "[email protected]", "GIT_COMMITTER_NAME": "T"},
295 )
296
297 # Incremental import
298 result = _invoke(
299 "bridge", "git-import", str(git_dir),
300 "--target", str(muse_dir),
301 "--incremental",
302 cwd=muse_dir,
303 )
304 assert result.exit_code == 0
305 commits_after_second = _get_muse_log(muse_dir)
306 assert len(commits_after_second) == 5, (
307 f"Expected 5 commits after incremental import, got {len(commits_after_second)}"
308 )
309
310 def test_attribution_map_applies(self, tmp_path: pathlib.Path) -> None:
311 git_dir = tmp_path / "git_repo"
312 muse_dir = tmp_path / "muse_repo"
313 attr_file = tmp_path / "attr.json"
314 attr_file.write_text(json.dumps({"[email protected]": "alice-muse"}))
315
316 _make_git_repo(git_dir, [
317 {
318 "files": {"a.py": "x=1"},
319 "message": "init",
320 "author_email": "[email protected]",
321 "author_name": "Alice",
322 }
323 ])
324 _make_muse_repo(muse_dir)
325
326 result = _invoke(
327 "bridge", "git-import", str(git_dir),
328 "--target", str(muse_dir),
329 "--attribution-map", str(attr_file),
330 cwd=muse_dir,
331 )
332 assert result.exit_code == 0
333
334 commits = _get_muse_log(muse_dir)
335 assert len(commits) == 1
336 # The author field should contain the mapped handle
337 assert "alice-muse" in commits[0].get("author", ""), (
338 f"Expected alice-muse in author, got: {commits[0]}"
339 )
340
341 def test_unmapped_email_gets_synthetic_handle(self, tmp_path: pathlib.Path) -> None:
342 git_dir = tmp_path / "git_repo"
343 muse_dir = tmp_path / "muse_repo"
344
345 _make_git_repo(git_dir, [
346 {
347 "files": {"a.py": "x=1"},
348 "message": "init",
349 "author_email": "[email protected]",
350 }
351 ])
352 _make_muse_repo(muse_dir)
353
354 result = _invoke(
355 "bridge", "git-import", str(git_dir),
356 "--target", str(muse_dir),
357 cwd=muse_dir,
358 )
359 assert result.exit_code == 0
360
361 commits = _get_muse_log(muse_dir)
362 assert len(commits) == 1
363 author = commits[0].get("author", "")
364 assert "git-import/" in author, f"Expected synthetic git-import/ handle, got: {author!r}"
365
366 def test_bridge_state_written_after_import(self, tmp_path: pathlib.Path) -> None:
367 git_dir = tmp_path / "git_repo"
368 muse_dir = tmp_path / "muse_repo"
369 _make_git_repo(git_dir, [{"files": {"a.py": "x=1"}, "message": "init"}])
370 _make_muse_repo(muse_dir)
371
372 result = _invoke(
373 "bridge", "git-import", str(git_dir),
374 "--target", str(muse_dir),
375 cwd=muse_dir,
376 )
377 assert result.exit_code == 0
378
379 state_file = git_bridge_state_path(muse_dir)
380 assert state_file.exists(), "git-bridge.toml was not written"
381 content = state_file.read_text()
382 assert "[last_import]" in content
383
384 def test_file_content_preserved(self, tmp_path: pathlib.Path) -> None:
385 git_dir = tmp_path / "git_repo"
386 muse_dir = tmp_path / "muse_repo"
387 expected_content = "# This is a test file\nresult = 42\n"
388 _make_git_repo(git_dir, [
389 {"files": {"src/calc.py": expected_content}, "message": "add calc"},
390 ])
391 _make_muse_repo(muse_dir)
392
393 result = _invoke(
394 "bridge", "git-import", str(git_dir),
395 "--target", str(muse_dir),
396 cwd=muse_dir,
397 )
398 assert result.exit_code == 0
399
400 # Read the snapshot manifest and find the object ID for src/calc.py
401 from muse.core.store import write_branch_ref
402 from muse.core.paths import git_bridge_state_path, heads_dir
403 import tomllib
404
405 # Get HEAD commit
406 log_result = _invoke("log", "--json", cwd=muse_dir)
407 log_data = json.loads(log_result.output.strip())
408 commits = log_data.get("commits", [])
409 assert commits, "No commits in log"
410
411 snapshot_id = commits[0].get("snapshot_id", "")
412 assert snapshot_id, f"No snapshot_id in commit: {commits[0]}"
413
414 from muse.core.store import read_snapshot
415 snapshot = read_snapshot(muse_dir, snapshot_id)
416 assert snapshot is not None, "Could not read snapshot"
417
418 assert "src/calc.py" in snapshot.manifest, (
419 f"src/calc.py not in manifest: {list(snapshot.manifest.keys())}"
420 )
421
422 object_id = snapshot.manifest["src/calc.py"]
423 from muse.core.object_store import read_object
424 stored = read_object(muse_dir, object_id)
425 assert stored is not None
426 assert stored.decode() == expected_content
427
428
429 # ---------------------------------------------------------------------------
430 # Tier 3 — Edge Cases
431 # ---------------------------------------------------------------------------
432
433 class TestEdgeCases:
434 """Edge case handling."""
435
436 def test_empty_git_repo_exits_zero(self, tmp_path: pathlib.Path) -> None:
437 git_dir = tmp_path / "git_repo"
438 muse_dir = tmp_path / "muse_repo"
439 # Create a git repo but no commits
440 subprocess.run(["git", "init", str(git_dir)], check=True, capture_output=True)
441 subprocess.run(
442 ["git", "-C", str(git_dir), "config", "user.email", "[email protected]"],
443 check=True, capture_output=True,
444 )
445 subprocess.run(
446 ["git", "-C", str(git_dir), "config", "user.name", "T"],
447 check=True, capture_output=True,
448 )
449 _make_muse_repo(muse_dir)
450
451 result = _invoke(
452 "bridge", "git-import", str(git_dir),
453 "--target", str(muse_dir),
454 "--json",
455 cwd=muse_dir,
456 )
457 assert result.exit_code == 0
458 # Check the JSON output includes total_commits_written: 0
459 found_done = False
460 for line in result.output.strip().splitlines():
461 if not line.strip():
462 continue
463 obj = json.loads(line)
464 if obj.get("event") == "done":
465 found_done = True
466 assert obj.get("total_commits_written", -1) == 0
467 assert found_done, f"No done event in output: {result.output!r}"
468
469 def test_from_ref_nonexistent_exits_user_error(self, tmp_path: pathlib.Path) -> None:
470 git_dir = tmp_path / "git_repo"
471 muse_dir = tmp_path / "muse_repo"
472 _make_git_repo(git_dir, [{"files": {"a.py": "x"}, "message": "init"}])
473 _make_muse_repo(muse_dir)
474
475 result = _invoke(
476 "bridge", "git-import", str(git_dir),
477 "--target", str(muse_dir),
478 "--from-ref", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
479 cwd=muse_dir,
480 )
481 # Should fail with USER_ERROR before writing anything
482 assert result.exit_code != 0
483
484 # No commit objects should have been written
485 c_dir = commits_dir(muse_dir)
486 commit_files = list(c_dir.glob("**/*.msgpack")) if c_dir.exists() else []
487 assert len(commit_files) == 0, f"from-ref with bad SHA wrote {len(commit_files)} commits"
488
489 def test_incremental_no_bridge_state_does_full_import(self, tmp_path: pathlib.Path) -> None:
490 git_dir = tmp_path / "git_repo"
491 muse_dir = tmp_path / "muse_repo"
492 _make_git_repo(git_dir, [
493 {"files": {"a.py": "x=1"}, "message": "first"},
494 {"files": {"b.py": "y=2"}, "message": "second"},
495 ])
496 _make_muse_repo(muse_dir)
497
498 # No bridge state file — incremental should fall back to full import
499 result = _invoke(
500 "bridge", "git-import", str(git_dir),
501 "--target", str(muse_dir),
502 "--incremental",
503 cwd=muse_dir,
504 )
505 assert result.exit_code == 0
506 commits = _get_muse_log(muse_dir)
507 assert len(commits) == 2, f"Expected 2 commits, got {len(commits)}"
508
509 def test_lfs_pointer_skipped_with_lfs_skip(self, tmp_path: pathlib.Path) -> None:
510 git_dir = tmp_path / "git_repo"
511 muse_dir = tmp_path / "muse_repo"
512 lfs_pointer = (
513 "version https://git-lfs.github.com/spec/v1\n"
514 "oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n"
515 "size 12345\n"
516 )
517 _make_git_repo(git_dir, [
518 {"files": {"large_file.bin": lfs_pointer, "a.py": "x=1"}, "message": "add files"},
519 ])
520 _make_muse_repo(muse_dir)
521
522 result = _invoke(
523 "bridge", "git-import", str(git_dir),
524 "--target", str(muse_dir),
525 "--lfs-skip",
526 cwd=muse_dir,
527 )
528 assert result.exit_code == 0
529
530 # The LFS pointer file should not appear in the manifest
531 log_result = _invoke("log", "--json", cwd=muse_dir)
532 log_data = json.loads(log_result.output.strip())
533 commits = log_data.get("commits", [])
534 if commits:
535 from muse.core.store import read_snapshot
536 snapshot = read_snapshot(muse_dir, commits[0].get("snapshot_id", ""))
537 if snapshot:
538 assert "large_file.bin" not in snapshot.manifest, (
539 "LFS pointer should be excluded when --lfs-skip is set"
540 )
541
542 def test_excluded_file_not_in_manifest(self, tmp_path: pathlib.Path) -> None:
543 git_dir = tmp_path / "git_repo"
544 muse_dir = tmp_path / "muse_repo"
545 _make_git_repo(git_dir, [
546 {
547 "files": {
548 "src/main.py": "x=1",
549 "src/util.pyc": "\x00" * 16,
550 },
551 "message": "add files",
552 },
553 ])
554 _make_muse_repo(muse_dir)
555
556 result = _invoke(
557 "bridge", "git-import", str(git_dir),
558 "--target", str(muse_dir),
559 cwd=muse_dir,
560 )
561 assert result.exit_code == 0
562
563 log_result = _invoke("log", "--json", cwd=muse_dir)
564 log_data = json.loads(log_result.output.strip())
565 commits = log_data.get("commits", [])
566 if commits:
567 from muse.core.store import read_snapshot
568 snapshot = read_snapshot(muse_dir, commits[0].get("snapshot_id", ""))
569 if snapshot:
570 assert "src/util.pyc" not in snapshot.manifest, (
571 ".pyc files should be excluded by default"
572 )
573
574 def test_conventional_commit_feat_becomes_minor_bump(self, tmp_path: pathlib.Path) -> None:
575 git_dir = tmp_path / "git_repo"
576 muse_dir = tmp_path / "muse_repo"
577 _make_git_repo(git_dir, [
578 {"files": {"a.py": "x=1"}, "message": "feat: add awesome feature"},
579 ])
580 _make_muse_repo(muse_dir)
581
582 result = _invoke(
583 "bridge", "git-import", str(git_dir),
584 "--target", str(muse_dir),
585 cwd=muse_dir,
586 )
587 assert result.exit_code == 0
588
589 log_result = _invoke("log", "--json", cwd=muse_dir)
590 log_data = json.loads(log_result.output.strip())
591 commits = log_data.get("commits", [])
592 assert commits
593 assert commits[0].get("sem_ver_bump") == "minor", (
594 f"feat: commit should have minor sem_ver_bump, got {commits[0].get('sem_ver_bump')!r}"
595 )
596
597 def test_conventional_commit_fix_becomes_patch_bump(self, tmp_path: pathlib.Path) -> None:
598 git_dir = tmp_path / "git_repo"
599 muse_dir = tmp_path / "muse_repo"
600 _make_git_repo(git_dir, [
601 {"files": {"a.py": "x=1"}, "message": "fix: correct off-by-one"},
602 ])
603 _make_muse_repo(muse_dir)
604
605 result = _invoke(
606 "bridge", "git-import", str(git_dir),
607 "--target", str(muse_dir),
608 cwd=muse_dir,
609 )
610 assert result.exit_code == 0
611
612 log_result = _invoke("log", "--json", cwd=muse_dir)
613 log_data = json.loads(log_result.output.strip())
614 commits = log_data.get("commits", [])
615 assert commits
616 assert commits[0].get("sem_ver_bump") == "patch"
617
618 def test_conventional_commit_breaking_becomes_major_bump(self, tmp_path: pathlib.Path) -> None:
619 git_dir = tmp_path / "git_repo"
620 muse_dir = tmp_path / "muse_repo"
621 _make_git_repo(git_dir, [
622 {"files": {"a.py": "x=1"}, "message": "feat!: BREAKING CHANGE remove old API"},
623 ])
624 _make_muse_repo(muse_dir)
625
626 result = _invoke(
627 "bridge", "git-import", str(git_dir),
628 "--target", str(muse_dir),
629 cwd=muse_dir,
630 )
631 assert result.exit_code == 0
632
633 log_result = _invoke("log", "--json", cwd=muse_dir)
634 log_data = json.loads(log_result.output.strip())
635 commits = log_data.get("commits", [])
636 assert commits
637 assert commits[0].get("sem_ver_bump") == "major"
638
639
640 # ---------------------------------------------------------------------------
641 # Tier 4 — Stress
642 # ---------------------------------------------------------------------------
643
644 class TestStress:
645 """Stress tests with large commit counts."""
646
647 def test_import_100_commits(self, tmp_path: pathlib.Path) -> None:
648 git_dir = tmp_path / "git_repo"
649 muse_dir = tmp_path / "muse_repo"
650
651 # Create 100 commits
652 commits = [
653 {"files": {f"file_{i:03d}.txt": f"content {i}"}, "message": f"commit {i:03d}"}
654 for i in range(100)
655 ]
656 _make_git_repo(git_dir, commits)
657 _make_muse_repo(muse_dir)
658
659 start = time.time()
660 result = _invoke(
661 "bridge", "git-import", str(git_dir),
662 "--target", str(muse_dir),
663 cwd=muse_dir,
664 )
665 elapsed = time.time() - start
666
667 assert result.exit_code == 0, f"import failed: {result.stderr}"
668 assert elapsed < 30.0, f"100-commit import took {elapsed:.1f}s (limit: 30s)"
669
670 log_commits = _get_muse_log(muse_dir)
671 assert len(log_commits) == 100, f"Expected 100 commits, got {len(log_commits)}"
672
673 def test_cat_file_stays_alive(self, tmp_path: pathlib.Path) -> None:
674 """Single _CatFile instance handles multiple reads without crashing."""
675 git_dir = tmp_path / "git_repo"
676 _make_git_repo(git_dir, [
677 {"files": {f"f{i}.txt": f"content {i}"}, "message": f"c{i}"}
678 for i in range(10)
679 ])
680
681 from muse.cli.commands.bridge import _CatFile, _git
682
683 # Get all blob SHAs from the git repo
684 ls_tree = _git(git_dir, "ls-tree", "-r", "--format=%(objectname)", "HEAD")
685 shas = [s.strip() for s in ls_tree.strip().splitlines() if s.strip()]
686
687 with _CatFile(git_dir) as cf:
688 for sha in shas:
689 content = cf.read(sha)
690 assert isinstance(content, bytes)
691 assert len(content) >= 0
692
693
694 # ---------------------------------------------------------------------------
695 # Tier 5 — Data Integrity
696 # ---------------------------------------------------------------------------
697
698 class TestDataIntegrity:
699 """Content-addressed integrity and determinism tests."""
700
701 def test_sha256_of_blob_matches_object_store(self, tmp_path: pathlib.Path) -> None:
702 git_dir = tmp_path / "git_repo"
703 muse_dir = tmp_path / "muse_repo"
704 content = "unique content for hash verification\n"
705 _make_git_repo(git_dir, [{"files": {"verify.txt": content}, "message": "add file"}])
706 _make_muse_repo(muse_dir)
707
708 result = _invoke(
709 "bridge", "git-import", str(git_dir),
710 "--target", str(muse_dir),
711 cwd=muse_dir,
712 )
713 assert result.exit_code == 0
714
715 log_result = _invoke("log", "--json", cwd=muse_dir)
716 log_data = json.loads(log_result.output.strip())
717 commits = log_data.get("commits", [])
718 assert commits
719
720 from muse.core.store import read_snapshot
721 from muse.core.object_store import read_object
722 from muse.core.types import blob_id
723
724 snapshot = read_snapshot(muse_dir, commits[0].get("snapshot_id", ""))
725 assert snapshot is not None
726 assert "verify.txt" in snapshot.manifest
727
728 stored_id = snapshot.manifest["verify.txt"]
729 stored_bytes = read_object(muse_dir, stored_id)
730 assert stored_bytes is not None
731
732 expected_id = blob_id(content.encode())
733 assert stored_id == expected_id, f"stored {stored_id} != expected {expected_id}"
734
735 def test_no_duplicate_objects_on_reimport(self, tmp_path: pathlib.Path) -> None:
736 git_dir = tmp_path / "git_repo"
737 muse_dir = tmp_path / "muse_repo"
738 _make_git_repo(git_dir, [
739 {"files": {"a.py": "x=1"}, "message": "init"},
740 {"files": {"b.py": "y=2"}, "message": "second"},
741 ])
742 _make_muse_repo(muse_dir)
743
744 # First import
745 result = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir)
746 assert result.exit_code == 0
747
748 # Count objects after first import
749 obj_dir = objects_dir(muse_dir)
750 first_count = sum(1 for _ in obj_dir.glob("**/*") if _.is_file())
751
752 # Second import of same repo — same commits
753 result = _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir)
754 assert result.exit_code == 0
755
756 second_count = sum(1 for _ in obj_dir.glob("**/*") if _.is_file())
757 assert second_count == first_count, (
758 f"Re-import created {second_count - first_count} new objects (expected 0)"
759 )
760
761 def test_reflog_appended(self, tmp_path: pathlib.Path) -> None:
762 git_dir = tmp_path / "git_repo"
763 muse_dir = tmp_path / "muse_repo"
764 _make_git_repo(git_dir, [{"files": {"a.py": "x=1"}, "message": "init"}])
765 _make_muse_repo(muse_dir)
766
767 result = _invoke(
768 "bridge", "git-import", str(git_dir),
769 "--target", str(muse_dir),
770 cwd=muse_dir,
771 )
772 assert result.exit_code == 0
773
774 # Reflog lives at .muse/logs/refs/heads/<branch>
775 log_dir = logs_dir(muse_dir)
776 log_files = list(log_dir.glob("**/*")) if log_dir.exists() else []
777 has_content = any(f.is_file() and f.stat().st_size > 0 for f in log_files)
778 assert has_content, (
779 f"No reflog entries were written after import. "
780 f"Log dir contents: {[str(f) for f in log_files]}"
781 )
782
783
784 # ---------------------------------------------------------------------------
785 # Tier 6 — Performance
786 # ---------------------------------------------------------------------------
787
788 class TestPerformance:
789 """Performance gate tests."""
790
791 @pytest.mark.slow
792 def test_100_commit_import_under_5_seconds(self, tmp_path: pathlib.Path) -> None:
793 git_dir = tmp_path / "git_repo"
794 muse_dir = tmp_path / "muse_repo"
795 commits = [
796 {"files": {f"f{i}.py": f"x={i}"}, "message": f"c{i}"}
797 for i in range(100)
798 ]
799 _make_git_repo(git_dir, commits)
800 _make_muse_repo(muse_dir)
801
802 start = time.time()
803 result = _invoke(
804 "bridge", "git-import", str(git_dir),
805 "--target", str(muse_dir),
806 cwd=muse_dir,
807 )
808 elapsed = time.time() - start
809 assert result.exit_code == 0
810 assert elapsed < 5.0, f"100-commit import took {elapsed:.2f}s (limit: 5s)"
811
812 @pytest.mark.slow
813 def test_incremental_1_commit_under_500ms(self, tmp_path: pathlib.Path) -> None:
814 git_dir = tmp_path / "git_repo"
815 muse_dir = tmp_path / "muse_repo"
816 _make_git_repo(git_dir, [{"files": {"a.py": "x=1"}, "message": "init"}])
817 _make_muse_repo(muse_dir)
818
819 # Full import first
820 _invoke("bridge", "git-import", str(git_dir), "--target", str(muse_dir), cwd=muse_dir)
821
822 # Add one more commit
823 (git_dir / "b.py").write_text("y=2")
824 subprocess.run(["git", "-C", str(git_dir), "add", "."], check=True, capture_output=True)
825 subprocess.run(
826 ["git", "-C", str(git_dir), "commit", "-m", "incremental"],
827 check=True, capture_output=True,
828 env={**os.environ, "GIT_AUTHOR_EMAIL": "[email protected]", "GIT_AUTHOR_NAME": "T",
829 "GIT_COMMITTER_EMAIL": "[email protected]", "GIT_COMMITTER_NAME": "T"},
830 )
831
832 start = time.time()
833 result = _invoke(
834 "bridge", "git-import", str(git_dir),
835 "--target", str(muse_dir),
836 "--incremental",
837 cwd=muse_dir,
838 )
839 elapsed = time.time() - start
840 assert result.exit_code == 0
841 assert elapsed < 0.5, f"Incremental 1-commit import took {elapsed:.3f}s (limit: 0.5s)"
842
843
844 # ---------------------------------------------------------------------------
845 # Tier 7 — Security
846 # ---------------------------------------------------------------------------
847
848 class TestSecurity:
849 """Security-sensitive input handling."""
850
851 def test_git_commit_message_ansi_stripped(self, tmp_path: pathlib.Path) -> None:
852 git_dir = tmp_path / "git_repo"
853 muse_dir = tmp_path / "muse_repo"
854 ansi_msg = "\x1b[31mRed message\x1b[0m"
855 _make_git_repo(git_dir, [{"files": {"a.py": "x"}, "message": ansi_msg}])
856 _make_muse_repo(muse_dir)
857
858 result = _invoke(
859 "bridge", "git-import", str(git_dir),
860 "--target", str(muse_dir),
861 cwd=muse_dir,
862 )
863 assert result.exit_code == 0
864
865 log_result = _invoke("log", "--json", cwd=muse_dir)
866 log_data = json.loads(log_result.output.strip())
867 commits = log_data.get("commits", [])
868 if commits:
869 msg = commits[0].get("message", "")
870 assert "\x1b[" not in msg, f"ANSI escape sequence found in stored message: {msg!r}"
871
872 def test_attribution_map_control_chars_rejected(self, tmp_path: pathlib.Path) -> None:
873 from muse.cli.commands.bridge import AttributionMapper
874 attr_file = tmp_path / "attr.json"
875 # Map with NUL byte in handle
876 attr_file.write_text(json.dumps({"[email protected]": "alice\x00bad"}))
877
878 mapper = AttributionMapper(attr_file)
879 handle = mapper.get_handle("[email protected]", "Alice")
880 # NUL byte must not appear in the returned handle
881 assert "\x00" not in handle, f"Control char in handle: {handle!r}"
882
883 def test_source_path_traversal_rejected(self, tmp_path: pathlib.Path) -> None:
884 muse_dir = tmp_path / "muse_repo"
885 _make_muse_repo(muse_dir)
886
887 # Use a path that looks like traversal — has no .git dir so should fail gracefully
888 result = _invoke(
889 "bridge", "git-import", "../../../../etc",
890 "--target", str(muse_dir),
891 cwd=muse_dir,
892 )
893 assert result.exit_code != 0, "Path traversal source should be rejected"
894
895
896 # ---------------------------------------------------------------------------
897 # Tier 8 — Docstrings
898 # ---------------------------------------------------------------------------
899
900 class TestDocstrings:
901 """Implementation symbols carry docstrings."""
902
903 def test_replay_commit_has_docstring(self) -> None:
904 from muse.cli.commands.bridge import _replay_commit
905 assert _replay_commit.__doc__, "_replay_commit missing docstring"
906
907 def test_cat_file_has_docstring(self) -> None:
908 from muse.cli.commands.bridge import _CatFile
909 assert _CatFile.__doc__, "_CatFile missing docstring"
910
911 def test_attribution_mapper_has_docstring(self) -> None:
912 from muse.cli.commands.bridge import AttributionMapper
913 assert AttributionMapper.__doc__, "AttributionMapper missing docstring"
914
915 def test_replay_branch_has_docstring(self) -> None:
916 from muse.cli.commands.bridge import _replay_branch
917 assert _replay_branch.__doc__, "_replay_branch missing docstring"
918
919 def test_batch_commit_log_has_docstring(self) -> None:
920 from muse.cli.commands.bridge import _batch_commit_log
921 assert _batch_commit_log.__doc__, "_batch_commit_log missing docstring"
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago