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