gabriel / muse public
test_cmd_rm.py python
798 lines 28.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 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 - Idempotency: rm already-staged-for-deletion file
16 - JSON includes duration_ms (float ≥ 0) and exit_code (int)
17 - JSON on error: --json emits structured output even on user errors
18 - Path traversal rejected: paths outside the repo root are rejected
19 - File already gone from disk: tracked file missing on disk is handled gracefully
20 - Staged-mode-D idempotent with --json
21 - --dry-run still runs safety checks
22 - --dry-run --force bypasses safety checks and emits dry_run status
23 - Symlink in working tree: symlink to a committed path is handled correctly
24 - Unicode filenames
25 - Stress: 200 files, remove half
26 - Stress with timing: duration_ms is present and non-negative
27 - Data integrity: stage is valid after removal
28 """
29
30 from __future__ import annotations
31
32 import datetime
33 import json
34 import pathlib
35
36 import pytest
37 from tests.cli_test_helper import CliRunner
38
39 from muse.core._types import blob_id
40 from muse.core.object_store import write_object
41 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
42 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
43 from muse.core._types import Manifest
44 from muse.plugins.code.stage import StagedFileMap, make_entry, read_stage, write_stage
45
46 type _EnvDict = dict[str, str]
47 type _FileBytes = dict[str, bytes]
48
49 cli = None # argparse migration — CliRunner ignores this arg
50 runner = CliRunner()
51
52 _REPO_ID = "rm-test"
53
54
55 # ---------------------------------------------------------------------------
56 # Test-repo bootstrap helpers
57 # ---------------------------------------------------------------------------
58
59
60 def _init_repo(path: pathlib.Path) -> pathlib.Path:
61 muse = path / ".muse"
62 for d in ("commits", "snapshots", "objects", "refs/heads"):
63 (muse / d).mkdir(parents=True, exist_ok=True)
64 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
65 (muse / "repo.json").write_text(
66 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
67 )
68 return path
69
70
71 def _env(repo: pathlib.Path) -> _EnvDict:
72 return {"MUSE_REPO_ROOT": str(repo)}
73
74
75 _counter = 0
76
77
78 def _commit_files(root: pathlib.Path, files: _FileBytes) -> str:
79 """Write *files* to disk and to the object store; create a commit."""
80 global _counter
81 _counter += 1
82 manifest: Manifest = {}
83 for rel_path, content in files.items():
84 oid = blob_id(content)
85 write_object(root, oid, content)
86 manifest[rel_path] = oid
87 abs_path = root / rel_path
88 abs_path.parent.mkdir(parents=True, exist_ok=True)
89 abs_path.write_bytes(content)
90 snap_id = compute_snapshot_id(manifest)
91 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
92 committed_at = datetime.datetime.now(datetime.timezone.utc)
93 commit_id = compute_commit_id(
94 repo_id=_REPO_ID,
95 parent_ids=[],
96 snapshot_id=snap_id,
97 message=f"commit {_counter}",
98 committed_at_iso=committed_at.isoformat(),
99 )
100 write_commit(
101 root,
102 CommitRecord(
103 commit_id=commit_id,
104 repo_id=_REPO_ID,
105 created_on_branch="main",
106 snapshot_id=snap_id,
107 message=f"commit {_counter}",
108 committed_at=committed_at,
109 ),
110 )
111 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id, encoding="utf-8")
112 return commit_id
113
114
115 # ---------------------------------------------------------------------------
116 # help
117 # ---------------------------------------------------------------------------
118
119
120 def test_rm_help() -> None:
121 result = runner.invoke(cli, ["rm", "--help"])
122 assert result.exit_code == 0
123 assert "--cached" in result.output
124
125
126 # ---------------------------------------------------------------------------
127 # --cached: stage deletion, keep file on disk
128 # ---------------------------------------------------------------------------
129
130
131 def test_rm_cached_stages_deletion(tmp_path: pathlib.Path) -> None:
132 """``muse rm --cached`` writes mode D to stage, leaves file on disk."""
133 _init_repo(tmp_path)
134 _commit_files(tmp_path, {"song.txt": b"verse\n"})
135
136 result = runner.invoke(
137 cli, ["rm", "--cached", "song.txt"], env=_env(tmp_path)
138 )
139 assert result.exit_code == 0
140
141 assert (tmp_path / "song.txt").exists()
142
143 stage = read_stage(tmp_path)
144 assert "song.txt" in stage
145 assert stage["song.txt"]["mode"] == "D"
146
147
148 def test_rm_cached_json_output(tmp_path: pathlib.Path) -> None:
149 """``muse rm --cached --json`` emits valid JSON with expected fields."""
150 _init_repo(tmp_path)
151 _commit_files(tmp_path, {"notes.txt": b"A\n"})
152
153 result = runner.invoke(
154 cli, ["rm", "--cached", "--json", "notes.txt"], env=_env(tmp_path)
155 )
156 assert result.exit_code == 0
157 data = json.loads(result.output)
158 assert data["status"] == "removed"
159 assert "notes.txt" in data["removed"]
160 assert data["cached"] is True
161 assert data["dry_run"] is False
162 assert data["count"] == 1
163
164
165 # ---------------------------------------------------------------------------
166 # Without --cached: stage deletion AND delete from disk
167 # ---------------------------------------------------------------------------
168
169
170 def test_rm_deletes_file_from_disk(tmp_path: pathlib.Path) -> None:
171 """``muse rm`` without --cached removes the file from disk."""
172 _init_repo(tmp_path)
173 _commit_files(tmp_path, {"beat.mid": b"\x00\x01\x02"})
174
175 result = runner.invoke(cli, ["rm", "beat.mid"], env=_env(tmp_path))
176 assert result.exit_code == 0
177
178 assert not (tmp_path / "beat.mid").exists()
179
180 stage = read_stage(tmp_path)
181 assert stage["beat.mid"]["mode"] == "D"
182
183
184 def test_rm_json_no_cached(tmp_path: pathlib.Path) -> None:
185 """``muse rm --json`` emits cached=false."""
186 _init_repo(tmp_path)
187 _commit_files(tmp_path, {"f.txt": b"x\n"})
188
189 result = runner.invoke(cli, ["rm", "--json", "f.txt"], env=_env(tmp_path))
190 assert result.exit_code == 0
191 data = json.loads(result.output)
192 assert data["cached"] is False
193 assert data["count"] == 1
194
195
196 # ---------------------------------------------------------------------------
197 # File not tracked → exit 1
198 # ---------------------------------------------------------------------------
199
200
201 def test_rm_untracked_file_exits_1(tmp_path: pathlib.Path) -> None:
202 """Removing an untracked file must exit non-zero."""
203 _init_repo(tmp_path)
204 _commit_files(tmp_path, {"existing.txt": b"x\n"})
205
206 result = runner.invoke(
207 cli, ["rm", "does_not_exist.txt"], env=_env(tmp_path)
208 )
209 assert result.exit_code != 0
210
211
212 def test_rm_untracked_does_not_affect_stage(tmp_path: pathlib.Path) -> None:
213 """Attempting to remove an untracked file must not mutate the stage."""
214 _init_repo(tmp_path)
215 _commit_files(tmp_path, {"a.txt": b"a\n"})
216
217 runner.invoke(cli, ["rm", "ghost.txt"], env=_env(tmp_path))
218 stage = read_stage(tmp_path)
219 assert "a.txt" not in stage
220
221
222 def test_rm_error_json_untracked(tmp_path: pathlib.Path) -> None:
223 """``muse rm --json`` on an untracked file emits JSON with exit_code=1."""
224 _init_repo(tmp_path)
225 _commit_files(tmp_path, {"existing.txt": b"x\n"})
226
227 result = runner.invoke(
228 cli, ["rm", "--json", "ghost.txt"], env=_env(tmp_path)
229 )
230 assert result.exit_code != 0
231 # First line is JSON; remaining lines are stderr error messages.
232 data = json.loads(result.output.splitlines()[0])
233 assert data["exit_code"] == 1
234 assert data["status"] == "error"
235 assert data["count"] == 0
236 assert isinstance(data["duration_ms"], float)
237
238
239 # ---------------------------------------------------------------------------
240 # Directory without -r → exit 1
241 # ---------------------------------------------------------------------------
242
243
244 def test_rm_directory_without_recursive_exits_1(tmp_path: pathlib.Path) -> None:
245 """Removing a directory path without -r must exit non-zero."""
246 _init_repo(tmp_path)
247 _commit_files(tmp_path, {"src/main.py": b"pass\n"})
248
249 result = runner.invoke(cli, ["rm", "--cached", "src"], env=_env(tmp_path))
250 assert result.exit_code != 0
251
252
253 def test_rm_error_json_directory_without_r(tmp_path: pathlib.Path) -> None:
254 """``muse rm --json <dir>`` without -r emits JSON with exit_code=1."""
255 _init_repo(tmp_path)
256 _commit_files(tmp_path, {"src/main.py": b"pass\n"})
257
258 result = runner.invoke(cli, ["rm", "--cached", "--json", "src"], env=_env(tmp_path))
259 assert result.exit_code != 0
260 data = json.loads(result.output.splitlines()[0])
261 assert data["exit_code"] == 1
262 assert data["status"] == "error"
263
264
265 def test_rm_directory_with_recursive_stages_all(tmp_path: pathlib.Path) -> None:
266 """``muse rm -r --cached <dir>`` stages deletion for every file under dir."""
267 _init_repo(tmp_path)
268 _commit_files(
269 tmp_path,
270 {
271 "src/a.py": b"a\n",
272 "src/b.py": b"b\n",
273 "other.txt": b"c\n",
274 },
275 )
276
277 result = runner.invoke(
278 cli, ["rm", "-r", "--cached", "src"], env=_env(tmp_path)
279 )
280 assert result.exit_code == 0
281
282 stage = read_stage(tmp_path)
283 assert stage["src/a.py"]["mode"] == "D"
284 assert stage["src/b.py"]["mode"] == "D"
285 assert "other.txt" not in stage
286
287
288 # ---------------------------------------------------------------------------
289 # Modified file without --force → exit 1
290 # ---------------------------------------------------------------------------
291
292
293 def test_rm_modified_file_without_force_exits_1(tmp_path: pathlib.Path) -> None:
294 """Removing a locally-modified file without --force must exit non-zero."""
295 _init_repo(tmp_path)
296 _commit_files(tmp_path, {"track.txt": b"original\n"})
297 (tmp_path / "track.txt").write_bytes(b"modified\n")
298
299 result = runner.invoke(cli, ["rm", "track.txt"], env=_env(tmp_path))
300 assert result.exit_code != 0
301 assert (tmp_path / "track.txt").exists()
302
303
304 def test_rm_error_json_modified_without_force(tmp_path: pathlib.Path) -> None:
305 """``muse rm --json`` on a modified file emits JSON with exit_code=1."""
306 _init_repo(tmp_path)
307 _commit_files(tmp_path, {"track.txt": b"original\n"})
308 (tmp_path / "track.txt").write_bytes(b"modified\n")
309
310 result = runner.invoke(cli, ["rm", "--json", "track.txt"], env=_env(tmp_path))
311 assert result.exit_code != 0
312 data = json.loads(result.output.splitlines()[0])
313 assert data["exit_code"] == 1
314 assert data["status"] == "error"
315 assert isinstance(data["duration_ms"], float)
316
317
318 def test_rm_modified_file_with_force_succeeds(tmp_path: pathlib.Path) -> None:
319 """``muse rm --force`` removes a locally-modified file."""
320 _init_repo(tmp_path)
321 _commit_files(tmp_path, {"track.txt": b"original\n"})
322 (tmp_path / "track.txt").write_bytes(b"modified\n")
323
324 result = runner.invoke(cli, ["rm", "--force", "track.txt"], env=_env(tmp_path))
325 assert result.exit_code == 0
326 assert not (tmp_path / "track.txt").exists()
327 assert read_stage(tmp_path)["track.txt"]["mode"] == "D"
328
329
330 def test_rm_cached_modified_no_force_ok(tmp_path: pathlib.Path) -> None:
331 """``muse rm --cached`` on a modified file is always safe (no disk delete)."""
332 _init_repo(tmp_path)
333 _commit_files(tmp_path, {"track.txt": b"original\n"})
334 (tmp_path / "track.txt").write_bytes(b"modified\n")
335
336 result = runner.invoke(
337 cli, ["rm", "--cached", "track.txt"], env=_env(tmp_path)
338 )
339 assert result.exit_code == 0
340 assert (tmp_path / "track.txt").read_bytes() == b"modified\n"
341 assert read_stage(tmp_path)["track.txt"]["mode"] == "D"
342
343
344 # ---------------------------------------------------------------------------
345 # Staged-addition without --force → exit 1
346 # ---------------------------------------------------------------------------
347
348
349 def test_rm_staged_addition_without_force_exits_1(tmp_path: pathlib.Path) -> None:
350 """Removing a staged-but-never-committed file without --force exits non-zero."""
351 _init_repo(tmp_path)
352 (tmp_path / "new.py").write_bytes(b"print('hi')\n")
353 stage: StagedFileMap = {
354 "new.py": make_entry(object_id=blob_id(b"print('hi')\n"), mode="A"),
355 }
356 write_stage(tmp_path, stage)
357
358 result = runner.invoke(cli, ["rm", "--cached", "new.py"], env=_env(tmp_path))
359 assert result.exit_code != 0
360
361
362 def test_rm_staged_addition_with_force_removes_from_stage(
363 tmp_path: pathlib.Path,
364 ) -> None:
365 """``muse rm --force --cached`` removes a staged-addition entry from stage."""
366 _init_repo(tmp_path)
367 (tmp_path / "new.py").write_bytes(b"print('hi')\n")
368 stage: StagedFileMap = {
369 "new.py": make_entry(object_id=blob_id(b"print('hi')\n"), mode="A"),
370 }
371 write_stage(tmp_path, stage)
372
373 result = runner.invoke(
374 cli, ["rm", "--force", "--cached", "new.py"], env=_env(tmp_path)
375 )
376 assert result.exit_code == 0
377 assert "new.py" not in read_stage(tmp_path)
378
379
380 # ---------------------------------------------------------------------------
381 # --dry-run: no side effects
382 # ---------------------------------------------------------------------------
383
384
385 def test_rm_dry_run_does_not_delete(tmp_path: pathlib.Path) -> None:
386 """``muse rm -n`` must not delete the file or write to the stage."""
387 _init_repo(tmp_path)
388 _commit_files(tmp_path, {"chord.txt": b"Am\n"})
389
390 result = runner.invoke(cli, ["rm", "-n", "chord.txt"], env=_env(tmp_path))
391 assert result.exit_code == 0
392 assert (tmp_path / "chord.txt").exists()
393 assert "chord.txt" not in read_stage(tmp_path)
394
395
396 def test_rm_dry_run_json(tmp_path: pathlib.Path) -> None:
397 """``muse rm -n --json`` emits valid JSON with status=dry_run."""
398 _init_repo(tmp_path)
399 _commit_files(tmp_path, {"chord.txt": b"Am\n"})
400
401 result = runner.invoke(
402 cli, ["rm", "-n", "--json", "chord.txt"], env=_env(tmp_path)
403 )
404 assert result.exit_code == 0
405 data = json.loads(result.output)
406 assert data["status"] == "dry_run"
407 assert "chord.txt" in data["removed"]
408 assert data["dry_run"] is True
409 assert data["count"] == 1
410
411
412 def test_rm_dry_run_cached(tmp_path: pathlib.Path) -> None:
413 """``muse rm -n --cached`` previews correctly, writes nothing."""
414 _init_repo(tmp_path)
415 _commit_files(tmp_path, {"f.txt": b"x\n"})
416
417 result = runner.invoke(
418 cli, ["rm", "-n", "--cached", "--json", "f.txt"], env=_env(tmp_path)
419 )
420 assert result.exit_code == 0
421 data = json.loads(result.output)
422 assert data["status"] == "dry_run"
423 assert data["cached"] is True
424
425
426 def test_rm_dry_run_still_checks_safety(tmp_path: pathlib.Path) -> None:
427 """``--dry-run`` without ``--force`` still rejects modified files."""
428 _init_repo(tmp_path)
429 _commit_files(tmp_path, {"track.txt": b"original\n"})
430 (tmp_path / "track.txt").write_bytes(b"modified\n")
431
432 result = runner.invoke(cli, ["rm", "-n", "track.txt"], env=_env(tmp_path))
433 assert result.exit_code != 0
434 # File must still be on disk and stage untouched.
435 assert (tmp_path / "track.txt").exists()
436 assert "track.txt" not in read_stage(tmp_path)
437
438
439 def test_rm_dry_run_force_bypasses_safety(tmp_path: pathlib.Path) -> None:
440 """``--dry-run --force`` previews removal of a modified file without touching anything."""
441 _init_repo(tmp_path)
442 _commit_files(tmp_path, {"track.txt": b"original\n"})
443 (tmp_path / "track.txt").write_bytes(b"modified\n")
444
445 result = runner.invoke(
446 cli, ["rm", "-n", "-f", "--json", "track.txt"], env=_env(tmp_path)
447 )
448 assert result.exit_code == 0
449 data = json.loads(result.output)
450 assert data["status"] == "dry_run"
451 assert "track.txt" in data["removed"]
452 # Nothing written.
453 assert (tmp_path / "track.txt").exists()
454 assert "track.txt" not in read_stage(tmp_path)
455
456
457 # ---------------------------------------------------------------------------
458 # Multiple paths in one invocation
459 # ---------------------------------------------------------------------------
460
461
462 def test_rm_multiple_files(tmp_path: pathlib.Path) -> None:
463 """Multiple paths in one ``muse rm`` invocation are all removed."""
464 _init_repo(tmp_path)
465 _commit_files(
466 tmp_path,
467 {"a.txt": b"a\n", "b.txt": b"b\n", "c.txt": b"c\n"},
468 )
469
470 result = runner.invoke(
471 cli, ["rm", "--cached", "--json", "a.txt", "b.txt"], env=_env(tmp_path)
472 )
473 assert result.exit_code == 0
474 data = json.loads(result.output)
475 assert data["count"] == 2
476 assert "a.txt" in data["removed"]
477 assert "b.txt" in data["removed"]
478 assert "c.txt" not in data["removed"]
479
480 stage = read_stage(tmp_path)
481 assert stage["a.txt"]["mode"] == "D"
482 assert stage["b.txt"]["mode"] == "D"
483 assert "c.txt" not in stage
484
485
486 # ---------------------------------------------------------------------------
487 # Idempotency: remove already-staged-for-deletion
488 # ---------------------------------------------------------------------------
489
490
491 def test_rm_already_staged_deletion_is_idempotent(tmp_path: pathlib.Path) -> None:
492 """Calling ``muse rm --cached`` twice on the same file is idempotent."""
493 _init_repo(tmp_path)
494 _commit_files(tmp_path, {"x.txt": b"x\n"})
495
496 runner.invoke(cli, ["rm", "--cached", "x.txt"], env=_env(tmp_path))
497 result = runner.invoke(cli, ["rm", "--cached", "x.txt"], env=_env(tmp_path))
498 assert result.exit_code == 0
499 assert read_stage(tmp_path)["x.txt"]["mode"] == "D"
500
501
502 def test_rm_idempotent_json_still_valid(tmp_path: pathlib.Path) -> None:
503 """Re-removing an already-staged-D file with --json still emits valid JSON."""
504 _init_repo(tmp_path)
505 _commit_files(tmp_path, {"x.txt": b"x\n"})
506
507 runner.invoke(cli, ["rm", "--cached", "x.txt"], env=_env(tmp_path))
508 result = runner.invoke(cli, ["rm", "--cached", "--json", "x.txt"], env=_env(tmp_path))
509 assert result.exit_code == 0
510 data = json.loads(result.output)
511 assert data["status"] == "removed"
512 assert "x.txt" in data["removed"]
513 assert data["exit_code"] == 0
514
515
516 # ---------------------------------------------------------------------------
517 # duration_ms and exit_code in JSON
518 # ---------------------------------------------------------------------------
519
520
521 def test_rm_json_has_duration_ms(tmp_path: pathlib.Path) -> None:
522 """``muse rm --json`` includes duration_ms as a non-negative float."""
523 _init_repo(tmp_path)
524 _commit_files(tmp_path, {"t.txt": b"t\n"})
525
526 result = runner.invoke(cli, ["rm", "--cached", "--json", "t.txt"], env=_env(tmp_path))
527 assert result.exit_code == 0
528 data = json.loads(result.output)
529 assert "duration_ms" in data, "Missing duration_ms field"
530 assert isinstance(data["duration_ms"], float)
531 assert data["duration_ms"] >= 0.0
532
533
534 def test_rm_json_has_exit_code_zero_on_success(tmp_path: pathlib.Path) -> None:
535 """``muse rm --json`` includes exit_code=0 on success."""
536 _init_repo(tmp_path)
537 _commit_files(tmp_path, {"t.txt": b"t\n"})
538
539 result = runner.invoke(cli, ["rm", "--cached", "--json", "t.txt"], env=_env(tmp_path))
540 assert result.exit_code == 0
541 data = json.loads(result.output)
542 assert "exit_code" in data, "Missing exit_code field"
543 assert data["exit_code"] == 0
544
545
546 def test_rm_json_dry_run_has_duration_ms_and_exit_code(tmp_path: pathlib.Path) -> None:
547 """``muse rm -n --json`` also includes duration_ms and exit_code."""
548 _init_repo(tmp_path)
549 _commit_files(tmp_path, {"t.txt": b"t\n"})
550
551 result = runner.invoke(cli, ["rm", "-n", "--json", "t.txt"], env=_env(tmp_path))
552 assert result.exit_code == 0
553 data = json.loads(result.output)
554 assert data["duration_ms"] >= 0.0
555 assert data["exit_code"] == 0
556
557
558 # ---------------------------------------------------------------------------
559 # JSON schema completeness
560 # ---------------------------------------------------------------------------
561
562
563 def test_rm_json_schema_all_fields(tmp_path: pathlib.Path) -> None:
564 """JSON output always contains all required fields."""
565 _init_repo(tmp_path)
566 _commit_files(tmp_path, {"z.txt": b"z\n"})
567
568 result = runner.invoke(
569 cli, ["rm", "--json", "z.txt"], env=_env(tmp_path)
570 )
571 assert result.exit_code == 0
572 data = json.loads(result.output)
573 for key in ("status", "removed", "cached", "dry_run", "count", "duration_ms", "exit_code"):
574 assert key in data, f"Missing key: {key}"
575
576
577 # ---------------------------------------------------------------------------
578 # Security: path traversal and outside-repo paths
579 # ---------------------------------------------------------------------------
580
581
582 def test_rm_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
583 """A path that escapes the repo root via ``..`` must be rejected."""
584 _init_repo(tmp_path)
585 _commit_files(tmp_path, {"a.txt": b"a\n"})
586
587 result = runner.invoke(cli, ["rm", "../escape.txt"], env=_env(tmp_path))
588 assert result.exit_code != 0
589
590
591 def test_rm_absolute_path_outside_repo_rejected(tmp_path: pathlib.Path) -> None:
592 """An absolute path outside the repo must be rejected with exit 1."""
593 _init_repo(tmp_path)
594 _commit_files(tmp_path, {"a.txt": b"a\n"})
595
596 outside = tmp_path.parent / "outside.txt"
597 outside.write_text("should not be removed", encoding="utf-8")
598
599 result = runner.invoke(cli, ["rm", str(outside)], env=_env(tmp_path))
600 assert result.exit_code != 0
601 assert outside.exists(), "File outside repo must not be deleted"
602
603
604 # ---------------------------------------------------------------------------
605 # Edge case: file already gone from disk
606 # ---------------------------------------------------------------------------
607
608
609 def test_rm_file_already_deleted_from_disk(tmp_path: pathlib.Path) -> None:
610 """``muse rm`` on a tracked file that's already missing from disk stages deletion."""
611 _init_repo(tmp_path)
612 _commit_files(tmp_path, {"gone.txt": b"was here\n"})
613 # Manually remove from disk without going through muse rm.
614 (tmp_path / "gone.txt").unlink()
615
616 result = runner.invoke(cli, ["rm", "--cached", "gone.txt"], env=_env(tmp_path))
617 assert result.exit_code == 0
618 assert read_stage(tmp_path)["gone.txt"]["mode"] == "D"
619
620
621 def test_rm_disk_missing_no_cached_succeeds(tmp_path: pathlib.Path) -> None:
622 """``muse rm`` (no --cached) on a file already gone from disk just stages deletion."""
623 _init_repo(tmp_path)
624 _commit_files(tmp_path, {"gone.txt": b"was here\n"})
625 (tmp_path / "gone.txt").unlink()
626
627 result = runner.invoke(cli, ["rm", "gone.txt"], env=_env(tmp_path))
628 assert result.exit_code == 0
629 assert read_stage(tmp_path)["gone.txt"]["mode"] == "D"
630
631
632 # ---------------------------------------------------------------------------
633 # Recursive removal with disk delete
634 # ---------------------------------------------------------------------------
635
636
637 def test_rm_recursive_deletes_from_disk(tmp_path: pathlib.Path) -> None:
638 """``muse rm -r <dir>`` removes all tracked files under dir from disk."""
639 _init_repo(tmp_path)
640 _commit_files(
641 tmp_path,
642 {
643 "static/app.css": b"body{}\n",
644 "static/app.js": b"console.log(1)\n",
645 "src/main.py": b"pass\n",
646 },
647 )
648
649 result = runner.invoke(
650 cli, ["rm", "-r", "--json", "static"], env=_env(tmp_path)
651 )
652 assert result.exit_code == 0
653 data = json.loads(result.output)
654 assert data["count"] == 2
655 assert not (tmp_path / "static" / "app.css").exists()
656 assert not (tmp_path / "static" / "app.js").exists()
657 assert (tmp_path / "src" / "main.py").exists()
658
659 stage = read_stage(tmp_path)
660 assert stage["static/app.css"]["mode"] == "D"
661 assert stage["static/app.js"]["mode"] == "D"
662 assert "src/main.py" not in stage
663
664
665 # ---------------------------------------------------------------------------
666 # Unicode filenames
667 # ---------------------------------------------------------------------------
668
669
670 def test_rm_unicode_filename(tmp_path: pathlib.Path) -> None:
671 """``muse rm`` handles filenames with non-ASCII characters."""
672 _init_repo(tmp_path)
673 _commit_files(tmp_path, {"café.txt": "café content\n".encode("utf-8")})
674
675 result = runner.invoke(cli, ["rm", "--cached", "--json", "café.txt"], env=_env(tmp_path))
676 assert result.exit_code == 0
677 data = json.loads(result.output)
678 assert data["count"] == 1
679 assert read_stage(tmp_path)["café.txt"]["mode"] == "D"
680
681
682 # ---------------------------------------------------------------------------
683 # Data integrity: stage is valid msgpack after removal
684 # ---------------------------------------------------------------------------
685
686
687 def test_rm_stage_integrity_after_removal(tmp_path: pathlib.Path) -> None:
688 """The stage remains readable (valid msgpack) after ``muse rm``."""
689 _init_repo(tmp_path)
690 _commit_files(
691 tmp_path,
692 {"keep.txt": b"keep\n", "remove.txt": b"remove\n"},
693 )
694
695 runner.invoke(cli, ["rm", "--cached", "remove.txt"], env=_env(tmp_path))
696
697 # Stage must be readable and contain exactly the expected entry.
698 stage = read_stage(tmp_path)
699 assert "remove.txt" in stage
700 assert stage["remove.txt"]["mode"] == "D"
701 assert "keep.txt" not in stage
702
703
704 def test_rm_stage_cleared_when_last_staged_entry_removed(tmp_path: pathlib.Path) -> None:
705 """After removing all staged entries, the stage file is cleaned up."""
706 _init_repo(tmp_path)
707 _commit_files(tmp_path, {"only.txt": b"only\n"})
708
709 # Stage the deletion — this should leave only.txt as "D".
710 # Then do a second commit to move it to HEAD as deleted... but since we
711 # can't easily do that here, just verify that re-staging works cleanly.
712 runner.invoke(cli, ["rm", "--cached", "only.txt"], env=_env(tmp_path))
713 stage = read_stage(tmp_path)
714 # The file is in HEAD so it becomes mode D (not removed from stage entirely).
715 assert stage["only.txt"]["mode"] == "D"
716
717
718 # ---------------------------------------------------------------------------
719 # Stress: 200 files, remove half
720 # ---------------------------------------------------------------------------
721
722
723 def test_rm_stress_200_files(tmp_path: pathlib.Path) -> None:
724 """Remove 100 of 200 committed files; verify stage and disk state."""
725 _init_repo(tmp_path)
726 files = {
727 f"file_{i:04d}.txt": f"content {i}\n".encode()
728 for i in range(200)
729 }
730 _commit_files(tmp_path, files)
731
732 to_remove = [f"file_{i:04d}.txt" for i in range(200) if i % 2 == 0]
733 result = runner.invoke(
734 cli,
735 ["rm", "--cached", "--json"] + to_remove,
736 env=_env(tmp_path),
737 )
738 assert result.exit_code == 0
739 data = json.loads(result.output)
740 assert data["count"] == 100
741
742 stage = read_stage(tmp_path)
743 for i in range(200):
744 name = f"file_{i:04d}.txt"
745 if i % 2 == 0:
746 assert stage[name]["mode"] == "D"
747 else:
748 assert name not in stage
749
750 for name in files:
751 assert (tmp_path / name).exists()
752
753
754 def test_rm_stress_timing(tmp_path: pathlib.Path) -> None:
755 """Stress remove 50 files with --json; duration_ms is present and non-negative."""
756 _init_repo(tmp_path)
757 files = {f"file_{i:03d}.txt": f"x{i}\n".encode() for i in range(50)}
758 _commit_files(tmp_path, files)
759
760 result = runner.invoke(
761 cli,
762 ["rm", "--cached", "--json"] + list(files),
763 env=_env(tmp_path),
764 )
765 assert result.exit_code == 0
766 data = json.loads(result.output)
767 assert data["count"] == 50
768 assert isinstance(data["duration_ms"], float)
769 assert data["duration_ms"] >= 0.0
770
771
772 class TestRegisterFlags:
773 def test_default_json_out_is_false(self):
774 import argparse
775 from muse.cli.commands.rm import register
776 p = argparse.ArgumentParser()
777 subs = p.add_subparsers()
778 register(subs)
779 args = p.parse_args(["rm", "src/billing.py"])
780 assert args.json_out is False
781
782 def test_json_flag_sets_json_out(self):
783 import argparse
784 from muse.cli.commands.rm import register
785 p = argparse.ArgumentParser()
786 subs = p.add_subparsers()
787 register(subs)
788 args = p.parse_args(["rm", "src/billing.py", "--json"])
789 assert args.json_out is True
790
791 def test_j_shorthand_sets_json_out(self):
792 import argparse
793 from muse.cli.commands.rm import register
794 p = argparse.ArgumentParser()
795 subs = p.add_subparsers()
796 register(subs)
797 args = p.parse_args(["rm", "src/billing.py", "-j"])
798 assert args.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago