gabriel / muse public
test_directories_feature.py python
1,158 lines 46.5 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 150 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 venv_muse = pathlib.Path(__file__).parent.parent / ".venv" / "bin" / "muse"
810 return subprocess.run(
811 [str(venv_muse), *args],
812 cwd=str(repo),
813 capture_output=True,
814 text=True,
815 )
816
817
818 class TestDirectoriesE2EWorkflow:
819 @pytest.fixture()
820 def repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
821 result = _muse(tmp_path, "init")
822 assert result.returncode == 0, result.stderr
823 return tmp_path
824
825 def test_commit_records_directories(self, repo: pathlib.Path) -> None:
826 (repo / "src").mkdir()
827 (repo / "src" / "main.py").write_text("x = 1\n")
828 r = _muse(repo, "commit", "-m", "add src/main.py")
829 assert r.returncode == 0, r.stderr
830
831 # Read the snapshot from store and confirm directories is populated
832 from muse.core.store import read_commit, get_head_snapshot_id
833 from muse.core.repo import read_repo_id
834 from muse.core.store import read_current_branch
835 repo_id = read_repo_id(repo)
836 branch = read_current_branch(repo)
837 snap_id = get_head_snapshot_id(repo, repo_id, branch)
838 assert snap_id is not None
839 snap = read_snapshot(repo, snap_id)
840 assert snap is not None
841 assert "src" in snap.directories
842
843 def test_snapshot_id_changes_on_dir_rename(self, repo: pathlib.Path) -> None:
844 (repo / "old_name").mkdir()
845 (repo / "old_name" / "f.py").write_text("x = 1\n")
846 _muse(repo, "commit", "-m", "add old_name/")
847
848 from muse.core.store import get_head_snapshot_id, read_current_branch
849 from muse.core.repo import read_repo_id
850 repo_id = read_repo_id(repo)
851 branch = read_current_branch(repo)
852 sid_before = get_head_snapshot_id(repo, repo_id, branch)
853
854 # Simulate rename: remove old dir, create new dir with same content
855 import shutil
856 shutil.move(str(repo / "old_name"), str(repo / "new_name"))
857 _muse(repo, "commit", "-m", "rename dir")
858
859 sid_after = get_head_snapshot_id(repo, repo_id, branch)
860 assert sid_before != sid_after
861
862 def test_status_handles_directory_rename_op(self, repo: pathlib.Path) -> None:
863 (repo / "src").mkdir()
864 (repo / "src" / "app.py").write_text("app = True\n")
865 _muse(repo, "commit", "-m", "initial")
866
867 import shutil
868 shutil.move(str(repo / "src"), str(repo / "lib"))
869
870 r = _muse(repo, "status")
871 assert r.returncode == 0, r.stderr
872
873 def test_nested_directories_tracked_through_commit(self, repo: pathlib.Path) -> None:
874 deep = repo / "a" / "b" / "c"
875 deep.mkdir(parents=True)
876 (deep / "f.py").write_text("pass\n")
877 r = _muse(repo, "commit", "-m", "deep nest")
878 assert r.returncode == 0, r.stderr
879
880 from muse.core.store import get_head_snapshot_id, read_current_branch
881 from muse.core.repo import read_repo_id
882 repo_id = read_repo_id(repo)
883 branch = read_current_branch(repo)
884 snap = read_snapshot(repo, get_head_snapshot_id(repo, repo_id, branch))
885 assert snap is not None
886 assert "a" in snap.directories
887 assert "a/b" in snap.directories
888 assert "a/b/c" in snap.directories
889
890
891 # ===========================================================================
892 # 13. Empty directory ghost bug
893 #
894 # Regression tests for: empty directories left on disk after their files are
895 # deleted and committed must NOT appear in `muse status --json` `added`.
896 #
897 # Root cause: CodePlugin.snapshot() recorded every directory visited by
898 # os.walk() into `dirs`, including empty ones. These empty dirs had no
899 # counterpart in HEAD, so diff() produced InsertOp entries for them,
900 # and status --json reported them as `added`.
901 # ===========================================================================
902
903 class TestEmptyDirectoryGhost:
904 """Empty orphan directories must not appear as 'added' in muse status."""
905
906 @pytest.fixture()
907 def repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
908 result = _muse(tmp_path, "init")
909 assert result.returncode == 0, result.stderr
910 return tmp_path
911
912 # ── Unit: snapshot() must not include empty dirs ──────────────────────────
913
914 def test_snapshot_excludes_empty_directory(self, tmp_path: pathlib.Path) -> None:
915 """CodePlugin.snapshot() must not list a directory that has no files."""
916 from muse.plugins.code.plugin import CodePlugin
917 _muse(tmp_path, "init")
918 plugin = CodePlugin()
919
920 # Empty nested directory — no files, no .musekeep
921 (tmp_path / "empty_pkg" / "sub").mkdir(parents=True)
922
923 snap = plugin.snapshot(tmp_path)
924 assert "empty_pkg" not in snap["directories"], (
925 "Empty directory 'empty_pkg' must not appear in snapshot directories"
926 )
927 assert "empty_pkg/sub" not in snap["directories"], (
928 "Empty nested directory 'empty_pkg/sub' must not appear in snapshot directories"
929 )
930
931 def test_snapshot_includes_dir_with_files(self, tmp_path: pathlib.Path) -> None:
932 """Directories containing files must still appear in the snapshot."""
933 from muse.plugins.code.plugin import CodePlugin
934 _muse(tmp_path, "init")
935 plugin = CodePlugin()
936
937 (tmp_path / "pkg").mkdir()
938 (tmp_path / "pkg" / "mod.py").write_text("x = 1\n")
939
940 snap = plugin.snapshot(tmp_path)
941 assert "pkg" in snap["directories"]
942
943 def test_snapshot_includes_musekeep_empty_dir(self, tmp_path: pathlib.Path) -> None:
944 """An empty directory with a .musekeep marker must be tracked."""
945 from muse.plugins.code.plugin import CodePlugin
946 _muse(tmp_path, "init")
947 plugin = CodePlugin()
948
949 (tmp_path / "intentionally_empty").mkdir()
950 (tmp_path / "intentionally_empty" / ".musekeep").write_text("")
951
952 snap = plugin.snapshot(tmp_path)
953 assert "intentionally_empty" in snap["directories"]
954
955 # ── Integration: status --json must not list orphan empty dirs ────────────
956
957 def test_status_clean_after_deleting_only_file_in_dir(self, repo: pathlib.Path) -> None:
958 """After deleting the last file in a directory and committing,
959 muse status must report clean — not list the empty leftover dir as added."""
960 pkg = repo / "tourdeforce" / "clients"
961 pkg.mkdir(parents=True)
962 (pkg / "module.py").write_text("x = 1\n")
963 _muse(repo, "commit", "-m", "add tourdeforce")
964
965 # Delete the file — leave the empty directories on disk
966 (pkg / "module.py").unlink()
967 _muse(repo, "commit", "-m", "delete module.py")
968
969 r = _muse(repo, "status", "--json")
970 assert r.returncode == 0, r.stderr
971 data = json.loads(r.stdout)
972
973 assert data["clean"] is True, (
974 f"Expected clean status after deleting all files; added={data['added']}"
975 )
976 assert "tourdeforce" not in data["added"], (
977 "Empty leftover directory 'tourdeforce' must not appear as added"
978 )
979 assert "tourdeforce/clients" not in data["added"], (
980 "Empty leftover directory 'tourdeforce/clients' must not appear as added"
981 )
982
983 def test_status_does_not_report_never_committed_empty_dir(self, repo: pathlib.Path) -> None:
984 """An empty directory that was never committed must not appear in added."""
985 (repo / "orphan" / "nested").mkdir(parents=True)
986 # No files, never committed
987
988 r = _muse(repo, "status", "--json")
989 assert r.returncode == 0, r.stderr
990 data = json.loads(r.stdout)
991
992 assert "orphan" not in data["added"], (
993 "Untracked empty directory 'orphan' must not appear as added"
994 )
995 assert "orphan/nested" not in data["added"], (
996 "Untracked empty nested directory must not appear as added"
997 )
998
999 def test_status_reports_added_for_dir_with_new_file(self, repo: pathlib.Path) -> None:
1000 """A new directory containing a real file must still appear as added."""
1001 (repo / "new_pkg").mkdir()
1002 (repo / "new_pkg" / "api.py").write_text("pass\n")
1003
1004 r = _muse(repo, "status", "--json")
1005 assert r.returncode == 0, r.stderr
1006 data = json.loads(r.stdout)
1007
1008 # The file should be added (the directory entry itself may or may not be
1009 # in added — what matters is the file is visible and dirs without files are not)
1010 all_added = data["added"]
1011 assert any("new_pkg" in p for p in all_added), (
1012 "New directory with a file should be reflected in added"
1013 )
1014
1015
1016 # ===========================================================================
1017 # 13. Stress / performance
1018 # ===========================================================================
1019
1020 class TestDirectoriesStress:
1021 def test_directories_from_manifest_1000_files(self) -> None:
1022 manifest = {
1023 f"pkg_{i}/sub_{j}/file_{k}.py": f"hash{i}{j}{k}"
1024 for i in range(10)
1025 for j in range(10)
1026 for k in range(10)
1027 }
1028 assert len(manifest) == 1000
1029 start = time.monotonic()
1030 dirs = directories_from_manifest(manifest)
1031 elapsed = time.monotonic() - start
1032 # Should complete in under 1 second for 1000 files
1033 assert elapsed < 1.0, f"directories_from_manifest took {elapsed:.3f}s for 1000 files"
1034 # 10 top-level dirs (pkg_0..9) + 100 second-level dirs (pkg_N/sub_M) = 110
1035 assert len(dirs) == 110
1036
1037 def test_detect_directory_renames_50_dirs(self) -> None:
1038 # 50 dirs each with 5 files, all renamed old_N → new_N
1039 last: Manifest = {}
1040 current: Manifest = {}
1041 for i in range(50):
1042 for j in range(5):
1043 h = _sha(f"content_{i}_{j}".encode())
1044 last[f"old_{i}/file_{j}.py"] = h
1045 current[f"new_{i}/file_{j}.py"] = h
1046
1047 deleted = {f"old_{i}" for i in range(50)}
1048 added = {f"new_{i}" for i in range(50)}
1049
1050 start = time.monotonic()
1051 renames = detect_directory_renames(deleted, added, last, current)
1052 elapsed = time.monotonic() - start
1053
1054 assert elapsed < 2.0, f"detect_directory_renames took {elapsed:.3f}s for 50 dirs"
1055 assert len(renames) == 50
1056
1057 def test_compute_snapshot_id_large_dir_list(self) -> None:
1058 manifest = {f"f_{i}.py": f"h{i}" for i in range(500)}
1059 dirs = [f"dir_{i}" for i in range(500)]
1060 start = time.monotonic()
1061 sid = compute_snapshot_id(manifest, dirs)
1062 elapsed = time.monotonic() - start
1063 assert elapsed < 1.0, f"compute_snapshot_id took {elapsed:.3f}s for 500 dirs"
1064 assert len(sid) == 64
1065
1066 def test_walk_workdir_with_dirs_deep_tree(self, tmp_path: pathlib.Path) -> None:
1067 # 20 levels of nesting
1068 deep = tmp_path
1069 for level in range(20):
1070 deep = deep / f"level_{level}"
1071 deep.mkdir()
1072 (deep / "leaf.py").write_bytes(b"x")
1073
1074 start = time.monotonic()
1075 files, dirs = walk_workdir_with_dirs(tmp_path)
1076 elapsed = time.monotonic() - start
1077
1078 assert elapsed < 2.0, f"walk_workdir_with_dirs took {elapsed:.3f}s on 20-level tree"
1079 assert "leaf.py" in "".join(files.keys())
1080 assert len(dirs) == 20
1081
1082
1083 # ===========================================================================
1084 # 14. Security
1085 # ===========================================================================
1086
1087 class TestDirectoriesSecurity:
1088 def test_path_traversal_in_directory_address_not_resolved(self) -> None:
1089 # directories_from_manifest should treat path components literally
1090 manifest = {"../../etc/shadow": "h1"}
1091 dirs = directories_from_manifest(manifest)
1092 # The result should contain "../.." and "../../etc" literally, not resolve them
1093 # The important thing: no OS path resolution happens
1094 for d in dirs:
1095 assert not pathlib.Path(d).is_absolute()
1096
1097 def test_null_byte_in_directory_path_handled(self) -> None:
1098 # Null bytes in paths are unusual but should not crash
1099 manifest = {"src\x00/evil.py": "h1"}
1100 try:
1101 dirs = directories_from_manifest(manifest)
1102 sid = compute_snapshot_id(manifest, dirs)
1103 assert len(sid) == 64
1104 except (ValueError, TypeError):
1105 pass # rejecting is also acceptable
1106
1107 def test_very_long_directory_path(self) -> None:
1108 long_name = "a" * 4096
1109 manifest = {f"{long_name}/f.py": "h1"}
1110 dirs = directories_from_manifest(manifest)
1111 assert dirs == [long_name]
1112 sid = compute_snapshot_id(manifest, dirs)
1113 assert len(sid) == 64
1114
1115 def test_symlinked_dir_not_followed_during_walk(self, tmp_path: pathlib.Path) -> None:
1116 sensitive = tmp_path / "sensitive"
1117 sensitive.mkdir()
1118 (sensitive / "secret.txt").write_bytes(b"SECRET")
1119
1120 repo_root = tmp_path / "repo"
1121 repo_root.mkdir()
1122 link = repo_root / "evil_link"
1123 link.symlink_to(sensitive)
1124
1125 files, dirs = walk_workdir_with_dirs(repo_root)
1126 assert "evil_link/secret.txt" not in files
1127
1128 def test_snapshot_record_with_adversarial_dirs_survives_roundtrip(self, store: pathlib.Path) -> None:
1129 # Adversarial: dirs containing special characters
1130 dirs = ["src", "src/sub dir", "a-b_c.d"]
1131 manifest = {"src/f.py": "h1"}
1132 sid = compute_snapshot_id(manifest, dirs)
1133 rec = SnapshotRecord(snapshot_id=sid, manifest=manifest, directories=dirs)
1134 # to_dict / from_dict roundtrip
1135 loaded = SnapshotRecord.from_dict(rec.to_dict())
1136 assert loaded.directories == dirs
1137
1138 def test_detect_directory_renames_no_prefix_confusion(self) -> None:
1139 # "a" should not confuse files under "ab/" as being under "a/"
1140 # because the prefix check uses "a/" (with trailing slash)
1141 last = {"a/f.py": "h1"}
1142 current = {"ab/f.py": "h1"}
1143 # "ab/f.py" does NOT start with "a/" so old_files under "a/" = {"f.py": "h1"}
1144 # but new_files under "ab/" = {"f.py": "h1"} — these DO match, so rename is detected
1145 # (which is correct: the file genuinely moved from a/ to ab/)
1146 renames = detect_directory_renames({"a"}, {"ab"}, last, current)
1147 assert renames == [("a", "ab")]
1148
1149 def test_detect_directory_renames_prefix_does_not_bleed_across_siblings(self) -> None:
1150 # "models" should never absorb files from "models_v2" in the source manifest
1151 # when looking at what files belong to "models/"
1152 last = {"models/user.py": "h1", "models_v2/user.py": "h2"}
1153 # Both dirs deleted, one new dir added with only models_v2's content
1154 current = {"new_home/user.py": "h2"}
1155 renames = detect_directory_renames({"models", "models_v2"}, {"new_home"}, last, current)
1156 # "new_home" has {"user.py": "h2"} which matches "models_v2/" not "models/"
1157 assert ("models_v2", "new_home") in renames
1158 assert ("models", "new_home") not in renames
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 150 days ago