gabriel / muse public
test_directories_feature.py python
1,167 lines 46.8 KB
Raw
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 41 days ago
1 """Comprehensive tests for the "directories as first-class objects" feature.
2
3 Covers every changed surface:
4 - directories_from_manifest (unit)
5 - walk_workdir_with_dirs (unit + integration)
6 - compute_snapshot_id with directories parameter (unit)
7 - detect_directory_renames (unit + property-style)
8 - diff_workdir_vs_snapshot 6-tuple (unit + integration)
9 - SnapshotRecord.directories serialisation round-trip (unit)
10 - write_snapshot / read_snapshot with directories (integration)
11 - CodePlugin.diff directory rename detection (integration)
12 - delta_summary directory rename counting (unit)
13 - replay_one propagates directories to new SnapshotRecord (integration)
14 - Full commit → branch → rename → commit → merge E2E workflow (e2e)
15 - Stress / performance (stress)
16 - Security: path traversal, symlinks, adversarial inputs (security)
17 """
18
19 from __future__ import annotations
20
21 import datetime
22 import hashlib
23 import json
24 import os
25 import pathlib
26 import subprocess
27 import sys
28 import time
29 import pytest
30
31 from muse.core.snapshot import (
32 compute_commit_id,
33 compute_snapshot_id,
34 detect_directory_renames,
35 diff_workdir_vs_snapshot,
36 directories_from_manifest,
37 hash_file,
38 walk_workdir_with_dirs,
39 )
40 from muse.core.store import (
41 CommitRecord,
42 SnapshotRecord,
43 read_commit,
44 read_snapshot,
45 write_commit,
46 write_snapshot,
47 )
48 from muse.domain import DirectoryRenameOp, SnapshotManifest
49 from muse.core._types import Manifest, MsgpackDict
50 from muse.plugins.code.plugin import CodePlugin
51
52
53 # ---------------------------------------------------------------------------
54 # Shared helpers
55 # ---------------------------------------------------------------------------
56
57 _REPO_ID = "test-repo-dirs"
58 _counter = 0
59
60
61 def _sha(data: bytes) -> str:
62 return hashlib.sha256(data).hexdigest()
63
64
65 def _init_store(root: pathlib.Path) -> None:
66 muse = root / ".muse"
67 for d in ("commits", "snapshots", "objects", "refs/heads"):
68 (muse / d).mkdir(parents=True, exist_ok=True)
69 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
70 (muse / "repo.json").write_text(
71 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
72 )
73
74
75 def _make_snap(root: pathlib.Path, manifest: Manifest, dirs: list[str] | None = None) -> SnapshotRecord:
76 dirs = dirs if dirs is not None else directories_from_manifest(manifest)
77 sid = compute_snapshot_id(manifest, dirs)
78 rec = SnapshotRecord(snapshot_id=sid, manifest=manifest, directories=dirs)
79 write_snapshot(root, rec)
80 return rec
81
82
83 def _make_commit_rec(
84 root: pathlib.Path,
85 snap: SnapshotRecord,
86 branch: str = "main",
87 parent_id: str | None = None,
88 message: str = "test commit",
89 ) -> CommitRecord:
90 global _counter
91 _counter += 1
92 committed_at = datetime.datetime.now(datetime.timezone.utc)
93 cid = compute_commit_id(
94 [parent_id] if parent_id else [],
95 snap.snapshot_id,
96 message,
97 committed_at.isoformat(),
98 )
99 rec = CommitRecord(
100 commit_id=cid,
101 repo_id=_REPO_ID,
102 branch=branch,
103 snapshot_id=snap.snapshot_id,
104 message=message,
105 committed_at=committed_at,
106 parent_commit_id=parent_id,
107 )
108 write_commit(root, rec)
109 (root / ".muse" / "refs" / "heads" / branch).write_text(cid, encoding="utf-8")
110 return rec
111
112
113 @pytest.fixture()
114 def store(tmp_path: pathlib.Path) -> pathlib.Path:
115 _init_store(tmp_path)
116 return tmp_path
117
118
119 @pytest.fixture()
120 def workdir(tmp_path: pathlib.Path) -> pathlib.Path:
121 return tmp_path
122
123
124 # ===========================================================================
125 # 1. directories_from_manifest — unit
126 # ===========================================================================
127
128 class TestDirectoriesFromManifest:
129 def test_empty_manifest_returns_empty(self) -> None:
130 assert directories_from_manifest({}) == []
131
132 def test_flat_files_no_dirs(self) -> None:
133 result = directories_from_manifest({"a.py": "h1", "b.py": "h2"})
134 assert result == []
135
136 def test_single_nested_file(self) -> None:
137 result = directories_from_manifest({"src/main.py": "h1"})
138 assert result == ["src"]
139
140 def test_deeply_nested(self) -> None:
141 result = directories_from_manifest({"a/b/c/d.py": "h1"})
142 assert result == ["a", "a/b", "a/b/c"]
143
144 def test_multiple_files_same_dir_deduped(self) -> None:
145 result = directories_from_manifest({"src/a.py": "h1", "src/b.py": "h2"})
146 assert result == ["src"]
147
148 def test_sibling_dirs(self) -> None:
149 result = directories_from_manifest({
150 "src/foo.py": "h1",
151 "tests/bar.py": "h2",
152 })
153 assert result == ["src", "tests"]
154
155 def test_mixed_flat_and_nested(self) -> None:
156 result = directories_from_manifest({
157 "root.py": "h0",
158 "src/main.py": "h1",
159 "src/lib/util.py": "h2",
160 })
161 assert result == ["src", "src/lib"]
162
163 def test_result_is_sorted(self) -> None:
164 result = directories_from_manifest({
165 "z/file.py": "h1",
166 "a/file.py": "h2",
167 "m/sub/file.py": "h3",
168 })
169 assert result == sorted(result)
170
171 def test_result_is_deduplicated(self) -> None:
172 result = directories_from_manifest({
173 "pkg/a.py": "h1",
174 "pkg/b.py": "h2",
175 "pkg/c.py": "h3",
176 })
177 assert result.count("pkg") == 1
178
179 def test_large_flat_tree_no_dirs(self) -> None:
180 manifest = {f"file_{i}.txt": f"hash{i}" for i in range(200)}
181 assert directories_from_manifest(manifest) == []
182
183 def test_preserves_posix_separators(self) -> None:
184 result = directories_from_manifest({"foo/bar/baz.py": "h"})
185 assert all("/" in d or d == "foo" for d in result)
186 assert "\\" not in "".join(result)
187
188
189 # ===========================================================================
190 # 2. compute_snapshot_id with directories — unit
191 # ===========================================================================
192
193 class TestComputeSnapshotIdWithDirectories:
194 def test_no_dirs_matches_legacy_behaviour(self) -> None:
195 m = {"a.py": "h1"}
196 assert compute_snapshot_id(m) == compute_snapshot_id(m, None)
197 assert compute_snapshot_id(m) == compute_snapshot_id(m, [])
198
199 def test_dirs_change_the_id(self) -> None:
200 m = {"a.py": "h1"}
201 without = compute_snapshot_id(m, [])
202 with_dir = compute_snapshot_id(m, ["src"])
203 assert without != with_dir
204
205 def test_different_dirs_different_id(self) -> None:
206 m = {"a.py": "h1"}
207 id1 = compute_snapshot_id(m, ["src"])
208 id2 = compute_snapshot_id(m, ["lib"])
209 assert id1 != id2
210
211 def test_same_files_same_dirs_deterministic(self) -> None:
212 m = {"a/b.py": "h1", "c/d.py": "h2"}
213 dirs = ["a", "c"]
214 assert compute_snapshot_id(m, dirs) == compute_snapshot_id(m, dirs)
215
216 def test_dir_order_independent(self) -> None:
217 m = {"a.py": "h1"}
218 id1 = compute_snapshot_id(m, ["src", "lib"])
219 id2 = compute_snapshot_id(m, ["lib", "src"])
220 assert id1 == id2
221
222 def test_file_rename_changes_id_even_with_same_dirs(self) -> None:
223 dirs = ["src"]
224 id1 = compute_snapshot_id({"src/a.py": "h1"}, dirs)
225 id2 = compute_snapshot_id({"src/b.py": "h1"}, dirs)
226 assert id1 != id2
227
228 def test_dir_rename_changes_id_same_file_content(self) -> None:
229 manifest = {"f.py": "h1"}
230 id_old = compute_snapshot_id(manifest, ["old_name"])
231 id_new = compute_snapshot_id(manifest, ["new_name"])
232 assert id_old != id_new
233
234 def test_result_is_64_hex_chars(self) -> None:
235 sid = compute_snapshot_id({"a.py": "h"}, ["src"])
236 assert len(sid) == 64
237 assert all(c in "0123456789abcdef" for c in sid)
238
239
240 # ===========================================================================
241 # 3. detect_directory_renames — unit
242 # ===========================================================================
243
244 class TestDetectDirectoryRenames:
245 def test_clean_single_rename(self) -> None:
246 last = {"old/a.py": "h1", "old/b.py": "h2"}
247 current = {"new/a.py": "h1", "new/b.py": "h2"}
248 renames = detect_directory_renames({"old"}, {"new"}, last, current)
249 assert renames == [("old", "new")]
250
251 def test_no_rename_content_changed(self) -> None:
252 last = {"old/a.py": "h1"}
253 current = {"new/a.py": "DIFFERENT"}
254 renames = detect_directory_renames({"old"}, {"new"}, last, current)
255 assert renames == []
256
257 def test_no_rename_empty_old_dir(self) -> None:
258 # old dir has no files in last_manifest → can't match
259 last: Manifest = {}
260 current = {"new/a.py": "h1"}
261 renames = detect_directory_renames({"old"}, {"new"}, last, current)
262 assert renames == []
263
264 def test_multiple_independent_renames(self) -> None:
265 last = {"foo/x.py": "h1", "bar/y.py": "h2"}
266 current = {"baz/x.py": "h1", "qux/y.py": "h2"}
267 renames = detect_directory_renames({"foo", "bar"}, {"baz", "qux"}, last, current)
268 assert set(renames) == {("foo", "baz"), ("bar", "qux")}
269
270 def test_ambiguous_candidates_not_renamed(self) -> None:
271 # Two added dirs have identical file sets → ambiguous, none matched
272 last = {"old/f.py": "h1"}
273 current = {"new1/f.py": "h1", "new2/f.py": "h1"}
274 renames = detect_directory_renames({"old"}, {"new1", "new2"}, last, current)
275 # Should match exactly one (first sorted candidate wins)
276 assert len(renames) == 1
277
278 def test_partial_match_not_renamed(self) -> None:
279 last = {"old/a.py": "h1", "old/b.py": "h2"}
280 current = {"new/a.py": "h1"} # b.py missing
281 renames = detect_directory_renames({"old"}, {"new"}, last, current)
282 assert renames == []
283
284 def test_extra_file_in_new_dir_not_renamed(self) -> None:
285 last = {"old/a.py": "h1"}
286 current = {"new/a.py": "h1", "new/extra.py": "h2"}
287 renames = detect_directory_renames({"old"}, {"new"}, last, current)
288 assert renames == []
289
290 def test_returns_list_of_tuples(self) -> None:
291 last = {"src/main.py": "abc"}
292 current = {"lib/main.py": "abc"}
293 result = detect_directory_renames({"src"}, {"lib"}, last, current)
294 assert isinstance(result, list)
295 assert all(isinstance(r, tuple) and len(r) == 2 for r in result)
296
297 def test_empty_sets_returns_empty(self) -> None:
298 assert detect_directory_renames(set(), set(), {}, {}) == []
299
300 def test_single_file_dir_rename(self) -> None:
301 last = {"pkg/module.py": "cafebabe"}
302 current = {"renamed_pkg/module.py": "cafebabe"}
303 renames = detect_directory_renames({"pkg"}, {"renamed_pkg"}, last, current)
304 assert renames == [("pkg", "renamed_pkg")]
305
306
307 # ===========================================================================
308 # 4. diff_workdir_vs_snapshot — 6-tuple (fix broken existing + new)
309 # ===========================================================================
310
311 class TestDiffWorkdirVsSnapshot6Tuple:
312 def test_returns_6_tuple(self, workdir: pathlib.Path) -> None:
313 result = diff_workdir_vs_snapshot(workdir, {})
314 assert len(result) == 6
315
316 def test_untracked_first_commit(self, workdir: pathlib.Path) -> None:
317 (workdir / "f.py").write_bytes(b"x")
318 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
319 diff_workdir_vs_snapshot(workdir, {})
320 assert added == set()
321 assert "f.py" in untracked
322
323 def test_added_file_detected(self, workdir: pathlib.Path) -> None:
324 (workdir / "f.py").write_bytes(b"x")
325 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
326 diff_workdir_vs_snapshot(workdir, {"other.py": "abc"})
327 assert "f.py" in added
328 assert "other.py" in deleted
329
330 def test_modified_file_detected(self, workdir: pathlib.Path) -> None:
331 f = workdir / "f.py"
332 f.write_bytes(b"new content")
333 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
334 diff_workdir_vs_snapshot(workdir, {"f.py": "oldhash"})
335 assert "f.py" in modified
336
337 def test_clean_workdir_all_empty(self, workdir: pathlib.Path) -> None:
338 f = workdir / "f.py"
339 f.write_bytes(b"content")
340 h = hash_file(f)
341 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
342 diff_workdir_vs_snapshot(workdir, {"f.py": h})
343 assert not added and not modified and not deleted and not untracked
344
345 def test_added_dir_detected(self, workdir: pathlib.Path) -> None:
346 (workdir / "src").mkdir()
347 (workdir / "src" / "main.py").write_bytes(b"x")
348 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
349 diff_workdir_vs_snapshot(workdir, {"root.py": "abc"}, last_directories=["lib"])
350 assert "src" in added_dirs
351 assert "lib" in deleted_dirs
352
353 def test_deleted_dir_detected(self, workdir: pathlib.Path) -> None:
354 (workdir / "f.py").write_bytes(b"x")
355 h = hash_file(workdir / "f.py")
356 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
357 diff_workdir_vs_snapshot(workdir, {"f.py": h}, last_directories=["old_dir"])
358 assert "old_dir" in deleted_dirs
359
360 def test_unchanged_dirs_not_in_delta(self, workdir: pathlib.Path) -> None:
361 (workdir / "src").mkdir()
362 (workdir / "src" / "main.py").write_bytes(b"x")
363 h = hash_file(workdir / "src" / "main.py")
364 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
365 diff_workdir_vs_snapshot(workdir, {"src/main.py": h}, last_directories=["src"])
366 assert "src" not in added_dirs
367 assert "src" not in deleted_dirs
368
369 def test_nonexistent_workdir_returns_all_deleted(self, tmp_path: pathlib.Path) -> None:
370 missing = tmp_path / "gone"
371 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
372 diff_workdir_vs_snapshot(missing, {"f.py": "h"}, last_directories=["src"])
373 assert "f.py" in deleted
374 assert "src" in deleted_dirs
375 assert not added
376
377 def test_pruned_dirs_not_tracked(self, workdir: pathlib.Path) -> None:
378 (workdir / "node_modules").mkdir()
379 (workdir / "node_modules" / "pkg.js").write_bytes(b"x")
380 (workdir / "src").mkdir()
381 (workdir / "src" / "app.py").write_bytes(b"y")
382 added, modified, deleted, untracked, added_dirs, deleted_dirs = \
383 diff_workdir_vs_snapshot(workdir, {})
384 assert "node_modules" not in added_dirs
385 assert "src" in added_dirs or "src/app.py" in untracked
386
387
388 # ===========================================================================
389 # 5. walk_workdir_with_dirs — unit
390 # ===========================================================================
391
392 class TestWalkWorkdirWithDirs:
393 def test_empty_dir_returns_empty(self, workdir: pathlib.Path) -> None:
394 files, dirs = walk_workdir_with_dirs(workdir)
395 assert files == {}
396 assert dirs == []
397
398 def test_flat_files_no_dirs(self, workdir: pathlib.Path) -> None:
399 (workdir / "a.py").write_bytes(b"x")
400 files, dirs = walk_workdir_with_dirs(workdir)
401 assert "a.py" in files
402 assert dirs == []
403
404 def test_nested_file_dir_tracked(self, workdir: pathlib.Path) -> None:
405 (workdir / "src").mkdir()
406 (workdir / "src" / "main.py").write_bytes(b"x")
407 files, dirs = walk_workdir_with_dirs(workdir)
408 assert "src/main.py" in files
409 assert "src" in dirs
410
411 def test_deeply_nested_dirs_all_tracked(self, workdir: pathlib.Path) -> None:
412 deep = workdir / "a" / "b" / "c"
413 deep.mkdir(parents=True)
414 (deep / "f.py").write_bytes(b"x")
415 files, dirs = walk_workdir_with_dirs(workdir)
416 assert "a" in dirs
417 assert "a/b" in dirs
418 assert "a/b/c" in dirs
419
420 def test_dirs_sorted(self, workdir: pathlib.Path) -> None:
421 for name in ("zzz", "aaa", "mmm"):
422 (workdir / name).mkdir()
423 (workdir / name / "f.py").write_bytes(b"x")
424 _, dirs = walk_workdir_with_dirs(workdir)
425 assert dirs == sorted(dirs)
426
427 def test_pruned_dirs_excluded(self, workdir: pathlib.Path) -> None:
428 (workdir / "node_modules").mkdir()
429 (workdir / "node_modules" / "lib.js").write_bytes(b"x")
430 (workdir / "__pycache__").mkdir()
431 (workdir / "__pycache__" / "mod.pyc").write_bytes(b"x")
432 _, dirs = walk_workdir_with_dirs(workdir)
433 assert "node_modules" not in dirs
434 assert "__pycache__" not in dirs
435
436 def test_symlinks_not_followed(self, workdir: pathlib.Path) -> None:
437 real = workdir / "real_dir"
438 real.mkdir()
439 (real / "secret.py").write_bytes(b"secret")
440 link = workdir / "link_dir"
441 link.symlink_to(real)
442 files, dirs = walk_workdir_with_dirs(workdir)
443 # symlink directory should not be descended (followlinks=False)
444 assert "link_dir/secret.py" not in files
445
446
447 # ===========================================================================
448 # 6. SnapshotRecord.directories serialisation — unit
449 # ===========================================================================
450
451 class TestSnapshotRecordDirectories:
452 def test_default_directories_is_empty_list(self) -> None:
453 rec = SnapshotRecord(snapshot_id="abc", manifest={})
454 assert rec.directories == []
455
456 def test_to_dict_includes_directories(self) -> None:
457 rec = SnapshotRecord(snapshot_id="abc", manifest={}, directories=["src", "lib"])
458 d = rec.to_dict()
459 assert d["directories"] == ["src", "lib"]
460
461 def test_from_dict_roundtrip(self) -> None:
462 rec = SnapshotRecord(snapshot_id="abc", manifest={"f.py": "h"}, directories=["pkg"])
463 loaded = SnapshotRecord.from_dict(rec.to_dict())
464 assert loaded.directories == ["pkg"]
465
466 def test_from_msgpack_roundtrip(self) -> None:
467 rec = SnapshotRecord(snapshot_id="xyz", manifest={}, directories=["a", "b"])
468 d: MsgpackDict = dict(rec.to_dict())
469 loaded = SnapshotRecord.from_msgpack(d)
470 assert loaded.directories == ["a", "b"]
471
472 def test_from_msgpack_missing_field_defaults_empty(self) -> None:
473 d: MsgpackDict = {
474 "snapshot_id": "abc",
475 "manifest": {},
476 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
477 "note": "",
478 }
479 rec = SnapshotRecord.from_msgpack(d)
480 assert rec.directories == []
481
482 def test_from_msgpack_filters_non_string_items(self) -> None:
483 d: MsgpackDict = {
484 "snapshot_id": "abc",
485 "manifest": {},
486 "directories": ["valid", 42, None, "also_valid"],
487 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
488 "note": "",
489 }
490 rec = SnapshotRecord.from_msgpack(d)
491 assert rec.directories == ["valid", "also_valid"]
492
493 def test_from_msgpack_non_list_directories_defaults_empty(self) -> None:
494 d: MsgpackDict = {
495 "snapshot_id": "abc",
496 "manifest": {},
497 "directories": "not-a-list",
498 "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
499 "note": "",
500 }
501 rec = SnapshotRecord.from_msgpack(d)
502 assert rec.directories == []
503
504 def test_to_dict_returns_copy_not_reference(self) -> None:
505 dirs = ["src"]
506 rec = SnapshotRecord(snapshot_id="abc", manifest={}, directories=dirs)
507 d = rec.to_dict()
508 d["directories"].append("mutated")
509 assert rec.directories == ["src"]
510
511
512 # ===========================================================================
513 # 7. write_snapshot / read_snapshot roundtrip with directories — integration
514 # ===========================================================================
515
516 class TestWriteReadSnapshotWithDirectories:
517 def test_roundtrip_preserves_directories(self, store: pathlib.Path) -> None:
518 manifest = {"src/main.py": "h1", "src/util.py": "h2"}
519 dirs = ["src"]
520 sid = compute_snapshot_id(manifest, dirs)
521 rec = SnapshotRecord(snapshot_id=sid, manifest=manifest, directories=dirs)
522 write_snapshot(store, rec)
523
524 loaded = read_snapshot(store, sid)
525 assert loaded is not None
526 assert loaded.directories == ["src"]
527
528 def test_roundtrip_empty_directories(self, store: pathlib.Path) -> None:
529 manifest = {"f.py": "h1"}
530 sid = compute_snapshot_id(manifest, [])
531 rec = SnapshotRecord(snapshot_id=sid, manifest=manifest, directories=[])
532 write_snapshot(store, rec)
533 loaded = read_snapshot(store, sid)
534 assert loaded is not None
535 assert loaded.directories == []
536
537 def test_roundtrip_deeply_nested_dirs(self, store: pathlib.Path) -> None:
538 manifest = {"a/b/c/d.py": "h1"}
539 dirs = directories_from_manifest(manifest)
540 sid = compute_snapshot_id(manifest, dirs)
541 rec = SnapshotRecord(snapshot_id=sid, manifest=manifest, directories=dirs)
542 write_snapshot(store, rec)
543 loaded = read_snapshot(store, sid)
544 assert loaded is not None
545 assert loaded.directories == ["a", "a/b", "a/b/c"]
546
547 def test_snapshot_id_includes_dirs_in_verification(self, store: pathlib.Path) -> None:
548 """read_snapshot verifies the stored ID — tampering with dirs must fail."""
549 manifest = {"f.py": "h1"}
550 dirs = ["src"]
551 sid = compute_snapshot_id(manifest, dirs)
552 rec = SnapshotRecord(snapshot_id=sid, manifest=manifest, directories=dirs)
553 write_snapshot(store, rec)
554
555 # Compute ID without dirs — must be different
556 sid_no_dirs = compute_snapshot_id(manifest, [])
557 assert sid != sid_no_dirs
558
559 def test_directory_rename_produces_different_snapshot_id(self, store: pathlib.Path) -> None:
560 manifest = {"f.py": "h1"}
561 id_old = compute_snapshot_id(manifest, ["old_name"])
562 id_new = compute_snapshot_id(manifest, ["new_name"])
563 assert id_old != id_new
564
565
566 # ===========================================================================
567 # 8. DirectoryRenameOp TypedDict — unit
568 # ===========================================================================
569
570 class TestDirectoryRenameOp:
571 def test_construct_fields(self) -> None:
572 op = DirectoryRenameOp(
573 op="directory_rename",
574 address="new/path",
575 from_address="old/path",
576 file_count=5,
577 )
578 assert op["op"] == "directory_rename"
579 assert op["address"] == "new/path"
580 assert op["from_address"] == "old/path"
581 assert op["file_count"] == 5
582
583 def test_zero_file_count_allowed(self) -> None:
584 op = DirectoryRenameOp(
585 op="directory_rename",
586 address="a",
587 from_address="b",
588 file_count=0,
589 )
590 assert op["file_count"] == 0
591
592
593 # ===========================================================================
594 # 9. CodePlugin.diff directory rename detection — integration
595 # ===========================================================================
596
597 class TestCodePluginDiffDirectories:
598 @pytest.fixture()
599 def plugin(self) -> CodePlugin:
600 from muse.plugins.code.plugin import CodePlugin
601 return CodePlugin()
602
603 def _snap(self, files: Manifest, dirs: list[str] | None = None) -> SnapshotManifest:
604 d = dirs if dirs is not None else directories_from_manifest(files)
605 return SnapshotManifest(files=files, domain="code", directories=d)
606
607 def test_directory_rename_emits_directory_rename_op(self, plugin: CodePlugin) -> None:
608 base = self._snap({"src/a.py": "h1", "src/b.py": "h2"}, ["src"])
609 target = self._snap({"lib/a.py": "h1", "lib/b.py": "h2"}, ["lib"])
610 delta = plugin.diff(base, target)
611 ops = delta["ops"]
612 dir_rename_ops = [o for o in ops if o["op"] == "directory_rename"]
613 assert len(dir_rename_ops) == 1
614 assert dir_rename_ops[0]["from_address"] == "src"
615 assert dir_rename_ops[0]["address"] == "lib"
616 assert dir_rename_ops[0]["file_count"] == 2
617
618 def test_directory_rename_suppresses_file_level_ops(self, plugin: CodePlugin) -> None:
619 base = self._snap({"src/a.py": "h1"}, ["src"])
620 target = self._snap({"lib/a.py": "h1"}, ["lib"])
621 delta = plugin.diff(base, target)
622 ops = delta["ops"]
623 # No plain insert/delete for the covered file paths
624 file_ops = [o for o in ops if o["op"] in ("insert", "delete") and "/" in o["address"]]
625 assert not any(o["address"] in ("src/a.py", "lib/a.py") for o in file_ops)
626
627 def test_plain_added_dir_emits_insert_op(self, plugin: CodePlugin) -> None:
628 base = self._snap({}, [])
629 target = self._snap({"new/f.py": "h1"}, ["new"])
630 delta = plugin.diff(base, target)
631 ops = delta["ops"]
632 insert_dir_ops = [o for o in ops if o["op"] == "insert" and o["address"] == "new"]
633 assert len(insert_dir_ops) == 1
634
635 def test_plain_deleted_dir_emits_delete_op(self, plugin: CodePlugin) -> None:
636 base = self._snap({"old/f.py": "h1"}, ["old"])
637 target = self._snap({}, [])
638 delta = plugin.diff(base, target)
639 ops = delta["ops"]
640 delete_dir_ops = [o for o in ops if o["op"] == "delete" and o["address"] == "old"]
641 assert len(delete_dir_ops) == 1
642
643 def test_no_dir_changes_no_dir_ops(self, plugin: CodePlugin) -> None:
644 base = self._snap({"src/a.py": "h1"}, ["src"])
645 target = self._snap({"src/a.py": "h2"}, ["src"])
646 delta = plugin.diff(base, target)
647 ops = delta["ops"]
648 dir_ops = [o for o in ops if o["op"] in ("directory_rename",) or
649 (o["op"] in ("insert", "delete") and "::" not in o["address"] and "/" not in o["address"])]
650 assert not any(o["op"] == "directory_rename" for o in dir_ops)
651
652 def test_no_directories_field_no_crash(self, plugin: CodePlugin) -> None:
653 # Snapshots without the directories key should not crash
654 base = SnapshotManifest(files={"a.py": "h1"}, domain="code", directories=[])
655 target = SnapshotManifest(files={"b.py": "h1"}, domain="code", directories=[])
656 delta = plugin.diff(base, target)
657 assert "ops" in delta
658
659
660 # ===========================================================================
661 # 10. delta_summary directory rename counting — unit
662 # ===========================================================================
663
664 class TestDeltaSummaryDirectories:
665 def _make_dir_rename_op(self, old: str, new: str, file_count: int = 1) -> DirectoryRenameOp:
666 return DirectoryRenameOp(
667 op="directory_rename",
668 address=new,
669 from_address=old,
670 file_count=file_count,
671 )
672
673 def test_no_changes_returns_no_changes(self) -> None:
674 from muse.plugins.code.symbol_diff import delta_summary
675 assert delta_summary([]) == "no changes"
676
677 def test_single_directory_rename(self) -> None:
678 from muse.plugins.code.symbol_diff import delta_summary
679 ops = [self._make_dir_rename_op("old", "new")]
680 result = delta_summary(ops)
681 assert "1 directory renamed" in result
682
683 def test_two_directory_renames_plural(self) -> None:
684 from muse.plugins.code.symbol_diff import delta_summary
685 ops = [
686 self._make_dir_rename_op("a", "x"),
687 self._make_dir_rename_op("b", "y"),
688 ]
689 result = delta_summary(ops)
690 assert "2 directories renamed" in result
691
692 def test_directory_rename_combined_with_file_ops(self) -> None:
693 from muse.plugins.code.symbol_diff import delta_summary
694 from muse.domain import InsertOp
695 insert_op = InsertOp(
696 op="insert", address="new_file.py",
697 position=None, content_id="h1", content_summary="",
698 )
699 rename_op = self._make_dir_rename_op("src", "lib")
700 result = delta_summary([insert_op, rename_op])
701 assert "added" in result
702 assert "directory renamed" in result
703
704 def test_directory_rename_not_counted_as_file(self) -> None:
705 from muse.plugins.code.symbol_diff import delta_summary
706 ops = [self._make_dir_rename_op("old", "new")]
707 result = delta_summary(ops)
708 assert "file" not in result
709
710
711 # ===========================================================================
712 # 11. replay_one propagates directories — integration
713 # ===========================================================================
714
715 class TestReplayOneWithDirectories:
716 def _write_obj(self, store: pathlib.Path, content: bytes) -> str:
717 from muse.core.object_store import write_object
718 oid = _sha(content)
719 write_object(store, oid, content)
720 return oid
721
722 def test_clean_merge_produces_snapshot_with_dirs(self, store: pathlib.Path) -> None:
723 from muse.core.rebase import replay_one
724 from muse.plugins.code.plugin import CodePlugin
725
726 plugin = CodePlugin()
727 domain = "code"
728
729 # Write actual file objects so apply_manifest can restore them
730 oid_a = self._write_obj(store, b"# a.py content\n")
731 oid_b = self._write_obj(store, b"# b.py content\n")
732
733 # Create a base commit: one file in src/
734 base_manifest = {"src/a.py": oid_a}
735 base_dirs = directories_from_manifest(base_manifest)
736 base_snap = _make_snap(store, base_manifest, base_dirs)
737 base_commit = _make_commit_rec(store, base_snap, message="base")
738
739 # Create "theirs" commit: adds src/b.py (same parent = base)
740 theirs_manifest = {"src/a.py": oid_a, "src/b.py": oid_b}
741 theirs_dirs = directories_from_manifest(theirs_manifest)
742 theirs_snap = _make_snap(store, theirs_manifest, theirs_dirs)
743 theirs_commit = _make_commit_rec(
744 store, theirs_snap, parent_id=base_commit.commit_id, message="theirs"
745 )
746
747 # replay theirs_commit on top of base_commit (onto = base)
748 result = replay_one(
749 root=store,
750 commit=theirs_commit,
751 parent_id=base_commit.commit_id,
752 plugin=plugin,
753 domain=domain,
754 repo_id=_REPO_ID,
755 branch="main",
756 )
757
758 assert isinstance(result, CommitRecord), f"Expected CommitRecord, got: {result}"
759 replayed_snap = read_snapshot(store, result.snapshot_id)
760 assert replayed_snap is not None
761 assert replayed_snap.directories == ["src"]
762
763 def test_conflict_returns_path_list_not_commit(self, store: pathlib.Path) -> None:
764 from muse.core.rebase import replay_one
765 from muse.plugins.code.plugin import CodePlugin
766
767 plugin = CodePlugin()
768
769 oid_v1 = self._write_obj(store, b"version 1\n")
770 oid_v2 = self._write_obj(store, b"version 2\n")
771 oid_v3 = self._write_obj(store, b"version 3\n")
772
773 # base: file a.py = v1
774 base_manifest = {"a.py": oid_v1}
775 base_snap = _make_snap(store, base_manifest)
776 base_commit = _make_commit_rec(store, base_snap, message="base")
777
778 # "theirs" modifies a.py from v1 → v2
779 theirs_manifest = {"a.py": oid_v2}
780 theirs_snap = _make_snap(store, theirs_manifest)
781 theirs_commit = _make_commit_rec(
782 store, theirs_snap, parent_id=base_commit.commit_id, message="theirs"
783 )
784
785 # "ours" (parent_id in replay) also modified a.py from v1 → v3 (conflict)
786 ours_manifest = {"a.py": oid_v3}
787 ours_snap = _make_snap(store, ours_manifest)
788 ours_commit = _make_commit_rec(store, ours_snap, message="ours")
789
790 result = replay_one(
791 root=store,
792 commit=theirs_commit,
793 parent_id=ours_commit.commit_id,
794 plugin=plugin,
795 domain="code",
796 repo_id=_REPO_ID,
797 branch="main",
798 )
799
800 # Should return conflict paths, not a CommitRecord
801 assert isinstance(result, list)
802
803
804 # ===========================================================================
805 # 12. E2E workflow — full CLI commit/branch/rename/merge cycle
806 # ===========================================================================
807
808 def _muse(repo: pathlib.Path, *args: str) -> subprocess.CompletedProcess[str]:
809 import shutil
810 import sys
811 # Prefer a .venv installation (development mode), fall back to the
812 # interpreter's sibling or the PATH-resolved binary.
813 venv_muse = pathlib.Path(__file__).parent.parent / ".venv" / "bin" / "muse"
814 if venv_muse.exists():
815 muse_bin = str(venv_muse)
816 else:
817 sibling = pathlib.Path(sys.executable).parent / "muse"
818 muse_bin = str(sibling) if sibling.exists() else (shutil.which("muse") or "muse")
819 return subprocess.run(
820 [muse_bin, *args],
821 cwd=str(repo),
822 capture_output=True,
823 text=True,
824 )
825
826
827 class TestDirectoriesE2EWorkflow:
828 @pytest.fixture()
829 def repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
830 result = _muse(tmp_path, "init")
831 assert result.returncode == 0, result.stderr
832 return tmp_path
833
834 def test_commit_records_directories(self, repo: pathlib.Path) -> None:
835 (repo / "src").mkdir()
836 (repo / "src" / "main.py").write_text("x = 1\n")
837 r = _muse(repo, "commit", "-m", "add src/main.py")
838 assert r.returncode == 0, r.stderr
839
840 # Read the snapshot from store and confirm directories is populated
841 from muse.core.store import read_commit, get_head_snapshot_id
842 from muse.core.repo import read_repo_id
843 from muse.core.store import read_current_branch
844 repo_id = read_repo_id(repo)
845 branch = read_current_branch(repo)
846 snap_id = get_head_snapshot_id(repo, repo_id, branch)
847 assert snap_id is not None
848 snap = read_snapshot(repo, snap_id)
849 assert snap is not None
850 assert "src" in snap.directories
851
852 def test_snapshot_id_changes_on_dir_rename(self, repo: pathlib.Path) -> None:
853 (repo / "old_name").mkdir()
854 (repo / "old_name" / "f.py").write_text("x = 1\n")
855 _muse(repo, "commit", "-m", "add old_name/")
856
857 from muse.core.store import get_head_snapshot_id, read_current_branch
858 from muse.core.repo import read_repo_id
859 repo_id = read_repo_id(repo)
860 branch = read_current_branch(repo)
861 sid_before = get_head_snapshot_id(repo, repo_id, branch)
862
863 # Simulate rename: remove old dir, create new dir with same content
864 import shutil
865 shutil.move(str(repo / "old_name"), str(repo / "new_name"))
866 _muse(repo, "commit", "-m", "rename dir")
867
868 sid_after = get_head_snapshot_id(repo, repo_id, branch)
869 assert sid_before != sid_after
870
871 def test_status_handles_directory_rename_op(self, repo: pathlib.Path) -> None:
872 (repo / "src").mkdir()
873 (repo / "src" / "app.py").write_text("app = True\n")
874 _muse(repo, "commit", "-m", "initial")
875
876 import shutil
877 shutil.move(str(repo / "src"), str(repo / "lib"))
878
879 r = _muse(repo, "status")
880 assert r.returncode == 0, r.stderr
881
882 def test_nested_directories_tracked_through_commit(self, repo: pathlib.Path) -> None:
883 deep = repo / "a" / "b" / "c"
884 deep.mkdir(parents=True)
885 (deep / "f.py").write_text("pass\n")
886 r = _muse(repo, "commit", "-m", "deep nest")
887 assert r.returncode == 0, r.stderr
888
889 from muse.core.store import get_head_snapshot_id, read_current_branch
890 from muse.core.repo import read_repo_id
891 repo_id = read_repo_id(repo)
892 branch = read_current_branch(repo)
893 snap = read_snapshot(repo, get_head_snapshot_id(repo, repo_id, branch))
894 assert snap is not None
895 assert "a" in snap.directories
896 assert "a/b" in snap.directories
897 assert "a/b/c" in snap.directories
898
899
900 # ===========================================================================
901 # 13. Empty directory ghost bug
902 #
903 # Regression tests for: empty directories left on disk after their files are
904 # deleted and committed must NOT appear in `muse status --json` `added`.
905 #
906 # Root cause: CodePlugin.snapshot() recorded every directory visited by
907 # os.walk() into `dirs`, including empty ones. These empty dirs had no
908 # counterpart in HEAD, so diff() produced InsertOp entries for them,
909 # and status --json reported them as `added`.
910 # ===========================================================================
911
912 class TestEmptyDirectoryGhost:
913 """Empty orphan directories must not appear as 'added' in muse status."""
914
915 @pytest.fixture()
916 def repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
917 result = _muse(tmp_path, "init")
918 assert result.returncode == 0, result.stderr
919 return tmp_path
920
921 # ── Unit: snapshot() must not include empty dirs ──────────────────────────
922
923 def test_snapshot_excludes_empty_directory(self, tmp_path: pathlib.Path) -> None:
924 """CodePlugin.snapshot() must not list a directory that has no files."""
925 from muse.plugins.code.plugin import CodePlugin
926 _muse(tmp_path, "init")
927 plugin = CodePlugin()
928
929 # Empty nested directory — no files, no .musekeep
930 (tmp_path / "empty_pkg" / "sub").mkdir(parents=True)
931
932 snap = plugin.snapshot(tmp_path)
933 assert "empty_pkg" not in snap["directories"], (
934 "Empty directory 'empty_pkg' must not appear in snapshot directories"
935 )
936 assert "empty_pkg/sub" not in snap["directories"], (
937 "Empty nested directory 'empty_pkg/sub' must not appear in snapshot directories"
938 )
939
940 def test_snapshot_includes_dir_with_files(self, tmp_path: pathlib.Path) -> None:
941 """Directories containing files must still appear in the snapshot."""
942 from muse.plugins.code.plugin import CodePlugin
943 _muse(tmp_path, "init")
944 plugin = CodePlugin()
945
946 (tmp_path / "pkg").mkdir()
947 (tmp_path / "pkg" / "mod.py").write_text("x = 1\n")
948
949 snap = plugin.snapshot(tmp_path)
950 assert "pkg" in snap["directories"]
951
952 def test_snapshot_includes_musekeep_empty_dir(self, tmp_path: pathlib.Path) -> None:
953 """An empty directory with a .musekeep marker must be tracked."""
954 from muse.plugins.code.plugin import CodePlugin
955 _muse(tmp_path, "init")
956 plugin = CodePlugin()
957
958 (tmp_path / "intentionally_empty").mkdir()
959 (tmp_path / "intentionally_empty" / ".musekeep").write_text("")
960
961 snap = plugin.snapshot(tmp_path)
962 assert "intentionally_empty" in snap["directories"]
963
964 # ── Integration: status --json must not list orphan empty dirs ────────────
965
966 def test_status_clean_after_deleting_only_file_in_dir(self, repo: pathlib.Path) -> None:
967 """After deleting the last file in a directory and committing,
968 muse status must report clean — not list the empty leftover dir as added."""
969 pkg = repo / "tourdeforce" / "clients"
970 pkg.mkdir(parents=True)
971 (pkg / "module.py").write_text("x = 1\n")
972 _muse(repo, "commit", "-m", "add tourdeforce")
973
974 # Delete the file — leave the empty directories on disk
975 (pkg / "module.py").unlink()
976 _muse(repo, "commit", "-m", "delete module.py")
977
978 r = _muse(repo, "status", "--json")
979 assert r.returncode == 0, r.stderr
980 data = json.loads(r.stdout)
981
982 assert data["clean"] is True, (
983 f"Expected clean status after deleting all files; added={data['added']}"
984 )
985 assert "tourdeforce" not in data["added"], (
986 "Empty leftover directory 'tourdeforce' must not appear as added"
987 )
988 assert "tourdeforce/clients" not in data["added"], (
989 "Empty leftover directory 'tourdeforce/clients' must not appear as added"
990 )
991
992 def test_status_does_not_report_never_committed_empty_dir(self, repo: pathlib.Path) -> None:
993 """An empty directory that was never committed must not appear in added."""
994 (repo / "orphan" / "nested").mkdir(parents=True)
995 # No files, never committed
996
997 r = _muse(repo, "status", "--json")
998 assert r.returncode == 0, r.stderr
999 data = json.loads(r.stdout)
1000
1001 assert "orphan" not in data["added"], (
1002 "Untracked empty directory 'orphan' must not appear as added"
1003 )
1004 assert "orphan/nested" not in data["added"], (
1005 "Untracked empty nested directory must not appear as added"
1006 )
1007
1008 def test_status_reports_added_for_dir_with_new_file(self, repo: pathlib.Path) -> None:
1009 """A new directory containing a real file must still appear as added."""
1010 (repo / "new_pkg").mkdir()
1011 (repo / "new_pkg" / "api.py").write_text("pass\n")
1012
1013 r = _muse(repo, "status", "--json")
1014 assert r.returncode == 0, r.stderr
1015 data = json.loads(r.stdout)
1016
1017 # The file should be added (the directory entry itself may or may not be
1018 # in added — what matters is the file is visible and dirs without files are not)
1019 all_added = data["added"]
1020 assert any("new_pkg" in p for p in all_added), (
1021 "New directory with a file should be reflected in added"
1022 )
1023
1024
1025 # ===========================================================================
1026 # 13. Stress / performance
1027 # ===========================================================================
1028
1029 class TestDirectoriesStress:
1030 def test_directories_from_manifest_1000_files(self) -> None:
1031 manifest = {
1032 f"pkg_{i}/sub_{j}/file_{k}.py": f"hash{i}{j}{k}"
1033 for i in range(10)
1034 for j in range(10)
1035 for k in range(10)
1036 }
1037 assert len(manifest) == 1000
1038 start = time.monotonic()
1039 dirs = directories_from_manifest(manifest)
1040 elapsed = time.monotonic() - start
1041 # Should complete in under 1 second for 1000 files
1042 assert elapsed < 1.0, f"directories_from_manifest took {elapsed:.3f}s for 1000 files"
1043 # 10 top-level dirs (pkg_0..9) + 100 second-level dirs (pkg_N/sub_M) = 110
1044 assert len(dirs) == 110
1045
1046 def test_detect_directory_renames_50_dirs(self) -> None:
1047 # 50 dirs each with 5 files, all renamed old_N → new_N
1048 last: Manifest = {}
1049 current: Manifest = {}
1050 for i in range(50):
1051 for j in range(5):
1052 h = _sha(f"content_{i}_{j}".encode())
1053 last[f"old_{i}/file_{j}.py"] = h
1054 current[f"new_{i}/file_{j}.py"] = h
1055
1056 deleted = {f"old_{i}" for i in range(50)}
1057 added = {f"new_{i}" for i in range(50)}
1058
1059 start = time.monotonic()
1060 renames = detect_directory_renames(deleted, added, last, current)
1061 elapsed = time.monotonic() - start
1062
1063 assert elapsed < 2.0, f"detect_directory_renames took {elapsed:.3f}s for 50 dirs"
1064 assert len(renames) == 50
1065
1066 def test_compute_snapshot_id_large_dir_list(self) -> None:
1067 manifest = {f"f_{i}.py": f"h{i}" for i in range(500)}
1068 dirs = [f"dir_{i}" for i in range(500)]
1069 start = time.monotonic()
1070 sid = compute_snapshot_id(manifest, dirs)
1071 elapsed = time.monotonic() - start
1072 assert elapsed < 1.0, f"compute_snapshot_id took {elapsed:.3f}s for 500 dirs"
1073 assert len(sid) == 64
1074
1075 def test_walk_workdir_with_dirs_deep_tree(self, tmp_path: pathlib.Path) -> None:
1076 # 20 levels of nesting
1077 deep = tmp_path
1078 for level in range(20):
1079 deep = deep / f"level_{level}"
1080 deep.mkdir()
1081 (deep / "leaf.py").write_bytes(b"x")
1082
1083 start = time.monotonic()
1084 files, dirs = walk_workdir_with_dirs(tmp_path)
1085 elapsed = time.monotonic() - start
1086
1087 assert elapsed < 2.0, f"walk_workdir_with_dirs took {elapsed:.3f}s on 20-level tree"
1088 assert "leaf.py" in "".join(files.keys())
1089 assert len(dirs) == 20
1090
1091
1092 # ===========================================================================
1093 # 14. Security
1094 # ===========================================================================
1095
1096 class TestDirectoriesSecurity:
1097 def test_path_traversal_in_directory_address_not_resolved(self) -> None:
1098 # directories_from_manifest should treat path components literally
1099 manifest = {"../../etc/shadow": "h1"}
1100 dirs = directories_from_manifest(manifest)
1101 # The result should contain "../.." and "../../etc" literally, not resolve them
1102 # The important thing: no OS path resolution happens
1103 for d in dirs:
1104 assert not pathlib.Path(d).is_absolute()
1105
1106 def test_null_byte_in_directory_path_handled(self) -> None:
1107 # Null bytes in paths are unusual but should not crash
1108 manifest = {"src\x00/evil.py": "h1"}
1109 try:
1110 dirs = directories_from_manifest(manifest)
1111 sid = compute_snapshot_id(manifest, dirs)
1112 assert len(sid) == 64
1113 except (ValueError, TypeError):
1114 pass # rejecting is also acceptable
1115
1116 def test_very_long_directory_path(self) -> None:
1117 long_name = "a" * 4096
1118 manifest = {f"{long_name}/f.py": "h1"}
1119 dirs = directories_from_manifest(manifest)
1120 assert dirs == [long_name]
1121 sid = compute_snapshot_id(manifest, dirs)
1122 assert len(sid) == 64
1123
1124 def test_symlinked_dir_not_followed_during_walk(self, tmp_path: pathlib.Path) -> None:
1125 sensitive = tmp_path / "sensitive"
1126 sensitive.mkdir()
1127 (sensitive / "secret.txt").write_bytes(b"SECRET")
1128
1129 repo_root = tmp_path / "repo"
1130 repo_root.mkdir()
1131 link = repo_root / "evil_link"
1132 link.symlink_to(sensitive)
1133
1134 files, dirs = walk_workdir_with_dirs(repo_root)
1135 assert "evil_link/secret.txt" not in files
1136
1137 def test_snapshot_record_with_adversarial_dirs_survives_roundtrip(self, store: pathlib.Path) -> None:
1138 # Adversarial: dirs containing special characters
1139 dirs = ["src", "src/sub dir", "a-b_c.d"]
1140 manifest = {"src/f.py": "h1"}
1141 sid = compute_snapshot_id(manifest, dirs)
1142 rec = SnapshotRecord(snapshot_id=sid, manifest=manifest, directories=dirs)
1143 # to_dict / from_dict roundtrip
1144 loaded = SnapshotRecord.from_dict(rec.to_dict())
1145 assert loaded.directories == dirs
1146
1147 def test_detect_directory_renames_no_prefix_confusion(self) -> None:
1148 # "a" should not confuse files under "ab/" as being under "a/"
1149 # because the prefix check uses "a/" (with trailing slash)
1150 last = {"a/f.py": "h1"}
1151 current = {"ab/f.py": "h1"}
1152 # "ab/f.py" does NOT start with "a/" so old_files under "a/" = {"f.py": "h1"}
1153 # but new_files under "ab/" = {"f.py": "h1"} — these DO match, so rename is detected
1154 # (which is correct: the file genuinely moved from a/ to ab/)
1155 renames = detect_directory_renames({"a"}, {"ab"}, last, current)
1156 assert renames == [("a", "ab")]
1157
1158 def test_detect_directory_renames_prefix_does_not_bleed_across_siblings(self) -> None:
1159 # "models" should never absorb files from "models_v2" in the source manifest
1160 # when looking at what files belong to "models/"
1161 last = {"models/user.py": "h1", "models_v2/user.py": "h2"}
1162 # Both dirs deleted, one new dir added with only models_v2's content
1163 current = {"new_home/user.py": "h2"}
1164 renames = detect_directory_renames({"models", "models_v2"}, {"new_home"}, last, current)
1165 # "new_home" has {"user.py": "h2"} which matches "models_v2/" not "models/"
1166 assert ("models_v2", "new_home") in renames
1167 assert ("models", "new_home") not in renames
File History 2 commits
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 41 days ago
sha256:a154bc65916614c833d5a40a10d81ba3eae0d0495b0afddd34dc34f18d5e91b8 fix: test suite alignment and typing audit — zero violations Sonnet 4.6 minor 49 days ago