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