gabriel / muse public
test_cmd_rm.py python
521 lines 17.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for ``muse rm``.
2
3 Covers:
4 - --cached: stage deletion without touching disk
5 - no --cached: stage deletion AND delete from disk
6 - -r / --recursive: required for directories
7 - -f / --force: bypass safety checks
8 - -n / --dry-run: preview without side effects
9 - --json: machine-readable output (always valid, including error paths)
10 - File not tracked → exit 1
11 - Directory without -r → exit 1
12 - Modified file without --force → exit 1
13 - Staged-addition without --force → exit 1
14 - Multiple paths in one invocation
15 - Stress: 200 files, remove half
16 """
17
18 from __future__ import annotations
19
20 import datetime
21 import hashlib
22 import json
23 import pathlib
24
25 import pytest
26 from tests.cli_test_helper import CliRunner
27
28 from muse.core.object_store import write_object
29 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
30 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
31 from muse.core._types import Manifest
32 from muse.plugins.code.stage import StagedFileMap, make_entry, read_stage, write_stage
33
34 type _EnvDict = dict[str, str]
35 type _FileBytes = dict[str, bytes]
36
37 cli = None # argparse migration — CliRunner ignores this arg
38 runner = CliRunner()
39
40 _REPO_ID = "rm-test"
41
42
43 # ---------------------------------------------------------------------------
44 # Test-repo bootstrap helpers
45 # ---------------------------------------------------------------------------
46
47
48 def _sha(data: bytes) -> str:
49 return hashlib.sha256(data).hexdigest()
50
51
52 def _init_repo(path: pathlib.Path) -> pathlib.Path:
53 muse = path / ".muse"
54 for d in ("commits", "snapshots", "objects", "refs/heads"):
55 (muse / d).mkdir(parents=True, exist_ok=True)
56 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
57 (muse / "repo.json").write_text(
58 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
59 )
60 return path
61
62
63 def _env(repo: pathlib.Path) -> _EnvDict:
64 return {"MUSE_REPO_ROOT": str(repo)}
65
66
67 _counter = 0
68
69
70 def _commit_files(root: pathlib.Path, files: _FileBytes) -> str:
71 """Write *files* to disk and to the object store; create a commit."""
72 global _counter
73 _counter += 1
74 manifest: Manifest = {}
75 for rel_path, content in files.items():
76 obj_id = _sha(content)
77 write_object(root, obj_id, content)
78 manifest[rel_path] = obj_id
79 # Write to disk so on-disk and committed content match.
80 abs_path = root / rel_path
81 abs_path.parent.mkdir(parents=True, exist_ok=True)
82 abs_path.write_bytes(content)
83 snap_id = compute_snapshot_id(manifest)
84 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
85 committed_at = datetime.datetime.now(datetime.timezone.utc)
86 commit_id = compute_commit_id(
87 [], snap_id, f"commit {_counter}", committed_at.isoformat()
88 )
89 write_commit(
90 root,
91 CommitRecord(
92 commit_id=commit_id,
93 repo_id=_REPO_ID,
94 branch="main",
95 snapshot_id=snap_id,
96 message=f"commit {_counter}",
97 committed_at=committed_at,
98 ),
99 )
100 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id, encoding="utf-8")
101 return commit_id
102
103
104 # ---------------------------------------------------------------------------
105 # help
106 # ---------------------------------------------------------------------------
107
108
109 def test_rm_help() -> None:
110 result = runner.invoke(cli, ["rm", "--help"])
111 assert result.exit_code == 0
112 assert "--cached" in result.output
113
114
115 # ---------------------------------------------------------------------------
116 # --cached: stage deletion, keep file on disk
117 # ---------------------------------------------------------------------------
118
119
120 def test_rm_cached_stages_deletion(tmp_path: pathlib.Path) -> None:
121 """``muse rm --cached`` writes mode D to stage, leaves file on disk."""
122 _init_repo(tmp_path)
123 _commit_files(tmp_path, {"song.txt": b"verse\n"})
124
125 result = runner.invoke(
126 cli, ["rm", "--cached", "song.txt"], env=_env(tmp_path)
127 )
128 assert result.exit_code == 0
129
130 # File still on disk.
131 assert (tmp_path / "song.txt").exists()
132
133 # Stage has a "D" entry for song.txt.
134 stage = read_stage(tmp_path)
135 assert "song.txt" in stage
136 assert stage["song.txt"]["mode"] == "D"
137
138
139 def test_rm_cached_json_output(tmp_path: pathlib.Path) -> None:
140 """``muse rm --cached --json`` emits valid JSON with expected fields."""
141 _init_repo(tmp_path)
142 _commit_files(tmp_path, {"notes.txt": b"A\n"})
143
144 result = runner.invoke(
145 cli, ["rm", "--cached", "--json", "notes.txt"], env=_env(tmp_path)
146 )
147 assert result.exit_code == 0
148 data = json.loads(result.output)
149 assert data["status"] == "removed"
150 assert "notes.txt" in data["removed"]
151 assert data["cached"] is True
152 assert data["dry_run"] is False
153 assert data["count"] == 1
154
155
156 # ---------------------------------------------------------------------------
157 # Without --cached: stage deletion AND delete from disk
158 # ---------------------------------------------------------------------------
159
160
161 def test_rm_deletes_file_from_disk(tmp_path: pathlib.Path) -> None:
162 """``muse rm`` without --cached removes the file from disk."""
163 _init_repo(tmp_path)
164 _commit_files(tmp_path, {"beat.mid": b"\x00\x01\x02"})
165
166 result = runner.invoke(cli, ["rm", "beat.mid"], env=_env(tmp_path))
167 assert result.exit_code == 0
168
169 # File gone from disk.
170 assert not (tmp_path / "beat.mid").exists()
171
172 # Stage has a "D" entry.
173 stage = read_stage(tmp_path)
174 assert stage["beat.mid"]["mode"] == "D"
175
176
177 def test_rm_json_no_cached(tmp_path: pathlib.Path) -> None:
178 """``muse rm --json`` emits cached=false."""
179 _init_repo(tmp_path)
180 _commit_files(tmp_path, {"f.txt": b"x\n"})
181
182 result = runner.invoke(cli, ["rm", "--json", "f.txt"], env=_env(tmp_path))
183 assert result.exit_code == 0
184 data = json.loads(result.output)
185 assert data["cached"] is False
186 assert data["count"] == 1
187
188
189 # ---------------------------------------------------------------------------
190 # File not tracked → exit 1
191 # ---------------------------------------------------------------------------
192
193
194 def test_rm_untracked_file_exits_1(tmp_path: pathlib.Path) -> None:
195 """Removing an untracked file must exit non-zero."""
196 _init_repo(tmp_path)
197 _commit_files(tmp_path, {"existing.txt": b"x\n"})
198
199 result = runner.invoke(
200 cli, ["rm", "does_not_exist.txt"], env=_env(tmp_path)
201 )
202 assert result.exit_code != 0
203
204
205 def test_rm_untracked_does_not_affect_stage(tmp_path: pathlib.Path) -> None:
206 """Attempting to remove an untracked file must not mutate the stage."""
207 _init_repo(tmp_path)
208 _commit_files(tmp_path, {"a.txt": b"a\n"})
209
210 runner.invoke(cli, ["rm", "ghost.txt"], env=_env(tmp_path))
211 stage = read_stage(tmp_path)
212 # Stage should still be empty (no entry added for a.txt or ghost.txt).
213 assert "a.txt" not in stage
214
215
216 # ---------------------------------------------------------------------------
217 # Directory without -r → exit 1
218 # ---------------------------------------------------------------------------
219
220
221 def test_rm_directory_without_recursive_exits_1(tmp_path: pathlib.Path) -> None:
222 """Removing a directory path without -r must exit non-zero."""
223 _init_repo(tmp_path)
224 _commit_files(tmp_path, {"src/main.py": b"pass\n"})
225
226 result = runner.invoke(cli, ["rm", "--cached", "src"], env=_env(tmp_path))
227 assert result.exit_code != 0
228
229
230 def test_rm_directory_with_recursive_stages_all(tmp_path: pathlib.Path) -> None:
231 """``muse rm -r --cached <dir>`` stages deletion for every file under dir."""
232 _init_repo(tmp_path)
233 _commit_files(
234 tmp_path,
235 {
236 "src/a.py": b"a\n",
237 "src/b.py": b"b\n",
238 "other.txt": b"c\n",
239 },
240 )
241
242 result = runner.invoke(
243 cli, ["rm", "-r", "--cached", "src"], env=_env(tmp_path)
244 )
245 assert result.exit_code == 0
246
247 stage = read_stage(tmp_path)
248 assert stage["src/a.py"]["mode"] == "D"
249 assert stage["src/b.py"]["mode"] == "D"
250 # File outside the directory must be untouched.
251 assert "other.txt" not in stage
252
253
254 # ---------------------------------------------------------------------------
255 # Modified file without --force → exit 1
256 # ---------------------------------------------------------------------------
257
258
259 def test_rm_modified_file_without_force_exits_1(tmp_path: pathlib.Path) -> None:
260 """Removing a locally-modified file without --force must exit non-zero."""
261 _init_repo(tmp_path)
262 _commit_files(tmp_path, {"track.txt": b"original\n"})
263 # Mutate the on-disk copy after committing.
264 (tmp_path / "track.txt").write_bytes(b"modified\n")
265
266 result = runner.invoke(cli, ["rm", "track.txt"], env=_env(tmp_path))
267 assert result.exit_code != 0
268 # File must not have been deleted.
269 assert (tmp_path / "track.txt").exists()
270
271
272 def test_rm_modified_file_with_force_succeeds(tmp_path: pathlib.Path) -> None:
273 """``muse rm --force`` removes a locally-modified file."""
274 _init_repo(tmp_path)
275 _commit_files(tmp_path, {"track.txt": b"original\n"})
276 (tmp_path / "track.txt").write_bytes(b"modified\n")
277
278 result = runner.invoke(cli, ["rm", "--force", "track.txt"], env=_env(tmp_path))
279 assert result.exit_code == 0
280 assert not (tmp_path / "track.txt").exists()
281 assert read_stage(tmp_path)["track.txt"]["mode"] == "D"
282
283
284 def test_rm_cached_modified_no_force_ok(tmp_path: pathlib.Path) -> None:
285 """``muse rm --cached`` on a modified file is always safe (no disk delete)."""
286 _init_repo(tmp_path)
287 _commit_files(tmp_path, {"track.txt": b"original\n"})
288 (tmp_path / "track.txt").write_bytes(b"modified\n")
289
290 # --cached never deletes from disk so the safety check is skipped.
291 result = runner.invoke(
292 cli, ["rm", "--cached", "track.txt"], env=_env(tmp_path)
293 )
294 assert result.exit_code == 0
295 # On-disk copy preserved (and modified).
296 assert (tmp_path / "track.txt").read_bytes() == b"modified\n"
297 assert read_stage(tmp_path)["track.txt"]["mode"] == "D"
298
299
300 # ---------------------------------------------------------------------------
301 # Staged-addition without --force → exit 1
302 # ---------------------------------------------------------------------------
303
304
305 def test_rm_staged_addition_without_force_exits_1(tmp_path: pathlib.Path) -> None:
306 """Removing a staged-but-never-committed file without --force exits non-zero."""
307 _init_repo(tmp_path)
308 # No commit — the file only exists in the stage (mode A).
309 (tmp_path / "new.py").write_bytes(b"print('hi')\n")
310 stage: StagedFileMap = {
311 "new.py": make_entry(object_id=_sha(b"print('hi')\n"), mode="A"),
312 }
313 write_stage(tmp_path, stage)
314
315 result = runner.invoke(cli, ["rm", "--cached", "new.py"], env=_env(tmp_path))
316 assert result.exit_code != 0
317
318
319 def test_rm_staged_addition_with_force_removes_from_stage(
320 tmp_path: pathlib.Path,
321 ) -> None:
322 """``muse rm --force --cached`` removes a staged-addition entry from stage."""
323 _init_repo(tmp_path)
324 (tmp_path / "new.py").write_bytes(b"print('hi')\n")
325 stage: StagedFileMap = {
326 "new.py": make_entry(object_id=_sha(b"print('hi')\n"), mode="A"),
327 }
328 write_stage(tmp_path, stage)
329
330 result = runner.invoke(
331 cli, ["rm", "--force", "--cached", "new.py"], env=_env(tmp_path)
332 )
333 assert result.exit_code == 0
334 # Entry must be gone from stage (not just flipped to D).
335 assert "new.py" not in read_stage(tmp_path)
336
337
338 # ---------------------------------------------------------------------------
339 # --dry-run: no side effects
340 # ---------------------------------------------------------------------------
341
342
343 def test_rm_dry_run_does_not_delete(tmp_path: pathlib.Path) -> None:
344 """``muse rm -n`` must not delete the file or write to the stage."""
345 _init_repo(tmp_path)
346 _commit_files(tmp_path, {"chord.txt": b"Am\n"})
347
348 result = runner.invoke(cli, ["rm", "-n", "chord.txt"], env=_env(tmp_path))
349 assert result.exit_code == 0
350 # File still on disk.
351 assert (tmp_path / "chord.txt").exists()
352 # Stage must be empty.
353 assert "chord.txt" not in read_stage(tmp_path)
354
355
356 def test_rm_dry_run_json(tmp_path: pathlib.Path) -> None:
357 """``muse rm -n --json`` emits valid JSON with status=dry_run."""
358 _init_repo(tmp_path)
359 _commit_files(tmp_path, {"chord.txt": b"Am\n"})
360
361 result = runner.invoke(
362 cli, ["rm", "-n", "--json", "chord.txt"], env=_env(tmp_path)
363 )
364 assert result.exit_code == 0
365 data = json.loads(result.output)
366 assert data["status"] == "dry_run"
367 assert "chord.txt" in data["removed"]
368 assert data["dry_run"] is True
369 assert data["count"] == 1
370
371
372 def test_rm_dry_run_cached(tmp_path: pathlib.Path) -> None:
373 """``muse rm -n --cached`` previews correctly, writes nothing."""
374 _init_repo(tmp_path)
375 _commit_files(tmp_path, {"f.txt": b"x\n"})
376
377 result = runner.invoke(
378 cli, ["rm", "-n", "--cached", "--json", "f.txt"], env=_env(tmp_path)
379 )
380 assert result.exit_code == 0
381 data = json.loads(result.output)
382 assert data["status"] == "dry_run"
383 assert data["cached"] is True
384
385
386 # ---------------------------------------------------------------------------
387 # Multiple paths in one invocation
388 # ---------------------------------------------------------------------------
389
390
391 def test_rm_multiple_files(tmp_path: pathlib.Path) -> None:
392 """Multiple paths in one ``muse rm`` invocation are all removed."""
393 _init_repo(tmp_path)
394 _commit_files(
395 tmp_path,
396 {"a.txt": b"a\n", "b.txt": b"b\n", "c.txt": b"c\n"},
397 )
398
399 result = runner.invoke(
400 cli, ["rm", "--cached", "--json", "a.txt", "b.txt"], env=_env(tmp_path)
401 )
402 assert result.exit_code == 0
403 data = json.loads(result.output)
404 assert data["count"] == 2
405 assert "a.txt" in data["removed"]
406 assert "b.txt" in data["removed"]
407 assert "c.txt" not in data["removed"]
408
409 stage = read_stage(tmp_path)
410 assert stage["a.txt"]["mode"] == "D"
411 assert stage["b.txt"]["mode"] == "D"
412 assert "c.txt" not in stage
413
414
415 # ---------------------------------------------------------------------------
416 # Idempotency: remove already-staged-for-deletion
417 # ---------------------------------------------------------------------------
418
419
420 def test_rm_already_staged_deletion_is_idempotent(tmp_path: pathlib.Path) -> None:
421 """Calling ``muse rm --cached`` twice on the same file is idempotent."""
422 _init_repo(tmp_path)
423 _commit_files(tmp_path, {"x.txt": b"x\n"})
424
425 runner.invoke(cli, ["rm", "--cached", "x.txt"], env=_env(tmp_path))
426 result = runner.invoke(cli, ["rm", "--cached", "x.txt"], env=_env(tmp_path))
427 # Second call should still exit 0 — the file is still "tracked" in HEAD.
428 assert result.exit_code == 0
429 assert read_stage(tmp_path)["x.txt"]["mode"] == "D"
430
431
432 # ---------------------------------------------------------------------------
433 # Stress: 200 files, remove half
434 # ---------------------------------------------------------------------------
435
436
437 def test_rm_stress_200_files(tmp_path: pathlib.Path) -> None:
438 """Remove 100 of 200 committed files; verify stage and disk state."""
439 _init_repo(tmp_path)
440 files = {
441 f"file_{i:04d}.txt": f"content {i}\n".encode()
442 for i in range(200)
443 }
444 _commit_files(tmp_path, files)
445
446 # Remove even-numbered files with --cached.
447 to_remove = [f"file_{i:04d}.txt" for i in range(200) if i % 2 == 0]
448 result = runner.invoke(
449 cli,
450 ["rm", "--cached", "--json"] + to_remove,
451 env=_env(tmp_path),
452 )
453 assert result.exit_code == 0
454 data = json.loads(result.output)
455 assert data["count"] == 100
456
457 stage = read_stage(tmp_path)
458 # All even-numbered files staged for deletion.
459 for i in range(200):
460 name = f"file_{i:04d}.txt"
461 if i % 2 == 0:
462 assert stage[name]["mode"] == "D"
463 else:
464 assert name not in stage
465
466 # All files still on disk (--cached).
467 for name in files:
468 assert (tmp_path / name).exists()
469
470
471 # ---------------------------------------------------------------------------
472 # JSON schema completeness
473 # ---------------------------------------------------------------------------
474
475
476 def test_rm_json_schema_all_fields(tmp_path: pathlib.Path) -> None:
477 """JSON output always contains status, removed, cached, dry_run, count."""
478 _init_repo(tmp_path)
479 _commit_files(tmp_path, {"z.txt": b"z\n"})
480
481 result = runner.invoke(
482 cli, ["rm", "--json", "z.txt"], env=_env(tmp_path)
483 )
484 assert result.exit_code == 0
485 data = json.loads(result.output)
486 for key in ("status", "removed", "cached", "dry_run", "count"):
487 assert key in data, f"Missing key: {key}"
488
489
490 # ---------------------------------------------------------------------------
491 # Recursive removal with disk delete
492 # ---------------------------------------------------------------------------
493
494
495 def test_rm_recursive_deletes_from_disk(tmp_path: pathlib.Path) -> None:
496 """``muse rm -r <dir>`` removes all tracked files under dir from disk."""
497 _init_repo(tmp_path)
498 _commit_files(
499 tmp_path,
500 {
501 "static/app.css": b"body{}\n",
502 "static/app.js": b"console.log(1)\n",
503 "src/main.py": b"pass\n",
504 },
505 )
506
507 result = runner.invoke(
508 cli, ["rm", "-r", "--json", "static"], env=_env(tmp_path)
509 )
510 assert result.exit_code == 0
511 data = json.loads(result.output)
512 assert data["count"] == 2
513 assert not (tmp_path / "static" / "app.css").exists()
514 assert not (tmp_path / "static" / "app.js").exists()
515 # File outside the directory must be untouched.
516 assert (tmp_path / "src" / "main.py").exists()
517
518 stage = read_stage(tmp_path)
519 assert stage["static/app.css"]["mode"] == "D"
520 assert stage["static/app.js"]["mode"] == "D"
521 assert "src/main.py" not in stage
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago