gabriel / muse public

test_cmd_code_add.py file-level

at sha256:9 · View file ↗ · Intel ↗

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