gabriel / muse public
test_cmd_code_add.py python
968 lines 37.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Comprehensive tests for ``muse code add`` and ``muse code reset``.
2
3 Review findings addressed
4 --------------------------
5 Security
6 * Path-traversal: staging a file outside the repo root is rejected.
7 * Symlink: symlinks are not followed during tree walks (followlinks=False).
8 * `.museignore`: ignored files are never staged even when explicitly named.
9
10 Performance
11 * Unchanged files (content = committed) are skipped — no object written.
12 * Already-staged files with the same content are skipped (idempotent).
13
14 New capabilities (added this review)
15 * ``--format json`` on ``muse code add`` — machine-readable output.
16 * ``--format json`` on ``muse code reset`` — machine-readable output.
17 * Breakdown summary in text output (N added, M modified, K deleted).
18
19 Stage persistence migration
20 * ``.muse/code/stage.json`` → ``.muse/code/stage.msgpack``.
21 * Legacy JSON is transparently migrated on first read.
22 * Corrupt msgpack clears stage and warns rather than silently returning {}.
23
24 Test categories
25 ---------------
26 I Security — path traversal, symlinks, ignore rules.
27 II JSON output — muse code add --format json.
28 III JSON output — muse code reset --format json.
29 IV Text output breakdown — "N added, M modified, K deleted".
30 V msgpack stage persistence — format, atomicity, migration.
31 VI Dry-run correctness — no writes, accurate preview.
32 VII Edge cases — fresh repo, no commits, multiple flags, cycles.
33 VIII Stress — 500-file staging, repeated cycles, large files.
34 """
35
36 from __future__ import annotations
37
38 import json
39 import os
40 import pathlib
41 import uuid
42
43 import msgpack
44 import pytest
45
46 from muse.plugins.code.stage import StagedEntry, read_stage, stage_path, write_stage, StagedFileMap
47 from muse.core._types import Manifest, blob_id, long_id, short_id, split_id
48 from muse.core.object_store import object_path
49 from tests.cli_test_helper import CliRunner
50
51 runner = CliRunner()
52 cli = None
53
54
55 # ---------------------------------------------------------------------------
56 # Helpers and fixtures
57 # ---------------------------------------------------------------------------
58
59
60 def _env(root: pathlib.Path) -> Manifest:
61 return {"MUSE_REPO_ROOT": str(root)}
62
63
64 def _run(root: pathlib.Path, *args: str) -> tuple[int, str]:
65 result = runner.invoke(cli, list(args), env=_env(root), catch_exceptions=False)
66 return result.exit_code, result.output
67
68
69 def _run_unchecked(root: pathlib.Path, *args: str) -> tuple[int, str]:
70 result = runner.invoke(cli, list(args), env=_env(root))
71 return result.exit_code, result.output
72
73
74 @pytest.fixture()
75 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
76 """Fresh code-domain repo with one committed file (main.py = 'x = 1')."""
77 monkeypatch.chdir(tmp_path)
78 r = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
79 assert r.exit_code == 0, r.output
80 (tmp_path / "main.py").write_text("x = 1\n")
81 r2 = runner.invoke(cli, ["commit", "--allow-empty", "-m", "init"], env=_env(tmp_path))
82 assert r2.exit_code == 0, r2.output
83 return tmp_path
84
85
86 # ===========================================================================
87 # I Security
88 # ===========================================================================
89
90
91 class TestSecurityI:
92 """Files outside the repo, symlinks, and ignored paths must never be staged."""
93
94 def test_I1_path_outside_repo_root_is_rejected(
95 self, repo: pathlib.Path
96 ) -> None:
97 """I1: staging a path outside the repo root exits non-zero."""
98 outside = repo.parent / f"secret_{uuid.uuid4().hex}.txt"
99 outside.write_text("secret\n")
100
101 code, _ = _run_unchecked(repo, "code", "add", str(outside))
102 assert code != 0 or str(outside) not in _read_stage(repo)
103
104 def test_I2_symlink_not_followed_during_dot_add(
105 self, repo: pathlib.Path
106 ) -> None:
107 """I2: symlinks to files outside the repo are never staged."""
108 outside = repo.parent / f"outside_{uuid.uuid4().hex}.txt"
109 outside.write_text("outside content\n")
110 link = repo / "link_to_outside.txt"
111 link.symlink_to(outside)
112
113 _run(repo, "code", "add", ".")
114 stage = _read_stage(repo)
115 assert "link_to_outside.txt" not in stage, "Symlink to outside must not be staged"
116
117 def test_I3_museignore_file_not_staged_by_dot(
118 self, repo: pathlib.Path
119 ) -> None:
120 """I3: .museignore exclusions are honoured by 'muse code add .'"""
121 # .museignore is TOML — use the proper section format.
122 (repo / ".museignore").write_text(
123 '[domain.code]\npatterns = ["*.secret"]\n'
124 )
125 (repo / "creds.secret").write_text("password=123\n")
126
127 _run(repo, "code", "add", ".")
128 stage = _read_stage(repo)
129 assert "creds.secret" not in stage, "Ignored file must not be staged"
130
131 def test_I4_museignore_file_not_staged_when_explicit(
132 self, repo: pathlib.Path
133 ) -> None:
134 """I4: even when explicitly named, .museignore exclusions prevent staging."""
135 (repo / ".museignore").write_text(
136 '[domain.code]\npatterns = ["private.py"]\n'
137 )
138 (repo / "private.py").write_text("SECRET = 'x'\n")
139
140 _run(repo, "code", "add", "private.py")
141 stage = _read_stage(repo)
142 assert "private.py" not in stage, "Explicitly named ignored file must not be staged"
143
144 def test_I5_hidden_files_staged_by_default(
145 self, repo: pathlib.Path
146 ) -> None:
147 """I5: hidden files (dotfiles) are staged by muse code add . (mirrors git behaviour)."""
148 (repo / ".env").write_text("API_KEY=secret\n")
149
150 _run(repo, "code", "add", ".")
151 stage = _read_stage(repo)
152 assert ".env" in stage, "Hidden .env must be staged by muse code add ."
153
154 def test_I6_pycache_not_staged(self, repo: pathlib.Path) -> None:
155 """I6: __pycache__ directories are never walked."""
156 cache = repo / "__pycache__"
157 cache.mkdir()
158 (cache / "main.cpython-311.pyc").write_bytes(b"\x00compiled\x00")
159
160 _run(repo, "code", "add", ".")
161 stage = _read_stage(repo)
162 for key in stage:
163 assert "__pycache__" not in key, f"Compiled cache file staged: {key}"
164
165 def test_I7_muse_dir_file_not_staged_by_dot(self, repo: pathlib.Path) -> None:
166 """I7: files inside .muse/ (VCS storage) are never staged by 'muse code add .'
167
168 Data-integrity invariant: the .muse/ directory is the VCS store itself.
169 Tracking its contents as repo files corrupts checkout — switching to a
170 branch whose snapshot omits them would delete live VCS internals from disk.
171 """
172 # agent-config writes these; they must never leak into the snapshot.
173 muse_dir = repo / ".muse"
174 (muse_dir / "agent.md").write_text("# agent config\n")
175 (muse_dir / "config.toml").write_text('[adapters]\nclaude = true\n')
176
177 _run(repo, "code", "add", ".")
178 stage = _read_stage(repo)
179 for key in stage:
180 assert not key.startswith(".muse/"), (
181 f"VCS-internal file leaked into stage: {key!r}"
182 )
183
184 def test_I8_muse_dir_file_not_staged_when_explicit(
185 self, repo: pathlib.Path
186 ) -> None:
187 """I8: explicitly naming a .muse/ file is silently rejected.
188
189 Data-integrity invariant: an agent that runs 'muse code add .muse/agent.md'
190 must not corrupt the snapshot. The file is silently dropped — same
191 treatment as a file outside the repo root.
192 """
193 muse_dir = repo / ".muse"
194 agent_md = muse_dir / "agent.md"
195 agent_md.write_text("# agent config\n")
196
197 _run(repo, "code", "add", ".muse/agent.md")
198 stage = _read_stage(repo)
199 assert ".muse/agent.md" not in stage, (
200 "Explicitly naming a .muse/ file must not add it to the stage"
201 )
202
203 def test_I9_muse_dir_subdir_not_staged_when_explicit(
204 self, repo: pathlib.Path
205 ) -> None:
206 """I9: passing .muse/ as a directory arg stages nothing from inside it."""
207 muse_dir = repo / ".muse"
208 (muse_dir / "agent.md").write_text("# config\n")
209
210 _run(repo, "code", "add", ".muse")
211 stage = _read_stage(repo)
212 for key in stage:
213 assert not key.startswith(".muse/"), (
214 f"VCS-internal file staged via directory arg: {key!r}"
215 )
216
217 def test_I10_muse_dir_not_staged_by_update_flag(
218 self, repo: pathlib.Path
219 ) -> None:
220 """I10: 'muse code add -u' re-staging head_manifest never includes .muse/ entries.
221
222 Defense-in-depth: if a .muse/ entry somehow reached the head manifest
223 (e.g. from a snapshot created before this fix), the -u path must still
224 silently drop it rather than perpetuating the corruption.
225 """
226 from muse.plugins.code.stage import write_stage, make_entry
227 from muse.core.snapshot import hash_file
228
229 # Plant a .muse/ file and force it into the head manifest via the
230 # stage, then commit — simulating the pre-fix corruption path.
231 muse_dir = repo / ".muse"
232 agent_md = muse_dir / "agent.md"
233 agent_md.write_text("# agent config\n")
234 oid = hash_file(agent_md)
235 # Write directly to stage (bypassing _collect_paths) to simulate
236 # the pre-fix scenario.
237 write_stage(repo, {".muse/agent.md": make_entry(oid, "A")})
238
239 # Commit will bake .muse/agent.md into the snapshot via the stage.
240 # After the commit we clear the stage and check that -u doesn't re-add it.
241 _run(repo, "commit", "-m", "simulate pre-fix corruption")
242
243 # Now .muse/agent.md is in head manifest. -u must not restage it.
244 _run(repo, "code", "add", "-u")
245 stage = _read_stage(repo)
246 for key in stage:
247 assert not key.startswith(".muse/"), (
248 f"muse code add -u re-staged VCS-internal file: {key!r}"
249 )
250
251 def test_I11_muse_dir_not_staged_by_all_flag(
252 self, repo: pathlib.Path
253 ) -> None:
254 """I11: 'muse code add -A' never stages .muse/ entries from head manifest."""
255 from muse.plugins.code.stage import write_stage, make_entry
256 from muse.core.snapshot import hash_file
257
258 muse_dir = repo / ".muse"
259 agent_md = muse_dir / "agent.md"
260 agent_md.write_text("# agent config\n")
261 oid = hash_file(agent_md)
262 write_stage(repo, {".muse/agent.md": make_entry(oid, "A")})
263 _run(repo, "commit", "-m", "simulate pre-fix corruption")
264
265 _run(repo, "code", "add", "-A")
266 stage = _read_stage(repo)
267 for key in stage:
268 assert not key.startswith(".muse/"), (
269 f"muse code add -A re-staged VCS-internal file: {key!r}"
270 )
271
272 def test_I12_snapshot_strips_muse_dir_entries_at_commit(
273 self, repo: pathlib.Path
274 ) -> None:
275 """I12: commit snapshot never contains .muse/ keys regardless of stage content.
276
277 Defense-in-depth at the snapshot layer: even if a .muse/ entry sneaks
278 into the stage (e.g. written directly by a third-party tool), the
279 snapshot built at commit time must strip it before persisting.
280 """
281 import json as _json
282 from muse.plugins.code.stage import write_stage, make_entry
283 from muse.core.snapshot import hash_file
284 from muse.core.store import get_head_commit_id
285 import msgpack
286
287 muse_dir = repo / ".muse"
288 agent_md = muse_dir / "agent.md"
289 agent_md.write_text("# agent config\n")
290 oid = hash_file(agent_md)
291 # Bypass _collect_paths and write directly to stage.
292 write_stage(repo, {".muse/agent.md": make_entry(oid, "A")})
293
294 _run(repo, "commit", "-m", "should strip .muse from snapshot")
295
296 # Read the snapshot the commit produced and verify it has no .muse/ keys.
297 commit_id = get_head_commit_id(repo, "main")
298 assert commit_id, "commit must have produced a HEAD"
299 _, hex_id = split_id(commit_id)
300 commit_file = next(
301 (repo / ".muse" / "commits").glob(f"sha256/{hex_id[:8]}*"),
302 None,
303 )
304 assert commit_file, f"commit file not found for {commit_id}"
305 commit_data = msgpack.unpackb(commit_file.read_bytes(), raw=False)
306 snap_id = commit_data["snapshot_id"]
307 _, snap_hex = split_id(snap_id)
308 snap_file = repo / ".muse" / "snapshots" / "sha256" / f"{snap_hex}.msgpack"
309 assert snap_file.exists(), "snapshot file must exist after commit"
310 snap_data = msgpack.unpackb(snap_file.read_bytes(), raw=False)
311 manifest = snap_data.get("manifest", {})
312 muse_keys = [k for k in manifest if k.startswith(".muse/")]
313 assert not muse_keys, (
314 f"Snapshot contains VCS-internal keys: {muse_keys}"
315 )
316
317
318 # ===========================================================================
319 # II JSON output — muse code add --format json
320 # ===========================================================================
321
322
323 class TestJsonOutputAddII:
324 """``muse code add --format json`` must emit valid, complete JSON."""
325
326 def test_II1_json_output_on_single_file_staged(
327 self, repo: pathlib.Path
328 ) -> None:
329 """II1: staging one file emits correct JSON with all required keys."""
330 (repo / "main.py").write_text("x = 2\n")
331
332 code, out = _run(repo, "code", "add", "--json", "main.py")
333 assert code == 0, out
334 data = json.loads(out.strip())
335 assert data["staged"] == 1
336 assert data["modified"] == 1
337 assert data["added"] == 0
338 assert data["deleted"] == 0
339 assert data["dry_run"] is False
340 assert any(f["path"] == "main.py" for f in data["files"])
341
342 def test_II2_json_output_new_file_is_added(
343 self, repo: pathlib.Path
344 ) -> None:
345 """II2: a brand-new file has mode 'new file' in JSON output."""
346 (repo / "brand_new.py").write_text("y = 99\n")
347
348 code, out = _run(repo, "code", "add", "--json", "brand_new.py")
349 assert code == 0, out
350 data = json.loads(out.strip())
351 assert data["added"] == 1
352 assert data["modified"] == 0
353 file_entry = next(f for f in data["files"] if f["path"] == "brand_new.py")
354 assert file_entry["mode"] == "new file"
355
356 def test_II3_json_output_deletion_counted(
357 self, repo: pathlib.Path
358 ) -> None:
359 """II3: staging a deletion records deleted=1 in JSON."""
360 (repo / "main.py").unlink()
361
362 code, out = _run(repo, "code", "add", "-u", "--json")
363 assert code == 0, out
364 data = json.loads(out.strip())
365 assert data["deleted"] == 1
366 assert any(f["mode"] == "deleted" for f in data["files"])
367
368 def test_II4_json_output_nothing_to_stage(
369 self, repo: pathlib.Path
370 ) -> None:
371 """II4: nothing to stage returns staged=0, not an error."""
372 # main.py is already at committed content — nothing to stage.
373 code, out = _run(repo, "code", "add", "--json", ".")
374 assert code == 0, out
375 data = json.loads(out.strip())
376 assert data["staged"] == 0
377
378 def test_II5_json_dry_run_flag_true(self, repo: pathlib.Path) -> None:
379 """II5: --dry-run sets dry_run=true in JSON and writes no stage."""
380 (repo / "main.py").write_text("# dry\n")
381
382 code, out = _run(
383 repo, "code", "add", "--dry-run", "--json", "main.py"
384 )
385 assert code == 0, out
386 data = json.loads(out.strip())
387 assert data["dry_run"] is True
388 assert data["staged"] == 1
389 assert not stage_path(repo).exists()
390
391 def test_II6_json_output_multiple_files(
392 self, repo: pathlib.Path
393 ) -> None:
394 """II6: multiple staged files all appear in the files list."""
395 for i in range(5):
396 (repo / f"f{i}.py").write_text(f"v = {i}\n")
397
398 code, out = _run(repo, "code", "add", "--json", "-A")
399 assert code == 0, out
400 data = json.loads(out.strip())
401 assert data["staged"] >= 5
402 paths = {f["path"] for f in data["files"]}
403 for i in range(5):
404 assert f"f{i}.py" in paths
405
406 def test_II7_json_output_is_valid_json(self, repo: pathlib.Path) -> None:
407 """II7: output is always parseable JSON, never raw text."""
408 (repo / "main.py").write_text("# changed\n")
409 _, out = _run(repo, "code", "add", "--json", "main.py")
410 json.loads(out.strip()) # must not raise
411
412
413 # ===========================================================================
414 # III JSON output — muse code reset --format json
415 # ===========================================================================
416
417
418 class TestJsonOutputResetIII:
419 """``muse code reset --format json`` must emit valid, complete JSON."""
420
421 def test_III1_json_reset_specific_file(self, repo: pathlib.Path) -> None:
422 """III1: resetting a staged file returns unstaged=1 in JSON."""
423 (repo / "main.py").write_text("# staged\n")
424 _run(repo, "code", "add", "main.py")
425
426 code, out = _run(repo, "code", "reset", "--json", "main.py")
427 assert code == 0, out
428 data = json.loads(out.strip())
429 assert data["unstaged"] == 1
430 assert "main.py" in data["files"]
431
432 def test_III2_json_reset_all(self, repo: pathlib.Path) -> None:
433 """III2: reset with no args clears all staged files, reports count in JSON."""
434 for i in range(3):
435 (repo / f"f{i}.py").write_text(f"x = {i}\n")
436 _run(repo, "code", "add", "-A")
437
438 code, out = _run(repo, "code", "reset", "--json")
439 assert code == 0, out
440 data = json.loads(out.strip())
441 assert data["unstaged"] >= 3
442
443 def test_III3_json_reset_nothing_staged(self, repo: pathlib.Path) -> None:
444 """III3: reset with nothing staged returns unstaged=0 in JSON."""
445 code, out = _run(repo, "code", "reset", "--json")
446 assert code == 0, out
447 data = json.loads(out.strip())
448 assert data["unstaged"] == 0
449 assert data["files"] == []
450
451 def test_III4_json_reset_preserves_other_staged_files(
452 self, repo: pathlib.Path
453 ) -> None:
454 """III4: resetting one file leaves others staged."""
455 (repo / "main.py").write_text("# changed\n")
456 (repo / "other.py").write_text("y = 9\n")
457 _run(repo, "code", "add", "-A")
458
459 code, out = _run(repo, "code", "reset", "--json", "other.py")
460 assert code == 0, out
461 data = json.loads(out.strip())
462 assert data["unstaged"] == 1
463 assert "other.py" in data["files"]
464
465 remaining = read_stage(repo)
466 assert "main.py" in remaining, "main.py must still be staged"
467 assert "other.py" not in remaining
468
469
470 # ===========================================================================
471 # IV Text output breakdown
472 # ===========================================================================
473
474
475 class TestTextOutputBreakdownIV:
476 """The text summary must show a breakdown: N added, M modified, K deleted."""
477
478 def test_IV1_text_shows_added_count(self, repo: pathlib.Path) -> None:
479 """IV1: new files appear in 'added' part of the breakdown."""
480 (repo / "new.py").write_text("z = 0\n")
481 _, out = _run(repo, "code", "add", "new.py")
482 assert "added" in out
483
484 def test_IV2_text_shows_modified_count(self, repo: pathlib.Path) -> None:
485 """IV2: modified tracked files appear in 'modified' part."""
486 (repo / "main.py").write_text("x = 999\n")
487 _, out = _run(repo, "code", "add", "main.py")
488 assert "modified" in out
489
490 def test_IV3_text_shows_deleted_count(self, repo: pathlib.Path) -> None:
491 """IV3: staged deletions appear in 'deleted' part."""
492 (repo / "main.py").unlink()
493 _, out = _run(repo, "code", "add", "-u")
494 assert "deleted" in out
495
496 def test_IV4_text_nothing_to_stage_message(
497 self, repo: pathlib.Path
498 ) -> None:
499 """IV4: when nothing changed, output explains nothing to stage."""
500 _, out = _run(repo, "code", "add", ".")
501 assert "Nothing" in out or "already up to date" in out
502
503 def test_IV5_text_breakdown_counts_match_actual(
504 self, repo: pathlib.Path
505 ) -> None:
506 """IV5: text breakdown totals match what was actually staged."""
507 (repo / "main.py").write_text("x = 2\n") # modified
508 (repo / "a.py").write_text("a = 1\n") # new
509 (repo / "b.py").write_text("b = 2\n") # new
510
511 _, out = _run(repo, "code", "add", "-A")
512 assert "1 modified" in out
513 assert "2 added" in out
514
515
516 # ===========================================================================
517 # V msgpack stage persistence
518 # ===========================================================================
519
520
521 class TestMsgpackPersistenceV:
522 """The stage index must be persisted as msgpack and survive round-trips."""
523
524 def test_V1_stage_file_is_msgpack_not_json(
525 self, repo: pathlib.Path
526 ) -> None:
527 """V1: after staging, the file on disk is valid msgpack, not JSON."""
528 (repo / "main.py").write_text("x = 9\n")
529 _run(repo, "code", "add", "main.py")
530
531 path = stage_path(repo)
532 assert path.exists(), "stage.msgpack must exist after staging"
533 raw = path.read_bytes()
534 assert not raw.startswith(b"{"), "Stage file must not be JSON"
535 data = msgpack.unpackb(raw, raw=False)
536 assert "entries" in data
537 assert "main.py" in data["entries"]
538
539 def test_V2_stage_round_trips_all_entry_fields(
540 self, repo: pathlib.Path
541 ) -> None:
542 """V2: object_id, mode, and staged_at survive a write/read cycle."""
543 (repo / "main.py").write_text("x = 42\n")
544 _run(repo, "code", "add", "main.py")
545
546 stage = read_stage(repo)
547 entry = stage["main.py"]
548 assert entry["object_id"].startswith("sha256:") and len(entry["object_id"]) == 71, \
549 "object_id must be a canonical long_id (sha256:<64hex>)"
550 assert entry["mode"] in ("A", "M", "D")
551 assert entry["staged_at"]
552
553 def test_V3_stage_atomic_write_no_tmp_file_after_success(
554 self, repo: pathlib.Path
555 ) -> None:
556 """V3: no .stage-tmp-* file lingers after a successful write."""
557 (repo / "main.py").write_text("x = 1\n")
558 _run(repo, "code", "add", "main.py")
559
560 stage_dir = repo / ".muse" / "code"
561 tmps = list(stage_dir.glob(".stage-tmp-*"))
562 assert tmps == [], f"Stale tmp files: {tmps}"
563
564 def test_V5_corrupt_msgpack_clears_and_returns_empty(
565 self, repo: pathlib.Path
566 ) -> None:
567 """V5: corrupt msgpack is deleted and read_stage returns {}."""
568 stage_dir = repo / ".muse" / "code"
569 stage_dir.mkdir(parents=True, exist_ok=True)
570 stage_path(repo).write_bytes(b"\xde\xad\xbe\xef garbage")
571
572 entries = read_stage(repo)
573 assert entries == {}
574 assert not stage_path(repo).exists(), "Corrupt stage file must be removed"
575
576 def test_V6_write_empty_removes_msgpack_file(
577 self, repo: pathlib.Path
578 ) -> None:
579 """V6: write_stage({}) removes stage.msgpack (clear the stage)."""
580 # Change main.py so it's different from the committed content.
581 (repo / "main.py").write_text("x = 999\n")
582 _run(repo, "code", "add", "main.py")
583 assert stage_path(repo).exists(), "Stage must exist after staging a changed file"
584
585 write_stage(repo, {})
586 assert not stage_path(repo).exists()
587
588 def test_V7_stage_version_is_2_in_msgpack(
589 self, repo: pathlib.Path
590 ) -> None:
591 """V7: msgpack file carries version=2."""
592 (repo / "main.py").write_text("x = 999\n")
593 _run(repo, "code", "add", "main.py")
594 assert stage_path(repo).exists(), "Stage must exist after staging"
595
596 raw = msgpack.unpackb(stage_path(repo).read_bytes(), raw=False)
597 assert raw["version"] == 2
598
599
600 # ===========================================================================
601 # VI Dry-run correctness
602 # ===========================================================================
603
604
605 class TestDryRunVI:
606 """--dry-run must preview accurately and never write anything."""
607
608 def test_VI1_dry_run_lists_files_that_would_be_staged(
609 self, repo: pathlib.Path
610 ) -> None:
611 """VI1: output lists every file that would be staged."""
612 (repo / "main.py").write_text("x = 3\n")
613 (repo / "new.py").write_text("y = 0\n")
614
615 _, out = _run(repo, "code", "add", "--dry-run", "-A")
616 assert "main.py" in out
617 assert "new.py" in out
618
619 def test_VI2_dry_run_does_not_write_stage_file(
620 self, repo: pathlib.Path
621 ) -> None:
622 """VI2: after dry-run, stage.msgpack must not exist."""
623 (repo / "main.py").write_text("x = 3\n")
624 _run(repo, "code", "add", "--dry-run", "main.py")
625 assert not stage_path(repo).exists()
626
627 def test_VI3_dry_run_does_not_write_objects(
628 self, repo: pathlib.Path
629 ) -> None:
630 """VI3: dry-run must not write any blobs to the object store."""
631 content = b"brand new content\n"
632 (repo / "brand_new.py").write_bytes(content)
633 oid = blob_id(content)
634 obj_path = object_path(repo, oid)
635
636 _run(repo, "code", "add", "--dry-run", "brand_new.py")
637 assert not obj_path.exists(), "Dry-run must not write objects to the store"
638
639 def test_VI4_dry_run_json_shows_correct_counts(
640 self, repo: pathlib.Path
641 ) -> None:
642 """VI4: --dry-run --format json shows accurate counts."""
643 (repo / "main.py").write_text("x = 5\n") # modified
644 (repo / "extra.py").write_text("z = 0\n") # new
645
646 _, out = _run(
647 repo, "code", "add", "--dry-run", "--json", "-A"
648 )
649 data = json.loads(out.strip())
650 assert data["dry_run"] is True
651 assert data["modified"] >= 1
652 assert data["added"] >= 1
653
654 def test_VI5_dry_run_output_stable_across_runs(
655 self, repo: pathlib.Path
656 ) -> None:
657 """VI5: running dry-run twice on the same tree produces identical output."""
658 (repo / "main.py").write_text("x = 7\n")
659
660 _, out1 = _run(repo, "code", "add", "--dry-run", "--json", ".")
661 _, out2 = _run(repo, "code", "add", "--dry-run", "--json", ".")
662 _volatile = {"duration_ms", "timestamp"}
663 d1 = {k: v for k, v in json.loads(out1).items() if k not in _volatile}
664 d2 = {k: v for k, v in json.loads(out2).items() if k not in _volatile}
665 assert d1 == d2
666
667
668 # ===========================================================================
669 # VII Edge cases
670 # ===========================================================================
671
672
673 class TestEdgeCasesVII:
674 """Edge cases: fresh repo, no commits, conflicting flags, etc."""
675
676 def test_VII1_stage_on_fresh_repo_no_commits(
677 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
678 ) -> None:
679 """VII1: staging works on a repo with no prior commits."""
680 monkeypatch.chdir(tmp_path)
681 runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
682 (tmp_path / "first.py").write_text("x = 1\n")
683
684 code, out = _run(tmp_path, "code", "add", "first.py")
685 assert code == 0, out
686 stage = read_stage(tmp_path)
687 assert "first.py" in stage
688 assert stage["first.py"]["mode"] == "A"
689
690 def test_VII2_staging_identical_content_is_idempotent(
691 self, repo: pathlib.Path
692 ) -> None:
693 """VII2: staging the same file twice with identical content is a no-op."""
694 (repo / "main.py").write_text("x = 10\n")
695 _run(repo, "code", "add", "main.py")
696
697 code, out = _run(repo, "code", "add", "main.py")
698 assert code == 0
699 assert "already up to date" in out or "Nothing" in out
700
701 def test_VII3_restaging_after_modification_updates_object_id(
702 self, repo: pathlib.Path
703 ) -> None:
704 """VII3: re-staging a file after modification updates the object_id."""
705 (repo / "main.py").write_text("v1\n")
706 _run(repo, "code", "add", "main.py")
707 oid_v1 = read_stage(repo)["main.py"]["object_id"]
708
709 (repo / "main.py").write_text("v2\n")
710 _run(repo, "code", "add", "main.py")
711 oid_v2 = read_stage(repo)["main.py"]["object_id"]
712
713 assert oid_v1 != oid_v2
714
715 def test_VII4_nonexistent_path_exits_nonzero(
716 self, repo: pathlib.Path
717 ) -> None:
718 """VII4: staging a non-existent, untracked path exits non-zero."""
719 code, _ = _run_unchecked(repo, "code", "add", "ghost.py")
720 assert code != 0
721
722 def test_VII5_directory_scoped_add_leaves_top_level_unstaged(
723 self, repo: pathlib.Path
724 ) -> None:
725 """VII5: 'muse code add subdir' stages only files under that directory."""
726 sub = repo / "sub"
727 sub.mkdir()
728 (sub / "a.py").write_text("a = 1\n")
729 (repo / "top.py").write_text("t = 1\n")
730
731 _run(repo, "code", "add", "sub")
732 stage = read_stage(repo)
733 assert "sub/a.py" in stage
734 assert "top.py" not in stage
735
736 def test_VII6_verbose_shows_per_file_mode(
737 self, repo: pathlib.Path
738 ) -> None:
739 """VII6: --verbose shows one line per staged file."""
740 (repo / "main.py").write_text("x = 2\n")
741 _, out = _run(repo, "code", "add", "-v", "main.py")
742 assert "main.py" in out
743
744 def test_VII7_reset_HEAD_syntax_alias(self, repo: pathlib.Path) -> None:
745 """VII7: 'muse code reset HEAD <file>' is identical to 'muse code reset <file>'."""
746 (repo / "main.py").write_text("x = 3\n")
747 _run(repo, "code", "add", "main.py")
748
749 code, _ = _run(repo, "code", "reset", "HEAD", "main.py")
750 assert code == 0
751 assert not stage_path(repo).exists()
752
753 def test_VII8_stage_then_commit_then_restage_works(
754 self, repo: pathlib.Path
755 ) -> None:
756 """VII8: full stage → commit → re-stage cycle works end-to-end."""
757 (repo / "main.py").write_text("x = 5\n")
758 _run(repo, "code", "add", "main.py")
759 _run(repo, "commit", "-m", "v2")
760
761 assert not stage_path(repo).exists()
762
763 (repo / "main.py").write_text("x = 6\n")
764 code, out = _run(repo, "code", "add", "main.py")
765 assert code == 0
766 assert "main.py" in read_stage(repo)
767
768 def test_VII9_update_flag_includes_modifications_not_new(
769 self, repo: pathlib.Path
770 ) -> None:
771 """VII9: -u stages tracked modifications but not new untracked files."""
772 (repo / "main.py").write_text("x = 99\n") # tracked, modified
773 (repo / "untracked.py").write_text("u = 0\n") # new, untracked
774
775 _run(repo, "code", "add", "-u")
776 stage = read_stage(repo)
777 assert "main.py" in stage
778 assert "untracked.py" not in stage
779
780
781 # ===========================================================================
782 # VIII Stress tests
783 # ===========================================================================
784
785
786 class TestStressVIII:
787 """High-volume and adversarial scenarios."""
788
789 def test_VIII1_stage_500_files_correct_count(
790 self, repo: pathlib.Path
791 ) -> None:
792 """VIII1: staging 500 files produces 500 entries in the stage index."""
793 for i in range(500):
794 (repo / f"module_{i:04d}.py").write_text(f"X = {i}\n")
795
796 code, out = _run(repo, "code", "add", "-A")
797 assert code == 0, out
798 stage = read_stage(repo)
799 assert len(stage) >= 500
800
801 def test_VIII2_500_files_json_output_correct(
802 self, repo: pathlib.Path
803 ) -> None:
804 """VIII2: JSON output for 500 files has correct counts."""
805 for i in range(500):
806 (repo / f"f_{i:04d}.py").write_text(f"X = {i}\n")
807
808 _, out = _run(repo, "code", "add", "-A", "--json")
809 data = json.loads(out.strip())
810 assert data["added"] >= 500
811 assert data["staged"] >= 500
812
813 def test_VIII3_stage_add_reset_cycle_50_times(
814 self, repo: pathlib.Path
815 ) -> None:
816 """VIII3: 50 add/reset cycles leave a clean stage each time."""
817 (repo / "main.py").write_text("x = 0\n")
818
819 for cycle in range(50):
820 (repo / "main.py").write_text(f"x = {cycle}\n")
821 code, _ = _run(repo, "code", "add", "main.py")
822 assert code == 0, f"Cycle {cycle}: add failed"
823
824 code, _ = _run(repo, "code", "reset", "main.py")
825 assert code == 0, f"Cycle {cycle}: reset failed"
826 assert not stage_path(repo).exists(), (
827 f"Cycle {cycle}: stage not cleared after reset"
828 )
829
830 def test_VIII4_large_file_stages_correctly(
831 self, repo: pathlib.Path
832 ) -> None:
833 """VIII4: a 5 MiB file stages and its object_id is correct."""
834 content = os.urandom(5 * 1024 * 1024)
835 (repo / "big.bin").write_bytes(content)
836
837 code, _ = _run(repo, "code", "add", "big.bin")
838 assert code == 0
839
840 stage = read_stage(repo)
841 assert "big.bin" in stage
842 expected_oid = blob_id(content)
843 assert stage["big.bin"]["object_id"] == expected_oid
844
845 def test_VIII5_all_modes_in_single_add(
846 self, repo: pathlib.Path
847 ) -> None:
848 """VIII5: a single add can capture added, modified, and deleted in one shot."""
849 # Add extra tracked file and commit first.
850 (repo / "to_delete.py").write_text("del = 1\n")
851 _run(repo, "code", "add", "to_delete.py")
852 _run(repo, "commit", "-m", "add to_delete")
853
854 (repo / "main.py").write_text("x = modified\n")
855 (repo / "to_delete.py").unlink()
856 (repo / "brand_new.py").write_text("new = True\n")
857
858 code, out = _run(repo, "code", "add", "--json", "-A")
859 assert code == 0, out
860 data = json.loads(out.strip())
861 assert data["modified"] >= 1
862 assert data["added"] >= 1
863 assert data["deleted"] >= 1
864
865 def test_VIII6_staging_after_many_commits_works(
866 self, repo: pathlib.Path
867 ) -> None:
868 """VIII6: staging still works correctly after many commits."""
869 for i in range(50):
870 (repo / "main.py").write_text(f"x = {i}\n")
871 _run(repo, "commit", "--allow-empty", "-m", f"commit {i}")
872
873 (repo / "main.py").write_text("x = final\n")
874 code, _ = _run(repo, "code", "add", "main.py")
875 assert code == 0
876 stage = read_stage(repo)
877 assert "main.py" in stage
878
879
880 # ===========================================================================
881 # IX Stat-cache performance — muse code add must use StatCache, not hash_file
882 # ===========================================================================
883
884
885 class TestStatCacheIX:
886 """muse code add must use the stat cache, not raw hash_file on every call."""
887
888 def test_IX1_stat_cache_used_structurally(self) -> None:
889 """IX1: code_stage module must import and use StatCache or load_cache."""
890 import inspect
891 from muse.cli.commands import code_stage as cs_module
892
893 source = inspect.getsource(cs_module)
894 assert "load_cache" in source or "StatCache" in source, (
895 "code_stage must import and use load_cache or StatCache for hashing"
896 )
897
898 def test_IX2_hash_file_not_called_on_unchanged_file(
899 self, repo: pathlib.Path
900 ) -> None:
901 """IX2: second code add on unchanged file must not rehash from disk.
902
903 After the first add the stat cache has a valid entry. The second add
904 must return the cached hash without calling _hash_str again.
905 """
906 from unittest.mock import patch
907
908 (repo / "cached.txt").write_text("stable content\n")
909 # First add — computes and caches the hash.
910 code, _ = _run(repo, "code", "add", "cached.txt")
911 assert code == 0
912
913 # Reset stage so the file is re-evaluated on the second add.
914 _run(repo, "code", "reset", "cached.txt")
915
916 # Second add — must hit the cache; _hash_str must NOT be called.
917 with patch("muse.core.stat_cache._hash_str") as mock_hash:
918 code2, _ = _run(repo, "code", "add", "cached.txt")
919
920 assert code2 == 0
921 mock_hash.assert_not_called(), (
922 "second code add on unchanged file called _hash_str — stat cache not used"
923 )
924
925 def test_IX3_stat_cache_file_written_after_add(
926 self, repo: pathlib.Path
927 ) -> None:
928 """IX3: stat_cache.msgpack must exist after code add (cache was saved)."""
929 (repo / "new_file.py").write_text("y = 2\n")
930 code, _ = _run(repo, "code", "add", "new_file.py")
931 assert code == 0
932 cache_path = repo / ".muse" / "stat_cache.msgpack"
933 assert cache_path.exists(), (
934 "stat_cache.msgpack not found — cache.save() not called after code add"
935 )
936
937 def test_IX4_modified_file_is_rehashed(
938 self, repo: pathlib.Path
939 ) -> None:
940 """IX4: modifying a file invalidates the cache entry so it is rehashed."""
941 from unittest.mock import patch
942 import muse.core.stat_cache as _sc
943
944 (repo / "mutable.py").write_text("v = 1\n")
945 _run(repo, "code", "add", "mutable.py")
946 _run(repo, "code", "reset", "mutable.py")
947
948 # Modify the file — mtime/size change → cache miss.
949 (repo / "mutable.py").write_text("v = 2\n")
950
951 # Spy on _hash_str but let the real function run so object_store
952 # integrity checks still pass.
953 with patch.object(_sc, "_hash_str", wraps=_sc._hash_str) as mock_hash:
954 code, _ = _run(repo, "code", "add", "mutable.py")
955
956 assert code == 0
957 mock_hash.assert_called(), (
958 "modified file should trigger a _hash_str call (cache miss)"
959 )
960
961
962 # ---------------------------------------------------------------------------
963 # Helper
964 # ---------------------------------------------------------------------------
965
966
967 def _read_stage(root: pathlib.Path) -> StagedFileMap:
968 return read_stage(root)
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 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 141 days ago