gabriel / muse public
test_integrity_I6_snapshot_scale.py python
601 lines 23.4 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Phase 1.6 — Linux-kernel scale snapshot manifest.
2
3 Tests cover:
4 - 75 000-file cold walk correctness and timing (< 30 s on tmpfs)
5 - Warm walk (cache hit, no changes) < 500 ms
6 - Partial change: only modified files re-hashed
7 - Memory ceiling: RSS stays flat during large-file streaming
8 - Deep directory nesting (100 levels) — no recursion errors
9 - Inode-based cache invalidation: atomic file replacement detected
10 - Concurrent walks: two processes walking simultaneously
11 - Cache size limit (MAX_CACHE_BYTES) enforced on load
12 - Cache format: msgpack v2 on disk
13 - Cache fsync + mkstemp atomicity: no fixed .tmp collision
14 - Deleted-file prune: stale cache entries removed after walk
15 - Empty repo: no crash, empty manifest
16 - Path safety: symlinks excluded, non-regular files excluded
17 """
18
19 from __future__ import annotations
20
21 import json
22 import os
23 import pathlib
24 import resource
25 import stat
26 import tempfile
27 import threading
28 import time
29
30 import msgpack
31 import pytest
32
33 from muse.core.snapshot import build_snapshot_manifest, walk_workdir
34 from muse.core.types import blob_id
35 from muse.core.paths import muse_dir, stat_cache_path as _stat_cache_path
36 from muse.core.stat_cache import (
37 MAX_CACHE_BYTES,
38 FileCacheEntry,
39 StatCache,
40 _CACHE_VERSION,
41 load_cache,
42 )
43
44 # ---------------------------------------------------------------------------
45 # Helpers
46 # ---------------------------------------------------------------------------
47
48 def _make_muse_dir(root: pathlib.Path) -> pathlib.Path:
49 d = muse_dir(root)
50 d.mkdir(exist_ok=True)
51 (d / "cache").mkdir(exist_ok=True)
52 return d
53
54
55 def _write(path: pathlib.Path, content: str = "x") -> pathlib.Path:
56 path.parent.mkdir(parents=True, exist_ok=True)
57 path.write_text(content, encoding="utf-8")
58 return path
59
60
61
62 def _create_files_flat(root: pathlib.Path, n: int, size_bytes: int = 256) -> None:
63 """Create *n* files under *root* with *size_bytes* of content each."""
64 content = b"x" * size_bytes
65 for i in range(n):
66 p = root / f"file_{i:06d}.txt"
67 p.write_bytes(content)
68
69
70 def _create_files_deep(root: pathlib.Path, depth: int, files_per_level: int = 2) -> None:
71 """Create a directory tree *depth* levels deep with files at each level."""
72 cur = root
73 for level in range(depth):
74 cur = cur / f"d{level:03d}"
75 cur.mkdir(exist_ok=True)
76 for f in range(files_per_level):
77 (cur / f"f{f}.txt").write_bytes(b"level" + str(level).encode())
78
79
80 # ---------------------------------------------------------------------------
81 # 1. Format — msgpack v2 on disk
82 # ---------------------------------------------------------------------------
83
84 class TestCacheFormat:
85 def test_cache_file_is_msgpack(self, tmp_path: pathlib.Path) -> None:
86 dot_muse = _make_muse_dir(tmp_path)
87 f = _write(tmp_path / "a.py", "hello")
88 cache = StatCache.load(dot_muse)
89 cache.get_object_hash(tmp_path, f)
90 cache.save()
91
92 cache_path = _stat_cache_path(dot_muse.parent)
93 assert cache_path.is_file()
94 assert cache_path.suffix == ".msgpack"
95 raw = msgpack.unpackb(cache_path.read_bytes(), raw=False)
96 assert raw["version"] == _CACHE_VERSION
97 assert _CACHE_VERSION == 2
98
99 def test_cache_entry_has_ino_field(self, tmp_path: pathlib.Path) -> None:
100 dot_muse = _make_muse_dir(tmp_path)
101 f = _write(tmp_path / "b.py", "world")
102 cache = StatCache.load(dot_muse)
103 cache.get_object_hash(tmp_path, f)
104 cache.save()
105
106 raw = msgpack.unpackb((_stat_cache_path(dot_muse.parent)).read_bytes(), raw=False)
107 entry = raw["entries"]["b.py"]
108 assert "ino" in entry
109 assert isinstance(entry["ino"], int)
110 assert entry["ino"] > 0
111
112 def test_version_mismatch_returns_empty(self, tmp_path: pathlib.Path) -> None:
113 dot_muse = _make_muse_dir(tmp_path)
114 # Write a v99 cache — should be discarded
115 bad = msgpack.packb({"version": 99, "entries": {}})
116 (_stat_cache_path(dot_muse.parent)).write_bytes(bad)
117 cache = StatCache.load(dot_muse)
118 assert len(cache._entries) == 0
119
120 def test_corrupt_cache_returns_empty(self, tmp_path: pathlib.Path) -> None:
121 dot_muse = _make_muse_dir(tmp_path)
122 (_stat_cache_path(dot_muse.parent)).write_bytes(b"\xff\x00garbage\xde\xad")
123 cache = StatCache.load(dot_muse)
124 assert len(cache._entries) == 0
125
126 def test_absent_cache_returns_empty(self, tmp_path: pathlib.Path) -> None:
127 dot_muse = _make_muse_dir(tmp_path)
128 cache = StatCache.load(dot_muse)
129 assert len(cache._entries) == 0
130
131
132 # ---------------------------------------------------------------------------
133 # 2. Inode-based invalidation
134 # ---------------------------------------------------------------------------
135
136 class TestInodeInvalidation:
137 def test_atomic_replace_invalidates_cache(self, tmp_path: pathlib.Path) -> None:
138 """Atomically replacing a file (same mtime/size) is detected via new inode."""
139 dot_muse = _make_muse_dir(tmp_path)
140 f = tmp_path / "data.bin"
141 content_a = b"A" * 64
142 content_b = b"B" * 64 # same size as content_a
143 f.write_bytes(content_a)
144
145 cache = StatCache.load(dot_muse)
146 hash_a = cache.get_object_hash(tmp_path, f)
147 cache.save()
148
149 # Atomically replace with different content (same size).
150 # Force mtime to be identical (same second) by setting it manually
151 # after the write — this simulates the NFS/tmpfs scenario.
152 with tempfile.NamedTemporaryFile(dir=tmp_path, delete=False) as tmp:
153 tmp.write(content_b)
154 tmp_name = tmp.name
155 orig_stat = f.stat()
156 os.replace(tmp_name, str(f))
157 # Restore original mtime to simulate a racy replacement
158 os.utime(str(f), (orig_stat.st_atime, orig_stat.st_mtime))
159
160 # The new file has a different inode — cache must miss
161 cache2 = StatCache.load(dot_muse)
162 hash_b = cache2.get_object_hash(tmp_path, f)
163
164 assert hash_a != hash_b, (
165 "Cache returned stale hash after atomic replacement — "
166 "inode invalidation is not working"
167 )
168
169 def test_mtime_size_same_but_ino_changed_is_miss(
170 self, tmp_path: pathlib.Path
171 ) -> None:
172 """If ino changes, cache must invalidate even with same mtime+size."""
173 dot_muse = _make_muse_dir(tmp_path)
174 f = tmp_path / "tricky.py"
175 f.write_bytes(b"old_content_pad") # 15 bytes
176
177 cache = StatCache.load(dot_muse)
178 st = f.stat()
179 old_hash = cache.get_cached("tricky.py", str(f), st.st_mtime, st.st_size, st.st_ino)
180 cache.save()
181
182 # Simulate a different inode by using a fresh inode value
183 fake_ino = st.st_ino + 999_999
184 cache2 = StatCache.load(dot_muse)
185 # Should miss because ino doesn't match — forces re-hash
186 entry = cache2._entries.get("tricky.py")
187 assert entry is not None
188 assert entry["ino"] != fake_ino
189 # Direct get_cached with wrong ino triggers miss
190 new_hash = cache2.get_cached("tricky.py", str(f), st.st_mtime, st.st_size, fake_ino)
191 # The content hash is still the same (same file contents)
192 assert new_hash == old_hash # same file content
193 assert cache2._dirty # but it was a miss (dirty)
194
195 def test_unchanged_file_is_cache_hit(self, tmp_path: pathlib.Path) -> None:
196 dot_muse = _make_muse_dir(tmp_path)
197 f = _write(tmp_path / "stable.py", "unchanged")
198 cache = StatCache.load(dot_muse)
199 h1 = cache.get_object_hash(tmp_path, f)
200 cache.save()
201
202 cache2 = StatCache.load(dot_muse)
203 cache2._dirty = False
204 h2 = cache2.get_object_hash(tmp_path, f)
205
206 assert h1 == h2
207 assert not cache2._dirty # genuine cache hit
208
209
210 # ---------------------------------------------------------------------------
211 # 3. Concurrent write safety (mkstemp — no .tmp collision)
212 # ---------------------------------------------------------------------------
213
214 class TestConcurrentSaveSafety:
215 def test_concurrent_saves_no_collision(self, tmp_path: pathlib.Path) -> None:
216 """Two threads saving simultaneously must not corrupt each other."""
217 dot_muse = _make_muse_dir(tmp_path)
218 errors: list[Exception] = []
219
220 def save_cache(i: int) -> None:
221 f = _write(tmp_path / f"thread_{i}.py", f"content {i}")
222 cache = StatCache.load(dot_muse)
223 cache.get_object_hash(tmp_path, f)
224 try:
225 cache.save()
226 except Exception as exc:
227 errors.append(exc)
228
229 threads = [threading.Thread(target=save_cache, args=(i,)) for i in range(20)]
230 for t in threads:
231 t.start()
232 for t in threads:
233 t.join()
234
235 assert not errors, f"Concurrent saves failed: {errors[:3]}"
236 # No stray .tmp files left behind (mkstemp writes into cache/)
237 tmp_files = list(_stat_cache_path(dot_muse.parent).parent.glob(".stat_cache_*.tmp"))
238 assert not tmp_files, f"Stray temp files: {tmp_files}"
239
240 def test_no_fixed_tmp_suffix(self, tmp_path: pathlib.Path) -> None:
241 """save() must NOT create a file named stat_cache.msgpack.tmp."""
242 dot_muse = _make_muse_dir(tmp_path)
243 f = _write(tmp_path / "x.py", "hi")
244 cache = StatCache.load(dot_muse)
245 cache.get_object_hash(tmp_path, f)
246 cache.save()
247
248 fixed_tmp = _stat_cache_path(dot_muse.parent).with_suffix(".msgpack.tmp")
249 assert not fixed_tmp.exists(), "Fixed .tmp name — concurrent save race possible"
250
251
252 # ---------------------------------------------------------------------------
253 # 4. Cache size limit (MAX_CACHE_BYTES)
254 # ---------------------------------------------------------------------------
255
256 class TestCacheSizeLimit:
257 def test_max_cache_bytes_is_exported(self) -> None:
258 assert isinstance(MAX_CACHE_BYTES, int)
259 assert MAX_CACHE_BYTES >= 64 * 1024 * 1024
260
261 def test_oversized_cache_returns_empty(
262 self, tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture
263 ) -> None:
264 import logging
265 dot_muse = _make_muse_dir(tmp_path)
266 # Write a valid cache but patch the size limit to reject it
267 f = _write(tmp_path / "z.py", "z" * 1000)
268 cache = StatCache.load(dot_muse)
269 cache.get_object_hash(tmp_path, f)
270 cache.save()
271
272 real_size = (_stat_cache_path(dot_muse.parent)).stat().st_size
273 import unittest.mock as _mock
274 with _mock.patch("muse.core.stat_cache.MAX_CACHE_BYTES", real_size - 1):
275 with caplog.at_level(logging.CRITICAL, logger="muse.core.stat_cache"):
276 loaded = StatCache.load(dot_muse)
277 assert len(loaded._entries) == 0
278 assert any(r.levelno >= logging.CRITICAL for r in caplog.records)
279
280 def test_exactly_at_limit_is_accepted(self, tmp_path: pathlib.Path) -> None:
281 dot_muse = _make_muse_dir(tmp_path)
282 f = _write(tmp_path / "y.py", "y")
283 cache = StatCache.load(dot_muse)
284 cache.get_object_hash(tmp_path, f)
285 cache.save()
286
287 real_size = (_stat_cache_path(dot_muse.parent)).stat().st_size
288 import unittest.mock as _mock
289 with _mock.patch("muse.core.stat_cache.MAX_CACHE_BYTES", real_size):
290 loaded = StatCache.load(dot_muse)
291 assert len(loaded._entries) == 1
292
293
294 # ---------------------------------------------------------------------------
295 # 5. Prune: deleted files evicted from cache
296 # ---------------------------------------------------------------------------
297
298 class TestPruneAfterWalk:
299 def test_deleted_file_pruned_on_walk(self, tmp_path: pathlib.Path) -> None:
300 dot_muse = _make_muse_dir(tmp_path)
301 fa = _write(tmp_path / "keep.py", "keep")
302 fb = _write(tmp_path / "delete_me.py", "bye")
303
304 # First walk — both files cached
305 walk_workdir(tmp_path)
306 cache = StatCache.load(dot_muse)
307 assert "keep.py" in cache._entries
308 assert "delete_me.py" in cache._entries
309
310 # Delete one file then re-walk
311 fb.unlink()
312 walk_workdir(tmp_path)
313
314 cache2 = StatCache.load(dot_muse)
315 assert "keep.py" in cache2._entries
316 assert "delete_me.py" not in cache2._entries, "Stale cache entry not pruned"
317
318
319 # ---------------------------------------------------------------------------
320 # 6. Path safety
321 # ---------------------------------------------------------------------------
322
323 class TestPathSafety:
324 def test_symlinks_excluded_from_manifest(self, tmp_path: pathlib.Path) -> None:
325 _make_muse_dir(tmp_path)
326 real = _write(tmp_path / "real.py", "real")
327 link = tmp_path / "link.py"
328 link.symlink_to(real)
329
330 manifest = build_snapshot_manifest(tmp_path)
331 assert "real.py" in manifest
332 assert "link.py" not in manifest, "Symlinks must be excluded"
333
334 def test_non_regular_files_excluded(self, tmp_path: pathlib.Path) -> None:
335 _make_muse_dir(tmp_path)
336 _write(tmp_path / "normal.py", "ok")
337 # Create a FIFO (named pipe) — non-regular file
338 fifo = tmp_path / "pipe.fifo"
339 os.mkfifo(str(fifo))
340
341 manifest = build_snapshot_manifest(tmp_path)
342 assert "normal.py" in manifest
343 assert "pipe.fifo" not in manifest
344
345 def test_muse_dir_excluded_from_manifest(self, tmp_path: pathlib.Path) -> None:
346 _make_muse_dir(tmp_path)
347 _write(tmp_path / "real.py", "code")
348
349 manifest = build_snapshot_manifest(tmp_path)
350 assert not any(k.startswith(".muse/") for k in manifest)
351
352
353 # ---------------------------------------------------------------------------
354 # 7. Warm-walk no-op: second commit on unchanged tree is fast
355 # ---------------------------------------------------------------------------
356
357 class TestWarmWalkPerformance:
358 def test_warm_walk_uses_cache_no_rehash(self, tmp_path: pathlib.Path) -> None:
359 """Unchanged files must not be re-hashed on the second walk."""
360 _make_muse_dir(tmp_path)
361 for i in range(100):
362 _write(tmp_path / f"f{i}.py", f"content {i}")
363
364 # Cold walk — populates cache
365 walk_workdir(tmp_path)
366
367 # Warm walk — should be all cache hits
368 cache_before = StatCache.load(muse_dir(tmp_path))
369 count_before = len(cache_before._entries)
370
371 walk_workdir(tmp_path)
372
373 # After warm walk, cache entries are unchanged (same count, not dirty)
374 cache_after = StatCache.load(muse_dir(tmp_path))
375 assert len(cache_after._entries) == count_before
376
377 @pytest.mark.slow
378 def test_warm_walk_500ms_on_1000_files(self, tmp_path: pathlib.Path) -> None:
379 """1000-file warm walk must complete in < 500 ms."""
380 _make_muse_dir(tmp_path)
381 for i in range(1000):
382 _write(tmp_path / f"warm_{i:04d}.py", f"content {i} " * 10)
383
384 walk_workdir(tmp_path) # cold
385
386 t0 = time.perf_counter()
387 walk_workdir(tmp_path) # warm
388 elapsed = time.perf_counter() - t0
389
390 assert elapsed < 0.5, (
391 f"Warm walk over 1000 files took {elapsed:.3f}s — must be < 500 ms. "
392 "Cache is not being consulted."
393 )
394
395
396 # ---------------------------------------------------------------------------
397 # 8. Deep directory nesting — no recursion errors
398 # ---------------------------------------------------------------------------
399
400 class TestDeepNesting:
401 def test_100_level_nesting_no_error(self, tmp_path: pathlib.Path) -> None:
402 """os.walk must not hit recursion limit at 100 directory levels."""
403 _make_muse_dir(tmp_path)
404 _create_files_deep(tmp_path, depth=100, files_per_level=1)
405
406 manifest = build_snapshot_manifest(tmp_path)
407 assert len(manifest) == 100, f"Expected 100 files, got {len(manifest)}"
408
409 def test_50_level_nesting_correct_paths(self, tmp_path: pathlib.Path) -> None:
410 """Paths at deep levels must be POSIX-relative with correct separators."""
411 _make_muse_dir(tmp_path)
412 cur = tmp_path
413 for i in range(50):
414 cur = cur / f"l{i}"
415 cur.mkdir()
416 leaf = cur / "deep.py"
417 leaf.write_bytes(b"deep")
418
419 manifest = build_snapshot_manifest(tmp_path)
420 # Path uses forward slashes regardless of OS
421 assert any("deep.py" in k and "/" in k for k in manifest)
422 assert not any("\\" in k for k in manifest)
423
424
425 # ---------------------------------------------------------------------------
426 # 9. Large-file streaming — memory stays flat
427 # ---------------------------------------------------------------------------
428
429 class TestLargeFileStreaming:
430 @pytest.mark.slow
431 def test_large_file_memory_flat(self, tmp_path: pathlib.Path) -> None:
432 """Hashing a 50 MiB file must not load it all into memory at once."""
433 import sys as _sys
434 _make_muse_dir(tmp_path)
435 large = tmp_path / "large.bin"
436 chunk = b"Z" * 65_536
437 with large.open("wb") as fh:
438 for _ in range(800): # 800 × 64 KiB = 50 MiB
439 fh.write(chunk)
440
441 rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
442 manifest = build_snapshot_manifest(tmp_path)
443 rss_after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
444
445 assert "large.bin" in manifest
446 # ru_maxrss is bytes on macOS, KiB on Linux.
447 _scale = 1024 * 1024 if _sys.platform == "darwin" else 1024
448 rss_delta_mib = (rss_after - rss_before) / _scale
449 # RSS growth must be < 10 MiB (file is 50 MiB — proves streaming)
450 assert rss_delta_mib < 10, (
451 f"RSS grew {rss_delta_mib:.1f} MiB while hashing a 50 MiB file "
452 "— file is being loaded entirely into memory."
453 )
454
455 def test_zero_byte_file_hashes_correctly(self, tmp_path: pathlib.Path) -> None:
456 _make_muse_dir(tmp_path)
457 empty = tmp_path / "empty.py"
458 empty.write_bytes(b"")
459
460 manifest = build_snapshot_manifest(tmp_path)
461 expected = blob_id(b"")
462 assert manifest["empty.py"] == expected
463
464
465 # ---------------------------------------------------------------------------
466 # 10. 75 000-file scale — cold walk correctness + warm walk timing
467 # ---------------------------------------------------------------------------
468
469 @pytest.mark.slow
470 class TestLinuxKernelScale:
471 def test_75k_cold_walk_correct_and_bounded(self, tmp_path: pathlib.Path) -> None:
472 """75k files: cold walk must be correct and complete in < 30 s."""
473 _make_muse_dir(tmp_path)
474 n = 75_000
475 content = b"k" * 1024 # 1 KiB per file
476
477 t_create = time.perf_counter()
478 for i in range(n):
479 subdir = tmp_path / f"d{i // 1000:03d}"
480 subdir.mkdir(exist_ok=True)
481 (subdir / f"f{i:06d}.c").write_bytes(content)
482 t_create_done = time.perf_counter()
483
484 t_walk = time.perf_counter()
485 manifest = build_snapshot_manifest(tmp_path)
486 t_walk_done = time.perf_counter()
487
488 walk_seconds = t_walk_done - t_walk
489 assert len(manifest) == n, f"Expected {n} files, got {len(manifest)}"
490 # All hashes must be the 1 KiB content hash (same content → same hash)
491 expected_hash = blob_id(content)
492 assert all(v == expected_hash for v in manifest.values()), (
493 "Hash mismatch — some files were hashed incorrectly"
494 )
495 assert walk_seconds < 30, (
496 f"75k cold walk took {walk_seconds:.1f}s — must be < 30 s"
497 )
498
499 def test_75k_warm_walk_zero_misses(self, tmp_path: pathlib.Path) -> None:
500 """75k files: warm walk must produce zero cache misses.
501
502 Uses ``_dirty`` as the oracle — if the cache has no misses after a
503 warm walk, ``_dirty`` remains False (no new hashes were computed).
504 This is platform-independent: it proves correctness regardless of
505 whether lstat or file-read latency dominates.
506 """
507 _make_muse_dir(tmp_path)
508 n = 75_000
509 content = b"w" * 512
510
511 for i in range(n):
512 subdir = tmp_path / f"d{i // 1000:03d}"
513 subdir.mkdir(exist_ok=True)
514 (subdir / f"f{i:06d}.go").write_bytes(content)
515
516 build_snapshot_manifest(tmp_path) # cold — populates cache
517
518 # Load the persisted cache and walk again; _dirty should stay False
519 # because every file matches its cached (ino, mtime, size).
520 cache = load_cache(tmp_path)
521 cache._dirty = False # reset to ensure we detect any miss
522 manifest2 = build_snapshot_manifest(tmp_path)
523
524 assert len(manifest2) == n
525 # Reload the cache from disk — if anything was re-hashed during the
526 # warm walk, the on-disk cache will have grown entries (or been re-written).
527 # More directly: run walk_workdir manually with our cache instance.
528 from muse.core.snapshot import walk_workdir as _walk
529 dot_muse = muse_dir(tmp_path)
530 cache2 = StatCache.load(dot_muse)
531 cache2._dirty = False
532 _ = _walk(tmp_path) # warm walk
533 # The on-disk cache must not have been re-written with any new entries
534 # (same inode, mtime, size → all cache hits → not dirty → no save)
535 assert not cache2._dirty, (
536 "Warm walk marked cache dirty — at least one file was re-hashed. "
537 "The stat cache is not being consulted correctly at 75k-file scale."
538 )
539
540 def test_cache_speedup_with_large_files(self, tmp_path: pathlib.Path) -> None:
541 """Cache delivers ≥ 5× speedup when file-content I/O dominates.
542
543 Uses 100 × 1 MiB files so cold-walk hashing clearly dominates lstat
544 overhead. Warm walk skips all file reads → dramatic speedup.
545 Platform-independent: cold is I/O bound; warm is stat-bound.
546 """
547 _make_muse_dir(tmp_path)
548 content = b"L" * (1024 * 1024) # 1 MiB per file
549 for i in range(100):
550 (tmp_path / f"large_{i:03d}.bin").write_bytes(content)
551
552 t_cold0 = time.perf_counter()
553 build_snapshot_manifest(tmp_path) # cold
554 t_cold = time.perf_counter() - t_cold0
555
556 t_warm0 = time.perf_counter()
557 build_snapshot_manifest(tmp_path) # warm
558 t_warm = time.perf_counter() - t_warm0
559
560 speedup = t_cold / t_warm if t_warm > 0 else float("inf")
561 assert speedup >= 5, (
562 f"Warm walk ({t_warm:.3f}s) is only {speedup:.1f}× faster than "
563 f"cold ({t_cold:.3f}s) — expected ≥ 5×. "
564 "Cache must skip all file-content reads on warm walk."
565 )
566
567 def test_75k_partial_change_only_rehashes_changed(
568 self, tmp_path: pathlib.Path
569 ) -> None:
570 """After changing 10 files, only those 10 should trigger a cache miss."""
571 _make_muse_dir(tmp_path)
572 n = 75_000
573 content = b"p" * 256
574
575 paths: list[pathlib.Path] = []
576 for i in range(n):
577 subdir = tmp_path / f"d{i // 1000:03d}"
578 subdir.mkdir(exist_ok=True)
579 p = subdir / f"f{i:06d}.rs"
580 p.write_bytes(content)
581 paths.append(p)
582
583 build_snapshot_manifest(tmp_path) # cold
584
585 # Modify 10 files
586 changed = paths[:10]
587 new_content = b"CHANGED" * 37 # different size ensures definite miss
588 for p in changed:
589 p.write_bytes(new_content)
590
591 manifest2 = build_snapshot_manifest(tmp_path)
592
593 new_hash = blob_id(new_content)
594 old_hash = blob_id(content)
595 changed_rels = {str(p.relative_to(tmp_path)).replace(os.sep, "/") for p in changed}
596
597 for rel, h in manifest2.items():
598 if rel in changed_rels:
599 assert h == new_hash, f"{rel} should have new hash"
600 else:
601 assert h == old_hash, f"{rel} should still have old hash"
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago