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