gabriel / muse public
test_migrate.py python
646 lines 26.0 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Tests for ``muse code migrate``.
2
3 Coverage tiers
4 --------------
5 - Unit: _should_exclude, _manifest_directories (pure Python, no git)
6 - Integration: full migrate against a real git repo — single branch, multi-commit,
7 rename/delete, exclusion patterns, --dry-run, --all branches, --json NDJSON
8 - Regression: bare-assert fix (_CatFile raises RuntimeError, not AssertionError)
9
10 Git fixture
11 -----------
12 Tests that exercise the git integration create a minimal git repo in ``tmp_path``
13 via subprocess. This is the single approved git usage in the Muse test suite —
14 the migrate command's sole purpose is to bridge FROM git INTO Muse.
15 """
16
17 from __future__ import annotations
18
19 import hashlib
20 import json
21 import pathlib
22 import subprocess
23
24 import pytest
25
26 from muse.core._types import long_id
27 from muse.core.store import read_snapshot
28 from tests.cli_test_helper import CliRunner
29
30 runner = CliRunner()
31
32 # ---------------------------------------------------------------------------
33 # Git fixture helpers
34 # ---------------------------------------------------------------------------
35
36
37 def _git(path: pathlib.Path, *args: str) -> str:
38 result = subprocess.run(
39 ["git", *args],
40 cwd=path,
41 capture_output=True,
42 text=True,
43 check=True,
44 )
45 return result.stdout.strip()
46
47
48 def _make_git_repo(path: pathlib.Path) -> pathlib.Path:
49 """Initialise a minimal git repo with stable author/committer identity."""
50 _git(path, "init", "-b", "main")
51 _git(path, "config", "user.email", "[email protected]")
52 _git(path, "config", "user.name", "Muse Test")
53 _git(path, "config", "commit.gpgsign", "false")
54 return path
55
56
57 def _commit(path: pathlib.Path, message: str, files: dict[str, str]) -> str:
58 """Write *files* and create a git commit; return the SHA."""
59 for name, content in files.items():
60 fp = path / name
61 fp.parent.mkdir(parents=True, exist_ok=True)
62 fp.write_text(content, encoding="utf-8")
63 _git(path, "add", name)
64 _git(path, "commit", "-m", message)
65 return _git(path, "rev-parse", "HEAD")
66
67
68 def _migrate(
69 git_repo: pathlib.Path,
70 target: pathlib.Path,
71 extra: list[str] | None = None,
72 ) -> "CliRunner.__class__.__call__": # type: ignore[name-defined]
73 args = ["code", "migrate", str(git_repo), "--target", str(target)]
74 if extra:
75 args += extra
76 return runner.invoke(None, args)
77
78
79 def _latest_manifest(muse_root: pathlib.Path) -> dict[str, str]:
80 """Return the manifest of the most recently written snapshot."""
81 snaps_dir = muse_root / ".muse" / "snapshots"
82 snap_files = sorted(snaps_dir.iterdir(), key=lambda p: p.stat().st_mtime)
83 assert snap_files, "No snapshots found in muse repo"
84 # Snapshots are stored as msgpack — use the public store API to read them.
85 for snap_path in reversed(snap_files):
86 snap_id = long_id(snap_path.stem)
87 snap = read_snapshot(muse_root, snap_id)
88 if snap is not None:
89 return snap.manifest
90 raise AssertionError("Could not read any snapshot")
91
92
93 # ---------------------------------------------------------------------------
94 # Unit — _should_exclude
95 # ---------------------------------------------------------------------------
96
97
98 class TestShouldExclude:
99 from muse.cli.commands.migrate import _should_exclude
100
101 def test_default_git_dir_excluded(self) -> None:
102 from muse.cli.commands.migrate import _should_exclude
103 assert _should_exclude(".git/config", (), ())
104
105 def test_default_muse_dir_excluded(self) -> None:
106 from muse.cli.commands.migrate import _should_exclude
107 assert _should_exclude(".muse/HEAD", (), ())
108
109 def test_default_pycache_excluded(self) -> None:
110 from muse.cli.commands.migrate import _should_exclude
111 assert _should_exclude("__pycache__/foo.pyc", (), ())
112
113 def test_default_pyc_suffix_excluded(self) -> None:
114 from muse.cli.commands.migrate import _should_exclude
115 assert _should_exclude("muse/core/store.pyc", (), ())
116
117 def test_default_ds_store_excluded(self) -> None:
118 from muse.cli.commands.migrate import _should_exclude
119 assert _should_exclude(".DS_Store", (), ())
120
121 def test_normal_py_file_not_excluded(self) -> None:
122 from muse.cli.commands.migrate import _should_exclude
123 assert not _should_exclude("muse/core/store.py", (), ())
124
125 def test_extra_prefix_excluded(self) -> None:
126 from muse.cli.commands.migrate import _should_exclude
127 assert _should_exclude("dist/wheel.whl", ("dist/",), ())
128
129 def test_extra_suffix_excluded(self) -> None:
130 from muse.cli.commands.migrate import _should_exclude
131 assert _should_exclude("package.lock", (), (".lock",))
132
133 def test_node_modules_excluded(self) -> None:
134 from muse.cli.commands.migrate import _should_exclude
135 assert _should_exclude("node_modules/lodash/index.js", (), ())
136
137 def test_venv_excluded(self) -> None:
138 from muse.cli.commands.migrate import _should_exclude
139 assert _should_exclude(".venv/bin/python", (), ())
140
141 def test_root_level_normal_file_not_excluded(self) -> None:
142 from muse.cli.commands.migrate import _should_exclude
143 assert not _should_exclude("README.md", (), ())
144
145 def test_prefix_dir_itself_excluded(self) -> None:
146 from muse.cli.commands.migrate import _should_exclude
147 # Path equal to the stripped prefix should still match.
148 assert _should_exclude(".git", (), ())
149
150
151 # ---------------------------------------------------------------------------
152 # Unit — _manifest_directories
153 # ---------------------------------------------------------------------------
154
155
156 class TestManifestDirectories:
157 def test_empty_manifest_gives_empty_list(self) -> None:
158 from muse.cli.commands.migrate import _manifest_directories
159 assert _manifest_directories({}) == []
160
161 def test_root_level_files_give_no_dirs(self) -> None:
162 from muse.cli.commands.migrate import _manifest_directories
163 result = _manifest_directories({"README.md": "abc", "main.py": "def"})
164 assert result == []
165
166 def test_nested_file_gives_parent_dirs(self) -> None:
167 from muse.cli.commands.migrate import _manifest_directories
168 result = _manifest_directories({"a/b/c.py": "oid"})
169 assert "a" in result
170 assert "a/b" in result
171
172 def test_dirs_are_sorted(self) -> None:
173 from muse.cli.commands.migrate import _manifest_directories
174 result = _manifest_directories({
175 "z/file.py": "o1",
176 "a/file.py": "o2",
177 "m/sub/file.py": "o3",
178 })
179 assert result == sorted(result)
180
181 def test_shared_parents_deduplicated(self) -> None:
182 from muse.cli.commands.migrate import _manifest_directories
183 result = _manifest_directories({
184 "src/foo.py": "o1",
185 "src/bar.py": "o2",
186 })
187 assert result.count("src") == 1
188
189 def test_deep_nesting(self) -> None:
190 from muse.cli.commands.migrate import _manifest_directories
191 result = _manifest_directories({"a/b/c/d/e.py": "o"})
192 assert set(result) == {"a", "a/b", "a/b/c", "a/b/c/d"}
193
194
195 # ---------------------------------------------------------------------------
196 # Integration — migrate a real git repo
197 # ---------------------------------------------------------------------------
198
199
200 @pytest.fixture()
201 def git_repo(tmp_path: pathlib.Path) -> pathlib.Path:
202 """A fresh git repo at tmp_path/git."""
203 repo = tmp_path / "git"
204 repo.mkdir()
205 return _make_git_repo(repo)
206
207
208 @pytest.fixture()
209 def muse_target(tmp_path: pathlib.Path) -> pathlib.Path:
210 """Empty dir at tmp_path/muse for the migrate target."""
211 t = tmp_path / "muse"
212 t.mkdir()
213 return t
214
215
216 class TestMigrateSingleBranch:
217 def test_single_commit_writes_muse_repo(
218 self, git_repo: pathlib.Path, muse_target: pathlib.Path
219 ) -> None:
220 _commit(git_repo, "init", {"hello.py": "print('hi')"})
221 result = _migrate(git_repo, muse_target)
222 assert result.exit_code == 0, result.output
223 assert (muse_target / ".muse" / "commits").exists()
224 assert (muse_target / ".muse" / "snapshots").exists()
225 assert (muse_target / ".muse" / "objects").exists()
226
227 def test_commit_count_matches(
228 self, git_repo: pathlib.Path, muse_target: pathlib.Path
229 ) -> None:
230 _commit(git_repo, "c1", {"a.py": "a"})
231 _commit(git_repo, "c2", {"b.py": "b"})
232 _commit(git_repo, "c3", {"c.py": "c"})
233 result = _migrate(git_repo, muse_target)
234 assert result.exit_code == 0, result.output
235 commits = list((muse_target / ".muse" / "commits").iterdir())
236 assert len(commits) == 3
237
238 def test_object_content_round_trips(
239 self, git_repo: pathlib.Path, muse_target: pathlib.Path
240 ) -> None:
241 content = "Hello, Muse!\n"
242 _commit(git_repo, "add file", {"hello.txt": content})
243 result = _migrate(git_repo, muse_target)
244 assert result.exit_code == 0, result.output
245 # Find the object by SHA-256 of the content.
246 from muse.core._types import blob_id
247 from muse.core.object_store import object_path
248 oid = blob_id(content.encode())
249 obj_path = object_path(muse_target, oid)
250 assert obj_path.exists()
251 assert obj_path.read_bytes() == content.encode()
252
253 def test_file_modification_tracked(
254 self, git_repo: pathlib.Path, muse_target: pathlib.Path
255 ) -> None:
256 _commit(git_repo, "add", {"file.py": "v1"})
257 _commit(git_repo, "modify", {"file.py": "v2"})
258 result = _migrate(git_repo, muse_target)
259 assert result.exit_code == 0, result.output
260 # Both versions should be in the object store.
261 from muse.core._types import blob_id
262 from muse.core.object_store import has_object
263 assert has_object(muse_target, blob_id(b"v1"))
264 assert has_object(muse_target, blob_id(b"v2"))
265
266 def test_deleted_file_not_in_final_snapshot(
267 self, git_repo: pathlib.Path, muse_target: pathlib.Path
268 ) -> None:
269 _commit(git_repo, "add", {"gone.py": "content", "stay.py": "keep"})
270 _git(git_repo, "rm", "gone.py")
271 _git(git_repo, "commit", "-m", "delete gone.py")
272 result = _migrate(git_repo, muse_target)
273 assert result.exit_code == 0, result.output
274 manifest = _latest_manifest(muse_target)
275 assert "gone.py" not in manifest
276 assert "stay.py" in manifest
277
278 def test_renamed_file_in_final_snapshot(
279 self, git_repo: pathlib.Path, muse_target: pathlib.Path
280 ) -> None:
281 _commit(git_repo, "add", {"old.py": "content"})
282 _git(git_repo, "mv", "old.py", "new.py")
283 _git(git_repo, "commit", "-m", "rename")
284 result = _migrate(git_repo, muse_target)
285 assert result.exit_code == 0, result.output
286 manifest = _latest_manifest(muse_target)
287 assert "new.py" in manifest
288 assert "old.py" not in manifest
289
290 def test_excluded_files_not_in_object_store(
291 self, git_repo: pathlib.Path, muse_target: pathlib.Path
292 ) -> None:
293 # .tmp is in _DEFAULT_EXCLUDE_SUFFIXES; .py is not excluded.
294 _commit(git_repo, "add", {"keep.py": "keep", "skip.tmp": "skip"})
295 result = _migrate(git_repo, muse_target)
296 assert result.exit_code == 0, result.output
297 manifest = _latest_manifest(muse_target)
298 assert "keep.py" in manifest
299 assert "skip.tmp" not in manifest
300
301 def test_extra_exclude_pattern(
302 self, git_repo: pathlib.Path, muse_target: pathlib.Path
303 ) -> None:
304 _commit(git_repo, "add", {"keep.py": "k", "dist/wheel.whl": "w"})
305 result = _migrate(git_repo, muse_target, extra=["--exclude", "dist/"])
306 assert result.exit_code == 0, result.output
307 manifest = _latest_manifest(muse_target)
308 assert "dist/wheel.whl" not in manifest
309 assert "keep.py" in manifest
310
311
312 class TestMigrateDryRun:
313 def test_dry_run_writes_nothing(
314 self, git_repo: pathlib.Path, muse_target: pathlib.Path
315 ) -> None:
316 _commit(git_repo, "c1", {"a.py": "a"})
317 result = _migrate(git_repo, muse_target, extra=["--dry-run"])
318 assert result.exit_code == 0, result.output
319 # No commits written in dry-run.
320 commits_dir = muse_target / ".muse" / "commits"
321 if commits_dir.exists():
322 assert list(commits_dir.iterdir()) == []
323
324 def test_dry_run_json_marks_dry_run_true(
325 self, git_repo: pathlib.Path, muse_target: pathlib.Path
326 ) -> None:
327 # Use --json so the done event is parseable regardless of logging config.
328 _commit(git_repo, "c1", {"a.py": "a"})
329 result = _migrate(git_repo, muse_target, extra=["--dry-run", "--json"])
330 assert result.exit_code == 0, result.output
331 lines = [ln.strip() for ln in result.output.splitlines() if ln.strip()]
332 events = [json.loads(ln) for ln in lines]
333 done = next(e for e in events if e["event"] == "done")
334 assert done["dry_run"] is True
335
336
337 class TestMigrateJson:
338 def test_json_emits_done_event(
339 self, git_repo: pathlib.Path, muse_target: pathlib.Path
340 ) -> None:
341 _commit(git_repo, "c1", {"a.py": "a"})
342 result = _migrate(git_repo, muse_target, extra=["--json"])
343 assert result.exit_code == 0, result.output
344 lines = [ln for ln in result.output.strip().splitlines() if ln.strip()]
345 events = [json.loads(ln) for ln in lines]
346 event_types = [e["event"] for e in events]
347 assert "done" in event_types
348 done = next(e for e in events if e["event"] == "done")
349 assert done["total_commits_written"] == 1
350 assert "duration_ms" in done
351 assert done["source"] == str(git_repo)
352 assert done["target"] == str(muse_target)
353
354 def test_json_emits_branch_start_and_done(
355 self, git_repo: pathlib.Path, muse_target: pathlib.Path
356 ) -> None:
357 _commit(git_repo, "c1", {"a.py": "a"})
358 result = _migrate(git_repo, muse_target, extra=["--json"])
359 assert result.exit_code == 0, result.output
360 lines = [ln for ln in result.output.strip().splitlines() if ln.strip()]
361 events = [json.loads(ln) for ln in lines]
362 event_types = [e["event"] for e in events]
363 assert "branch_start" in event_types
364 assert "branch_done" in event_types
365
366 def test_json_emits_progress_events(
367 self, git_repo: pathlib.Path, muse_target: pathlib.Path
368 ) -> None:
369 for i in range(5):
370 _commit(git_repo, f"c{i}", {f"f{i}.py": str(i)})
371 result = _migrate(git_repo, muse_target, extra=["--json"])
372 assert result.exit_code == 0, result.output
373 lines = [ln for ln in result.output.strip().splitlines() if ln.strip()]
374 events = [json.loads(ln) for ln in lines]
375 progress = [e for e in events if e["event"] == "progress"]
376 assert len(progress) >= 1
377 for p in progress:
378 assert "committed" in p
379 assert "total" in p
380 assert "branch" in p
381
382 def test_json_all_lines_are_valid_json(
383 self, git_repo: pathlib.Path, muse_target: pathlib.Path
384 ) -> None:
385 _commit(git_repo, "c1", {"a.py": "a"})
386 _commit(git_repo, "c2", {"b.py": "b"})
387 result = _migrate(git_repo, muse_target, extra=["--json"])
388 assert result.exit_code == 0, result.output
389 for line in result.output.strip().splitlines():
390 line = line.strip()
391 if line:
392 json.loads(line) # must not raise
393
394 def test_json_dry_run_done_event_has_dry_run_true(
395 self, git_repo: pathlib.Path, muse_target: pathlib.Path
396 ) -> None:
397 _commit(git_repo, "c1", {"a.py": "a"})
398 result = _migrate(git_repo, muse_target, extra=["--json", "--dry-run"])
399 assert result.exit_code == 0, result.output
400 lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()]
401 events = [json.loads(ln) for ln in lines]
402 done = next(e for e in events if e["event"] == "done")
403 assert done["dry_run"] is True
404
405
406 class TestMigrateMultiBranch:
407 def test_all_flag_migrates_multiple_branches(
408 self, git_repo: pathlib.Path, muse_target: pathlib.Path
409 ) -> None:
410 _commit(git_repo, "root", {"base.py": "base"})
411 _git(git_repo, "checkout", "-b", "feature")
412 _commit(git_repo, "feat", {"feat.py": "feat"})
413 _git(git_repo, "checkout", "main")
414 result = _migrate(git_repo, muse_target, extra=["--all"])
415 assert result.exit_code == 0, result.output
416 # Both branches should exist in the muse repo.
417 assert (muse_target / ".muse" / "refs" / "heads" / "main").exists()
418 assert (muse_target / ".muse" / "refs" / "heads" / "feature").exists()
419
420 def test_branch_flag_selects_specific_branch(
421 self, git_repo: pathlib.Path, muse_target: pathlib.Path
422 ) -> None:
423 _commit(git_repo, "root", {"base.py": "base"})
424 _git(git_repo, "checkout", "-b", "feature")
425 _commit(git_repo, "feat", {"feat.py": "feat"})
426 _git(git_repo, "checkout", "main")
427 result = _migrate(git_repo, muse_target, extra=["--branch", "main"])
428 assert result.exit_code == 0, result.output
429 assert (muse_target / ".muse" / "refs" / "heads" / "main").exists()
430 # feature branch was not requested.
431 assert not (muse_target / ".muse" / "refs" / "heads" / "feature").exists()
432
433
434 class TestMigrateErrors:
435 def test_not_a_git_repo_exits_1(self, tmp_path: pathlib.Path) -> None:
436 not_git = tmp_path / "not_git"
437 not_git.mkdir()
438 target = tmp_path / "muse"
439 target.mkdir()
440 result = _migrate(not_git, target)
441 assert result.exit_code == 1
442
443 def test_no_init_flag_skips_muse_init(
444 self, git_repo: pathlib.Path, muse_target: pathlib.Path
445 ) -> None:
446 # Without a pre-existing .muse/repo.json and with --no-init, migrate
447 # should fail cleanly (cannot load repo_id).
448 _commit(git_repo, "c1", {"a.py": "a"})
449 result = _migrate(git_repo, muse_target, extra=["--no-init"])
450 # Will either fail or succeed depending on implementation, but must not crash.
451 assert result.exit_code in (0, 1)
452
453
454 # ---------------------------------------------------------------------------
455 # Integrity — object store completeness after migrate
456 # ---------------------------------------------------------------------------
457
458
459 def _all_snapshots(muse_root: pathlib.Path) -> list:
460 """Return all SnapshotRecord objects from the local store."""
461 from muse.core.store import read_snapshot
462 snaps_dir = muse_root / ".muse" / "snapshots"
463 if not snaps_dir.exists():
464 return []
465 records = []
466 for snap_file in snaps_dir.iterdir():
467 hex_id = snap_file.stem
468 if len(hex_id) != 64:
469 continue
470 snap_id = long_id(hex_id)
471 snap = read_snapshot(muse_root, snap_id)
472 if snap is not None:
473 records.append(snap)
474 return records
475
476
477 def _all_commits(muse_root: pathlib.Path) -> list:
478 """Return all CommitRecord objects from the local store."""
479 from muse.core.store import read_commit
480 commits_dir = muse_root / ".muse" / "commits"
481 if not commits_dir.exists():
482 return []
483 records = []
484 for commit_file in commits_dir.iterdir():
485 commit_id = commit_file.stem
486 if len(commit_id) != 64:
487 continue
488 commit = read_commit(muse_root, commit_id)
489 if commit is not None:
490 records.append(commit)
491 return records
492
493
494 class TestMigrateIntegrity:
495 """Every OID referenced in snapshots must exist in the local object store."""
496
497 def test_all_manifest_oids_exist_in_object_store(
498 self, git_repo: pathlib.Path, muse_target: pathlib.Path
499 ) -> None:
500 _commit(git_repo, "c1", {"alpha.py": "hello world\n"})
501 _commit(git_repo, "c2", {"alpha.py": "hello world v2\n", "beta.py": "other\n"})
502 _commit(git_repo, "c3", {"gamma/deep.py": "nested file\n"})
503 result = _migrate(git_repo, muse_target)
504 assert result.exit_code == 0, result.output
505
506 from muse.core.object_store import has_object
507 missing: list[str] = []
508 for snap in _all_snapshots(muse_target):
509 for path, oid in snap.manifest.items():
510 if not has_object(muse_target, oid):
511 missing.append(f"{path} → {oid[:16]}…")
512
513 assert not missing, (
514 f"Object store incomplete — {len(missing)} OID(s) missing:\n"
515 + "\n".join(missing)
516 )
517
518 def test_stored_object_sha256_matches_filename(
519 self, git_repo: pathlib.Path, muse_target: pathlib.Path
520 ) -> None:
521 _commit(git_repo, "c1", {"file.py": "content that matters\n"})
522 result = _migrate(git_repo, muse_target)
523 assert result.exit_code == 0, result.output
524
525 from muse.core.object_store import iter_stored_objects
526 corrupt: list[str] = []
527 for oid, obj_file in iter_stored_objects(muse_target):
528 actual = hashlib.sha256(obj_file.read_bytes()).hexdigest()
529 if long_id(actual) != oid:
530 corrupt.append(f"{oid[len('sha256:'):len('sha256:') + 16]}… stored with wrong content")
531 assert not corrupt, "\n".join(corrupt)
532
533 def test_every_commit_references_existing_snapshot(
534 self, git_repo: pathlib.Path, muse_target: pathlib.Path
535 ) -> None:
536 _commit(git_repo, "c1", {"a.py": "a"})
537 _commit(git_repo, "c2", {"b.py": "b"})
538 result = _migrate(git_repo, muse_target)
539 assert result.exit_code == 0, result.output
540
541 from muse.core.store import read_snapshot
542 broken: list[str] = []
543 for commit in _all_commits(muse_target):
544 snap = read_snapshot(muse_target, commit.snapshot_id)
545 if snap is None:
546 broken.append(
547 f"commit {commit.commit_id[:16]}… → missing snapshot {commit.snapshot_id[:16]}…"
548 )
549 assert not broken, "\n".join(broken)
550
551 def test_binary_file_objects_written_correctly(
552 self, git_repo: pathlib.Path, muse_target: pathlib.Path
553 ) -> None:
554 # Simulate a binary blob (e.g. a small MIDI-like byte sequence).
555 binary_content = bytes(range(256)) * 4 # 1 KiB of non-UTF-8 bytes
556 binary_path = git_repo / "sample.mid"
557 binary_path.write_bytes(binary_content)
558 _git(git_repo, "add", "sample.mid")
559 _git(git_repo, "commit", "-m", "add binary")
560 result = _migrate(git_repo, muse_target)
561 assert result.exit_code == 0, result.output
562
563 from muse.core._types import blob_id
564 from muse.core.object_store import object_path
565 oid = blob_id(binary_content)
566 obj_path = object_path(muse_target, oid)
567 assert obj_path.exists(), f"Binary object {oid[:30]}… missing from store"
568 assert obj_path.read_bytes() == binary_content
569
570 def test_object_count_matches_unique_file_versions(
571 self, git_repo: pathlib.Path, muse_target: pathlib.Path
572 ) -> None:
573 # Two different versions of the same file = two objects.
574 # One unchanged file across commits = one object (deduplication).
575 _commit(git_repo, "c1", {"a.py": "v1", "stable.py": "unchanged"})
576 _commit(git_repo, "c2", {"a.py": "v2", "stable.py": "unchanged"})
577 result = _migrate(git_repo, muse_target)
578 assert result.exit_code == 0, result.output
579
580 from muse.core.object_store import iter_stored_objects
581 all_oids = [oid for oid, _ in iter_stored_objects(muse_target)]
582 # Expect exactly 3 unique objects: v1, v2, and unchanged.
583 assert len(set(all_oids)) == 3
584
585 def test_no_empty_objects_written(
586 self, git_repo: pathlib.Path, muse_target: pathlib.Path
587 ) -> None:
588 """Objects with zero bytes should never be written to the store."""
589 _commit(git_repo, "c1", {"real.py": "content"})
590 result = _migrate(git_repo, muse_target)
591 assert result.exit_code == 0, result.output
592
593 from muse.core.object_store import iter_stored_objects
594 empty_objects = [oid for oid, p in iter_stored_objects(muse_target) if p.stat().st_size == 0]
595 assert not empty_objects, f"Empty objects found in store: {empty_objects}"
596
597 def test_silent_drop_warns_on_unreadable_blob(
598 self, git_repo: pathlib.Path, muse_target: pathlib.Path, caplog: pytest.LogCaptureFixture
599 ) -> None:
600 """If cat.read() returns empty for a non-empty blob, migrate must emit a
601 warning — never silently drop the file with no diagnostic output."""
602 import logging
603 import unittest.mock as mock
604 from muse.cli.commands.migrate import _CatFile
605
606 _commit(git_repo, "c1", {"important.py": "critical content\n"})
607
608 def patched_read(self: _CatFile, sha: str) -> bytes:
609 # Simulate a transient pipe error returning empty for any blob.
610 return b""
611
612 with caplog.at_level(logging.WARNING, logger="muse.cli.commands.migrate"):
613 with mock.patch.object(_CatFile, "read", patched_read):
614 result = _migrate(git_repo, muse_target)
615
616 assert result.exit_code == 0, result.output
617 assert "git cat-file returned empty" in caplog.text, (
618 "migrate silently dropped a file when cat.read() returned b'' — "
619 "expected a WARNING log record but none was emitted.\n"
620 f"Captured log:\n{caplog.text}"
621 )
622
623
624 # ---------------------------------------------------------------------------
625 # Regression — _CatFile raises RuntimeError, not AssertionError
626 # ---------------------------------------------------------------------------
627
628
629 class TestCatFileRegression:
630 def test_catfile_raises_on_pipe_failure(self) -> None:
631 """_CatFile must use RuntimeError, not bare assert, for pipe checks."""
632 import subprocess as sp
633 from muse.cli.commands.migrate import _CatFile
634
635 # Patch Popen to return a proc with stdin=None to simulate pipe failure.
636 original_popen = sp.Popen
637
638 class _FakeProc:
639 stdin = None
640 stdout = None
641 def kill(self) -> None: pass
642
643 import unittest.mock as mock
644 with mock.patch("muse.cli.commands.migrate.subprocess.Popen", return_value=_FakeProc()):
645 with pytest.raises(RuntimeError):
646 _CatFile(pathlib.Path("/tmp"))
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago