gabriel / muse public
test_directory_tracking.py python
344 lines 13.0 KB
Raw
sha256:a154bc65916614c833d5a40a10d81ba3eae0d0495b0afddd34dc34f18d5e91b8 fix: test suite alignment and typing audit — zero violations Sonnet 4.6 minor ⚠ breaking 22 days ago
1 """TDD — first-class directory tracking in muse status and muse diff.
2
3 Covers:
4 ST-1 muse status text: untracked empty dir appears with trailing slash
5 ST-2 muse status JSON: untracked empty dir in `untracked` list with trailing slash
6 ST-3 muse diff text: new empty dir prints `A test/` (trailing slash)
7 ST-4 muse diff --stat: new empty dir counted as directory, not file
8 ST-5 AddressedInsertOp for new dirs carries trailing slash in address
9 ST-6 AddressedDeleteOp for removed dirs carries trailing slash in address
10 """
11 from __future__ import annotations
12
13 import json
14 import pathlib
15 from collections.abc import Mapping
16
17 import pytest
18
19 from tests.cli_test_helper import CliRunner
20 from muse.core.paths import muse_dir, ref_path
21 from muse.core.object_store import write_object
22 from muse.core.ids import hash_commit, hash_snapshot
23 from muse.core.commits import CommitRecord, write_commit
24 from muse.core.snapshots import SnapshotRecord, write_snapshot
25 from muse.core.types import blob_id
26
27 runner = CliRunner()
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34 def _init_repo(path: pathlib.Path) -> pathlib.Path:
35 """Create a minimal code-domain repo with one commit."""
36 dot = muse_dir(path)
37 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
38 (dot / d).mkdir(parents=True, exist_ok=True)
39 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
40 (dot / "repo.json").write_text(
41 json.dumps({"repo_id": "dir-track-test", "domain": "code"}),
42 encoding="utf-8",
43 )
44 return path
45
46
47 def _make_commit(
48 root: pathlib.Path,
49 files: Mapping[str, bytes],
50 branch: str = "main",
51 parent: str | None = None,
52 directories: list[str] | None = None,
53 ) -> str:
54 """Write objects + snapshot + commit; advance branch ref."""
55 import datetime
56 manifest: dict[str, str] = {}
57 for rel, content in files.items():
58 oid = blob_id(content)
59 write_object(root, oid, content)
60 manifest[rel] = oid
61 snap_id = hash_snapshot(manifest, directories or [])
62 write_snapshot(
63 root,
64 SnapshotRecord(
65 snapshot_id=snap_id,
66 manifest=manifest,
67 directories=directories or [],
68 ),
69 )
70 committed_at = datetime.datetime.now(datetime.timezone.utc)
71 parent_ids = [parent] if parent else []
72 commit_id = hash_commit(
73 parent_ids=parent_ids,
74 snapshot_id=snap_id,
75 message="test commit",
76 committed_at_iso=committed_at.isoformat(),
77 )
78 write_commit(
79 root,
80 CommitRecord(
81 commit_id=commit_id,
82 branch=branch,
83 snapshot_id=snap_id,
84 message="test commit",
85 committed_at=committed_at,
86 parent_commit_id=parent,
87 ),
88 )
89 ref_path(root, branch).write_text(commit_id, encoding="utf-8")
90 return commit_id
91
92
93 def _env(root: pathlib.Path) -> Mapping[str, str]:
94 return {"MUSE_REPO_ROOT": str(root)}
95
96
97 # ---------------------------------------------------------------------------
98 # ST-1 muse status text: untracked empty dir shows with trailing slash
99 # ---------------------------------------------------------------------------
100
101 class TestStatusTextUntracked:
102 def test_untracked_empty_dir_shown_with_trailing_slash(
103 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
104 ) -> None:
105 """muse status long-form text must list an untracked empty dir as `test/`."""
106 root = _init_repo(tmp_path)
107 _make_commit(root, {"readme.md": b"# hello\n"})
108 monkeypatch.chdir(root)
109 (root / "test").mkdir()
110
111 result = runner.invoke(None, ["status"], env=_env(root))
112
113 assert result.exit_code == 0
114 assert "test/" in result.output, (
115 f"Expected 'test/' in status output but got:\n{result.output}"
116 )
117
118 def test_untracked_empty_dir_not_shown_without_slash(
119 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
120 ) -> None:
121 """Ensure the bare 'test' (no slash) does NOT appear — slash is the signal."""
122 root = _init_repo(tmp_path)
123 _make_commit(root, {"readme.md": b"# hello\n"})
124 monkeypatch.chdir(root)
125 (root / "test").mkdir()
126
127 result = runner.invoke(None, ["status"], env=_env(root))
128
129 lines = [l.strip() for l in result.output.splitlines()]
130 # "test" without trailing slash must not appear as a standalone entry
131 assert "test" not in lines, (
132 f"Bare 'test' (no slash) appeared in status output:\n{result.output}"
133 )
134
135
136 # ---------------------------------------------------------------------------
137 # ST-2 muse status JSON: untracked empty dir in `untracked` list with slash
138 # ---------------------------------------------------------------------------
139
140 class TestStatusJsonUntracked:
141 def test_untracked_empty_dir_in_json_untracked(
142 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
143 ) -> None:
144 """muse status --json must include `test/` in the `untracked` list."""
145 root = _init_repo(tmp_path)
146 _make_commit(root, {"readme.md": b"# hello\n"})
147 monkeypatch.chdir(root)
148 (root / "test").mkdir()
149
150 result = runner.invoke(None, ["status", "--json"], env=_env(root))
151
152 assert result.exit_code == 0
153 data = json.loads(result.output)
154 assert "test/" in data["untracked"], (
155 f"Expected 'test/' in untracked but got: {data['untracked']}"
156 )
157
158 def test_dirty_when_untracked_empty_dir_present(
159 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
160 ) -> None:
161 """Repo must be dirty when an untracked empty dir exists."""
162 root = _init_repo(tmp_path)
163 _make_commit(root, {"readme.md": b"# hello\n"})
164 monkeypatch.chdir(root)
165 (root / "mydir").mkdir()
166
167 result = runner.invoke(None, ["status", "--json"], env=_env(root))
168
169 data = json.loads(result.output)
170 assert data["dirty"] is True
171 assert data["clean"] is False
172
173
174 # ---------------------------------------------------------------------------
175 # ST-3 muse diff text: new empty dir prints `A test/` (trailing slash)
176 # ---------------------------------------------------------------------------
177
178 class TestDiffTextDirectory:
179 def test_new_empty_dir_shows_with_trailing_slash(
180 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
181 ) -> None:
182 """muse diff must print `A test/` — not `A test` — for a staged empty dir."""
183 root = _init_repo(tmp_path)
184 _make_commit(root, {"readme.md": b"# hello\n"})
185 monkeypatch.chdir(root)
186 (root / "test").mkdir()
187 # Stage the dir first — untracked dirs are invisible to diff (like git).
188 runner.invoke(None, ["code", "add", "test/"], env=_env(root))
189
190 result = runner.invoke(None, ["diff"], env=_env(root))
191
192 assert result.exit_code == 0
193 assert "test/" in result.output, (
194 f"Expected 'test/' in diff output but got:\n{result.output}"
195 )
196
197 def test_new_empty_dir_not_shown_without_slash(
198 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
199 ) -> None:
200 """The bare `A test` (no slash) must not appear for a directory."""
201 root = _init_repo(tmp_path)
202 _make_commit(root, {"readme.md": b"# hello\n"})
203 monkeypatch.chdir(root)
204 (root / "test").mkdir()
205
206 result = runner.invoke(None, ["diff"], env=_env(root))
207
208 # "A test\n" (no slash) must not appear — only "A test/" is correct
209 assert "A test\n" not in result.output, (
210 f"Bare 'A test' appeared in diff output:\n{result.output}"
211 )
212
213
214 # ---------------------------------------------------------------------------
215 # ST-4 muse diff --stat: new empty dir counted as directory, not file
216 # ---------------------------------------------------------------------------
217
218 class TestDiffStatDirectory:
219 def test_stat_shows_directory_not_file(
220 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
221 ) -> None:
222 """muse diff --stat must say `1 added directory`, not `1 added file`."""
223 root = _init_repo(tmp_path)
224 _make_commit(root, {"readme.md": b"# hello\n"})
225 monkeypatch.chdir(root)
226 (root / "test").mkdir()
227 # Stage first — untracked dirs are invisible to diff (like git).
228 runner.invoke(None, ["code", "add", "test/"], env=_env(root))
229
230 result = runner.invoke(None, ["diff", "--stat"], env=_env(root))
231
232 assert result.exit_code == 0
233 assert "directory" in result.output, (
234 f"Expected 'directory' in --stat output but got:\n{result.output}"
235 )
236 assert "added file" not in result.output, (
237 f"'added file' must not appear for a directory in --stat:\n{result.output}"
238 )
239
240 def test_stat_file_and_dir_counted_separately(
241 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
242 ) -> None:
243 """When both a file and a dir are added, stat counts them separately."""
244 root = _init_repo(tmp_path)
245 _make_commit(root, {"readme.md": b"# hello\n"})
246 monkeypatch.chdir(root)
247 (root / "test").mkdir()
248 (root / "new.py").write_text("# new\n", encoding="utf-8")
249 # Stage dir first — untracked dirs are invisible to diff (like git).
250 runner.invoke(None, ["code", "add", "test/"], env=_env(root))
251
252 result = runner.invoke(None, ["diff", "--stat"], env=_env(root))
253
254 assert result.exit_code == 0
255 # Both a file count and a directory count should appear
256 assert "file" in result.output
257 assert "directory" in result.output
258
259
260 # ---------------------------------------------------------------------------
261 # ST-5 AddressedInsertOp for new dirs carries trailing slash in address
262 # ---------------------------------------------------------------------------
263
264 class TestDirectoryOpsAlgebra:
265 def test_addressed_insert_op_address_has_trailing_slash(
266 self, tmp_path: pathlib.Path
267 ) -> None:
268 """AddressedInsertOp emitted for a new empty dir must have address ending in '/'."""
269 from muse.domain import SnapshotManifest
270 from muse.plugins.code.plugin import CodePlugin
271
272 root = _init_repo(tmp_path)
273 plugin = CodePlugin()
274
275 base = SnapshotManifest(files={}, domain="code", directories=[])
276 target = SnapshotManifest(files={}, domain="code", directories=["test"])
277
278 delta = plugin.diff(base, target)
279 ops = delta["ops"]
280
281 insert_ops = [o for o in ops if o["op"] == "insert"]
282 assert insert_ops, "Expected at least one insert op for new directory"
283 for op in insert_ops:
284 assert op["address"].endswith("/"), (
285 f"Directory insert op address must end with '/': {op['address']!r}"
286 )
287
288 def test_addressed_delete_op_address_has_trailing_slash(
289 self, tmp_path: pathlib.Path
290 ) -> None:
291 """AddressedDeleteOp emitted for a removed empty dir must have address ending in '/'."""
292 from muse.domain import SnapshotManifest
293 from muse.plugins.code.plugin import CodePlugin
294
295 root = _init_repo(tmp_path)
296 plugin = CodePlugin()
297
298 base = SnapshotManifest(files={}, domain="code", directories=["test"])
299 target = SnapshotManifest(files={}, domain="code", directories=[])
300
301 delta = plugin.diff(base, target)
302 ops = delta["ops"]
303
304 delete_ops = [o for o in ops if o["op"] == "delete"]
305 assert delete_ops, "Expected at least one delete op for removed directory"
306 for op in delete_ops:
307 assert op["address"].endswith("/"), (
308 f"Directory delete op address must end with '/': {op['address']!r}"
309 )
310
311 def test_rename_op_address_has_trailing_slash(
312 self, tmp_path: pathlib.Path
313 ) -> None:
314 """RenameOp for a directory must have address and from_address both ending in '/'."""
315 from muse.domain import SnapshotManifest
316 from muse.plugins.code.plugin import CodePlugin
317
318 root = _init_repo(tmp_path)
319 plugin = CodePlugin()
320
321 # old/ → new/ rename: same files, different directory prefix
322 content = b"# file\n"
323 oid = blob_id(content)
324 write_object(root, oid, content)
325
326 base = SnapshotManifest(
327 files={"old/file.py": oid}, domain="code", directories=["old"]
328 )
329 target = SnapshotManifest(
330 files={"new/file.py": oid}, domain="code", directories=["new"]
331 )
332
333 delta = plugin.diff(base, target, repo_root=root)
334 ops = delta["ops"]
335
336 rename_ops = [o for o in ops if o["op"] == "rename" and "::" not in o["address"]]
337 assert rename_ops, "Expected a rename op for directory rename"
338 for op in rename_ops:
339 assert op["address"].endswith("/"), (
340 f"RenameOp address must end with '/': {op['address']!r}"
341 )
342 assert op["from_address"].endswith("/"), (
343 f"RenameOp from_address must end with '/': {op['from_address']!r}"
344 )
File History 3 commits
sha256:a154bc65916614c833d5a40a10d81ba3eae0d0495b0afddd34dc34f18d5e91b8 fix: test suite alignment and typing audit — zero violations Sonnet 4.6 minor 22 days ago
sha256:8b3bbc331871a67d637a5dfd8fa2dcdcf6c73b682bab4cd11fb534220913e7bc fix: untracked dirs invisible to muse diff; add 30-test dir… Sonnet 4.6 minor 22 days ago
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4 feat: first-class directory tracking across status, diff, r… Sonnet 4.6 minor 22 days ago