gabriel / muse public
test_cmd_code_add.py python
982 lines 37.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 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 hashlib
39 import json
40 import os
41 import pathlib
42 import uuid
43
44 import msgpack
45 import pytest
46
47 from muse.plugins.code.stage import StagedEntry, read_stage, stage_path, write_stage, StagedFileMap
48 from muse.core._types import Manifest
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_not_staged_by_default(
145 self, repo: pathlib.Path
146 ) -> None:
147 """I5: hidden files (dotfiles) are skipped by _walk_tree."""
148 (repo / ".env").write_text("API_KEY=secret\n")
149
150 _run(repo, "code", "add", ".")
151 stage = _read_stage(repo)
152 assert ".env" not in stage, "Hidden .env must not be staged"
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 commit_file = next(
300 (repo / ".muse" / "commits").glob(f"{commit_id[:8]}*"),
301 None,
302 )
303 assert commit_file, f"commit file not found for {commit_id[:8]}"
304 commit_data = msgpack.unpackb(commit_file.read_bytes(), raw=False)
305 snap_id = commit_data["snapshot_id"]
306 snap_file = repo / ".muse" / "snapshots" / f"{snap_id}.msgpack"
307 assert snap_file.exists(), "snapshot file must exist after commit"
308 snap_data = msgpack.unpackb(snap_file.read_bytes(), raw=False)
309 manifest = snap_data.get("manifest", {})
310 muse_keys = [k for k in manifest if k.startswith(".muse/")]
311 assert not muse_keys, (
312 f"Snapshot contains VCS-internal keys: {muse_keys}"
313 )
314
315
316 # ===========================================================================
317 # II JSON output — muse code add --format json
318 # ===========================================================================
319
320
321 class TestJsonOutputAddII:
322 """``muse code add --format json`` must emit valid, complete JSON."""
323
324 def test_II1_json_output_on_single_file_staged(
325 self, repo: pathlib.Path
326 ) -> None:
327 """II1: staging one file emits correct JSON with all required keys."""
328 (repo / "main.py").write_text("x = 2\n")
329
330 code, out = _run(repo, "code", "add", "--format", "json", "main.py")
331 assert code == 0, out
332 data = json.loads(out.strip())
333 assert data["staged"] == 1
334 assert data["modified"] == 1
335 assert data["added"] == 0
336 assert data["deleted"] == 0
337 assert data["dry_run"] is False
338 assert any(f["path"] == "main.py" for f in data["files"])
339
340 def test_II2_json_output_new_file_is_added(
341 self, repo: pathlib.Path
342 ) -> None:
343 """II2: a brand-new file has mode 'new file' in JSON output."""
344 (repo / "brand_new.py").write_text("y = 99\n")
345
346 code, out = _run(repo, "code", "add", "--format", "json", "brand_new.py")
347 assert code == 0, out
348 data = json.loads(out.strip())
349 assert data["added"] == 1
350 assert data["modified"] == 0
351 file_entry = next(f for f in data["files"] if f["path"] == "brand_new.py")
352 assert file_entry["mode"] == "new file"
353
354 def test_II3_json_output_deletion_counted(
355 self, repo: pathlib.Path
356 ) -> None:
357 """II3: staging a deletion records deleted=1 in JSON."""
358 (repo / "main.py").unlink()
359
360 code, out = _run(repo, "code", "add", "-u", "--format", "json")
361 assert code == 0, out
362 data = json.loads(out.strip())
363 assert data["deleted"] == 1
364 assert any(f["mode"] == "deleted" for f in data["files"])
365
366 def test_II4_json_output_nothing_to_stage(
367 self, repo: pathlib.Path
368 ) -> None:
369 """II4: nothing to stage returns staged=0, not an error."""
370 # main.py is already at committed content — nothing to stage.
371 code, out = _run(repo, "code", "add", "--format", "json", ".")
372 assert code == 0, out
373 data = json.loads(out.strip())
374 assert data["staged"] == 0
375
376 def test_II5_json_dry_run_flag_true(self, repo: pathlib.Path) -> None:
377 """II5: --dry-run sets dry_run=true in JSON and writes no stage."""
378 (repo / "main.py").write_text("# dry\n")
379
380 code, out = _run(
381 repo, "code", "add", "--dry-run", "--format", "json", "main.py"
382 )
383 assert code == 0, out
384 data = json.loads(out.strip())
385 assert data["dry_run"] is True
386 assert data["staged"] == 1
387 assert not stage_path(repo).exists()
388
389 def test_II6_json_output_multiple_files(
390 self, repo: pathlib.Path
391 ) -> None:
392 """II6: multiple staged files all appear in the files list."""
393 for i in range(5):
394 (repo / f"f{i}.py").write_text(f"v = {i}\n")
395
396 code, out = _run(repo, "code", "add", "--format", "json", "-A")
397 assert code == 0, out
398 data = json.loads(out.strip())
399 assert data["staged"] >= 5
400 paths = {f["path"] for f in data["files"]}
401 for i in range(5):
402 assert f"f{i}.py" in paths
403
404 def test_II7_json_output_is_valid_json(self, repo: pathlib.Path) -> None:
405 """II7: output is always parseable JSON, never raw text."""
406 (repo / "main.py").write_text("# changed\n")
407 _, out = _run(repo, "code", "add", "--format", "json", "main.py")
408 json.loads(out.strip()) # must not raise
409
410
411 # ===========================================================================
412 # III JSON output — muse code reset --format json
413 # ===========================================================================
414
415
416 class TestJsonOutputResetIII:
417 """``muse code reset --format json`` must emit valid, complete JSON."""
418
419 def test_III1_json_reset_specific_file(self, repo: pathlib.Path) -> None:
420 """III1: resetting a staged file returns unstaged=1 in JSON."""
421 (repo / "main.py").write_text("# staged\n")
422 _run(repo, "code", "add", "main.py")
423
424 code, out = _run(repo, "code", "reset", "--format", "json", "main.py")
425 assert code == 0, out
426 data = json.loads(out.strip())
427 assert data["unstaged"] == 1
428 assert "main.py" in data["files"]
429
430 def test_III2_json_reset_all(self, repo: pathlib.Path) -> None:
431 """III2: reset with no args clears all staged files, reports count in JSON."""
432 for i in range(3):
433 (repo / f"f{i}.py").write_text(f"x = {i}\n")
434 _run(repo, "code", "add", "-A")
435
436 code, out = _run(repo, "code", "reset", "--format", "json")
437 assert code == 0, out
438 data = json.loads(out.strip())
439 assert data["unstaged"] >= 3
440
441 def test_III3_json_reset_nothing_staged(self, repo: pathlib.Path) -> None:
442 """III3: reset with nothing staged returns unstaged=0 in JSON."""
443 code, out = _run(repo, "code", "reset", "--format", "json")
444 assert code == 0, out
445 data = json.loads(out.strip())
446 assert data["unstaged"] == 0
447 assert data["files"] == []
448
449 def test_III4_json_reset_preserves_other_staged_files(
450 self, repo: pathlib.Path
451 ) -> None:
452 """III4: resetting one file leaves others staged."""
453 (repo / "main.py").write_text("# changed\n")
454 (repo / "other.py").write_text("y = 9\n")
455 _run(repo, "code", "add", "-A")
456
457 code, out = _run(repo, "code", "reset", "--format", "json", "other.py")
458 assert code == 0, out
459 data = json.loads(out.strip())
460 assert data["unstaged"] == 1
461 assert "other.py" in data["files"]
462
463 remaining = read_stage(repo)
464 assert "main.py" in remaining, "main.py must still be staged"
465 assert "other.py" not in remaining
466
467
468 # ===========================================================================
469 # IV Text output breakdown
470 # ===========================================================================
471
472
473 class TestTextOutputBreakdownIV:
474 """The text summary must show a breakdown: N added, M modified, K deleted."""
475
476 def test_IV1_text_shows_added_count(self, repo: pathlib.Path) -> None:
477 """IV1: new files appear in 'added' part of the breakdown."""
478 (repo / "new.py").write_text("z = 0\n")
479 _, out = _run(repo, "code", "add", "new.py")
480 assert "added" in out
481
482 def test_IV2_text_shows_modified_count(self, repo: pathlib.Path) -> None:
483 """IV2: modified tracked files appear in 'modified' part."""
484 (repo / "main.py").write_text("x = 999\n")
485 _, out = _run(repo, "code", "add", "main.py")
486 assert "modified" in out
487
488 def test_IV3_text_shows_deleted_count(self, repo: pathlib.Path) -> None:
489 """IV3: staged deletions appear in 'deleted' part."""
490 (repo / "main.py").unlink()
491 _, out = _run(repo, "code", "add", "-u")
492 assert "deleted" in out
493
494 def test_IV4_text_nothing_to_stage_message(
495 self, repo: pathlib.Path
496 ) -> None:
497 """IV4: when nothing changed, output explains nothing to stage."""
498 _, out = _run(repo, "code", "add", ".")
499 assert "Nothing" in out or "already up to date" in out
500
501 def test_IV5_text_breakdown_counts_match_actual(
502 self, repo: pathlib.Path
503 ) -> None:
504 """IV5: text breakdown totals match what was actually staged."""
505 (repo / "main.py").write_text("x = 2\n") # modified
506 (repo / "a.py").write_text("a = 1\n") # new
507 (repo / "b.py").write_text("b = 2\n") # new
508
509 _, out = _run(repo, "code", "add", "-A")
510 assert "1 modified" in out
511 assert "2 added" in out
512
513
514 # ===========================================================================
515 # V msgpack stage persistence
516 # ===========================================================================
517
518
519 class TestMsgpackPersistenceV:
520 """The stage index must be persisted as msgpack and survive round-trips."""
521
522 def test_V1_stage_file_is_msgpack_not_json(
523 self, repo: pathlib.Path
524 ) -> None:
525 """V1: after staging, the file on disk is valid msgpack, not JSON."""
526 (repo / "main.py").write_text("x = 9\n")
527 _run(repo, "code", "add", "main.py")
528
529 path = stage_path(repo)
530 assert path.exists(), "stage.msgpack must exist after staging"
531 raw = path.read_bytes()
532 assert not raw.startswith(b"{"), "Stage file must not be JSON"
533 data = msgpack.unpackb(raw, raw=False)
534 assert "entries" in data
535 assert "main.py" in data["entries"]
536
537 def test_V2_stage_round_trips_all_entry_fields(
538 self, repo: pathlib.Path
539 ) -> None:
540 """V2: object_id, mode, and staged_at survive a write/read cycle."""
541 (repo / "main.py").write_text("x = 42\n")
542 _run(repo, "code", "add", "main.py")
543
544 stage = read_stage(repo)
545 entry = stage["main.py"]
546 assert len(entry["object_id"]) == 64, "object_id must be 64-char SHA-256"
547 assert entry["mode"] in ("A", "M", "D")
548 assert entry["staged_at"]
549
550 def test_V3_stage_atomic_write_no_tmp_file_after_success(
551 self, repo: pathlib.Path
552 ) -> None:
553 """V3: no .stage-tmp-* file lingers after a successful write."""
554 (repo / "main.py").write_text("x = 1\n")
555 _run(repo, "code", "add", "main.py")
556
557 stage_dir = repo / ".muse" / "code"
558 tmps = list(stage_dir.glob(".stage-tmp-*"))
559 assert tmps == [], f"Stale tmp files: {tmps}"
560
561 def test_V4_legacy_json_migrated_on_first_read(
562 self, repo: pathlib.Path
563 ) -> None:
564 """V4: if stage.json exists (legacy), read_stage migrates it to msgpack."""
565 stage_dir = repo / ".muse" / "code"
566 stage_dir.mkdir(parents=True, exist_ok=True)
567 oid = "a" * 64
568 legacy = stage_dir / "stage.json"
569 legacy.write_text(json.dumps({
570 "version": 1,
571 "entries": {"main.py": {"object_id": oid, "mode": "M", "staged_at": "x"}},
572 }))
573
574 entries = read_stage(repo)
575 assert "main.py" in entries
576 assert not legacy.exists(), "Legacy JSON must be removed after migration"
577 assert stage_path(repo).exists(), "Msgpack must be created after migration"
578
579 def test_V5_corrupt_msgpack_clears_and_returns_empty(
580 self, repo: pathlib.Path
581 ) -> None:
582 """V5: corrupt msgpack is deleted and read_stage returns {}."""
583 stage_dir = repo / ".muse" / "code"
584 stage_dir.mkdir(parents=True, exist_ok=True)
585 stage_path(repo).write_bytes(b"\xde\xad\xbe\xef garbage")
586
587 entries = read_stage(repo)
588 assert entries == {}
589 assert not stage_path(repo).exists(), "Corrupt stage file must be removed"
590
591 def test_V6_write_empty_removes_msgpack_file(
592 self, repo: pathlib.Path
593 ) -> None:
594 """V6: write_stage({}) removes stage.msgpack (clear the stage)."""
595 # Change main.py so it's different from the committed content.
596 (repo / "main.py").write_text("x = 999\n")
597 _run(repo, "code", "add", "main.py")
598 assert stage_path(repo).exists(), "Stage must exist after staging a changed file"
599
600 write_stage(repo, {})
601 assert not stage_path(repo).exists()
602
603 def test_V7_stage_version_is_2_in_msgpack(
604 self, repo: pathlib.Path
605 ) -> None:
606 """V7: msgpack file carries version=2."""
607 (repo / "main.py").write_text("x = 999\n")
608 _run(repo, "code", "add", "main.py")
609 assert stage_path(repo).exists(), "Stage must exist after staging"
610
611 raw = msgpack.unpackb(stage_path(repo).read_bytes(), raw=False)
612 assert raw["version"] == 2
613
614
615 # ===========================================================================
616 # VI Dry-run correctness
617 # ===========================================================================
618
619
620 class TestDryRunVI:
621 """--dry-run must preview accurately and never write anything."""
622
623 def test_VI1_dry_run_lists_files_that_would_be_staged(
624 self, repo: pathlib.Path
625 ) -> None:
626 """VI1: output lists every file that would be staged."""
627 (repo / "main.py").write_text("x = 3\n")
628 (repo / "new.py").write_text("y = 0\n")
629
630 _, out = _run(repo, "code", "add", "--dry-run", "-A")
631 assert "main.py" in out
632 assert "new.py" in out
633
634 def test_VI2_dry_run_does_not_write_stage_file(
635 self, repo: pathlib.Path
636 ) -> None:
637 """VI2: after dry-run, stage.msgpack must not exist."""
638 (repo / "main.py").write_text("x = 3\n")
639 _run(repo, "code", "add", "--dry-run", "main.py")
640 assert not stage_path(repo).exists()
641
642 def test_VI3_dry_run_does_not_write_objects(
643 self, repo: pathlib.Path
644 ) -> None:
645 """VI3: dry-run must not write any blobs to the object store."""
646 content = b"brand new content\n"
647 (repo / "brand_new.py").write_bytes(content)
648 oid = hashlib.sha256(content).hexdigest()
649 obj_path = repo / ".muse" / "objects" / oid[:2] / oid[2:]
650
651 _run(repo, "code", "add", "--dry-run", "brand_new.py")
652 assert not obj_path.exists(), "Dry-run must not write objects to the store"
653
654 def test_VI4_dry_run_json_shows_correct_counts(
655 self, repo: pathlib.Path
656 ) -> None:
657 """VI4: --dry-run --format json shows accurate counts."""
658 (repo / "main.py").write_text("x = 5\n") # modified
659 (repo / "extra.py").write_text("z = 0\n") # new
660
661 _, out = _run(
662 repo, "code", "add", "--dry-run", "--format", "json", "-A"
663 )
664 data = json.loads(out.strip())
665 assert data["dry_run"] is True
666 assert data["modified"] >= 1
667 assert data["added"] >= 1
668
669 def test_VI5_dry_run_output_stable_across_runs(
670 self, repo: pathlib.Path
671 ) -> None:
672 """VI5: running dry-run twice on the same tree produces identical output."""
673 (repo / "main.py").write_text("x = 7\n")
674
675 _, out1 = _run(repo, "code", "add", "--dry-run", "--format", "json", ".")
676 _, out2 = _run(repo, "code", "add", "--dry-run", "--format", "json", ".")
677 d1 = json.loads(out1); d1.pop("duration_ms", None)
678 d2 = json.loads(out2); d2.pop("duration_ms", None)
679 assert d1 == d2
680
681
682 # ===========================================================================
683 # VII Edge cases
684 # ===========================================================================
685
686
687 class TestEdgeCasesVII:
688 """Edge cases: fresh repo, no commits, conflicting flags, etc."""
689
690 def test_VII1_stage_on_fresh_repo_no_commits(
691 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
692 ) -> None:
693 """VII1: staging works on a repo with no prior commits."""
694 monkeypatch.chdir(tmp_path)
695 runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
696 (tmp_path / "first.py").write_text("x = 1\n")
697
698 code, out = _run(tmp_path, "code", "add", "first.py")
699 assert code == 0, out
700 stage = read_stage(tmp_path)
701 assert "first.py" in stage
702 assert stage["first.py"]["mode"] == "A"
703
704 def test_VII2_staging_identical_content_is_idempotent(
705 self, repo: pathlib.Path
706 ) -> None:
707 """VII2: staging the same file twice with identical content is a no-op."""
708 (repo / "main.py").write_text("x = 10\n")
709 _run(repo, "code", "add", "main.py")
710
711 code, out = _run(repo, "code", "add", "main.py")
712 assert code == 0
713 assert "already up to date" in out or "Nothing" in out
714
715 def test_VII3_restaging_after_modification_updates_object_id(
716 self, repo: pathlib.Path
717 ) -> None:
718 """VII3: re-staging a file after modification updates the object_id."""
719 (repo / "main.py").write_text("v1\n")
720 _run(repo, "code", "add", "main.py")
721 oid_v1 = read_stage(repo)["main.py"]["object_id"]
722
723 (repo / "main.py").write_text("v2\n")
724 _run(repo, "code", "add", "main.py")
725 oid_v2 = read_stage(repo)["main.py"]["object_id"]
726
727 assert oid_v1 != oid_v2
728
729 def test_VII4_nonexistent_path_exits_nonzero(
730 self, repo: pathlib.Path
731 ) -> None:
732 """VII4: staging a non-existent, untracked path exits non-zero."""
733 code, _ = _run_unchecked(repo, "code", "add", "ghost.py")
734 assert code != 0
735
736 def test_VII5_directory_scoped_add_leaves_top_level_unstaged(
737 self, repo: pathlib.Path
738 ) -> None:
739 """VII5: 'muse code add subdir' stages only files under that directory."""
740 sub = repo / "sub"
741 sub.mkdir()
742 (sub / "a.py").write_text("a = 1\n")
743 (repo / "top.py").write_text("t = 1\n")
744
745 _run(repo, "code", "add", "sub")
746 stage = read_stage(repo)
747 assert "sub/a.py" in stage
748 assert "top.py" not in stage
749
750 def test_VII6_verbose_shows_per_file_mode(
751 self, repo: pathlib.Path
752 ) -> None:
753 """VII6: --verbose shows one line per staged file."""
754 (repo / "main.py").write_text("x = 2\n")
755 _, out = _run(repo, "code", "add", "-v", "main.py")
756 assert "main.py" in out
757
758 def test_VII7_reset_HEAD_syntax_alias(self, repo: pathlib.Path) -> None:
759 """VII7: 'muse code reset HEAD <file>' is identical to 'muse code reset <file>'."""
760 (repo / "main.py").write_text("x = 3\n")
761 _run(repo, "code", "add", "main.py")
762
763 code, _ = _run(repo, "code", "reset", "HEAD", "main.py")
764 assert code == 0
765 assert not stage_path(repo).exists()
766
767 def test_VII8_stage_then_commit_then_restage_works(
768 self, repo: pathlib.Path
769 ) -> None:
770 """VII8: full stage → commit → re-stage cycle works end-to-end."""
771 (repo / "main.py").write_text("x = 5\n")
772 _run(repo, "code", "add", "main.py")
773 _run(repo, "commit", "-m", "v2")
774
775 assert not stage_path(repo).exists()
776
777 (repo / "main.py").write_text("x = 6\n")
778 code, out = _run(repo, "code", "add", "main.py")
779 assert code == 0
780 assert "main.py" in read_stage(repo)
781
782 def test_VII9_update_flag_includes_modifications_not_new(
783 self, repo: pathlib.Path
784 ) -> None:
785 """VII9: -u stages tracked modifications but not new untracked files."""
786 (repo / "main.py").write_text("x = 99\n") # tracked, modified
787 (repo / "untracked.py").write_text("u = 0\n") # new, untracked
788
789 _run(repo, "code", "add", "-u")
790 stage = read_stage(repo)
791 assert "main.py" in stage
792 assert "untracked.py" not in stage
793
794
795 # ===========================================================================
796 # VIII Stress tests
797 # ===========================================================================
798
799
800 class TestStressVIII:
801 """High-volume and adversarial scenarios."""
802
803 def test_VIII1_stage_500_files_correct_count(
804 self, repo: pathlib.Path
805 ) -> None:
806 """VIII1: staging 500 files produces 500 entries in the stage index."""
807 for i in range(500):
808 (repo / f"module_{i:04d}.py").write_text(f"X = {i}\n")
809
810 code, out = _run(repo, "code", "add", "-A")
811 assert code == 0, out
812 stage = read_stage(repo)
813 assert len(stage) >= 500
814
815 def test_VIII2_500_files_json_output_correct(
816 self, repo: pathlib.Path
817 ) -> None:
818 """VIII2: JSON output for 500 files has correct counts."""
819 for i in range(500):
820 (repo / f"f_{i:04d}.py").write_text(f"X = {i}\n")
821
822 _, out = _run(repo, "code", "add", "-A", "--format", "json")
823 data = json.loads(out.strip())
824 assert data["added"] >= 500
825 assert data["staged"] >= 500
826
827 def test_VIII3_stage_add_reset_cycle_50_times(
828 self, repo: pathlib.Path
829 ) -> None:
830 """VIII3: 50 add/reset cycles leave a clean stage each time."""
831 (repo / "main.py").write_text("x = 0\n")
832
833 for cycle in range(50):
834 (repo / "main.py").write_text(f"x = {cycle}\n")
835 code, _ = _run(repo, "code", "add", "main.py")
836 assert code == 0, f"Cycle {cycle}: add failed"
837
838 code, _ = _run(repo, "code", "reset", "main.py")
839 assert code == 0, f"Cycle {cycle}: reset failed"
840 assert not stage_path(repo).exists(), (
841 f"Cycle {cycle}: stage not cleared after reset"
842 )
843
844 def test_VIII4_large_file_stages_correctly(
845 self, repo: pathlib.Path
846 ) -> None:
847 """VIII4: a 5 MiB file stages and its object_id is correct."""
848 content = os.urandom(5 * 1024 * 1024)
849 (repo / "big.bin").write_bytes(content)
850
851 code, _ = _run(repo, "code", "add", "big.bin")
852 assert code == 0
853
854 stage = read_stage(repo)
855 assert "big.bin" in stage
856 expected_oid = hashlib.sha256(content).hexdigest()
857 assert stage["big.bin"]["object_id"] == expected_oid
858
859 def test_VIII5_all_modes_in_single_add(
860 self, repo: pathlib.Path
861 ) -> None:
862 """VIII5: a single add can capture added, modified, and deleted in one shot."""
863 # Add extra tracked file and commit first.
864 (repo / "to_delete.py").write_text("del = 1\n")
865 _run(repo, "code", "add", "to_delete.py")
866 _run(repo, "commit", "-m", "add to_delete")
867
868 (repo / "main.py").write_text("x = modified\n")
869 (repo / "to_delete.py").unlink()
870 (repo / "brand_new.py").write_text("new = True\n")
871
872 code, out = _run(repo, "code", "add", "--format", "json", "-A")
873 assert code == 0, out
874 data = json.loads(out.strip())
875 assert data["modified"] >= 1
876 assert data["added"] >= 1
877 assert data["deleted"] >= 1
878
879 def test_VIII6_staging_after_many_commits_works(
880 self, repo: pathlib.Path
881 ) -> None:
882 """VIII6: staging still works correctly after many commits."""
883 for i in range(50):
884 (repo / "main.py").write_text(f"x = {i}\n")
885 _run(repo, "commit", "--allow-empty", "-m", f"commit {i}")
886
887 (repo / "main.py").write_text("x = final\n")
888 code, _ = _run(repo, "code", "add", "main.py")
889 assert code == 0
890 stage = read_stage(repo)
891 assert "main.py" in stage
892
893
894 # ===========================================================================
895 # IX Stat-cache performance — muse code add must use StatCache, not hash_file
896 # ===========================================================================
897
898
899 class TestStatCacheIX:
900 """muse code add must use the stat cache, not raw hash_file on every call."""
901
902 def test_IX1_stat_cache_used_structurally(self) -> None:
903 """IX1: code_stage module must import and use StatCache or load_cache."""
904 import inspect
905 from muse.cli.commands import code_stage as cs_module
906
907 source = inspect.getsource(cs_module)
908 assert "load_cache" in source or "StatCache" in source, (
909 "code_stage must import and use load_cache or StatCache for hashing"
910 )
911
912 def test_IX2_hash_file_not_called_on_unchanged_file(
913 self, repo: pathlib.Path
914 ) -> None:
915 """IX2: second code add on unchanged file must not rehash from disk.
916
917 After the first add the stat cache has a valid entry. The second add
918 must return the cached hash without calling _hash_str again.
919 """
920 from unittest.mock import patch
921
922 (repo / "cached.txt").write_text("stable content\n")
923 # First add — computes and caches the hash.
924 code, _ = _run(repo, "code", "add", "cached.txt")
925 assert code == 0
926
927 # Reset stage so the file is re-evaluated on the second add.
928 _run(repo, "code", "reset", "cached.txt")
929
930 # Second add — must hit the cache; _hash_str must NOT be called.
931 with patch("muse.core.stat_cache._hash_str") as mock_hash:
932 code2, _ = _run(repo, "code", "add", "cached.txt")
933
934 assert code2 == 0
935 mock_hash.assert_not_called(), (
936 "second code add on unchanged file called _hash_str — stat cache not used"
937 )
938
939 def test_IX3_stat_cache_file_written_after_add(
940 self, repo: pathlib.Path
941 ) -> None:
942 """IX3: stat_cache.msgpack must exist after code add (cache was saved)."""
943 (repo / "new_file.py").write_text("y = 2\n")
944 code, _ = _run(repo, "code", "add", "new_file.py")
945 assert code == 0
946 cache_path = repo / ".muse" / "stat_cache.msgpack"
947 assert cache_path.exists(), (
948 "stat_cache.msgpack not found — cache.save() not called after code add"
949 )
950
951 def test_IX4_modified_file_is_rehashed(
952 self, repo: pathlib.Path
953 ) -> None:
954 """IX4: modifying a file invalidates the cache entry so it is rehashed."""
955 from unittest.mock import patch
956 import muse.core.stat_cache as _sc
957
958 (repo / "mutable.py").write_text("v = 1\n")
959 _run(repo, "code", "add", "mutable.py")
960 _run(repo, "code", "reset", "mutable.py")
961
962 # Modify the file — mtime/size change → cache miss.
963 (repo / "mutable.py").write_text("v = 2\n")
964
965 # Spy on _hash_str but let the real function run so object_store
966 # integrity checks still pass.
967 with patch.object(_sc, "_hash_str", wraps=_sc._hash_str) as mock_hash:
968 code, _ = _run(repo, "code", "add", "mutable.py")
969
970 assert code == 0
971 mock_hash.assert_called(), (
972 "modified file should trigger a _hash_str call (cache miss)"
973 )
974
975
976 # ---------------------------------------------------------------------------
977 # Helper
978 # ---------------------------------------------------------------------------
979
980
981 def _read_stage(root: pathlib.Path) -> StagedFileMap:
982 return read_stage(root)
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago