gabriel / muse public
test_core_snapshot.py python
436 lines 17.8 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for muse.core.snapshot — content-addressed snapshot computation.
2
3 Test categories
4 ---------------
5 - TestHashFile — unit: SHA-256 hash_file
6 - TestBuildSnapshotManifest — unit: full manifest walks
7 - TestNestedRepoWalk — unit/integration: nested .muse repos are excluded
8 - TestComputeSnapshotId — unit: snapshot id derivation
9 - TestComputeCommitId — unit: commit id derivation
10 - TestDiffWorkdirVsSnapshot — unit: diff logic
11 """
12
13 import os
14 import pathlib
15 import threading
16 import time
17
18 import pytest
19
20 from muse.core.snapshot import (
21 build_snapshot_manifest,
22 compute_commit_id,
23 compute_snapshot_id,
24 diff_workdir_vs_snapshot,
25 hash_file,
26 walk_workdir,
27 walk_workdir_with_dirs,
28 )
29
30
31 @pytest.fixture
32 def workdir(tmp_path: pathlib.Path) -> pathlib.Path:
33 return tmp_path
34
35
36 class TestHashFile:
37 def test_consistent(self, tmp_path: pathlib.Path) -> None:
38 f = tmp_path / "file.mid"
39 f.write_bytes(b"hello world")
40 assert hash_file(f) == hash_file(f)
41
42 def test_different_content_different_hash(self, tmp_path: pathlib.Path) -> None:
43 a = tmp_path / "a.mid"
44 b = tmp_path / "b.mid"
45 a.write_bytes(b"aaa")
46 b.write_bytes(b"bbb")
47 assert hash_file(a) != hash_file(b)
48
49 def test_known_hash(self, tmp_path: pathlib.Path) -> None:
50 import hashlib
51 content = b"muse"
52 f = tmp_path / "f.mid"
53 f.write_bytes(content)
54 expected = hashlib.sha256(content).hexdigest()
55 assert hash_file(f) == expected
56
57
58 class TestBuildSnapshotManifest:
59 def test_empty_workdir(self, workdir: pathlib.Path) -> None:
60 assert build_snapshot_manifest(workdir) == {}
61
62 def test_single_file(self, workdir: pathlib.Path) -> None:
63 (workdir / "beat.mid").write_bytes(b"drums")
64 manifest = build_snapshot_manifest(workdir)
65 assert "beat.mid" in manifest
66 assert len(manifest["beat.mid"]) == 64 # sha256 hex
67
68 def test_nested_file(self, workdir: pathlib.Path) -> None:
69 (workdir / "tracks").mkdir()
70 (workdir / "tracks" / "bass.mid").write_bytes(b"bass")
71 manifest = build_snapshot_manifest(workdir)
72 assert "tracks/bass.mid" in manifest
73
74 def test_secrets_excluded_by_builtin_blocklist(self, workdir: pathlib.Path) -> None:
75 """Built-in secrets blocklist protects even without a .museignore file."""
76 (workdir / ".env").write_bytes(b"SECRET=abc")
77 (workdir / ".DS_Store").write_bytes(b"junk")
78 (workdir / "beat.mid").write_bytes(b"drums")
79 manifest = build_snapshot_manifest(workdir)
80 assert ".env" not in manifest
81 assert ".DS_Store" not in manifest
82 assert "beat.mid" in manifest
83
84 def test_dotfiles_tracked_when_not_ignored(self, workdir: pathlib.Path) -> None:
85 """Non-secret dotfiles like .cursorrules are tracked by default."""
86 (workdir / ".cursorrules").write_bytes(b"# rules")
87 (workdir / ".editorconfig").write_bytes(b"[*]\nindent_size=4")
88 manifest = build_snapshot_manifest(workdir)
89 assert ".cursorrules" in manifest
90 assert ".editorconfig" in manifest
91
92 def test_museignore_excludes_custom_pattern(self, workdir: pathlib.Path) -> None:
93 """A pattern in .museignore excludes the matched file."""
94 (workdir / ".museignore").write_bytes(b'[global]\npatterns = ["*.secret"]\n')
95 (workdir / "api.secret").write_bytes(b"token")
96 (workdir / "beat.mid").write_bytes(b"drums")
97 manifest = build_snapshot_manifest(workdir)
98 assert "api.secret" not in manifest
99 assert "beat.mid" in manifest
100
101 def test_deterministic_order(self, workdir: pathlib.Path) -> None:
102 for name in ["c.mid", "a.mid", "b.mid"]:
103 (workdir / name).write_bytes(name.encode())
104 m1 = build_snapshot_manifest(workdir)
105 m2 = build_snapshot_manifest(workdir)
106 assert m1 == m2
107
108
109 class TestComputeSnapshotId:
110 def test_empty_manifest(self) -> None:
111 sid = compute_snapshot_id({})
112 assert len(sid) == 64
113
114 def test_deterministic(self) -> None:
115 manifest = {"a.mid": "hash1", "b.mid": "hash2"}
116 assert compute_snapshot_id(manifest) == compute_snapshot_id(manifest)
117
118 def test_order_independent(self) -> None:
119 m1 = {"a.mid": "h1", "b.mid": "h2"}
120 m2 = {"b.mid": "h2", "a.mid": "h1"}
121 assert compute_snapshot_id(m1) == compute_snapshot_id(m2)
122
123 def test_different_content_different_id(self) -> None:
124 m1 = {"a.mid": "h1"}
125 m2 = {"a.mid": "h2"}
126 assert compute_snapshot_id(m1) != compute_snapshot_id(m2)
127
128
129 class TestComputeCommitId:
130 def test_deterministic(self) -> None:
131 kwargs = dict(parent_ids=["p1"], snapshot_id="1" * 64, message="msg", committed_at_iso="2026-01-01T00:00:00+00:00")
132 assert compute_commit_id(**kwargs) == compute_commit_id(**kwargs)
133
134 def test_parent_order_independent(self) -> None:
135 a = compute_commit_id(parent_ids=["p1", "p2"], snapshot_id="1" * 64, message="m", committed_at_iso="t")
136 b = compute_commit_id(parent_ids=["p2", "p1"], snapshot_id="1" * 64, message="m", committed_at_iso="t")
137 assert a == b
138
139 def test_different_messages_different_ids(self) -> None:
140 a = compute_commit_id(parent_ids=[], snapshot_id="1" * 64, message="msg1", committed_at_iso="t")
141 b = compute_commit_id(parent_ids=[], snapshot_id="1" * 64, message="msg2", committed_at_iso="t")
142 assert a != b
143
144
145 class TestDiffWorkdirVsSnapshot:
146 def test_new_repo_all_untracked(self, workdir: pathlib.Path) -> None:
147 (workdir / "beat.mid").write_bytes(b"x")
148 added, modified, deleted, untracked, added_dirs, deleted_dirs = diff_workdir_vs_snapshot(workdir, {})
149 assert added == set()
150 assert untracked == {"beat.mid"}
151
152 def test_added_file(self, workdir: pathlib.Path) -> None:
153 (workdir / "beat.mid").write_bytes(b"x")
154 last = {"other.mid": "abc"}
155 added, modified, deleted, untracked, added_dirs, deleted_dirs = diff_workdir_vs_snapshot(workdir, last)
156 assert "beat.mid" in added
157 assert "other.mid" in deleted
158
159 def test_modified_file(self, workdir: pathlib.Path) -> None:
160 f = workdir / "beat.mid"
161 f.write_bytes(b"new content")
162 last = {"beat.mid": "oldhash"}
163 added, modified, deleted, untracked, added_dirs, deleted_dirs = diff_workdir_vs_snapshot(workdir, last)
164 assert "beat.mid" in modified
165
166 def test_clean_workdir(self, workdir: pathlib.Path) -> None:
167 f = workdir / "beat.mid"
168 f.write_bytes(b"content")
169 from muse.core.snapshot import hash_file
170 h = hash_file(f)
171 added, modified, deleted, untracked, added_dirs, deleted_dirs = diff_workdir_vs_snapshot(workdir, {"beat.mid": h})
172 assert not added and not modified and not deleted and not untracked
173
174 def test_ignored_extant_file_not_reported_as_deleted(
175 self, workdir: pathlib.Path
176 ) -> None:
177 """A file that was tracked, is now in .museignore, and still exists on
178 disk must NOT appear in ``deleted``. It was intentionally moved out of
179 tracking — reporting it as deleted would block checkout and cause stash
180 pop to unlink it."""
181 (workdir / ".museignore").write_bytes(
182 b'[global]\npatterns = ["app.js"]\n'
183 )
184 (workdir / "app.js").write_bytes(b"// build artifact")
185 (workdir / "src.py").write_bytes(b"# source")
186 from muse.core.snapshot import hash_file
187 # Pretend HEAD tracked both files.
188 last = {
189 "app.js": hash_file(workdir / "app.js"),
190 "src.py": hash_file(workdir / "src.py"),
191 }
192 added, modified, deleted, _, _, _ = diff_workdir_vs_snapshot(workdir, last)
193 assert "app.js" not in deleted, (
194 "ignored-and-extant file must not appear in deleted"
195 )
196 assert "src.py" not in deleted
197
198 def test_ignored_absent_file_is_reported_as_deleted(
199 self, workdir: pathlib.Path
200 ) -> None:
201 """A file that is in .museignore but is genuinely absent from disk IS
202 deleted and must appear in ``deleted``."""
203 (workdir / ".museignore").write_bytes(
204 b'[global]\npatterns = ["app.js"]\n'
205 )
206 # app.js is in .museignore but does NOT exist on disk.
207 (workdir / "src.py").write_bytes(b"# source")
208 from muse.core.snapshot import hash_file
209 last = {
210 "app.js": "a" * 64, # was in HEAD but is gone from disk
211 "src.py": hash_file(workdir / "src.py"),
212 }
213 added, modified, deleted, _, _, _ = diff_workdir_vs_snapshot(workdir, last)
214 assert "app.js" in deleted, (
215 "ignored file that is genuinely absent from disk must still be deleted"
216 )
217
218
219 # ---------------------------------------------------------------------------
220 # Nested repo boundary — unit / integration
221 # ---------------------------------------------------------------------------
222
223 def _make_nested_repo(parent: pathlib.Path, name: str) -> pathlib.Path:
224 """Create a child directory that looks like a muse repo (.muse/ present)."""
225 child = parent / name
226 child.mkdir(parents=True, exist_ok=True)
227 (child / ".muse").mkdir()
228 (child / ".muse" / "repo.json").write_text('{"repo_id": "child"}')
229 return child
230
231
232 class TestNestedRepoWalk:
233 """Nested muse repos must be excluded from the parent's walk.
234
235 The parent repo's ``os.walk`` must prune any subdirectory that contains
236 its own ``.muse/`` directory. This mirrors git submodule behaviour —
237 child repo files belong to the child snapshot, not the parent.
238 """
239
240 # --- walk_workdir -------------------------------------------------------
241
242 def test_nested_repo_files_excluded(self, tmp_path: pathlib.Path) -> None:
243 """Files inside a nested repo do not appear in the parent manifest."""
244 (tmp_path / "parent.py").write_bytes(b"# parent")
245 child = _make_nested_repo(tmp_path, "child_repo")
246 (child / "child.py").write_bytes(b"# child")
247
248 manifest = walk_workdir(tmp_path)
249 assert "parent.py" in manifest
250 assert "child_repo/child.py" not in manifest
251
252 def test_nested_repo_root_dir_excluded(self, tmp_path: pathlib.Path) -> None:
253 """The child root directory itself is not descended into."""
254 _make_nested_repo(tmp_path, "child_repo")
255 manifest = walk_workdir(tmp_path)
256 # No key should start with child_repo/
257 assert not any(k.startswith("child_repo/") for k in manifest)
258
259 def test_sibling_dirs_still_walked(self, tmp_path: pathlib.Path) -> None:
260 """Normal subdirs next to a nested repo are still walked."""
261 _make_nested_repo(tmp_path, "child_repo")
262 sibling = tmp_path / "src"
263 sibling.mkdir()
264 (sibling / "main.py").write_bytes(b"# main")
265
266 manifest = walk_workdir(tmp_path)
267 assert "src/main.py" in manifest
268
269 def test_deeply_nested_repo_excluded(self, tmp_path: pathlib.Path) -> None:
270 """Nested repos two levels deep are also excluded."""
271 mid = tmp_path / "packages"
272 mid.mkdir()
273 (mid / "shared.py").write_bytes(b"# shared")
274 child = _make_nested_repo(mid, "plugin")
275 (child / "plugin.py").write_bytes(b"# plugin")
276
277 manifest = walk_workdir(tmp_path)
278 assert "packages/shared.py" in manifest
279 assert "packages/plugin/plugin.py" not in manifest
280
281 def test_multiple_nested_repos_all_excluded(self, tmp_path: pathlib.Path) -> None:
282 """Multiple sibling nested repos are all pruned."""
283 _make_nested_repo(tmp_path, "repo_a")
284 _make_nested_repo(tmp_path, "repo_b")
285 _make_nested_repo(tmp_path, "repo_c")
286 (tmp_path / "root.py").write_bytes(b"# root")
287 for repo in ("repo_a", "repo_b", "repo_c"):
288 ((tmp_path / repo) / "file.py").write_bytes(b"# file")
289
290 manifest = walk_workdir(tmp_path)
291 assert "root.py" in manifest
292 for repo in ("repo_a", "repo_b", "repo_c"):
293 assert f"{repo}/file.py" not in manifest
294
295 # --- walk_workdir_with_dirs ---------------------------------------------
296
297 def test_dirs_output_excludes_nested_repo(self, tmp_path: pathlib.Path) -> None:
298 """walk_workdir_with_dirs must not list the nested repo as a directory."""
299 _make_nested_repo(tmp_path, "child_repo")
300 src = tmp_path / "src"
301 src.mkdir()
302 (src / "a.py").write_bytes(b"a")
303
304 _, dirs = walk_workdir_with_dirs(tmp_path)
305 assert "src" in dirs
306 assert "child_repo" not in dirs
307
308 # --- build_snapshot_manifest (public API) --------------------------------
309
310 def test_build_snapshot_manifest_excludes_nested(self, tmp_path: pathlib.Path) -> None:
311 """build_snapshot_manifest is the public wrapper — same boundary."""
312 (tmp_path / "root.py").write_bytes(b"# root")
313 child = _make_nested_repo(tmp_path, "nested")
314 (child / "nested.py").write_bytes(b"# nested")
315
316 manifest = build_snapshot_manifest(tmp_path)
317 assert "root.py" in manifest
318 assert "nested/nested.py" not in manifest
319
320 # --- diff_workdir_vs_snapshot integration --------------------------------
321
322 def test_diff_does_not_report_nested_files_as_added(
323 self, tmp_path: pathlib.Path
324 ) -> None:
325 """diff sees an empty last-snapshot: nested files must not appear as untracked."""
326 (tmp_path / "root.py").write_bytes(b"# root")
327 child = _make_nested_repo(tmp_path, "sub")
328 (child / "sub.py").write_bytes(b"# sub")
329
330 added, modified, deleted, untracked, _, _ = diff_workdir_vs_snapshot(
331 tmp_path, {}
332 )
333 assert "root.py" in untracked
334 assert not any(k.startswith("sub/") for k in untracked)
335 assert not any(k.startswith("sub/") for k in added)
336
337 # --- data integrity -----------------------------------------------------
338
339 def test_manifest_keys_posix_separators(self, tmp_path: pathlib.Path) -> None:
340 """Manifest keys always use '/' regardless of OS."""
341 sub = tmp_path / "a" / "b"
342 sub.mkdir(parents=True)
343 (sub / "file.py").write_bytes(b"x")
344 manifest = walk_workdir(tmp_path)
345 assert "a/b/file.py" in manifest
346 assert all("/" in k or "/" not in k for k in manifest) # no backslash keys
347 assert not any("\\" in k for k in manifest)
348
349 def test_nested_muse_dir_itself_not_tracked(self, tmp_path: pathlib.Path) -> None:
350 """The .muse/ directory of a nested repo is not tracked as a file."""
351 child = _make_nested_repo(tmp_path, "child")
352 (child / "real.py").write_bytes(b"x")
353 manifest = walk_workdir(tmp_path)
354 assert not any(".muse" in k for k in manifest)
355
356 # --- security -----------------------------------------------------------
357
358 def test_symlink_to_nested_repo_not_followed(self, tmp_path: pathlib.Path) -> None:
359 """A symlink pointing at a directory that has .muse/ is not followed.
360 walk_workdir uses followlinks=False so symlinks are excluded by design."""
361 real = _make_nested_repo(tmp_path, "real_repo")
362 (real / "secret.py").write_bytes(b"# secret")
363 link = tmp_path / "link_to_repo"
364 link.symlink_to(real)
365
366 manifest = walk_workdir(tmp_path)
367 assert "link_to_repo/secret.py" not in manifest
368
369 def test_symlink_to_regular_dir_not_followed(self, tmp_path: pathlib.Path) -> None:
370 """Symlinks to any directory are never followed — followlinks=False."""
371 real = tmp_path / "outside"
372 real.mkdir()
373 (real / "file.py").write_bytes(b"x")
374 link = tmp_path / "link_to_dir"
375 link.symlink_to(real)
376
377 manifest = walk_workdir(tmp_path)
378 assert "link_to_dir/file.py" not in manifest
379
380 def test_nested_repo_with_unusual_name(self, tmp_path: pathlib.Path) -> None:
381 """Nested repos with names containing spaces or dots are excluded."""
382 for name in ("my.repo", "repo name", ".hidden_repo"):
383 child = tmp_path / name
384 child.mkdir()
385 (child / ".muse").mkdir()
386 (child / "file.py").write_bytes(b"x")
387
388 (tmp_path / "root.py").write_bytes(b"r")
389 manifest = walk_workdir(tmp_path)
390 assert "root.py" in manifest
391 assert not any("file.py" in k for k in manifest)
392
393 # --- performance --------------------------------------------------------
394
395 def test_large_parent_with_nested_repo_fast(self, tmp_path: pathlib.Path) -> None:
396 """Walking 500-file parent with a nested repo completes in < 2 s."""
397 for i in range(500):
398 (tmp_path / f"file_{i:04d}.py").write_bytes(b"x" * 100)
399 child = _make_nested_repo(tmp_path, "child")
400 for i in range(200):
401 (child / f"child_{i:04d}.py").write_bytes(b"x" * 100)
402
403 start = time.monotonic()
404 manifest = walk_workdir(tmp_path)
405 elapsed = time.monotonic() - start
406
407 assert elapsed < 2.0, f"walk took {elapsed:.2f}s — too slow"
408 # Parent files included, child files excluded.
409 assert len(manifest) == 500
410 assert not any(k.startswith("child/") for k in manifest)
411
412 def test_concurrent_walks_consistent(self, tmp_path: pathlib.Path) -> None:
413 """Concurrent walks of the same tree return identical manifests."""
414 (tmp_path / "a.py").write_bytes(b"a")
415 (tmp_path / "b.py").write_bytes(b"b")
416 _make_nested_repo(tmp_path, "child")
417 (tmp_path / "child" / "c.py").write_bytes(b"c")
418
419 results: list[dict] = []
420 errors: list[Exception] = []
421
422 def _walk() -> None:
423 try:
424 results.append(walk_workdir(tmp_path))
425 except Exception as exc:
426 errors.append(exc)
427
428 threads = [threading.Thread(target=_walk) for _ in range(8)]
429 for t in threads:
430 t.start()
431 for t in threads:
432 t.join()
433
434 assert not errors
435 assert len(results) == 8
436 assert all(r == results[0] for r in results), "concurrent walks diverged"
File History 5 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
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago