gabriel / muse public
test_bridge_git_export.py python
1,108 lines 36.6 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
1 """Phase 3 TDD tests for ``muse bridge git-export``.
2
3 Tests are organised into eight tiers:
4
5 Tier 1 — Shape/Schema flag presence, dry-run output, default values
6 Tier 2 — Round-Trip full export integration tests
7 Tier 3 — Edge Cases bad --git-dir, new branches, spaces in paths, etc.
8 Tier 4 — Stress 500-file snapshot export
9 Tier 5 — Data Integrity SHA-256 correctness, message traceability, bridge state
10 Tier 6 — Performance time gates for delete+replace cycles
11 Tier 7 — Security shell injection, branch name validation, fix-modes safety
12 Tier 8 — Docstrings implementation docstrings present
13
14 NOTE: git subprocess calls in this file are INTENTIONAL — they create real git
15 repositories used as export targets. The bridge command itself is the Muse
16 CLI. The muse codebase otherwise never uses git.
17 """
18
19 from __future__ import annotations
20
21 import hashlib
22 import json
23 import os
24 import pathlib
25 import subprocess
26 import time
27 from collections.abc import Mapping
28
29 import pytest
30
31 from tests.cli_test_helper import CliRunner
32
33 runner = CliRunner()
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _invoke(*args: str, cwd: pathlib.Path | None = None) -> "CliRunner":
42 """Invoke the muse CLI from *cwd*."""
43 return runner.invoke(None, list(args), cwd=cwd)
44
45
46 def _make_git_repo(path: pathlib.Path) -> pathlib.Path:
47 """Create an empty git repo with an initial commit so HEAD exists."""
48 path.mkdir(parents=True, exist_ok=True)
49 subprocess.run(["git", "init", str(path)], check=True, capture_output=True)
50 subprocess.run(
51 ["git", "-C", str(path), "config", "user.email", "[email protected]"],
52 check=True, capture_output=True,
53 )
54 subprocess.run(
55 ["git", "-C", str(path), "config", "user.name", "Test"],
56 check=True, capture_output=True,
57 )
58 (path / "README.md").write_text("init")
59 subprocess.run(
60 ["git", "-C", str(path), "add", "."], check=True, capture_output=True
61 )
62 subprocess.run(
63 ["git", "-C", str(path), "commit", "-m", "init"],
64 check=True, capture_output=True,
65 )
66 return path
67
68
69 def _make_muse_repo(path: pathlib.Path, files: Mapping[str, str] | None = None) -> pathlib.Path:
70 """Initialise a Muse repo at *path* and commit *files* (or a default set).
71
72 Returns the repo root path.
73 """
74 path.mkdir(parents=True, exist_ok=True)
75 result = _invoke("init", cwd=path)
76 assert result.exit_code == 0, f"muse init failed: {result.stderr}"
77
78 if files is None:
79 files = {"hello.txt": "hello world\n", "src/main.py": "print('hi')\n"}
80
81 for rel, content in files.items():
82 full = path / rel
83 full.parent.mkdir(parents=True, exist_ok=True)
84 full.write_text(content)
85
86 # Stage and commit
87 add_result = _invoke("code", "add", ".", cwd=path)
88 assert add_result.exit_code == 0, f"muse code add failed: {add_result.stderr}"
89
90 commit_result = _invoke(
91 "commit", "-m", "feat: initial files",
92 "--agent-id", "claude-code",
93 "--model-id", "claude-sonnet-4-6",
94 "--sign",
95 cwd=path,
96 )
97 assert commit_result.exit_code == 0, f"muse commit failed: {commit_result.stderr}"
98 return path
99
100
101 def _git_log_count(git_dir: pathlib.Path, branch: str = "HEAD") -> int:
102 """Return the number of commits on *branch* in the git repo at *git_dir*."""
103 result = subprocess.run(
104 ["git", "-C", str(git_dir), "rev-list", "--count", branch],
105 capture_output=True,
106 )
107 if result.returncode != 0:
108 return 0
109 return int(result.stdout.decode().strip())
110
111
112 def _git_latest_sha(git_dir: pathlib.Path) -> str:
113 """Return the HEAD commit SHA in the git repo at *git_dir*."""
114 result = subprocess.run(
115 ["git", "-C", str(git_dir), "log", "--format=%H", "-1"],
116 capture_output=True,
117 )
118 return result.stdout.decode().strip()
119
120
121 def _git_latest_message(git_dir: pathlib.Path) -> str:
122 """Return the HEAD commit message in the git repo."""
123 result = subprocess.run(
124 ["git", "-C", str(git_dir), "log", "--format=%s", "-1"],
125 capture_output=True,
126 )
127 return result.stdout.decode().strip()
128
129
130 def _git_file_list(git_dir: pathlib.Path, branch: str = "HEAD") -> list[str]:
131 """Return sorted list of tracked file paths in *git_dir*."""
132 result = subprocess.run(
133 ["git", "-C", str(git_dir), "ls-tree", "-r", "--name-only", branch],
134 capture_output=True,
135 )
136 if result.returncode != 0:
137 return []
138 return sorted(result.stdout.decode().splitlines())
139
140
141 def _git_file_content(git_dir: pathlib.Path, rel_path: str) -> bytes:
142 """Return the content of *rel_path* in *git_dir*."""
143 return (git_dir / rel_path).read_bytes()
144
145
146 # ---------------------------------------------------------------------------
147 # Tier 1 — Shape / Schema
148 # ---------------------------------------------------------------------------
149
150
151 class TestSchemaFlags:
152 """Flag presence, dry-run shape, default values."""
153
154 def test_help_has_muse_ref(self) -> None:
155 r = _invoke("bridge", "git-export", "--help")
156 assert "--muse-ref" in r.output
157
158 def test_help_has_git_dir(self) -> None:
159 r = _invoke("bridge", "git-export", "--help")
160 assert "--git-dir" in r.output
161
162 def test_help_has_git_branch(self) -> None:
163 r = _invoke("bridge", "git-export", "--help")
164 assert "--git-branch" in r.output
165
166 def test_help_has_no_push(self) -> None:
167 r = _invoke("bridge", "git-export", "--help")
168 assert "--no-push" in r.output
169
170 def test_help_has_fix_modes(self) -> None:
171 r = _invoke("bridge", "git-export", "--help")
172 assert "--fix-modes" in r.output
173
174 def test_help_has_watch(self) -> None:
175 r = _invoke("bridge", "git-export", "--help")
176 assert "--watch" in r.output
177
178 def test_dry_run_emits_json_with_dry_run_true(self, tmp_path: pathlib.Path) -> None:
179 muse_dir = tmp_path / "muse"
180 git_dir = tmp_path / "git"
181 _make_muse_repo(muse_dir)
182 _make_git_repo(git_dir)
183
184 r = _invoke(
185 "bridge", "git-export",
186 "--git-dir", str(git_dir),
187 "--no-push",
188 "--dry-run",
189 "--json",
190 cwd=muse_dir,
191 )
192 assert r.exit_code == 0, r.output
193 data = json.loads(r.output.strip())
194 assert data["dry_run"] is True
195
196 def test_default_commit_message_contains_commit_id_placeholder(self) -> None:
197 r = _invoke("bridge", "git-export", "--help")
198 assert "{commit_id}" in r.output
199
200 def test_strip_muse_metadata_default_true(self, tmp_path: pathlib.Path) -> None:
201 """By default .muse/ must be absent from the exported git tree."""
202 muse_dir = tmp_path / "muse"
203 git_dir = tmp_path / "git"
204 _make_muse_repo(muse_dir)
205 _make_git_repo(git_dir)
206
207 r = _invoke(
208 "bridge", "git-export",
209 "--git-dir", str(git_dir),
210 "--no-push",
211 "--json",
212 cwd=muse_dir,
213 )
214 assert r.exit_code == 0, r.stderr
215
216 files = _git_file_list(git_dir, branch="muse-mirror")
217 assert not any(f.startswith(".muse/") for f in files), (
218 f"Found .muse/ files in git export: {[f for f in files if f.startswith('.muse/')]}"
219 )
220
221
222 # ---------------------------------------------------------------------------
223 # Tier 2 — Round-Trip / Integration
224 # ---------------------------------------------------------------------------
225
226
227 class TestRoundTrip:
228 """Full export integration: Muse HEAD → git commit."""
229
230 def test_export_creates_git_commit(self, tmp_path: pathlib.Path) -> None:
231 muse_dir = tmp_path / "muse"
232 git_dir = tmp_path / "git"
233 _make_muse_repo(muse_dir)
234 _make_git_repo(git_dir)
235
236 r = _invoke(
237 "bridge", "git-export",
238 "--git-dir", str(git_dir),
239 "--no-push",
240 "--json",
241 cwd=muse_dir,
242 )
243 assert r.exit_code == 0, f"export failed:\nstdout={r.output}\nstderr={r.stderr}"
244 data = json.loads(r.output.strip())
245 assert data["git_sha"] != ""
246
247 def test_export_produces_mirror_commit_message(self, tmp_path: pathlib.Path) -> None:
248 muse_dir = tmp_path / "muse"
249 git_dir = tmp_path / "git"
250 _make_muse_repo(muse_dir)
251 _make_git_repo(git_dir)
252
253 r = _invoke(
254 "bridge", "git-export",
255 "--git-dir", str(git_dir),
256 "--no-push",
257 "--json",
258 cwd=muse_dir,
259 )
260 assert r.exit_code == 0, r.stderr
261
262 msg = _git_latest_message(git_dir)
263 assert msg.startswith("mirror: muse sha256:"), (
264 f"Expected 'mirror: muse sha256:...' but got: {msg!r}"
265 )
266
267 def test_export_twice_no_new_muse_commit_no_new_git_commit(
268 self, tmp_path: pathlib.Path
269 ) -> None:
270 muse_dir = tmp_path / "muse"
271 git_dir = tmp_path / "git"
272 _make_muse_repo(muse_dir)
273 _make_git_repo(git_dir)
274
275 # First export
276 r1 = _invoke(
277 "bridge", "git-export",
278 "--git-dir", str(git_dir),
279 "--no-push",
280 "--json",
281 cwd=muse_dir,
282 )
283 assert r1.exit_code == 0, r1.stderr
284 sha1 = _git_latest_sha(git_dir)
285
286 # Second export — same Muse HEAD, no changes
287 r2 = _invoke(
288 "bridge", "git-export",
289 "--git-dir", str(git_dir),
290 "--no-push",
291 "--json",
292 cwd=muse_dir,
293 )
294 assert r2.exit_code == 0, r2.stderr
295 data2 = json.loads(r2.output.strip())
296 # No new git commit (git_sha empty means nothing committed)
297 assert data2["git_sha"] == "" or _git_latest_sha(git_dir) == sha1
298
299 def test_fix_modes_sets_644_on_files(self, tmp_path: pathlib.Path) -> None:
300 muse_dir = tmp_path / "muse"
301 git_dir = tmp_path / "git"
302 _make_muse_repo(muse_dir)
303 _make_git_repo(git_dir)
304
305 r = _invoke(
306 "bridge", "git-export",
307 "--git-dir", str(git_dir),
308 "--no-push",
309 "--fix-modes",
310 "--json",
311 cwd=muse_dir,
312 )
313 assert r.exit_code == 0, r.stderr
314
315 import stat
316
317 for p in git_dir.rglob("*"):
318 if p.is_file() and ".git" not in p.parts:
319 mode = p.stat().st_mode & 0o777
320 assert mode == 0o644, f"{p} has mode {oct(mode)}, expected 0o644"
321
322 def test_exclude_pattern_omits_files(self, tmp_path: pathlib.Path) -> None:
323 muse_dir = tmp_path / "muse"
324 git_dir = tmp_path / "git"
325 _make_muse_repo(muse_dir, files={
326 "secret.key": "TOP SECRET",
327 "public.txt": "public data",
328 })
329 _make_git_repo(git_dir)
330
331 r = _invoke(
332 "bridge", "git-export",
333 "--git-dir", str(git_dir),
334 "--no-push",
335 "--exclude", "*.key",
336 "--json",
337 cwd=muse_dir,
338 )
339 assert r.exit_code == 0, r.stderr
340
341 files = _git_file_list(git_dir, branch="muse-mirror")
342 assert "secret.key" not in files
343 assert "public.txt" in files
344
345 def test_bridge_state_last_export_written(self, tmp_path: pathlib.Path) -> None:
346 muse_dir = tmp_path / "muse"
347 git_dir = tmp_path / "git"
348 _make_muse_repo(muse_dir)
349 _make_git_repo(git_dir)
350
351 r = _invoke(
352 "bridge", "git-export",
353 "--git-dir", str(git_dir),
354 "--no-push",
355 "--json",
356 cwd=muse_dir,
357 )
358 assert r.exit_code == 0, r.stderr
359
360 from muse.core.bridge.state import read_bridge_state
361 state = read_bridge_state(muse_dir)
362 le = state["last_export"]
363 assert le.get("muse_commit_id", "").startswith("sha256:")
364 assert le.get("git_ref") == "muse-mirror"
365
366 def test_no_push_does_not_push(self, tmp_path: pathlib.Path) -> None:
367 """With --no-push the 'pushed' field is False in JSON output."""
368 muse_dir = tmp_path / "muse"
369 git_dir = tmp_path / "git"
370 _make_muse_repo(muse_dir)
371 _make_git_repo(git_dir)
372
373 r = _invoke(
374 "bridge", "git-export",
375 "--git-dir", str(git_dir),
376 "--no-push",
377 "--json",
378 cwd=muse_dir,
379 )
380 assert r.exit_code == 0, r.stderr
381 data = json.loads(r.output.strip())
382 assert data["pushed"] is False
383
384 def test_muse_dir_absent_from_git_tree(self, tmp_path: pathlib.Path) -> None:
385 """The .muse/ directory must never appear in the exported git tree."""
386 muse_dir = tmp_path / "muse"
387 git_dir = tmp_path / "git"
388 _make_muse_repo(muse_dir)
389 _make_git_repo(git_dir)
390
391 _invoke(
392 "bridge", "git-export",
393 "--git-dir", str(git_dir),
394 "--no-push",
395 cwd=muse_dir,
396 )
397
398 files = _git_file_list(git_dir, branch="muse-mirror")
399 # Files starting with ".muse/" (directory) must be absent.
400 # Root-level .museattributes and .museignore are legitimate repo files.
401 assert not any(f.startswith(".muse/") for f in files), (
402 f"Found .muse/ directory files in git export: "
403 f"{[f for f in files if f.startswith('.muse/')]}"
404 )
405
406 def test_git_bridge_toml_absent_from_git_tree(self, tmp_path: pathlib.Path) -> None:
407 muse_dir = tmp_path / "muse"
408 git_dir = tmp_path / "git"
409 _make_muse_repo(muse_dir)
410 _make_git_repo(git_dir)
411
412 _invoke(
413 "bridge", "git-export",
414 "--git-dir", str(git_dir),
415 "--no-push",
416 cwd=muse_dir,
417 )
418
419 files = _git_file_list(git_dir, branch="muse-mirror")
420 assert ".muse/git-bridge.toml" not in files
421
422
423 # ---------------------------------------------------------------------------
424 # Tier 3 — Edge Cases
425 # ---------------------------------------------------------------------------
426
427
428 class TestEdgeCases:
429 """Edge case and error handling tests."""
430
431 def test_git_dir_not_git_repo_exits_user_error(
432 self, tmp_path: pathlib.Path
433 ) -> None:
434 muse_dir = tmp_path / "muse"
435 not_git = tmp_path / "not_git"
436 not_git.mkdir()
437 _make_muse_repo(muse_dir)
438
439 from muse.core.errors import ExitCode
440 r = _invoke(
441 "bridge", "git-export",
442 "--git-dir", str(not_git),
443 "--no-push",
444 cwd=muse_dir,
445 )
446 assert r.exit_code == ExitCode.USER_ERROR
447
448 def test_git_branch_not_yet_in_git_is_created(
449 self, tmp_path: pathlib.Path
450 ) -> None:
451 muse_dir = tmp_path / "muse"
452 git_dir = tmp_path / "git"
453 _make_muse_repo(muse_dir)
454 _make_git_repo(git_dir)
455
456 new_branch = "new-export-branch"
457 r = _invoke(
458 "bridge", "git-export",
459 "--git-dir", str(git_dir),
460 "--git-branch", new_branch,
461 "--no-push",
462 "--json",
463 cwd=muse_dir,
464 )
465 assert r.exit_code == 0, r.stderr
466
467 # Branch should now exist
468 result = subprocess.run(
469 ["git", "-C", str(git_dir), "rev-parse", "--verify", new_branch],
470 capture_output=True,
471 )
472 assert result.returncode == 0, f"Branch {new_branch!r} not created"
473
474 def test_muse_ref_nonexistent_branch_exits_user_error(
475 self, tmp_path: pathlib.Path
476 ) -> None:
477 muse_dir = tmp_path / "muse"
478 git_dir = tmp_path / "git"
479 _make_muse_repo(muse_dir)
480 _make_git_repo(git_dir)
481
482 from muse.core.errors import ExitCode
483 r = _invoke(
484 "bridge", "git-export",
485 "--git-dir", str(git_dir),
486 "--muse-ref", "nonexistent-branch",
487 "--no-push",
488 cwd=muse_dir,
489 )
490 assert r.exit_code == ExitCode.USER_ERROR
491
492 def test_allow_empty_creates_commit_with_no_changes(
493 self, tmp_path: pathlib.Path
494 ) -> None:
495 muse_dir = tmp_path / "muse"
496 git_dir = tmp_path / "git"
497 _make_muse_repo(muse_dir)
498 _make_git_repo(git_dir)
499
500 # First export — creates a commit
501 r1 = _invoke(
502 "bridge", "git-export",
503 "--git-dir", str(git_dir),
504 "--no-push",
505 "--json",
506 cwd=muse_dir,
507 )
508 assert r1.exit_code == 0, r1.stderr
509 sha1 = _git_latest_sha(git_dir)
510
511 # Second export with --allow-empty — should create another commit even though
512 # nothing changed
513 r2 = _invoke(
514 "bridge", "git-export",
515 "--git-dir", str(git_dir),
516 "--no-push",
517 "--allow-empty",
518 "--json",
519 cwd=muse_dir,
520 )
521 assert r2.exit_code == 0, r2.stderr
522 data2 = json.loads(r2.output.strip())
523 assert data2["git_sha"] != "", "Expected a new commit with --allow-empty"
524 sha2 = _git_latest_sha(git_dir)
525 assert sha2 != sha1
526
527 def test_spaces_in_file_path_exported_correctly(
528 self, tmp_path: pathlib.Path
529 ) -> None:
530 muse_dir = tmp_path / "muse"
531 git_dir = tmp_path / "git"
532 _make_muse_repo(muse_dir, files={
533 "file with spaces.txt": "content here\n",
534 "normal.txt": "normal\n",
535 })
536 _make_git_repo(git_dir)
537
538 r = _invoke(
539 "bridge", "git-export",
540 "--git-dir", str(git_dir),
541 "--no-push",
542 "--json",
543 cwd=muse_dir,
544 )
545 assert r.exit_code == 0, r.stderr
546
547 files = _git_file_list(git_dir, branch="muse-mirror")
548 assert "file with spaces.txt" in files
549
550 def test_git_dir_with_dirty_working_tree_proceeds(
551 self, tmp_path: pathlib.Path
552 ) -> None:
553 """Export should proceed even when the git working tree is dirty."""
554 muse_dir = tmp_path / "muse"
555 git_dir = tmp_path / "git"
556 _make_muse_repo(muse_dir)
557 _make_git_repo(git_dir)
558
559 # Dirty the git working tree
560 (git_dir / "dirty_file.txt").write_text("uncommitted change")
561
562 r = _invoke(
563 "bridge", "git-export",
564 "--git-dir", str(git_dir),
565 "--no-push",
566 "--json",
567 cwd=muse_dir,
568 )
569 assert r.exit_code == 0, f"Expected success with dirty git tree:\n{r.stderr}"
570
571
572 # ---------------------------------------------------------------------------
573 # Tier 4 — Stress
574 # ---------------------------------------------------------------------------
575
576
577 class TestStress:
578 """High-volume export tests."""
579
580 @pytest.mark.timeout(15)
581 def test_export_500_files_completes_under_15s(
582 self, tmp_path: pathlib.Path
583 ) -> None:
584 muse_dir = tmp_path / "muse"
585 git_dir = tmp_path / "git"
586
587 # Create a muse repo with 500 files
588 files = {f"file_{i:04d}.txt": f"content {i}\n" for i in range(500)}
589 _make_muse_repo(muse_dir, files=files)
590 _make_git_repo(git_dir)
591
592 start = time.monotonic()
593 r = _invoke(
594 "bridge", "git-export",
595 "--git-dir", str(git_dir),
596 "--no-push",
597 "--json",
598 cwd=muse_dir,
599 )
600 elapsed = time.monotonic() - start
601
602 assert r.exit_code == 0, r.stderr
603 assert elapsed < 15.0, f"Export took {elapsed:.1f}s > 15s"
604
605 data = json.loads(r.output.strip())
606 # muse init also creates .museattributes and .museignore → 502 total
607 assert data["files_written"] >= 500
608
609
610 # ---------------------------------------------------------------------------
611 # Tier 5 — Data Integrity
612 # ---------------------------------------------------------------------------
613
614
615 class TestDataIntegrity:
616 """SHA-256 correctness, message traceability, bridge state accuracy."""
617
618 def test_exported_file_sha256_matches_muse_object_store(
619 self, tmp_path: pathlib.Path
620 ) -> None:
621 muse_dir = tmp_path / "muse"
622 git_dir = tmp_path / "git"
623 files = {
624 "alpha.txt": "alpha content\n",
625 "beta.py": "x = 1\n",
626 }
627 _make_muse_repo(muse_dir, files=files)
628 _make_git_repo(git_dir)
629
630 r = _invoke(
631 "bridge", "git-export",
632 "--git-dir", str(git_dir),
633 "--no-push",
634 "--json",
635 cwd=muse_dir,
636 )
637 assert r.exit_code == 0, r.stderr
638
639 # Every exported file content must match what's in the Muse snapshot
640 for rel, content_str in files.items():
641 exported_bytes = _git_file_content(git_dir, rel)
642 expected_bytes = content_str.encode()
643 assert exported_bytes == expected_bytes, (
644 f"Content mismatch for {rel}: "
645 f"got {exported_bytes!r}, expected {expected_bytes!r}"
646 )
647
648 def test_git_commit_message_contains_muse_commit_id(
649 self, tmp_path: pathlib.Path
650 ) -> None:
651 muse_dir = tmp_path / "muse"
652 git_dir = tmp_path / "git"
653 _make_muse_repo(muse_dir)
654 _make_git_repo(git_dir)
655
656 r = _invoke(
657 "bridge", "git-export",
658 "--git-dir", str(git_dir),
659 "--no-push",
660 "--json",
661 cwd=muse_dir,
662 )
663 assert r.exit_code == 0, r.stderr
664 data = json.loads(r.output.strip())
665 muse_commit_id = data["muse_commit_id"]
666
667 git_msg = _git_latest_message(git_dir)
668 assert muse_commit_id in git_msg, (
669 f"Muse commit ID {muse_commit_id!r} not in git message {git_msg!r}"
670 )
671
672 def test_bridge_state_git_sha_matches_git_log(
673 self, tmp_path: pathlib.Path
674 ) -> None:
675 muse_dir = tmp_path / "muse"
676 git_dir = tmp_path / "git"
677 _make_muse_repo(muse_dir)
678 _make_git_repo(git_dir)
679
680 _invoke(
681 "bridge", "git-export",
682 "--git-dir", str(git_dir),
683 "--no-push",
684 cwd=muse_dir,
685 )
686
687 from muse.core.bridge.state import read_bridge_state
688 state = read_bridge_state(muse_dir)
689 bridge_sha = state["last_export"].get("git_sha", "")
690 actual_sha = _git_latest_sha(git_dir)
691 assert bridge_sha == actual_sha, (
692 f"Bridge state git_sha {bridge_sha!r} != git HEAD {actual_sha!r}"
693 )
694
695 def test_muse_commit_id_in_json_output_matches_bridge_state(
696 self, tmp_path: pathlib.Path
697 ) -> None:
698 muse_dir = tmp_path / "muse"
699 git_dir = tmp_path / "git"
700 _make_muse_repo(muse_dir)
701 _make_git_repo(git_dir)
702
703 r = _invoke(
704 "bridge", "git-export",
705 "--git-dir", str(git_dir),
706 "--no-push",
707 "--json",
708 cwd=muse_dir,
709 )
710 assert r.exit_code == 0, r.stderr
711 data = json.loads(r.output.strip())
712 json_commit_id = data["muse_commit_id"]
713
714 from muse.core.bridge.state import read_bridge_state
715 state = read_bridge_state(muse_dir)
716 bridge_commit_id = state["last_export"].get("muse_commit_id", "")
717
718 assert json_commit_id == bridge_commit_id
719
720
721 # ---------------------------------------------------------------------------
722 # Tier 6 — Performance
723 # ---------------------------------------------------------------------------
724
725
726 class TestPerformance:
727 """Time-gated performance tests."""
728
729 @pytest.mark.timeout(5)
730 def test_delete_replace_200_files_under_5s(
731 self, tmp_path: pathlib.Path
732 ) -> None:
733 muse_dir = tmp_path / "muse"
734 git_dir = tmp_path / "git"
735 files = {f"perf_{i:04d}.txt": f"line {i}\n" for i in range(200)}
736 _make_muse_repo(muse_dir, files=files)
737 _make_git_repo(git_dir)
738
739 # First export to populate git with 200 files
740 r1 = _invoke(
741 "bridge", "git-export",
742 "--git-dir", str(git_dir),
743 "--no-push",
744 cwd=muse_dir,
745 )
746 assert r1.exit_code == 0, r1.stderr
747
748 # Second export should delete+replace 200 files quickly
749 start = time.monotonic()
750 r2 = _invoke(
751 "bridge", "git-export",
752 "--git-dir", str(git_dir),
753 "--no-push",
754 "--allow-empty",
755 cwd=muse_dir,
756 )
757 elapsed = time.monotonic() - start
758 assert r2.exit_code == 0, r2.stderr
759 assert elapsed < 5.0, f"Delete+replace took {elapsed:.1f}s > 5s"
760
761 @pytest.mark.timeout(10)
762 def test_full_export_cycle_200_files_under_10s(
763 self, tmp_path: pathlib.Path
764 ) -> None:
765 muse_dir = tmp_path / "muse"
766 git_dir = tmp_path / "git"
767 files = {f"cycle_{i:04d}.txt": f"data {i}\n" for i in range(200)}
768 _make_muse_repo(muse_dir, files=files)
769 _make_git_repo(git_dir)
770
771 start = time.monotonic()
772 r = _invoke(
773 "bridge", "git-export",
774 "--git-dir", str(git_dir),
775 "--no-push",
776 "--json",
777 cwd=muse_dir,
778 )
779 elapsed = time.monotonic() - start
780
781 assert r.exit_code == 0, r.stderr
782 assert elapsed < 10.0, f"Full export took {elapsed:.1f}s > 10s"
783
784
785 # ---------------------------------------------------------------------------
786 # Tier 7 — Security
787 # ---------------------------------------------------------------------------
788
789
790 class TestSecurity:
791 """Shell injection, branch name safety, fix-modes scope."""
792
793 def test_shell_injection_in_commit_message_is_safe(
794 self, tmp_path: pathlib.Path
795 ) -> None:
796 """--commit-message with shell metacharacters must not be shell-expanded."""
797 muse_dir = tmp_path / "muse"
798 git_dir = tmp_path / "git"
799 _make_muse_repo(muse_dir)
800 _make_git_repo(git_dir)
801
802 evil_msg = "mirror: muse {commit_id}; echo INJECTED"
803 r = _invoke(
804 "bridge", "git-export",
805 "--git-dir", str(git_dir),
806 "--no-push",
807 "--commit-message", evil_msg,
808 "--json",
809 cwd=muse_dir,
810 )
811 assert r.exit_code == 0, r.stderr
812
813 # The git commit message should contain the literal semicolon — not execute it
814 full_msg = subprocess.run(
815 ["git", "-C", str(git_dir), "log", "--format=%B", "-1"],
816 capture_output=True,
817 ).stdout.decode()
818 assert "INJECTED" not in full_msg or ";" in full_msg # semicolon is in the message verbatim
819
820 def test_git_branch_with_semicolon_is_rejected(
821 self, tmp_path: pathlib.Path
822 ) -> None:
823 """--git-branch with a semicolon should exit with USER_ERROR."""
824 muse_dir = tmp_path / "muse"
825 git_dir = tmp_path / "git"
826 _make_muse_repo(muse_dir)
827 _make_git_repo(git_dir)
828
829 from muse.core.errors import ExitCode
830 r = _invoke(
831 "bridge", "git-export",
832 "--git-dir", str(git_dir),
833 "--git-branch", "branch;evil",
834 "--no-push",
835 cwd=muse_dir,
836 )
837 assert r.exit_code == ExitCode.USER_ERROR
838
839 def test_fix_modes_does_not_chmod_git_directory(
840 self, tmp_path: pathlib.Path
841 ) -> None:
842 """--fix-modes must never touch .git/ internals."""
843 muse_dir = tmp_path / "muse"
844 git_dir = tmp_path / "git"
845 _make_muse_repo(muse_dir)
846 _make_git_repo(git_dir)
847
848 # Record .git/ modes before export
849 git_config = git_dir / ".git" / "config"
850 mode_before = git_config.stat().st_mode
851
852 r = _invoke(
853 "bridge", "git-export",
854 "--git-dir", str(git_dir),
855 "--no-push",
856 "--fix-modes",
857 "--json",
858 cwd=muse_dir,
859 )
860 assert r.exit_code == 0, r.stderr
861
862 mode_after = git_config.stat().st_mode
863 assert mode_before == mode_after, (
864 f".git/config mode changed from {oct(mode_before)} to {oct(mode_after)}"
865 )
866
867
868 # ---------------------------------------------------------------------------
869 # Tier 7b — Shebang-based executable bit (issue #38)
870 # ---------------------------------------------------------------------------
871
872
873 class TestHasShebang:
874 """Unit tests for GitExporter._has_shebang."""
875
876 def test_bash_shebang(self, tmp_path: pathlib.Path) -> None:
877 from muse.core.bridge.exporter import GitExporter
878 f = tmp_path / "run.sh"
879 f.write_bytes(b"#!/usr/bin/env bash\necho hi\n")
880 assert GitExporter._has_shebang(f) is True
881
882 def test_node_shebang(self, tmp_path: pathlib.Path) -> None:
883 from muse.core.bridge.exporter import GitExporter
884 f = tmp_path / "run.mjs"
885 f.write_bytes(b"#!/usr/bin/env node\nconsole.log('hi');\n")
886 assert GitExporter._has_shebang(f) is True
887
888 def test_python_shebang_no_env(self, tmp_path: pathlib.Path) -> None:
889 from muse.core.bridge.exporter import GitExporter
890 f = tmp_path / "run.py"
891 f.write_bytes(b"#!/usr/bin/python3\nprint('hi')\n")
892 assert GitExporter._has_shebang(f) is True
893
894 def test_plain_text_no_shebang(self, tmp_path: pathlib.Path) -> None:
895 from muse.core.bridge.exporter import GitExporter
896 f = tmp_path / "README.md"
897 f.write_bytes(b"# title\nhello\n")
898 assert GitExporter._has_shebang(f) is False
899
900 def test_empty_file_no_shebang(self, tmp_path: pathlib.Path) -> None:
901 from muse.core.bridge.exporter import GitExporter
902 f = tmp_path / "empty"
903 f.write_bytes(b"")
904 assert GitExporter._has_shebang(f) is False
905
906 def test_one_byte_file_no_shebang(self, tmp_path: pathlib.Path) -> None:
907 from muse.core.bridge.exporter import GitExporter
908 f = tmp_path / "x"
909 f.write_bytes(b"#")
910 assert GitExporter._has_shebang(f) is False
911
912 def test_shebang_after_leading_whitespace_is_not_shebang(self, tmp_path: pathlib.Path) -> None:
913 from muse.core.bridge.exporter import GitExporter
914 f = tmp_path / "x.sh"
915 f.write_bytes(b" #!/usr/bin/env bash\n")
916 assert GitExporter._has_shebang(f) is False
917
918 def test_nonexistent_file_returns_false(self, tmp_path: pathlib.Path) -> None:
919 from muse.core.bridge.exporter import GitExporter
920 assert GitExporter._has_shebang(tmp_path / "nonexistent") is False
921
922
923 class TestFixFileModesShebang:
924 """fix_file_modes applies 0o755 to shebang files, 0o644 to others."""
925
926 def _build_exporter(self, git_dir: pathlib.Path) -> "GitExporter":
927 from unittest.mock import MagicMock
928 from muse.core.bridge.exporter import GitExporter
929 e = MagicMock(spec=GitExporter)
930 e.git_dir = git_dir
931 e.fix_file_modes = GitExporter.fix_file_modes.__get__(e, GitExporter)
932 return e
933
934 def test_shebang_script_gets_755(self, tmp_path: pathlib.Path) -> None:
935 git_dir = tmp_path / "g"
936 git_dir.mkdir()
937 (git_dir / "scripts").mkdir()
938 script = git_dir / "scripts" / "run.sh"
939 script.write_bytes(b"#!/usr/bin/env bash\necho hi\n")
940 readme = git_dir / "README.md"
941 readme.write_bytes(b"# hello\n")
942
943 e = self._build_exporter(git_dir)
944 e.fix_file_modes({"scripts/run.sh": "sha256:fake", "README.md": "sha256:fake"})
945
946 assert script.stat().st_mode & 0o777 == 0o755
947 assert readme.stat().st_mode & 0o777 == 0o644
948
949 def test_node_script_gets_755(self, tmp_path: pathlib.Path) -> None:
950 git_dir = tmp_path / "g"
951 git_dir.mkdir()
952 f = git_dir / "cli.mjs"
953 f.write_bytes(b"#!/usr/bin/env node\nconsole.log('hi');\n")
954
955 e = self._build_exporter(git_dir)
956 e.fix_file_modes({"cli.mjs": "sha256:fake"})
957
958 assert f.stat().st_mode & 0o777 == 0o755
959
960 def test_regular_file_gets_644(self, tmp_path: pathlib.Path) -> None:
961 git_dir = tmp_path / "g"
962 git_dir.mkdir()
963 f = git_dir / "main.py"
964 f.write_bytes(b"print('hi')\n")
965
966 e = self._build_exporter(git_dir)
967 e.fix_file_modes({"main.py": "sha256:fake"})
968
969 assert f.stat().st_mode & 0o777 == 0o644
970
971 def test_setuid_bit_never_set(self, tmp_path: pathlib.Path) -> None:
972 git_dir = tmp_path / "g"
973 git_dir.mkdir()
974 f = git_dir / "evil.sh"
975 f.write_bytes(b"#!/bin/sh\n")
976 f.chmod(0o4755) # pre-set setuid
977
978 e = self._build_exporter(git_dir)
979 e.fix_file_modes({"evil.sh": "sha256:fake"})
980
981 mode = f.stat().st_mode & 0o7777
982 assert mode == 0o755, f"setuid not cleared: {oct(mode)}"
983
984 def test_dotgit_still_never_touched(self, tmp_path: pathlib.Path) -> None:
985 git_dir = tmp_path / "g"
986 (git_dir / ".git").mkdir(parents=True)
987 gitfile = git_dir / ".git" / "HEAD"
988 gitfile.write_bytes(b"ref: refs/heads/main\n")
989 original_mode = gitfile.stat().st_mode
990
991 e = self._build_exporter(git_dir)
992 e.fix_file_modes({".git/HEAD": "sha256:fake"})
993
994 assert gitfile.stat().st_mode == original_mode
995
996
997 class TestBridgeExportShebangEndToEnd:
998 """Full bridge git-export round-trip: shebang script lands as git 100755."""
999
1000 def test_executable_script_exported_as_100755(self, tmp_path: pathlib.Path) -> None:
1001 muse_dir = tmp_path / "muse"
1002 git_dir = tmp_path / "git"
1003
1004 _make_muse_repo(muse_dir, files={
1005 "scripts/deploy.sh": "#!/usr/bin/env bash\necho deploy\n",
1006 "README.md": "# hello\n",
1007 })
1008 # Make the script executable in the working tree (for realism; muse
1009 # doesn't store mode, so the bridge must derive it from content).
1010 (muse_dir / "scripts" / "deploy.sh").chmod(0o755)
1011
1012 _make_git_repo(git_dir)
1013
1014 r = _invoke(
1015 "bridge", "git-export",
1016 "--git-dir", str(git_dir),
1017 "--no-push",
1018 "--fix-modes",
1019 "--json",
1020 cwd=muse_dir,
1021 )
1022 assert r.exit_code == 0, r.stderr
1023
1024 result = subprocess.run(
1025 ["git", "-C", str(git_dir), "ls-tree", "-r", "muse-mirror"],
1026 capture_output=True, text=True,
1027 )
1028 assert result.returncode == 0
1029 lines = result.stdout.splitlines()
1030 by_path = {parts[3]: parts[0] for l in lines if len(parts := l.split()) == 4}
1031 assert by_path.get("scripts/deploy.sh") == "100755", (
1032 f"expected 100755 for deploy.sh, got: {by_path}"
1033 )
1034 assert by_path.get("README.md") == "100644"
1035
1036 def test_fix_modes_default_is_true(self, tmp_path: pathlib.Path) -> None:
1037 """--fix-modes should default to True so exec bits are restored without opt-in."""
1038 muse_dir = tmp_path / "muse"
1039 git_dir = tmp_path / "git"
1040 _make_muse_repo(muse_dir, files={
1041 "run.sh": "#!/usr/bin/env bash\necho hi\n",
1042 })
1043 _make_git_repo(git_dir)
1044
1045 # No --fix-modes flag — should apply by default.
1046 r = _invoke(
1047 "bridge", "git-export",
1048 "--git-dir", str(git_dir),
1049 "--no-push",
1050 "--json",
1051 cwd=muse_dir,
1052 )
1053 assert r.exit_code == 0, r.stderr
1054
1055 # Check on-disk mode of the exported file.
1056 exported = git_dir / "run.sh"
1057 assert exported.stat().st_mode & 0o777 == 0o755
1058
1059 def test_no_fix_modes_flag_leaves_modes_unchanged(self, tmp_path: pathlib.Path) -> None:
1060 """--no-fix-modes disables mode correction entirely."""
1061 muse_dir = tmp_path / "muse"
1062 git_dir = tmp_path / "git"
1063 _make_muse_repo(muse_dir, files={
1064 "run.sh": "#!/usr/bin/env bash\necho hi\n",
1065 })
1066 _make_git_repo(git_dir)
1067
1068 r = _invoke(
1069 "bridge", "git-export",
1070 "--git-dir", str(git_dir),
1071 "--no-push",
1072 "--no-fix-modes",
1073 "--json",
1074 cwd=muse_dir,
1075 )
1076 assert r.exit_code == 0, r.stderr
1077
1078 # With --no-fix-modes the file should NOT have been chmodded to 755.
1079 exported = git_dir / "run.sh"
1080 mode = exported.stat().st_mode & 0o777
1081 assert mode != 0o755, f"expected non-755 with --no-fix-modes, got {oct(mode)}"
1082
1083
1084 # ---------------------------------------------------------------------------
1085 # Tier 8 — Docstrings
1086 # ---------------------------------------------------------------------------
1087
1088
1089 class TestDocstrings:
1090 """Implementation docstrings are present."""
1091
1092 def test_git_exporter_has_class_docstring(self) -> None:
1093 from muse.core.bridge.exporter import GitExporter
1094 assert GitExporter.__doc__ is not None
1095 assert len(GitExporter.__doc__.strip()) > 20
1096
1097 def test_sync_to_git_has_docstring(self) -> None:
1098 from muse.core.bridge.exporter import GitExporter
1099 assert GitExporter.sync_to_git.__doc__ is not None
1100 assert len(GitExporter.sync_to_git.__doc__.strip()) > 10
1101
1102 def test_run_git_export_has_docstring_mentioning_ci(self) -> None:
1103 from muse.core.bridge.exporter import run_git_export
1104 doc = run_git_export.__doc__ or ""
1105 assert len(doc.strip()) > 20
1106 assert "CI" in doc or "MUSE_AGENT" in doc, (
1107 f"run_git_export docstring should mention CI env vars, got: {doc[:200]!r}"
1108 )
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 28 days ago