gabriel / muse public
test_migrate.py python
473 lines 18.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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.store import read_snapshot
27 from tests.cli_test_helper import CliRunner
28
29 runner = CliRunner()
30
31 # ---------------------------------------------------------------------------
32 # Git fixture helpers
33 # ---------------------------------------------------------------------------
34
35
36 def _git(path: pathlib.Path, *args: str) -> str:
37 result = subprocess.run(
38 ["git", *args],
39 cwd=path,
40 capture_output=True,
41 text=True,
42 check=True,
43 )
44 return result.stdout.strip()
45
46
47 def _make_git_repo(path: pathlib.Path) -> pathlib.Path:
48 """Initialise a minimal git repo with stable author/committer identity."""
49 _git(path, "init", "-b", "main")
50 _git(path, "config", "user.email", "[email protected]")
51 _git(path, "config", "user.name", "Muse Test")
52 _git(path, "config", "commit.gpgsign", "false")
53 return path
54
55
56 def _commit(path: pathlib.Path, message: str, files: dict[str, str]) -> str:
57 """Write *files* and create a git commit; return the SHA."""
58 for name, content in files.items():
59 fp = path / name
60 fp.parent.mkdir(parents=True, exist_ok=True)
61 fp.write_text(content, encoding="utf-8")
62 _git(path, "add", name)
63 _git(path, "commit", "-m", message)
64 return _git(path, "rev-parse", "HEAD")
65
66
67 def _migrate(
68 git_repo: pathlib.Path,
69 target: pathlib.Path,
70 extra: list[str] | None = None,
71 ) -> "CliRunner.__class__.__call__": # type: ignore[name-defined]
72 args = ["code", "migrate", str(git_repo), "--target", str(target)]
73 if extra:
74 args += extra
75 return runner.invoke(None, args)
76
77
78 def _latest_manifest(muse_root: pathlib.Path) -> dict[str, str]:
79 """Return the manifest of the most recently written snapshot."""
80 snaps_dir = muse_root / ".muse" / "snapshots"
81 snap_files = sorted(snaps_dir.iterdir(), key=lambda p: p.stat().st_mtime)
82 assert snap_files, "No snapshots found in muse repo"
83 # Snapshots are stored as msgpack — use the public store API to read them.
84 for snap_path in reversed(snap_files):
85 snap_id = snap_path.stem # filename without extension
86 snap = read_snapshot(muse_root, snap_id)
87 if snap is not None:
88 return snap.manifest
89 raise AssertionError("Could not read any snapshot")
90
91
92 # ---------------------------------------------------------------------------
93 # Unit — _should_exclude
94 # ---------------------------------------------------------------------------
95
96
97 class TestShouldExclude:
98 from muse.cli.commands.migrate import _should_exclude
99
100 def test_default_git_dir_excluded(self) -> None:
101 from muse.cli.commands.migrate import _should_exclude
102 assert _should_exclude(".git/config", (), ())
103
104 def test_default_muse_dir_excluded(self) -> None:
105 from muse.cli.commands.migrate import _should_exclude
106 assert _should_exclude(".muse/HEAD", (), ())
107
108 def test_default_pycache_excluded(self) -> None:
109 from muse.cli.commands.migrate import _should_exclude
110 assert _should_exclude("__pycache__/foo.pyc", (), ())
111
112 def test_default_pyc_suffix_excluded(self) -> None:
113 from muse.cli.commands.migrate import _should_exclude
114 assert _should_exclude("muse/core/store.pyc", (), ())
115
116 def test_default_ds_store_excluded(self) -> None:
117 from muse.cli.commands.migrate import _should_exclude
118 assert _should_exclude(".DS_Store", (), ())
119
120 def test_normal_py_file_not_excluded(self) -> None:
121 from muse.cli.commands.migrate import _should_exclude
122 assert not _should_exclude("muse/core/store.py", (), ())
123
124 def test_extra_prefix_excluded(self) -> None:
125 from muse.cli.commands.migrate import _should_exclude
126 assert _should_exclude("dist/wheel.whl", ("dist/",), ())
127
128 def test_extra_suffix_excluded(self) -> None:
129 from muse.cli.commands.migrate import _should_exclude
130 assert _should_exclude("package.lock", (), (".lock",))
131
132 def test_node_modules_excluded(self) -> None:
133 from muse.cli.commands.migrate import _should_exclude
134 assert _should_exclude("node_modules/lodash/index.js", (), ())
135
136 def test_venv_excluded(self) -> None:
137 from muse.cli.commands.migrate import _should_exclude
138 assert _should_exclude(".venv/bin/python", (), ())
139
140 def test_root_level_normal_file_not_excluded(self) -> None:
141 from muse.cli.commands.migrate import _should_exclude
142 assert not _should_exclude("README.md", (), ())
143
144 def test_prefix_dir_itself_excluded(self) -> None:
145 from muse.cli.commands.migrate import _should_exclude
146 # Path equal to the stripped prefix should still match.
147 assert _should_exclude(".git", (), ())
148
149
150 # ---------------------------------------------------------------------------
151 # Unit — _manifest_directories
152 # ---------------------------------------------------------------------------
153
154
155 class TestManifestDirectories:
156 def test_empty_manifest_gives_empty_list(self) -> None:
157 from muse.cli.commands.migrate import _manifest_directories
158 assert _manifest_directories({}) == []
159
160 def test_root_level_files_give_no_dirs(self) -> None:
161 from muse.cli.commands.migrate import _manifest_directories
162 result = _manifest_directories({"README.md": "abc", "main.py": "def"})
163 assert result == []
164
165 def test_nested_file_gives_parent_dirs(self) -> None:
166 from muse.cli.commands.migrate import _manifest_directories
167 result = _manifest_directories({"a/b/c.py": "oid"})
168 assert "a" in result
169 assert "a/b" in result
170
171 def test_dirs_are_sorted(self) -> None:
172 from muse.cli.commands.migrate import _manifest_directories
173 result = _manifest_directories({
174 "z/file.py": "o1",
175 "a/file.py": "o2",
176 "m/sub/file.py": "o3",
177 })
178 assert result == sorted(result)
179
180 def test_shared_parents_deduplicated(self) -> None:
181 from muse.cli.commands.migrate import _manifest_directories
182 result = _manifest_directories({
183 "src/foo.py": "o1",
184 "src/bar.py": "o2",
185 })
186 assert result.count("src") == 1
187
188 def test_deep_nesting(self) -> None:
189 from muse.cli.commands.migrate import _manifest_directories
190 result = _manifest_directories({"a/b/c/d/e.py": "o"})
191 assert set(result) == {"a", "a/b", "a/b/c", "a/b/c/d"}
192
193
194 # ---------------------------------------------------------------------------
195 # Integration — migrate a real git repo
196 # ---------------------------------------------------------------------------
197
198
199 @pytest.fixture()
200 def git_repo(tmp_path: pathlib.Path) -> pathlib.Path:
201 """A fresh git repo at tmp_path/git."""
202 repo = tmp_path / "git"
203 repo.mkdir()
204 return _make_git_repo(repo)
205
206
207 @pytest.fixture()
208 def muse_target(tmp_path: pathlib.Path) -> pathlib.Path:
209 """Empty dir at tmp_path/muse for the migrate target."""
210 t = tmp_path / "muse"
211 t.mkdir()
212 return t
213
214
215 class TestMigrateSingleBranch:
216 def test_single_commit_writes_muse_repo(
217 self, git_repo: pathlib.Path, muse_target: pathlib.Path
218 ) -> None:
219 _commit(git_repo, "init", {"hello.py": "print('hi')"})
220 result = _migrate(git_repo, muse_target)
221 assert result.exit_code == 0, result.output
222 assert (muse_target / ".muse" / "commits").exists()
223 assert (muse_target / ".muse" / "snapshots").exists()
224 assert (muse_target / ".muse" / "objects").exists()
225
226 def test_commit_count_matches(
227 self, git_repo: pathlib.Path, muse_target: pathlib.Path
228 ) -> None:
229 _commit(git_repo, "c1", {"a.py": "a"})
230 _commit(git_repo, "c2", {"b.py": "b"})
231 _commit(git_repo, "c3", {"c.py": "c"})
232 result = _migrate(git_repo, muse_target)
233 assert result.exit_code == 0, result.output
234 commits = list((muse_target / ".muse" / "commits").iterdir())
235 assert len(commits) == 3
236
237 def test_object_content_round_trips(
238 self, git_repo: pathlib.Path, muse_target: pathlib.Path
239 ) -> None:
240 content = "Hello, Muse!\n"
241 _commit(git_repo, "add file", {"hello.txt": content})
242 result = _migrate(git_repo, muse_target)
243 assert result.exit_code == 0, result.output
244 # Find the object by SHA-256 of the content.
245 oid = hashlib.sha256(content.encode()).hexdigest()
246 obj_path = muse_target / ".muse" / "objects" / oid[:2] / oid[2:]
247 assert obj_path.exists()
248 assert obj_path.read_bytes() == content.encode()
249
250 def test_file_modification_tracked(
251 self, git_repo: pathlib.Path, muse_target: pathlib.Path
252 ) -> None:
253 _commit(git_repo, "add", {"file.py": "v1"})
254 _commit(git_repo, "modify", {"file.py": "v2"})
255 result = _migrate(git_repo, muse_target)
256 assert result.exit_code == 0, result.output
257 # Both versions should be in the object store.
258 oid_v1 = hashlib.sha256(b"v1").hexdigest()
259 oid_v2 = hashlib.sha256(b"v2").hexdigest()
260 assert (muse_target / ".muse" / "objects" / oid_v1[:2] / oid_v1[2:]).exists()
261 assert (muse_target / ".muse" / "objects" / oid_v2[:2] / oid_v2[2:]).exists()
262
263 def test_deleted_file_not_in_final_snapshot(
264 self, git_repo: pathlib.Path, muse_target: pathlib.Path
265 ) -> None:
266 _commit(git_repo, "add", {"gone.py": "content", "stay.py": "keep"})
267 _git(git_repo, "rm", "gone.py")
268 _git(git_repo, "commit", "-m", "delete gone.py")
269 result = _migrate(git_repo, muse_target)
270 assert result.exit_code == 0, result.output
271 manifest = _latest_manifest(muse_target)
272 assert "gone.py" not in manifest
273 assert "stay.py" in manifest
274
275 def test_renamed_file_in_final_snapshot(
276 self, git_repo: pathlib.Path, muse_target: pathlib.Path
277 ) -> None:
278 _commit(git_repo, "add", {"old.py": "content"})
279 _git(git_repo, "mv", "old.py", "new.py")
280 _git(git_repo, "commit", "-m", "rename")
281 result = _migrate(git_repo, muse_target)
282 assert result.exit_code == 0, result.output
283 manifest = _latest_manifest(muse_target)
284 assert "new.py" in manifest
285 assert "old.py" not in manifest
286
287 def test_excluded_files_not_in_object_store(
288 self, git_repo: pathlib.Path, muse_target: pathlib.Path
289 ) -> None:
290 # .tmp is in _DEFAULT_EXCLUDE_SUFFIXES; .py is not excluded.
291 _commit(git_repo, "add", {"keep.py": "keep", "skip.tmp": "skip"})
292 result = _migrate(git_repo, muse_target)
293 assert result.exit_code == 0, result.output
294 manifest = _latest_manifest(muse_target)
295 assert "keep.py" in manifest
296 assert "skip.tmp" not in manifest
297
298 def test_extra_exclude_pattern(
299 self, git_repo: pathlib.Path, muse_target: pathlib.Path
300 ) -> None:
301 _commit(git_repo, "add", {"keep.py": "k", "dist/wheel.whl": "w"})
302 result = _migrate(git_repo, muse_target, extra=["--exclude", "dist/"])
303 assert result.exit_code == 0, result.output
304 manifest = _latest_manifest(muse_target)
305 assert "dist/wheel.whl" not in manifest
306 assert "keep.py" in manifest
307
308
309 class TestMigrateDryRun:
310 def test_dry_run_writes_nothing(
311 self, git_repo: pathlib.Path, muse_target: pathlib.Path
312 ) -> None:
313 _commit(git_repo, "c1", {"a.py": "a"})
314 result = _migrate(git_repo, muse_target, extra=["--dry-run"])
315 assert result.exit_code == 0, result.output
316 # No commits written in dry-run.
317 commits_dir = muse_target / ".muse" / "commits"
318 if commits_dir.exists():
319 assert list(commits_dir.iterdir()) == []
320
321 def test_dry_run_json_marks_dry_run_true(
322 self, git_repo: pathlib.Path, muse_target: pathlib.Path
323 ) -> None:
324 # Use --json so the done event is parseable regardless of logging config.
325 _commit(git_repo, "c1", {"a.py": "a"})
326 result = _migrate(git_repo, muse_target, extra=["--dry-run", "--json"])
327 assert result.exit_code == 0, result.output
328 lines = [ln.strip() for ln in result.output.splitlines() if ln.strip()]
329 events = [json.loads(ln) for ln in lines]
330 done = next(e for e in events if e["event"] == "done")
331 assert done["dry_run"] is True
332
333
334 class TestMigrateJson:
335 def test_json_emits_done_event(
336 self, git_repo: pathlib.Path, muse_target: pathlib.Path
337 ) -> None:
338 _commit(git_repo, "c1", {"a.py": "a"})
339 result = _migrate(git_repo, muse_target, extra=["--json"])
340 assert result.exit_code == 0, result.output
341 lines = [ln for ln in result.output.strip().splitlines() if ln.strip()]
342 events = [json.loads(ln) for ln in lines]
343 event_types = [e["event"] for e in events]
344 assert "done" in event_types
345 done = next(e for e in events if e["event"] == "done")
346 assert done["total_commits_written"] == 1
347 assert "elapsed_seconds" in done
348 assert done["source"] == str(git_repo)
349 assert done["target"] == str(muse_target)
350
351 def test_json_emits_branch_start_and_done(
352 self, git_repo: pathlib.Path, muse_target: pathlib.Path
353 ) -> None:
354 _commit(git_repo, "c1", {"a.py": "a"})
355 result = _migrate(git_repo, muse_target, extra=["--json"])
356 assert result.exit_code == 0, result.output
357 lines = [ln for ln in result.output.strip().splitlines() if ln.strip()]
358 events = [json.loads(ln) for ln in lines]
359 event_types = [e["event"] for e in events]
360 assert "branch_start" in event_types
361 assert "branch_done" in event_types
362
363 def test_json_emits_progress_events(
364 self, git_repo: pathlib.Path, muse_target: pathlib.Path
365 ) -> None:
366 for i in range(5):
367 _commit(git_repo, f"c{i}", {f"f{i}.py": str(i)})
368 result = _migrate(git_repo, muse_target, extra=["--json"])
369 assert result.exit_code == 0, result.output
370 lines = [ln for ln in result.output.strip().splitlines() if ln.strip()]
371 events = [json.loads(ln) for ln in lines]
372 progress = [e for e in events if e["event"] == "progress"]
373 assert len(progress) >= 1
374 for p in progress:
375 assert "committed" in p
376 assert "total" in p
377 assert "branch" in p
378
379 def test_json_all_lines_are_valid_json(
380 self, git_repo: pathlib.Path, muse_target: pathlib.Path
381 ) -> None:
382 _commit(git_repo, "c1", {"a.py": "a"})
383 _commit(git_repo, "c2", {"b.py": "b"})
384 result = _migrate(git_repo, muse_target, extra=["--json"])
385 assert result.exit_code == 0, result.output
386 for line in result.output.strip().splitlines():
387 line = line.strip()
388 if line:
389 json.loads(line) # must not raise
390
391 def test_json_dry_run_done_event_has_dry_run_true(
392 self, git_repo: pathlib.Path, muse_target: pathlib.Path
393 ) -> None:
394 _commit(git_repo, "c1", {"a.py": "a"})
395 result = _migrate(git_repo, muse_target, extra=["--json", "--dry-run"])
396 assert result.exit_code == 0, result.output
397 lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()]
398 events = [json.loads(ln) for ln in lines]
399 done = next(e for e in events if e["event"] == "done")
400 assert done["dry_run"] is True
401
402
403 class TestMigrateMultiBranch:
404 def test_all_flag_migrates_multiple_branches(
405 self, git_repo: pathlib.Path, muse_target: pathlib.Path
406 ) -> None:
407 _commit(git_repo, "root", {"base.py": "base"})
408 _git(git_repo, "checkout", "-b", "feature")
409 _commit(git_repo, "feat", {"feat.py": "feat"})
410 _git(git_repo, "checkout", "main")
411 result = _migrate(git_repo, muse_target, extra=["--all"])
412 assert result.exit_code == 0, result.output
413 # Both branches should exist in the muse repo.
414 assert (muse_target / ".muse" / "refs" / "heads" / "main").exists()
415 assert (muse_target / ".muse" / "refs" / "heads" / "feature").exists()
416
417 def test_branch_flag_selects_specific_branch(
418 self, git_repo: pathlib.Path, muse_target: pathlib.Path
419 ) -> None:
420 _commit(git_repo, "root", {"base.py": "base"})
421 _git(git_repo, "checkout", "-b", "feature")
422 _commit(git_repo, "feat", {"feat.py": "feat"})
423 _git(git_repo, "checkout", "main")
424 result = _migrate(git_repo, muse_target, extra=["--branch", "main"])
425 assert result.exit_code == 0, result.output
426 assert (muse_target / ".muse" / "refs" / "heads" / "main").exists()
427 # feature branch was not requested.
428 assert not (muse_target / ".muse" / "refs" / "heads" / "feature").exists()
429
430
431 class TestMigrateErrors:
432 def test_not_a_git_repo_exits_1(self, tmp_path: pathlib.Path) -> None:
433 not_git = tmp_path / "not_git"
434 not_git.mkdir()
435 target = tmp_path / "muse"
436 target.mkdir()
437 result = _migrate(not_git, target)
438 assert result.exit_code == 1
439
440 def test_no_init_flag_skips_muse_init(
441 self, git_repo: pathlib.Path, muse_target: pathlib.Path
442 ) -> None:
443 # Without a pre-existing .muse/repo.json and with --no-init, migrate
444 # should fail cleanly (cannot load repo_id).
445 _commit(git_repo, "c1", {"a.py": "a"})
446 result = _migrate(git_repo, muse_target, extra=["--no-init"])
447 # Will either fail or succeed depending on implementation, but must not crash.
448 assert result.exit_code in (0, 1)
449
450
451 # ---------------------------------------------------------------------------
452 # Regression — _CatFile raises RuntimeError, not AssertionError
453 # ---------------------------------------------------------------------------
454
455
456 class TestCatFileRegression:
457 def test_catfile_raises_on_pipe_failure(self) -> None:
458 """_CatFile must use RuntimeError, not bare assert, for pipe checks."""
459 import subprocess as sp
460 from muse.cli.commands.migrate import _CatFile
461
462 # Patch Popen to return a proc with stdin=None to simulate pipe failure.
463 original_popen = sp.Popen
464
465 class _FakeProc:
466 stdin = None
467 stdout = None
468 def kill(self) -> None: pass
469
470 import unittest.mock as mock
471 with mock.patch("muse.cli.commands.migrate.subprocess.Popen", return_value=_FakeProc()):
472 with pytest.raises(RuntimeError):
473 _CatFile(pathlib.Path("/tmp"))
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago