gabriel / muse public
test_dir_lifecycle.py python
433 lines 18.1 KB
Raw
sha256:a154bc65916614c833d5a40a10d81ba3eae0d0495b0afddd34dc34f18d5e91b8 fix: test suite alignment and typing audit — zero violations Sonnet 4.6 minor ⚠ breaking 23 days ago
1 """Comprehensive directory lifecycle tests — all states × all commands.
2
3 State matrix:
4 U — untracked: on disk, not in HEAD, not staged
5 SA — staged added: on disk, not in HEAD, staged A
6 C — clean: on disk, in HEAD, not staged
7 UD — unstaged del: not on disk, in HEAD, not staged
8 SD — staged deleted: not on disk, in HEAD, staged D
9 SR — staged renamed: muse mv produced D(old)+A(new)+rename_map
10
11 Commands covered: muse status (text+json), muse diff, muse code add, muse mv,
12 muse code reset, muse commit.
13 """
14 from __future__ import annotations
15
16 import datetime
17 import json
18 import os
19 import pathlib
20 import shutil
21 from collections.abc import Mapping
22
23 import pytest
24
25 from tests.cli_test_helper import CliRunner
26 from muse.core.paths import muse_dir, ref_path
27 from muse.core.object_store import write_object
28 from muse.core.ids import hash_commit, hash_snapshot
29 from muse.core.commits import CommitRecord, write_commit
30 from muse.core.snapshots import SnapshotRecord, write_snapshot
31 from muse.core.types import blob_id
32
33 runner = CliRunner()
34
35
36 # ---------------------------------------------------------------------------
37 # Shared helpers
38 # ---------------------------------------------------------------------------
39
40 def _make_repo(path: pathlib.Path, dirs: list[str] | None = None) -> pathlib.Path:
41 """Init a minimal code-domain repo with one committed file."""
42 dot = muse_dir(path)
43 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
44 (dot / d).mkdir(parents=True, exist_ok=True)
45 (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
46 (dot / "repo.json").write_text(
47 json.dumps({"repo_id": "test", "domain": "code"}), encoding="utf-8"
48 )
49 content = b"readme\n"
50 oid = blob_id(content)
51 write_object(path, oid, content)
52 manifest = {"readme.md": oid}
53 snap_id = hash_snapshot(manifest, dirs or [])
54 write_snapshot(path, SnapshotRecord(
55 snapshot_id=snap_id, manifest=manifest, directories=dirs or []
56 ))
57 now = datetime.datetime.now(datetime.timezone.utc)
58 cid = hash_commit(
59 parent_ids=[], snapshot_id=snap_id,
60 message="init", committed_at_iso=now.isoformat(),
61 )
62 write_commit(path, CommitRecord(
63 commit_id=cid, branch="main", snapshot_id=snap_id,
64 message="init", committed_at=now, parent_commit_id=None,
65 ))
66 ref_path(path, "main").write_text(cid, encoding="utf-8")
67 (path / "readme.md").write_bytes(content)
68 return path
69
70
71 def _env(root: pathlib.Path) -> Mapping[str, str]:
72 return {"MUSE_REPO_ROOT": str(root)}
73
74
75 def _status_json(root: pathlib.Path) -> Mapping[str, object]:
76 return json.loads(runner.invoke(None, ["status", "--json"], env=_env(root)).output)
77
78
79 def _diff_json(root: pathlib.Path) -> Mapping[str, object]:
80 return json.loads(runner.invoke(None, ["diff", "--json"], env=_env(root)).output)
81
82
83 # ---------------------------------------------------------------------------
84 # State U — untracked empty directory
85 # ---------------------------------------------------------------------------
86
87 class TestStateUntracked:
88 """Empty dir exists on disk, not in HEAD, not staged."""
89
90 def test_status_json_shows_untracked(self, tmp_path: pathlib.Path) -> None:
91 root = _make_repo(tmp_path)
92 (root / "newdir").mkdir()
93 d = _status_json(root)
94 assert "newdir/" in d["untracked"]
95 assert d["clean"] is False
96
97 def test_status_text_labels_untracked_directory(self, tmp_path: pathlib.Path) -> None:
98 root = _make_repo(tmp_path)
99 (root / "newdir").mkdir()
100 out = runner.invoke(None, ["status"], env=_env(root)).output
101 assert "untracked directory" in out
102 assert "newdir/" in out
103
104 def test_not_in_added_or_deleted(self, tmp_path: pathlib.Path) -> None:
105 root = _make_repo(tmp_path)
106 (root / "newdir").mkdir()
107 d = _status_json(root)
108 assert "newdir/" not in d["added"]
109 assert "newdir/" not in d["deleted"]
110
111 def test_diff_does_not_show_untracked(self, tmp_path: pathlib.Path) -> None:
112 """Untracked dirs are invisible to diff (same as git)."""
113 root = _make_repo(tmp_path)
114 (root / "newdir").mkdir()
115 d = _diff_json(root)
116 assert d["has_changes"] is False
117
118
119 # ---------------------------------------------------------------------------
120 # State SA — staged added (new empty dir, staged via muse code add)
121 # ---------------------------------------------------------------------------
122
123 class TestStateStagedAdded:
124 """Empty dir on disk, not in HEAD, staged as A sentinel."""
125
126 def test_shows_in_staged_added(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
127 root = _make_repo(tmp_path)
128 (root / "newdir").mkdir()
129 monkeypatch.chdir(root)
130 runner.invoke(None, ["code", "add", "newdir/"], env=_env(root))
131 d = _status_json(root)
132 assert "newdir/" in d["staged"]["added"]
133 assert "newdir/" in d["added"]
134 assert "newdir/" not in d["untracked"]
135
136 def test_status_text_shows_new_directory(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
137 root = _make_repo(tmp_path)
138 (root / "newdir").mkdir()
139 monkeypatch.chdir(root)
140 runner.invoke(None, ["code", "add", "newdir/"], env=_env(root))
141 out = runner.invoke(None, ["status"], env=_env(root)).output
142 assert "new directory" in out
143 assert "newdir/" in out
144
145 def test_diff_shows_as_added(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
146 root = _make_repo(tmp_path)
147 (root / "newdir").mkdir()
148 monkeypatch.chdir(root)
149 runner.invoke(None, ["code", "add", "newdir/"], env=_env(root))
150 d = _diff_json(root)
151 assert "newdir/" in d["added"]
152
153 def test_reset_clears_back_to_untracked(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
154 root = _make_repo(tmp_path)
155 (root / "newdir").mkdir()
156 monkeypatch.chdir(root)
157 runner.invoke(None, ["code", "add", "newdir/"], env=_env(root))
158 runner.invoke(None, ["code", "reset"], env=_env(root))
159 d = _status_json(root)
160 assert "newdir/" in d["untracked"]
161 assert d["staged"]["added"] == []
162
163 def test_commit_includes_directory(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
164 root = _make_repo(tmp_path)
165 (root / "newdir").mkdir()
166 monkeypatch.chdir(root)
167 runner.invoke(None, ["code", "add", "newdir/"], env=_env(root))
168 runner.invoke(None, ["commit", "-m", "add dir"], env=_env(root))
169 # After commit, dir is clean
170 d = _status_json(root)
171 assert d["clean"] is True
172
173
174 # ---------------------------------------------------------------------------
175 # State C — clean (committed dir still on disk)
176 # ---------------------------------------------------------------------------
177
178 class TestStateClean:
179 """Empty dir on disk, in HEAD dirs, not staged → nothing to report."""
180
181 def test_clean_when_committed_dir_unchanged(self, tmp_path: pathlib.Path) -> None:
182 root = _make_repo(tmp_path, dirs=["mydir"])
183 (root / "mydir").mkdir()
184 d = _status_json(root)
185 assert d["clean"] is True
186 assert d["untracked"] == []
187 assert d["added"] == []
188 assert d["deleted"] == []
189
190 def test_status_text_says_nothing_to_commit(self, tmp_path: pathlib.Path) -> None:
191 root = _make_repo(tmp_path, dirs=["mydir"])
192 (root / "mydir").mkdir()
193 out = runner.invoke(None, ["status"], env=_env(root)).output
194 assert "working tree clean" in out
195
196
197 # ---------------------------------------------------------------------------
198 # State UD — unstaged deletion (committed dir removed from disk)
199 # ---------------------------------------------------------------------------
200
201 class TestStateUnstagedDeleted:
202 """Committed empty dir removed from disk, not yet staged."""
203
204 def test_shows_in_deleted(self, tmp_path: pathlib.Path) -> None:
205 root = _make_repo(tmp_path, dirs=["mydir"])
206 (root / "mydir").mkdir()
207 shutil.rmtree(root / "mydir")
208 d = _status_json(root)
209 assert "mydir/" in d["deleted"]
210 assert d["clean"] is False
211
212 def test_in_unstaged_deleted(self, tmp_path: pathlib.Path) -> None:
213 root = _make_repo(tmp_path, dirs=["mydir"])
214 (root / "mydir").mkdir()
215 shutil.rmtree(root / "mydir")
216 d = _status_json(root)
217 assert "mydir/" in d["unstaged"]["deleted"]
218 assert "mydir/" not in d["staged"]["deleted"]
219
220 def test_status_text_shows_deleted(self, tmp_path: pathlib.Path) -> None:
221 root = _make_repo(tmp_path, dirs=["mydir"])
222 (root / "mydir").mkdir()
223 shutil.rmtree(root / "mydir")
224 out = runner.invoke(None, ["status"], env=_env(root)).output
225 assert "deleted" in out
226 assert "mydir/" in out
227
228 def test_code_add_dot_stages_deletion(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
229 root = _make_repo(tmp_path, dirs=["mydir"])
230 (root / "mydir").mkdir()
231 shutil.rmtree(root / "mydir")
232 monkeypatch.chdir(root)
233 out = runner.invoke(None, ["code", "add", "."], env=_env(root)).output
234 assert "director" in out # "1 directory" or "directories"
235 d = _status_json(root)
236 assert "mydir/" in d["staged"]["deleted"]
237
238 def test_code_add_explicit_path_stages_deletion(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
239 root = _make_repo(tmp_path, dirs=["mydir"])
240 (root / "mydir").mkdir()
241 shutil.rmtree(root / "mydir")
242 monkeypatch.chdir(root)
243 runner.invoke(None, ["code", "add", "mydir/"], env=_env(root))
244 d = _status_json(root)
245 assert "mydir/" in d["staged"]["deleted"]
246
247
248 # ---------------------------------------------------------------------------
249 # State SD — staged deletion
250 # ---------------------------------------------------------------------------
251
252 class TestStateStagedDeleted:
253 """Committed empty dir staged for deletion."""
254
255 def test_shows_in_staged_deleted(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
256 root = _make_repo(tmp_path, dirs=["mydir"])
257 (root / "mydir").mkdir()
258 shutil.rmtree(root / "mydir")
259 monkeypatch.chdir(root)
260 runner.invoke(None, ["code", "add", "."], env=_env(root))
261 d = _status_json(root)
262 assert "mydir/" in d["staged"]["deleted"]
263 assert "mydir/" not in d["unstaged"]["deleted"]
264
265 def test_status_text_shows_deleted_directory(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
266 root = _make_repo(tmp_path, dirs=["mydir"])
267 (root / "mydir").mkdir()
268 shutil.rmtree(root / "mydir")
269 monkeypatch.chdir(root)
270 runner.invoke(None, ["code", "add", "."], env=_env(root))
271 out = runner.invoke(None, ["status"], env=_env(root)).output
272 assert "deleted directory" in out
273 assert "mydir/" in out
274
275 def test_reset_moves_back_to_unstaged(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
276 root = _make_repo(tmp_path, dirs=["mydir"])
277 (root / "mydir").mkdir()
278 shutil.rmtree(root / "mydir")
279 monkeypatch.chdir(root)
280 runner.invoke(None, ["code", "add", "."], env=_env(root))
281 runner.invoke(None, ["code", "reset"], env=_env(root))
282 d = _status_json(root)
283 assert "mydir/" in d["unstaged"]["deleted"]
284 assert d["staged"]["deleted"] == []
285
286 def test_no_double_entry_in_deleted(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
287 """mydir/ must appear exactly once in deleted[], not twice."""
288 root = _make_repo(tmp_path, dirs=["mydir"])
289 (root / "mydir").mkdir()
290 shutil.rmtree(root / "mydir")
291 monkeypatch.chdir(root)
292 runner.invoke(None, ["code", "add", "."], env=_env(root))
293 d = _status_json(root)
294 assert d["deleted"].count("mydir/") == 1
295
296
297 # ---------------------------------------------------------------------------
298 # State SR — staged rename (muse mv)
299 # ---------------------------------------------------------------------------
300
301 class TestStateStagedRenamed:
302 """Committed empty dir renamed via muse mv — D(old)+A(new)+rename_map."""
303
304 def test_status_json_shows_renamed(self, tmp_path: pathlib.Path) -> None:
305 root = _make_repo(tmp_path, dirs=["olddir"])
306 (root / "olddir").mkdir()
307 runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root))
308 d = _status_json(root)
309 assert "olddir/" in d["renamed"]
310 assert d["renamed"]["olddir/"] == "newdir/"
311
312 def test_staged_renamed_not_in_added_or_deleted(self, tmp_path: pathlib.Path) -> None:
313 root = _make_repo(tmp_path, dirs=["olddir"])
314 (root / "olddir").mkdir()
315 runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root))
316 d = _status_json(root)
317 assert "olddir/" not in d["added"]
318 assert "newdir/" not in d["added"]
319 assert "olddir/" not in d["deleted"]
320 assert "newdir/" not in d["deleted"]
321
322 def test_total_changes_is_one(self, tmp_path: pathlib.Path) -> None:
323 root = _make_repo(tmp_path, dirs=["olddir"])
324 (root / "olddir").mkdir()
325 runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root))
326 d = _status_json(root)
327 assert d["total_changes"] == 1
328
329 def test_status_text_shows_renamed_directory(self, tmp_path: pathlib.Path) -> None:
330 root = _make_repo(tmp_path, dirs=["olddir"])
331 (root / "olddir").mkdir()
332 runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root))
333 out = runner.invoke(None, ["status"], env=_env(root)).output
334 assert "renamed directory" in out
335 assert "olddir/" in out
336 assert "newdir/" in out
337
338 def test_diff_shows_r_not_a_plus_d(self, tmp_path: pathlib.Path) -> None:
339 root = _make_repo(tmp_path, dirs=["olddir"])
340 (root / "olddir").mkdir()
341 runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root))
342 d = _diff_json(root)
343 assert "olddir/" in d["renamed"]
344 assert d["renamed"]["olddir/"] == "newdir/"
345 assert "olddir/" not in d["deleted"]
346 assert "newdir/" not in d["added"]
347
348 def test_reset_clears_rename_map(self, tmp_path: pathlib.Path) -> None:
349 root = _make_repo(tmp_path, dirs=["olddir"])
350 (root / "olddir").mkdir()
351 runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root))
352 runner.invoke(None, ["code", "reset"], env=_env(root))
353 d = _status_json(root)
354 assert d["renamed"] == {}
355 # After reset: olddir deleted from disk, newdir untracked
356 assert "olddir/" in d["deleted"]
357 assert "newdir/" in d["untracked"]
358
359 def test_trailing_slash_on_dest_is_cosmetic(self, tmp_path: pathlib.Path) -> None:
360 """muse mv olddir newdir/ where newdir doesn't exist → rename to newdir."""
361 root = _make_repo(tmp_path, dirs=["olddir"])
362 (root / "olddir").mkdir()
363 runner.invoke(None, ["mv", "olddir", "newdir/"], env=_env(root))
364 d = _status_json(root)
365 assert "olddir/" in d["renamed"]
366 assert d["renamed"]["olddir/"] == "newdir/"
367
368 def test_rename_chain_collapses(self, tmp_path: pathlib.Path) -> None:
369 """A→B then B→C collapses to A→C in the rename map."""
370 root = _make_repo(tmp_path, dirs=["alpha"])
371 (root / "alpha").mkdir()
372 runner.invoke(None, ["mv", "alpha", "beta"], env=_env(root))
373 runner.invoke(None, ["mv", "beta", "gamma"], env=_env(root))
374 d = _status_json(root)
375 assert "alpha/" in d["renamed"]
376 assert d["renamed"]["alpha/"] == "gamma/"
377 assert "beta/" not in d["renamed"]
378
379 def test_move_inside_existing_dir(self, tmp_path: pathlib.Path) -> None:
380 """muse mv src existingdir/ where existingdir exists → moves inside."""
381 root = _make_repo(tmp_path, dirs=["src", "lib"])
382 (root / "src").mkdir()
383 (root / "lib").mkdir()
384 runner.invoke(None, ["mv", "src", "lib/"], env=_env(root))
385 # lib/ exists → src moves inside to lib/src
386 assert (root / "lib" / "src").exists()
387 assert not (root / "src").exists()
388
389
390 # ---------------------------------------------------------------------------
391 # muse mv for non-empty directories (files inside)
392 # ---------------------------------------------------------------------------
393
394 class TestMvNonEmptyDir:
395 def test_mv_nonempty_dir_restages_all_files(self, tmp_path: pathlib.Path) -> None:
396 root = _make_repo(tmp_path)
397 content = b"hello\n"
398 oid = blob_id(content)
399 write_object(root, oid, content)
400 # Manually add a file in a subdirectory to HEAD
401 from muse.core.refs import get_head_commit_id, read_current_branch
402 from muse.core.commits import read_commit
403 from muse.core.snapshots import read_snapshot, write_snapshot
404 branch = read_current_branch(root)
405 cid = get_head_commit_id(root, branch)
406 commit = read_commit(root, cid)
407 snap = read_snapshot(root, commit.snapshot_id)
408 new_manifest = dict(snap.manifest)
409 new_manifest["src/hello.py"] = oid
410 new_snap_id = hash_snapshot(new_manifest, [])
411 write_snapshot(root, SnapshotRecord(
412 snapshot_id=new_snap_id, manifest=new_manifest, directories=[]
413 ))
414 now = datetime.datetime.now(datetime.timezone.utc)
415 new_cid = hash_commit(
416 parent_ids=[cid], snapshot_id=new_snap_id,
417 message="add file", committed_at_iso=now.isoformat(),
418 )
419 write_commit(root, CommitRecord(
420 commit_id=new_cid, branch=branch, snapshot_id=new_snap_id,
421 message="add file", committed_at=now, parent_commit_id=cid,
422 ))
423 ref_path(root, branch).write_text(new_cid, encoding="utf-8")
424 (root / "src").mkdir()
425 (root / "src" / "hello.py").write_bytes(content)
426
427 runner.invoke(None, ["mv", "src", "lib"], env=_env(root))
428
429 d = _status_json(root)
430 assert "lib/hello.py" in d["staged"]["added"]
431 assert "src/hello.py" in d["staged"]["deleted"]
432 assert (root / "lib" / "hello.py").exists()
433 assert not (root / "src").exists()
File History 2 commits
sha256:a154bc65916614c833d5a40a10d81ba3eae0d0495b0afddd34dc34f18d5e91b8 fix: test suite alignment and typing audit — zero violations Sonnet 4.6 minor 23 days ago
sha256:8b3bbc331871a67d637a5dfd8fa2dcdcf6c73b682bab4cd11fb534220913e7bc fix: untracked dirs invisible to muse diff; add 30-test dir… Sonnet 4.6 minor 23 days ago