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