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