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