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