gabriel / muse public
test_bridge_git_export.py python
1,320 lines 45.4 KB
Raw
sha256:d25a7c7c4fbf0db573ca9a8db4f8f88f8e88ae732d8c7cdfa2624ebeabd130f0 fix: git-export deletes ignored/untracked local files not o… Sonnet 4.6 minor ⚠ breaking 11 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 5b — Ignored/untracked file protection (issue #65 regression)
723 #
724 # Root cause: sync_to_git's delete pass removed every file under --git-dir
725 # not on a hardcoded/--exclude list, without checking whether Muse had ever
726 # actually written that path. Ignored, untracked local files (.env,
727 # config/local.yaml) that were never part of any Muse snapshot were
728 # permanently deleted. Fix: track the manifest paths from the *previous*
729 # successful export (sidecar JSON) and only ever consider
730 # owned_before - owned_now as delete candidates — never an unconditional
731 # sweep of the whole working tree. Ignore-rule consultation (.gitignore via
732 # `git check-ignore`, .museignore) is a second, independent safety net on
733 # top of that for the case where a delete candidate's path was legitimately
734 # Muse-owned before but is now also gitignored.
735 # ---------------------------------------------------------------------------
736
737
738 class TestIgnoredFileProtection:
739 """Ignored/untracked local files must never be deleted by git-export."""
740
741 def test_untracked_gitignored_file_survives_export(
742 self, tmp_path: pathlib.Path
743 ) -> None:
744 """The exact issue #65 reproduction: .env and config/local.yaml
745 survive a git-export run against a populated --git-dir."""
746 muse_dir = tmp_path / "muse"
747 git_dir = tmp_path / "git"
748 _make_muse_repo(muse_dir, files={"tracked.txt": "v1\n"})
749 _make_git_repo(git_dir)
750
751 (git_dir / ".gitignore").write_text(".env\nconfig/local.yaml\n")
752 (git_dir / "config").mkdir(parents=True, exist_ok=True)
753 (git_dir / ".env").write_text("SECRET=keep\n")
754 (git_dir / "config" / "local.yaml").write_text("x: keep\n")
755
756 r = _invoke(
757 "bridge", "git-export",
758 "--git-dir", str(git_dir),
759 "--no-push",
760 "--json",
761 cwd=muse_dir,
762 )
763 assert r.exit_code == 0, r.stderr
764
765 assert (git_dir / ".env").exists(), ".env was deleted by git-export"
766 assert (git_dir / ".env").read_text() == "SECRET=keep\n"
767 assert (git_dir / "config" / "local.yaml").exists(), (
768 "config/local.yaml was deleted by git-export"
769 )
770
771 def test_untracked_unignored_file_survives_first_export(
772 self, tmp_path: pathlib.Path
773 ) -> None:
774 """A file with no ignore rule at all still survives a first export —
775 ownership (was this path ever in a Muse-written manifest) is the
776 real protection, not merely whether the path happens to be
777 gitignored."""
778 muse_dir = tmp_path / "muse"
779 git_dir = tmp_path / "git"
780 _make_muse_repo(muse_dir, files={"tracked.txt": "v1\n"})
781 _make_git_repo(git_dir)
782
783 (git_dir / "scratch-notes.txt").write_text("not muse's business\n")
784
785 r = _invoke(
786 "bridge", "git-export",
787 "--git-dir", str(git_dir),
788 "--no-push",
789 "--json",
790 cwd=muse_dir,
791 )
792 assert r.exit_code == 0, r.stderr
793 assert (git_dir / "scratch-notes.txt").exists()
794
795 def test_previously_owned_file_removed_when_dropped_from_manifest(
796 self, tmp_path: pathlib.Path
797 ) -> None:
798 """A file Muse legitimately wrote in a prior export, then removed
799 from the Muse snapshot, must still be deleted on the next export —
800 the fix must not break legitimate stale-file cleanup."""
801 muse_dir = tmp_path / "muse"
802 git_dir = tmp_path / "git"
803 _make_muse_repo(
804 muse_dir, files={"tracked.txt": "v1\n", "old_module.py": "x = 1\n"}
805 )
806 _make_git_repo(git_dir)
807
808 r1 = _invoke(
809 "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json",
810 cwd=muse_dir,
811 )
812 assert r1.exit_code == 0, r1.stderr
813 assert (git_dir / "old_module.py").exists()
814
815 # Remove old_module.py from the Muse snapshot and commit.
816 (muse_dir / "old_module.py").unlink()
817 rm = _invoke("code", "add", "-u", cwd=muse_dir)
818 assert rm.exit_code == 0, rm.stderr
819 commit2 = _invoke("commit", "-m", "remove old_module", cwd=muse_dir)
820 assert commit2.exit_code == 0, commit2.stderr
821
822 r2 = _invoke(
823 "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json",
824 cwd=muse_dir,
825 )
826 assert r2.exit_code == 0, r2.stderr
827 assert not (git_dir / "old_module.py").exists(), (
828 "old_module.py should have been removed — it was legitimately "
829 "dropped from the Muse snapshot, not merely untracked"
830 )
831
832 def test_reused_path_protected_when_currently_museignored(
833 self, tmp_path: pathlib.Path
834 ) -> None:
835 """A path that was Muse-owned before, is no longer in the manifest,
836 but the file currently at that path is museignored — must be
837 protected unless --allow-delete-ignored is passed. This is the
838 defense-in-depth case ignore-checking exists for, distinct from the
839 ownership-tracking fix above.
840
841 Uses .museignore rather than .gitignore for this specific scenario:
842 `git check-ignore` never reports a path as ignored while it is still
843 tracked in the git index (standard, correct git behavior — a
844 tracked file overrides gitignore rules everywhere in git, not just
845 here). Since the exporter's own prior commit tracked this path, a
846 freshly-added .gitignore rule for it would not be honored by
847 `git check-ignore --stdin` until an actual `git rm --cached` (or
848 equivalent) removes it from the index first. .museignore has no such
849 limitation — it is Muse's own policy layer, evaluated independently
850 of git's tracking state, so it is the mechanism that actually
851 protects a reused, previously-tracked path.
852 """
853 muse_dir = tmp_path / "muse"
854 git_dir = tmp_path / "git"
855 _make_muse_repo(
856 muse_dir, files={"tracked.txt": "v1\n", "config/local.yaml": "from muse\n"}
857 )
858 _make_git_repo(git_dir)
859
860 r1 = _invoke(
861 "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json",
862 cwd=muse_dir,
863 )
864 assert r1.exit_code == 0, r1.stderr
865 assert (git_dir / "config" / "local.yaml").exists()
866
867 # Drop config/local.yaml from the Muse snapshot.
868 (muse_dir / "config" / "local.yaml").unlink()
869 rm = _invoke("code", "add", "-u", cwd=muse_dir)
870 assert rm.exit_code == 0, rm.stderr
871 commit2 = _invoke("commit", "-m", "remove config/local.yaml", cwd=muse_dir)
872 assert commit2.exit_code == 0, commit2.stderr
873
874 # The human replaces it locally with their own file at the exact
875 # same path, and marks it museignored, in between export runs.
876 (muse_dir / ".museignore").write_text(
877 '[global]\npatterns = ["config/local.yaml"]\n'
878 )
879 (git_dir / "config" / "local.yaml").write_text("my own local secret\n")
880
881 r2 = _invoke(
882 "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json",
883 cwd=muse_dir,
884 )
885 assert r2.exit_code == 0, r2.stderr
886 assert (git_dir / "config" / "local.yaml").exists(), (
887 "museignored file at a reused path must survive without "
888 "--allow-delete-ignored"
889 )
890 assert (git_dir / "config" / "local.yaml").read_text() == "my own local secret\n"
891
892 def test_allow_delete_ignored_flag_overrides_protection(
893 self, tmp_path: pathlib.Path
894 ) -> None:
895 """--allow-delete-ignored is the explicit escape hatch — with it
896 passed, a reused, currently-museignored, no-longer-owned path is
897 deleted as before this fix."""
898 muse_dir = tmp_path / "muse"
899 git_dir = tmp_path / "git"
900 _make_muse_repo(
901 muse_dir, files={"tracked.txt": "v1\n", "config/local.yaml": "from muse\n"}
902 )
903 _make_git_repo(git_dir)
904
905 _invoke(
906 "bridge", "git-export", "--git-dir", str(git_dir), "--no-push", "--json",
907 cwd=muse_dir,
908 )
909
910 (muse_dir / "config" / "local.yaml").unlink()
911 _invoke("code", "add", "-u", cwd=muse_dir)
912 _invoke("commit", "-m", "remove config/local.yaml", cwd=muse_dir)
913
914 (muse_dir / ".museignore").write_text(
915 '[global]\npatterns = ["config/local.yaml"]\n'
916 )
917 (git_dir / "config" / "local.yaml").write_text("my own local secret\n")
918
919 r2 = _invoke(
920 "bridge", "git-export",
921 "--git-dir", str(git_dir),
922 "--no-push",
923 "--allow-delete-ignored",
924 "--json",
925 cwd=muse_dir,
926 )
927 assert r2.exit_code == 0, r2.stderr
928 assert not (git_dir / "config" / "local.yaml").exists(), (
929 "--allow-delete-ignored should have permitted deletion"
930 )
931
932
933 # ---------------------------------------------------------------------------
934 # Tier 6 — Performance
935 # ---------------------------------------------------------------------------
936
937
938 class TestPerformance:
939 """Time-gated performance tests."""
940
941 @pytest.mark.timeout(5)
942 def test_delete_replace_200_files_under_5s(
943 self, tmp_path: pathlib.Path
944 ) -> None:
945 muse_dir = tmp_path / "muse"
946 git_dir = tmp_path / "git"
947 files = {f"perf_{i:04d}.txt": f"line {i}\n" for i in range(200)}
948 _make_muse_repo(muse_dir, files=files)
949 _make_git_repo(git_dir)
950
951 # First export to populate git with 200 files
952 r1 = _invoke(
953 "bridge", "git-export",
954 "--git-dir", str(git_dir),
955 "--no-push",
956 cwd=muse_dir,
957 )
958 assert r1.exit_code == 0, r1.stderr
959
960 # Second export should delete+replace 200 files quickly
961 start = time.monotonic()
962 r2 = _invoke(
963 "bridge", "git-export",
964 "--git-dir", str(git_dir),
965 "--no-push",
966 "--allow-empty",
967 cwd=muse_dir,
968 )
969 elapsed = time.monotonic() - start
970 assert r2.exit_code == 0, r2.stderr
971 assert elapsed < 5.0, f"Delete+replace took {elapsed:.1f}s > 5s"
972
973 @pytest.mark.timeout(10)
974 def test_full_export_cycle_200_files_under_10s(
975 self, tmp_path: pathlib.Path
976 ) -> None:
977 muse_dir = tmp_path / "muse"
978 git_dir = tmp_path / "git"
979 files = {f"cycle_{i:04d}.txt": f"data {i}\n" for i in range(200)}
980 _make_muse_repo(muse_dir, files=files)
981 _make_git_repo(git_dir)
982
983 start = time.monotonic()
984 r = _invoke(
985 "bridge", "git-export",
986 "--git-dir", str(git_dir),
987 "--no-push",
988 "--json",
989 cwd=muse_dir,
990 )
991 elapsed = time.monotonic() - start
992
993 assert r.exit_code == 0, r.stderr
994 assert elapsed < 10.0, f"Full export took {elapsed:.1f}s > 10s"
995
996
997 # ---------------------------------------------------------------------------
998 # Tier 7 — Security
999 # ---------------------------------------------------------------------------
1000
1001
1002 class TestSecurity:
1003 """Shell injection, branch name safety, fix-modes scope."""
1004
1005 def test_shell_injection_in_commit_message_is_safe(
1006 self, tmp_path: pathlib.Path
1007 ) -> None:
1008 """--commit-message with shell metacharacters must not be shell-expanded."""
1009 muse_dir = tmp_path / "muse"
1010 git_dir = tmp_path / "git"
1011 _make_muse_repo(muse_dir)
1012 _make_git_repo(git_dir)
1013
1014 evil_msg = "mirror: muse {commit_id}; echo INJECTED"
1015 r = _invoke(
1016 "bridge", "git-export",
1017 "--git-dir", str(git_dir),
1018 "--no-push",
1019 "--commit-message", evil_msg,
1020 "--json",
1021 cwd=muse_dir,
1022 )
1023 assert r.exit_code == 0, r.stderr
1024
1025 # The git commit message should contain the literal semicolon — not execute it
1026 full_msg = subprocess.run(
1027 ["git", "-C", str(git_dir), "log", "--format=%B", "-1"],
1028 capture_output=True,
1029 ).stdout.decode()
1030 assert "INJECTED" not in full_msg or ";" in full_msg # semicolon is in the message verbatim
1031
1032 def test_git_branch_with_semicolon_is_rejected(
1033 self, tmp_path: pathlib.Path
1034 ) -> None:
1035 """--git-branch with a semicolon should exit with USER_ERROR."""
1036 muse_dir = tmp_path / "muse"
1037 git_dir = tmp_path / "git"
1038 _make_muse_repo(muse_dir)
1039 _make_git_repo(git_dir)
1040
1041 from muse.core.errors import ExitCode
1042 r = _invoke(
1043 "bridge", "git-export",
1044 "--git-dir", str(git_dir),
1045 "--git-branch", "branch;evil",
1046 "--no-push",
1047 cwd=muse_dir,
1048 )
1049 assert r.exit_code == ExitCode.USER_ERROR
1050
1051 def test_fix_modes_does_not_chmod_git_directory(
1052 self, tmp_path: pathlib.Path
1053 ) -> None:
1054 """--fix-modes must never touch .git/ internals."""
1055 muse_dir = tmp_path / "muse"
1056 git_dir = tmp_path / "git"
1057 _make_muse_repo(muse_dir)
1058 _make_git_repo(git_dir)
1059
1060 # Record .git/ modes before export
1061 git_config = git_dir / ".git" / "config"
1062 mode_before = git_config.stat().st_mode
1063
1064 r = _invoke(
1065 "bridge", "git-export",
1066 "--git-dir", str(git_dir),
1067 "--no-push",
1068 "--fix-modes",
1069 "--json",
1070 cwd=muse_dir,
1071 )
1072 assert r.exit_code == 0, r.stderr
1073
1074 mode_after = git_config.stat().st_mode
1075 assert mode_before == mode_after, (
1076 f".git/config mode changed from {oct(mode_before)} to {oct(mode_after)}"
1077 )
1078
1079
1080 # ---------------------------------------------------------------------------
1081 # Tier 7b — Shebang-based executable bit (issue #38)
1082 # ---------------------------------------------------------------------------
1083
1084
1085 class TestHasShebang:
1086 """Unit tests for GitExporter._has_shebang."""
1087
1088 def test_bash_shebang(self, tmp_path: pathlib.Path) -> None:
1089 from muse.core.bridge.exporter import GitExporter
1090 f = tmp_path / "run.sh"
1091 f.write_bytes(b"#!/usr/bin/env bash\necho hi\n")
1092 assert GitExporter._has_shebang(f) is True
1093
1094 def test_node_shebang(self, tmp_path: pathlib.Path) -> None:
1095 from muse.core.bridge.exporter import GitExporter
1096 f = tmp_path / "run.mjs"
1097 f.write_bytes(b"#!/usr/bin/env node\nconsole.log('hi');\n")
1098 assert GitExporter._has_shebang(f) is True
1099
1100 def test_python_shebang_no_env(self, tmp_path: pathlib.Path) -> None:
1101 from muse.core.bridge.exporter import GitExporter
1102 f = tmp_path / "run.py"
1103 f.write_bytes(b"#!/usr/bin/python3\nprint('hi')\n")
1104 assert GitExporter._has_shebang(f) is True
1105
1106 def test_plain_text_no_shebang(self, tmp_path: pathlib.Path) -> None:
1107 from muse.core.bridge.exporter import GitExporter
1108 f = tmp_path / "README.md"
1109 f.write_bytes(b"# title\nhello\n")
1110 assert GitExporter._has_shebang(f) is False
1111
1112 def test_empty_file_no_shebang(self, tmp_path: pathlib.Path) -> None:
1113 from muse.core.bridge.exporter import GitExporter
1114 f = tmp_path / "empty"
1115 f.write_bytes(b"")
1116 assert GitExporter._has_shebang(f) is False
1117
1118 def test_one_byte_file_no_shebang(self, tmp_path: pathlib.Path) -> None:
1119 from muse.core.bridge.exporter import GitExporter
1120 f = tmp_path / "x"
1121 f.write_bytes(b"#")
1122 assert GitExporter._has_shebang(f) is False
1123
1124 def test_shebang_after_leading_whitespace_is_not_shebang(self, tmp_path: pathlib.Path) -> None:
1125 from muse.core.bridge.exporter import GitExporter
1126 f = tmp_path / "x.sh"
1127 f.write_bytes(b" #!/usr/bin/env bash\n")
1128 assert GitExporter._has_shebang(f) is False
1129
1130 def test_nonexistent_file_returns_false(self, tmp_path: pathlib.Path) -> None:
1131 from muse.core.bridge.exporter import GitExporter
1132 assert GitExporter._has_shebang(tmp_path / "nonexistent") is False
1133
1134
1135 class TestFixFileModesShebang:
1136 """fix_file_modes applies 0o755 to shebang files, 0o644 to others."""
1137
1138 def _build_exporter(self, git_dir: pathlib.Path) -> "GitExporter":
1139 from unittest.mock import MagicMock
1140 from muse.core.bridge.exporter import GitExporter
1141 e = MagicMock(spec=GitExporter)
1142 e.git_dir = git_dir
1143 e.fix_file_modes = GitExporter.fix_file_modes.__get__(e, GitExporter)
1144 return e
1145
1146 def test_shebang_script_gets_755(self, tmp_path: pathlib.Path) -> None:
1147 git_dir = tmp_path / "g"
1148 git_dir.mkdir()
1149 (git_dir / "scripts").mkdir()
1150 script = git_dir / "scripts" / "run.sh"
1151 script.write_bytes(b"#!/usr/bin/env bash\necho hi\n")
1152 readme = git_dir / "README.md"
1153 readme.write_bytes(b"# hello\n")
1154
1155 e = self._build_exporter(git_dir)
1156 e.fix_file_modes({"scripts/run.sh": "sha256:fake", "README.md": "sha256:fake"})
1157
1158 assert script.stat().st_mode & 0o777 == 0o755
1159 assert readme.stat().st_mode & 0o777 == 0o644
1160
1161 def test_node_script_gets_755(self, tmp_path: pathlib.Path) -> None:
1162 git_dir = tmp_path / "g"
1163 git_dir.mkdir()
1164 f = git_dir / "cli.mjs"
1165 f.write_bytes(b"#!/usr/bin/env node\nconsole.log('hi');\n")
1166
1167 e = self._build_exporter(git_dir)
1168 e.fix_file_modes({"cli.mjs": "sha256:fake"})
1169
1170 assert f.stat().st_mode & 0o777 == 0o755
1171
1172 def test_regular_file_gets_644(self, tmp_path: pathlib.Path) -> None:
1173 git_dir = tmp_path / "g"
1174 git_dir.mkdir()
1175 f = git_dir / "main.py"
1176 f.write_bytes(b"print('hi')\n")
1177
1178 e = self._build_exporter(git_dir)
1179 e.fix_file_modes({"main.py": "sha256:fake"})
1180
1181 assert f.stat().st_mode & 0o777 == 0o644
1182
1183 def test_setuid_bit_never_set(self, tmp_path: pathlib.Path) -> None:
1184 git_dir = tmp_path / "g"
1185 git_dir.mkdir()
1186 f = git_dir / "evil.sh"
1187 f.write_bytes(b"#!/bin/sh\n")
1188 f.chmod(0o4755) # pre-set setuid
1189
1190 e = self._build_exporter(git_dir)
1191 e.fix_file_modes({"evil.sh": "sha256:fake"})
1192
1193 mode = f.stat().st_mode & 0o7777
1194 assert mode == 0o755, f"setuid not cleared: {oct(mode)}"
1195
1196 def test_dotgit_still_never_touched(self, tmp_path: pathlib.Path) -> None:
1197 git_dir = tmp_path / "g"
1198 (git_dir / ".git").mkdir(parents=True)
1199 gitfile = git_dir / ".git" / "HEAD"
1200 gitfile.write_bytes(b"ref: refs/heads/main\n")
1201 original_mode = gitfile.stat().st_mode
1202
1203 e = self._build_exporter(git_dir)
1204 e.fix_file_modes({".git/HEAD": "sha256:fake"})
1205
1206 assert gitfile.stat().st_mode == original_mode
1207
1208
1209 class TestBridgeExportShebangEndToEnd:
1210 """Full bridge git-export round-trip: shebang script lands as git 100755."""
1211
1212 def test_executable_script_exported_as_100755(self, tmp_path: pathlib.Path) -> None:
1213 muse_dir = tmp_path / "muse"
1214 git_dir = tmp_path / "git"
1215
1216 _make_muse_repo(muse_dir, files={
1217 "scripts/deploy.sh": "#!/usr/bin/env bash\necho deploy\n",
1218 "README.md": "# hello\n",
1219 })
1220 # Make the script executable in the working tree (for realism; muse
1221 # doesn't store mode, so the bridge must derive it from content).
1222 (muse_dir / "scripts" / "deploy.sh").chmod(0o755)
1223
1224 _make_git_repo(git_dir)
1225
1226 r = _invoke(
1227 "bridge", "git-export",
1228 "--git-dir", str(git_dir),
1229 "--no-push",
1230 "--fix-modes",
1231 "--json",
1232 cwd=muse_dir,
1233 )
1234 assert r.exit_code == 0, r.stderr
1235
1236 result = subprocess.run(
1237 ["git", "-C", str(git_dir), "ls-tree", "-r", "muse-mirror"],
1238 capture_output=True, text=True,
1239 )
1240 assert result.returncode == 0
1241 lines = result.stdout.splitlines()
1242 by_path = {parts[3]: parts[0] for l in lines if len(parts := l.split()) == 4}
1243 assert by_path.get("scripts/deploy.sh") == "100755", (
1244 f"expected 100755 for deploy.sh, got: {by_path}"
1245 )
1246 assert by_path.get("README.md") == "100644"
1247
1248 def test_fix_modes_default_is_true(self, tmp_path: pathlib.Path) -> None:
1249 """--fix-modes should default to True so exec bits are restored without opt-in."""
1250 muse_dir = tmp_path / "muse"
1251 git_dir = tmp_path / "git"
1252 _make_muse_repo(muse_dir, files={
1253 "run.sh": "#!/usr/bin/env bash\necho hi\n",
1254 })
1255 _make_git_repo(git_dir)
1256
1257 # No --fix-modes flag — should apply by default.
1258 r = _invoke(
1259 "bridge", "git-export",
1260 "--git-dir", str(git_dir),
1261 "--no-push",
1262 "--json",
1263 cwd=muse_dir,
1264 )
1265 assert r.exit_code == 0, r.stderr
1266
1267 # Check on-disk mode of the exported file.
1268 exported = git_dir / "run.sh"
1269 assert exported.stat().st_mode & 0o777 == 0o755
1270
1271 def test_no_fix_modes_flag_leaves_modes_unchanged(self, tmp_path: pathlib.Path) -> None:
1272 """--no-fix-modes disables mode correction entirely."""
1273 muse_dir = tmp_path / "muse"
1274 git_dir = tmp_path / "git"
1275 _make_muse_repo(muse_dir, files={
1276 "run.sh": "#!/usr/bin/env bash\necho hi\n",
1277 })
1278 _make_git_repo(git_dir)
1279
1280 r = _invoke(
1281 "bridge", "git-export",
1282 "--git-dir", str(git_dir),
1283 "--no-push",
1284 "--no-fix-modes",
1285 "--json",
1286 cwd=muse_dir,
1287 )
1288 assert r.exit_code == 0, r.stderr
1289
1290 # With --no-fix-modes the file should NOT have been chmodded to 755.
1291 exported = git_dir / "run.sh"
1292 mode = exported.stat().st_mode & 0o777
1293 assert mode != 0o755, f"expected non-755 with --no-fix-modes, got {oct(mode)}"
1294
1295
1296 # ---------------------------------------------------------------------------
1297 # Tier 8 — Docstrings
1298 # ---------------------------------------------------------------------------
1299
1300
1301 class TestDocstrings:
1302 """Implementation docstrings are present."""
1303
1304 def test_git_exporter_has_class_docstring(self) -> None:
1305 from muse.core.bridge.exporter import GitExporter
1306 assert GitExporter.__doc__ is not None
1307 assert len(GitExporter.__doc__.strip()) > 20
1308
1309 def test_sync_to_git_has_docstring(self) -> None:
1310 from muse.core.bridge.exporter import GitExporter
1311 assert GitExporter.sync_to_git.__doc__ is not None
1312 assert len(GitExporter.sync_to_git.__doc__.strip()) > 10
1313
1314 def test_run_git_export_has_docstring_mentioning_ci(self) -> None:
1315 from muse.core.bridge.exporter import run_git_export
1316 doc = run_git_export.__doc__ or ""
1317 assert len(doc.strip()) > 20
1318 assert "CI" in doc or "MUSE_AGENT" in doc, (
1319 f"run_git_export docstring should mention CI env vars, got: {doc[:200]!r}"
1320 )
File History 1 commit
sha256:d25a7c7c4fbf0db573ca9a8db4f8f88f8e88ae732d8c7cdfa2624ebeabd130f0 fix: git-export deletes ignored/untracked local files not o… Sonnet 4.6 minor 11 days ago