gabriel / muse public
test_code_stage.py python
998 lines 38.3 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Tests for ``muse code add`` / ``muse code reset`` and stage-aware commit/status.
2
3 Coverage matrix:
4
5 Unit tests (pure functions):
6 - _split_into_hunks: empty diff, single hunk, multi-hunk, trailing newlines
7 - _apply_hunks_to_bytes: accept all, accept none, accept partial, new-file
8 - _infer_mode: all three modes (A / M / D)
9 - _colorize_hunk: color escape codes present for +/- lines
10
11 Integration tests (CLI round-trips):
12 - muse code add <file> — stages modified file as mode M
13 - muse code add <new-file> — stages new file as mode A
14 - muse code add . — stages everything
15 - muse code add -A — stages all including new files
16 - muse code add -u — stages tracked files only (excludes untracked)
17 - muse code add -u — stages deleted files as mode D
18 - muse code add <dir> — expands directory recursively
19 - muse code add --dry-run — shows intent without writing
20 - muse code add -v — verbose per-file output
21 - muse code add (re-stage) — updates object_id when file changes again
22 - nonexistent path — exits non-zero
23 - wrong domain — exits non-zero
24
25 Stage-aware commit:
26 - Only staged files appear in the committed snapshot
27 - Unstaged changes do NOT appear in the committed snapshot
28 - Stage is cleared after a successful commit
29 - Staged deletion removes file from next commit
30
31 muse status — three-bucket view:
32 - "Changes staged for commit" section present
33 - "Changes not staged" section present
34 - Untracked files listed
35 - --format json includes staged/unstaged/untracked keys
36 - --json format
37
38 muse code reset:
39 - reset <file> — unstages that file only
40 - reset HEAD <file> — Git-syntax alias works
41 - reset (no args) — clears everything
42 - reset when nothing staged — exits cleanly
43
44 Resilience:
45 - Corrupt stage.json degrades gracefully (read_stage returns {})
46 - Staging a file outside the repo root is rejected
47
48 Stress:
49 - Staging 100 files in one shot
50 """
51
52 from __future__ import annotations
53
54 import json
55 import os
56 import pathlib
57
58 import msgpack
59 import pytest
60
61 from muse.core._types import fake_id
62 from muse.plugins.code.stage import read_stage, stage_path, StagedEntry, StagedFileMap
63 from tests.cli_test_helper import CliRunner
64
65 cli = None # argparse migration — CliRunner ignores this arg
66 runner = CliRunner()
67
68
69 def _read_stage_raw(root: pathlib.Path) -> StagedFileMap:
70 """Read the current stage index using the production API."""
71 return read_stage(root)
72
73
74 # ---------------------------------------------------------------------------
75 # Unit tests — pure functions
76 # ---------------------------------------------------------------------------
77
78
79 class TestSplitIntoHunks:
80 """Unit tests for _split_into_hunks (no I/O)."""
81
82 def _run(self, diff_text: str) -> list[list[str]]:
83 from muse.cli.commands.code_stage import _split_into_hunks
84 lines = [l + "\n" for l in diff_text.splitlines()]
85 return _split_into_hunks(lines)
86
87 def test_empty_diff_returns_no_hunks(self) -> None:
88 assert self._run("") == []
89
90 def test_single_hunk(self) -> None:
91 diff = (
92 "--- a/foo.py\n"
93 "+++ b/foo.py\n"
94 "@@ -1,2 +1,3 @@\n"
95 " def f():\n"
96 "- pass\n"
97 "+ return 1\n"
98 )
99 hunks = self._run(diff)
100 assert len(hunks) == 1
101 assert any("@@" in l for l in hunks[0])
102
103 def test_multi_hunk_has_header_on_each(self) -> None:
104 diff = (
105 "--- a/foo.py\n"
106 "+++ b/foo.py\n"
107 "@@ -1,2 +1,3 @@\n"
108 " line1\n"
109 "-old\n"
110 "+new\n"
111 "@@ -10,2 +11,3 @@\n"
112 " line10\n"
113 "-old10\n"
114 "+new10\n"
115 )
116 hunks = self._run(diff)
117 assert len(hunks) == 2
118 # Each hunk starts with the file header (--- / +++), then @@
119 for h in hunks:
120 assert any(l.startswith("---") for l in h)
121 assert any(l.startswith("+++") for l in h)
122 assert any(l.startswith("@@") for l in h)
123
124 def test_no_header_lines_before_first_hunk_is_still_valid(self) -> None:
125 diff = (
126 "@@ -1,1 +1,1 @@\n"
127 "-old\n"
128 "+new\n"
129 )
130 hunks = self._run(diff)
131 assert len(hunks) == 1
132
133
134 class TestApplyHunksToBytes:
135 """Unit tests for _apply_hunks_to_bytes."""
136
137 def _run(self, before: str, diff_text: str, accept_all: bool = True) -> str:
138 from muse.cli.commands.code_stage import _split_into_hunks, _apply_hunks_to_bytes
139
140 before_lines = before.splitlines(keepends=True)
141 after_lines = diff_text.splitlines(keepends=True)
142
143 import difflib
144 diff = list(difflib.unified_diff(
145 before_lines, after_lines, fromfile="a/f", tofile="b/f", lineterm=""
146 ))
147 diff_nl = [l + "\n" for l in diff]
148 hunks = _split_into_hunks(diff_nl)
149
150 accepted = hunks if accept_all else []
151 result = _apply_hunks_to_bytes(before.encode(), accepted)
152 return result.decode()
153
154 def test_accept_all_hunks_produces_after_content(self) -> None:
155 before = "def f():\n pass\n"
156 after = "def f():\n return 1\n"
157 result = self._run(before, after, accept_all=True)
158 assert "return 1" in result
159
160 def test_accept_no_hunks_preserves_original(self) -> None:
161 before = "def f():\n pass\n"
162 after = "def f():\n return 1\n"
163 result = self._run(before, after, accept_all=False)
164 assert result == before
165
166 def test_new_file_from_empty(self) -> None:
167 """Staging a new file from empty before-bytes produces after-content."""
168 before = ""
169 after = "x = 1\ny = 2\n"
170 result = self._run(before, after, accept_all=True)
171 assert "x = 1" in result
172
173 def test_binary_safe_with_replacement(self) -> None:
174 from muse.cli.commands.code_stage import _apply_hunks_to_bytes
175 result = _apply_hunks_to_bytes(b"\xff\xfe", [])
176 assert isinstance(result, bytes)
177
178
179 class TestInferMode:
180 """Unit tests for _infer_mode."""
181
182 def _run(self, rel: str, head: Manifest, exists: bool) -> str:
183 from muse.cli.commands.code_stage import _infer_mode
184 return _infer_mode(rel, head, exists)
185
186 def test_existing_tracked_is_M(self) -> None:
187 assert self._run("src/a.py", {"src/a.py": "abc"}, True) == "M"
188
189 def test_new_untracked_is_A(self) -> None:
190 assert self._run("src/new.py", {}, True) == "A"
191
192 def test_missing_from_disk_is_D(self) -> None:
193 assert self._run("src/gone.py", {"src/gone.py": "abc"}, False) == "D"
194
195 def test_missing_and_not_tracked_is_D(self) -> None:
196 # Shouldn't normally occur, but must not crash.
197 assert self._run("ghost.py", {}, False) == "D"
198
199
200 class TestColorizeHunk:
201 """Unit tests for _colorize_hunk."""
202
203 def test_added_lines_get_green(self) -> None:
204 from muse.cli.commands.code_stage import _colorize_hunk
205 result = _colorize_hunk(["+new line\n"])
206 assert "\x1b[32m" in result # green
207
208 def test_removed_lines_get_red(self) -> None:
209 from muse.cli.commands.code_stage import _colorize_hunk
210 result = _colorize_hunk(["-old line\n"])
211 assert "\x1b[31m" in result # red
212
213 def test_file_header_not_colored(self) -> None:
214 from muse.cli.commands.code_stage import _colorize_hunk
215 result = _colorize_hunk(["--- a/foo.py\n", "+++ b/foo.py\n"])
216 # file header lines should not get red/green
217 assert "\x1b[31m" not in result
218 assert "\x1b[32m" not in result
219
220 def test_at_at_header_gets_cyan(self) -> None:
221 from muse.cli.commands.code_stage import _colorize_hunk
222 result = _colorize_hunk(["@@ -1,2 +1,3 @@\n"])
223 assert "\x1b[36m" in result # cyan
224
225
226 # ---------------------------------------------------------------------------
227 # Fixtures
228 # ---------------------------------------------------------------------------
229
230
231 def _env(root: pathlib.Path) -> Manifest:
232 return {"MUSE_REPO_ROOT": str(root)}
233
234
235 @pytest.fixture()
236 def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
237 """Initialise a fresh code-domain Muse repo with one initial commit."""
238 monkeypatch.chdir(tmp_path)
239
240 result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
241 assert result.exit_code == 0, result.output
242
243 (tmp_path / "auth.py").write_text("def authenticate():\n pass\n")
244 (tmp_path / "models.py").write_text("class User:\n pass\n")
245
246 r = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path))
247 assert r.exit_code == 0, r.output
248
249 return tmp_path
250
251
252 # ---------------------------------------------------------------------------
253 # muse code add — integration tests
254 # ---------------------------------------------------------------------------
255
256
257 class TestCodeAdd:
258 def test_stage_modified_file_is_mode_M(self, code_repo: pathlib.Path) -> None:
259 (code_repo / "auth.py").write_text("def authenticate():\n return True\n")
260 result = runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
261 assert result.exit_code == 0, result.output
262 assert "Staged 1 file" in result.output
263
264 stage = _read_stage_raw(code_repo)
265 assert stage["auth.py"]["mode"] == "M"
266
267 def test_stage_new_file_is_mode_A(self, code_repo: pathlib.Path) -> None:
268 (code_repo / "new_module.py").write_text("x = 1\n")
269 runner.invoke(cli, ["code", "add", "new_module.py"], env=_env(code_repo))
270 stage = _read_stage_raw(code_repo)
271 assert stage["new_module.py"]["mode"] == "A"
272
273 def test_stage_dot_stages_everything(self, code_repo: pathlib.Path) -> None:
274 (code_repo / "auth.py").write_text("# changed\n")
275 runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
276 stage = _read_stage_raw(code_repo)
277 assert "auth.py" in stage
278
279 def test_stage_A_includes_new_files(self, code_repo: pathlib.Path) -> None:
280 (code_repo / "auth.py").write_text("# changed\n")
281 (code_repo / "new.py").write_text("x = 1\n")
282 runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
283 stage = _read_stage_raw(code_repo)
284 assert "auth.py" in stage
285 assert "new.py" in stage
286
287 def test_stage_u_excludes_new_untracked_files(
288 self, code_repo: pathlib.Path
289 ) -> None:
290 """-u stages only tracked files; new/untracked files are NOT staged."""
291 (code_repo / "auth.py").write_text("# tracked change\n")
292 (code_repo / "brand_new.py").write_text("x = 1\n")
293
294 runner.invoke(cli, ["code", "add", "-u"], env=_env(code_repo))
295
296 assert stage_path(code_repo).exists()
297 stage = _read_stage_raw(code_repo)
298 assert "auth.py" in stage
299 assert "brand_new.py" not in stage
300
301 def test_stage_u_includes_deleted_files(self, code_repo: pathlib.Path) -> None:
302 (code_repo / "models.py").unlink()
303 runner.invoke(cli, ["code", "add", "-u"], env=_env(code_repo))
304 stage = _read_stage_raw(code_repo)
305 assert "models.py" in stage
306 assert stage["models.py"]["mode"] == "D"
307
308 def test_stage_directory_expands_recursively(
309 self, code_repo: pathlib.Path
310 ) -> None:
311 src = code_repo / "src"
312 src.mkdir()
313 (src / "a.py").write_text("x = 1\n")
314 (src / "b.py").write_text("y = 2\n")
315
316 runner.invoke(cli, ["code", "add", "src"], env=_env(code_repo))
317 stage = _read_stage_raw(code_repo)
318 assert "src/a.py" in stage
319 assert "src/b.py" in stage
320
321 def test_dry_run_does_not_write_stage(self, code_repo: pathlib.Path) -> None:
322 (code_repo / "auth.py").write_text("# dry\n")
323 runner.invoke(
324 cli, ["code", "add", "--dry-run", "auth.py"], env=_env(code_repo)
325 )
326 assert not stage_path(code_repo).exists()
327
328 def test_dry_run_output_shows_files(self, code_repo: pathlib.Path) -> None:
329 (code_repo / "auth.py").write_text("# dry\n")
330 result = runner.invoke(
331 cli, ["code", "add", "--dry-run", "auth.py"], env=_env(code_repo)
332 )
333 assert "auth.py" in result.output
334
335 def test_verbose_shows_per_file_output(self, code_repo: pathlib.Path) -> None:
336 (code_repo / "auth.py").write_text("# verbose\n")
337 result = runner.invoke(
338 cli, ["code", "add", "-v", "auth.py"], env=_env(code_repo)
339 )
340 assert result.exit_code == 0
341 assert "auth.py" in result.output
342
343 def test_restage_updates_object_id(self, code_repo: pathlib.Path) -> None:
344 """Staging a file twice with different content updates the object_id."""
345 (code_repo / "auth.py").write_text("# version 1\n")
346 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
347 oid_v1 = _read_stage_raw(code_repo)["auth.py"]["object_id"]
348
349 (code_repo / "auth.py").write_text("# version 2\n")
350 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
351 oid_v2 = _read_stage_raw(code_repo)["auth.py"]["object_id"]
352
353 assert oid_v1 != oid_v2
354
355 def test_staging_unchanged_file_is_idempotent(
356 self, code_repo: pathlib.Path
357 ) -> None:
358 """Staging a file that has not changed since last staging is a no-op."""
359 (code_repo / "auth.py").write_text("# same\n")
360 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
361 result = runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
362 assert result.exit_code == 0
363 assert "already up to date" in result.output
364
365 def test_nonexistent_path_exits_error(self, code_repo: pathlib.Path) -> None:
366 result = runner.invoke(
367 cli, ["code", "add", "does_not_exist.py"], env=_env(code_repo)
368 )
369 assert result.exit_code != 0
370
371 def test_wrong_domain_exits_error(
372 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
373 ) -> None:
374 monkeypatch.chdir(tmp_path)
375 runner.invoke(cli, ["init", "--domain", "midi"], env=_env(tmp_path))
376 result = runner.invoke(cli, ["code", "add", "file.py"], env=_env(tmp_path))
377 assert result.exit_code != 0
378
379
380 # ---------------------------------------------------------------------------
381 # Stage-aware commit
382 # ---------------------------------------------------------------------------
383
384
385 class TestStageAwareCommit:
386 def test_only_staged_file_is_committed(self, code_repo: pathlib.Path) -> None:
387 (code_repo / "auth.py").write_text("def authenticate():\n return True\n")
388 (code_repo / "models.py").write_text("class User:\n name = 'anon'\n")
389
390 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
391
392 r = runner.invoke(
393 cli, ["commit", "-m", "auth only", "--json"],
394 env=_env(code_repo),
395 )
396 assert r.exit_code == 0, r.output
397 data = json.loads(r.output.strip())
398
399 from muse.core.store import read_commit, read_snapshot
400 from muse.core.object_store import read_object
401
402 commit = read_commit(code_repo, data["commit_id"])
403 assert commit is not None
404 snap = read_snapshot(code_repo, commit.snapshot_id)
405 assert snap is not None
406
407 auth_bytes = read_object(code_repo, snap.manifest["auth.py"])
408 assert auth_bytes is not None
409 assert b"return True" in auth_bytes
410
411 models_bytes = read_object(code_repo, snap.manifest["models.py"])
412 assert models_bytes is not None
413 # models.py was NOT staged — should have old content (pass, not name='anon')
414 assert b"name = 'anon'" not in models_bytes
415 assert b"pass" in models_bytes
416
417 def test_stage_cleared_after_commit(self, code_repo: pathlib.Path) -> None:
418 (code_repo / "auth.py").write_text("# cleared after commit\n")
419 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
420
421 assert stage_path(code_repo).exists()
422
423 runner.invoke(cli, ["commit", "-m", "clear stage test"], env=_env(code_repo))
424 assert not stage_path(code_repo).exists()
425
426 def test_staged_deletion_removes_file_from_commit(
427 self, code_repo: pathlib.Path
428 ) -> None:
429 (code_repo / "models.py").unlink()
430 runner.invoke(cli, ["code", "add", "-u"], env=_env(code_repo))
431
432 r = runner.invoke(
433 cli, ["commit", "-m", "delete models", "--json"],
434 env=_env(code_repo),
435 )
436 assert r.exit_code == 0, r.output
437 data = json.loads(r.output.strip())
438
439 from muse.core.store import read_commit, read_snapshot
440 commit = read_commit(code_repo, data["commit_id"])
441 assert commit is not None
442 snap = read_snapshot(code_repo, commit.snapshot_id)
443 assert snap is not None
444 assert "models.py" not in snap.manifest
445
446 def test_full_snapshot_when_no_stage(self, code_repo: pathlib.Path) -> None:
447 """Without a stage, commit captures the full working tree."""
448 (code_repo / "extra.py").write_text("z = 99\n")
449
450 r = runner.invoke(
451 cli, ["commit", "-m", "full snapshot", "--json"],
452 env=_env(code_repo),
453 )
454 assert r.exit_code == 0, r.output
455 data = json.loads(r.output.strip())
456
457 from muse.core.store import read_commit, read_snapshot
458 commit = read_commit(code_repo, data["commit_id"])
459 assert commit is not None
460 snap = read_snapshot(code_repo, commit.snapshot_id)
461 assert snap is not None
462 assert "extra.py" in snap.manifest
463
464
465 # ---------------------------------------------------------------------------
466 # muse status — staged view
467 # ---------------------------------------------------------------------------
468
469
470 class TestStageStatus:
471 def test_shows_staged_section_when_stage_active(
472 self, code_repo: pathlib.Path
473 ) -> None:
474 (code_repo / "auth.py").write_text("# staged change\n")
475 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
476
477 result = runner.invoke(cli, ["status"], env=_env(code_repo))
478 assert result.exit_code == 0, result.output
479 assert "staged for commit" in result.output
480 assert "auth.py" in result.output
481
482 def test_shows_unstaged_section_for_unmodified_tracked_with_changes(
483 self, code_repo: pathlib.Path
484 ) -> None:
485 (code_repo / "auth.py").write_text("# staged\n")
486 (code_repo / "models.py").write_text("# NOT staged\n")
487 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
488
489 result = runner.invoke(cli, ["status"], env=_env(code_repo))
490 assert "not staged" in result.output
491 assert "models.py" in result.output
492
493 def test_shows_untracked_section(self, code_repo: pathlib.Path) -> None:
494 (code_repo / "auth.py").write_text("# staged\n")
495 (code_repo / "brand_new.py").write_text("x = 1\n")
496 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
497
498 result = runner.invoke(cli, ["status"], env=_env(code_repo))
499 assert "Untracked" in result.output
500 assert "brand_new.py" in result.output
501
502 def test_json_format_has_all_buckets(self, code_repo: pathlib.Path) -> None:
503 (code_repo / "auth.py").write_text("# json stage\n")
504 (code_repo / "new_file.py").write_text("x = 1\n")
505 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
506
507 result = runner.invoke(
508 cli, ["status", "--json"], env=_env(code_repo)
509 )
510 assert result.exit_code == 0, result.output
511 data = json.loads(result.output.strip())
512 assert "staged" in data
513 assert "unstaged" in data
514 assert "untracked" in data
515 assert "auth.py" in data["staged"]["modified"]
516 assert "new_file.py" in data["untracked"]
517
518 def test_json_format_with_stage(self, code_repo: pathlib.Path) -> None:
519 (code_repo / "auth.py").write_text("# staged\n")
520 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
521
522 result = runner.invoke(cli, ["status", "--json"], env=_env(code_repo))
523 assert result.exit_code == 0
524 assert "auth.py" in result.output
525
526 def test_short_format_with_stage(self, code_repo: pathlib.Path) -> None:
527 (code_repo / "auth.py").write_text("# short\n")
528 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
529
530 result = runner.invoke(cli, ["status", "--short"], env=_env(code_repo))
531 assert result.exit_code == 0
532 assert "auth.py" in result.output
533
534 def test_clean_tree_after_commit_clears_stage(
535 self, code_repo: pathlib.Path
536 ) -> None:
537 """After staging and committing, status should show clean tree."""
538 (code_repo / "auth.py").write_text("# committed\n")
539 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
540 runner.invoke(cli, ["commit", "-m", "staged commit"], env=_env(code_repo))
541
542 result = runner.invoke(cli, ["status"], env=_env(code_repo))
543 assert result.exit_code == 0
544 # No stage file → falls back to normal drift-based status.
545 assert "staged for commit" not in result.output
546
547
548 # ---------------------------------------------------------------------------
549 # muse code reset
550 # ---------------------------------------------------------------------------
551
552
553 class TestCodeReset:
554 def test_reset_specific_file(self, code_repo: pathlib.Path) -> None:
555 (code_repo / "auth.py").write_text("# staged\n")
556 (code_repo / "models.py").write_text("# also staged\n")
557 runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
558
559 result = runner.invoke(
560 cli, ["code", "reset", "auth.py"], env=_env(code_repo)
561 )
562 assert result.exit_code == 0
563 stage = _read_stage_raw(code_repo)
564 assert "auth.py" not in stage
565 assert "models.py" in stage
566
567 def test_reset_HEAD_syntax(self, code_repo: pathlib.Path) -> None:
568 (code_repo / "auth.py").write_text("# head\n")
569 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
570 result = runner.invoke(
571 cli, ["code", "reset", "HEAD", "auth.py"], env=_env(code_repo)
572 )
573 assert result.exit_code == 0
574 assert not stage_path(code_repo).exists()
575
576 def test_reset_no_args_clears_all(self, code_repo: pathlib.Path) -> None:
577 (code_repo / "auth.py").write_text("# a\n")
578 (code_repo / "models.py").write_text("# b\n")
579 runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
580 result = runner.invoke(cli, ["code", "reset"], env=_env(code_repo))
581 assert result.exit_code == 0
582 assert not stage_path(code_repo).exists()
583
584 def test_reset_when_nothing_staged(self, code_repo: pathlib.Path) -> None:
585 result = runner.invoke(cli, ["code", "reset"], env=_env(code_repo))
586 assert result.exit_code == 0
587 assert "Nothing staged" in result.output
588
589 def test_reset_nonexistent_file_does_not_crash(
590 self, code_repo: pathlib.Path
591 ) -> None:
592 (code_repo / "auth.py").write_text("# staged\n")
593 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
594 result = runner.invoke(
595 cli, ["code", "reset", "not_in_stage.py"], env=_env(code_repo)
596 )
597 assert result.exit_code == 0
598 assert "not staged" in result.output
599
600
601 # ---------------------------------------------------------------------------
602 # Resilience
603 # ---------------------------------------------------------------------------
604
605
606 class TestResilience:
607 def test_corrupt_stage_msgpack_returns_empty(
608 self, code_repo: pathlib.Path
609 ) -> None:
610 """Corrupt stage.msgpack must degrade gracefully — returns {} on read."""
611 stage_dir = code_repo / ".muse" / "code"
612 stage_dir.mkdir(parents=True, exist_ok=True)
613 (stage_dir / "stage.msgpack").write_bytes(b"\xde\xad\xbe\xef garbage")
614
615 entries = read_stage(code_repo)
616 assert entries == {}
617
618 def test_truncated_stage_msgpack_returns_empty(
619 self, code_repo: pathlib.Path
620 ) -> None:
621 stage_dir = code_repo / ".muse" / "code"
622 stage_dir.mkdir(parents=True, exist_ok=True)
623 (stage_dir / "stage.msgpack").write_bytes(b"\x00\x01\x02")
624
625 entries = read_stage(code_repo)
626 assert entries == {}
627
628 def test_legacy_json_is_migrated_transparently(
629 self, code_repo: pathlib.Path
630 ) -> None:
631 """Legacy stage.json is read and transparently migrated to msgpack."""
632 import json as _json
633 stage_dir = code_repo / ".muse" / "code"
634 stage_dir.mkdir(parents=True, exist_ok=True)
635 legacy = stage_dir / "stage.json"
636 legacy.write_text(_json.dumps({
637 "version": 1,
638 "entries": {
639 "auth.py": {"object_id": "abc123" * 10 + "ab12", "mode": "M", "staged_at": "2026-01-01T00:00:00+00:00"},
640 },
641 }))
642
643 entries = read_stage(code_repo)
644 assert "auth.py" in entries
645 assert entries["auth.py"]["mode"] == "M"
646 # Legacy JSON must be removed after migration.
647 assert not legacy.exists()
648 # Msgpack file must now exist.
649 assert stage_path(code_repo).exists()
650
651 def test_missing_stage_returns_empty(self, code_repo: pathlib.Path) -> None:
652 entries = read_stage(code_repo)
653 assert entries == {}
654
655 def test_write_empty_entries_removes_file(
656 self, code_repo: pathlib.Path
657 ) -> None:
658 from muse.plugins.code.stage import write_stage, StagedFileMap
659
660 path = stage_path(code_repo)
661 path.parent.mkdir(parents=True, exist_ok=True)
662 # Create a non-empty msgpack file first.
663 import msgpack as _mp
664 path.write_bytes(_mp.packb({"version": 2, "entries": {"f.py": {"object_id": "a" * 64, "mode": "M", "staged_at": "x"}}}, use_bin_type=True))
665
666 write_stage(code_repo, {})
667 assert not path.exists()
668
669 def test_clear_stage_idempotent(self, code_repo: pathlib.Path) -> None:
670 from muse.plugins.code.stage import clear_stage, StagedFileMap
671
672 clear_stage(code_repo) # no stage to clear — must not raise
673 clear_stage(code_repo) # idempotent
674
675
676 # ---------------------------------------------------------------------------
677 # Stress test
678 # ---------------------------------------------------------------------------
679
680
681 class TestStageStress:
682 def test_stage_100_files(
683 self, code_repo: pathlib.Path
684 ) -> None:
685 """Staging 100 files must complete without error and write all entries."""
686 for i in range(100):
687 (code_repo / f"module_{i:03d}.py").write_text(f"X_{i} = {i}\n")
688
689 result = runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
690 assert result.exit_code == 0, result.output
691
692 stage = _read_stage_raw(code_repo)
693 # 100 new files + 2 original tracked files (auth.py, models.py)
694 assert len(stage) >= 100
695
696 def test_commit_100_staged_files(
697 self, code_repo: pathlib.Path
698 ) -> None:
699 """Committing 100 staged files produces a correct manifest."""
700 for i in range(100):
701 (code_repo / f"mod_{i:03d}.py").write_text(f"V = {i}\n")
702
703 runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
704 r = runner.invoke(
705 cli, ["commit", "-m", "100 files", "--json"],
706 env=_env(code_repo),
707 )
708 assert r.exit_code == 0, r.output
709 data = json.loads(r.output.strip())
710
711 from muse.core.store import read_commit, read_snapshot
712 commit = read_commit(code_repo, data["commit_id"])
713 assert commit is not None
714 snap = read_snapshot(code_repo, commit.snapshot_id)
715 assert snap is not None
716 assert len(snap.manifest) >= 100
717
718
719 def test_add_all_stages_deletions(
720 code_repo: pathlib.Path,
721 ) -> None:
722 """``muse code add -A`` must stage tracked files that have been deleted.
723
724 Regression test: before the fix, ``-A`` used ``_walk_tree`` which only
725 returns files present on disk. Deleted tracked files were therefore
726 silently omitted and the deletion was never recorded in the stage.
727 """
728 # code_repo already has auth.py and models.py committed.
729 os.remove(code_repo / "auth.py")
730
731 r = runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
732 assert r.exit_code == 0, r.output
733
734 from muse.plugins.code.stage import read_stage, StagedFileMap
735 stage = read_stage(code_repo)
736 assert "auth.py" in stage, "deleted tracked file must appear in stage"
737 assert stage["auth.py"]["mode"] == "D", "deleted file must have mode D"
738
739
740 def test_add_dot_stages_museattributes(
741 code_repo: pathlib.Path,
742 ) -> None:
743 """`muse code add .` must stage `.museattributes` when it exists.
744
745 Regression test: before the fix, ``_walk_tree`` skipped all files whose
746 name started with ``.``, so ``.museattributes`` and ``.museignore`` could
747 never be staged with ``muse code add .`` — they required an explicit path.
748 """
749 (code_repo / ".museattributes").write_text("[*.py]\nmerge = python\n")
750
751 r = runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
752 assert r.exit_code == 0, r.output
753
754 from muse.plugins.code.stage import read_stage
755 stage = read_stage(code_repo)
756 assert ".museattributes" in stage, ".museattributes must be staged by `muse code add .`"
757
758
759 def test_add_dot_stages_museignore(
760 code_repo: pathlib.Path,
761 ) -> None:
762 """`muse code add .` must stage `.museignore` itself when it exists.
763
764 The file that controls ignore patterns should be version-controlled just
765 like ``.gitignore`` is — ``muse code add .`` must include it.
766 Note: the test uses empty patterns so the file doesn't suppress itself.
767 """
768 (code_repo / ".museignore").write_text('[global]\npatterns = []\n')
769
770 r = runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
771 assert r.exit_code == 0, r.output
772
773 from muse.plugins.code.stage import read_stage
774 stage = read_stage(code_repo)
775 assert ".museignore" in stage, ".museignore itself must be staged by `muse code add .`"
776
777
778 def test_add_dot_does_not_stage_museignore_files(
779 code_repo: pathlib.Path,
780 ) -> None:
781 """``muse code add .`` must not stage files matched by ``.museignore``.
782
783 Regression test: before the fix, ``_walk_tree`` never consulted
784 ``.museignore``, so any file on disk — including ones the user explicitly
785 excluded — could be silently staged and committed.
786 """
787 (code_repo / ".museignore").write_text('[global]\npatterns = ["*.log"]\n')
788 (code_repo / "debug.log").write_text("ignored content\n")
789 (code_repo / "app.py").write_text("# new code\n")
790
791 r = runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
792 assert r.exit_code == 0, r.output
793
794 from muse.plugins.code.stage import read_stage, StagedFileMap
795 stage = read_stage(code_repo)
796 assert "debug.log" not in stage, ".museignore'd file must NOT be staged"
797 assert "app.py" in stage, "non-ignored new file must be staged"
798
799
800 def test_add_dot_does_not_stage_unchanged_files(
801 code_repo: pathlib.Path,
802 ) -> None:
803 """``muse code add .`` must only stage files whose content differs from HEAD.
804
805 Regression test for the bug where ``muse code add .`` staged every file in
806 the working tree regardless of whether it had changed, because the
807 "skip-if-already-staged" guard was only consulted (and only correct) after a
808 second ``add`` run. On a fresh stage the check was vacuously false for all
809 files, so even unchanged files were staged.
810 """
811 # Make an initial commit so HEAD has a manifest.
812 (code_repo / "alpha.py").write_text("x = 1\n")
813 (code_repo / "beta.py").write_text("y = 2\n")
814 runner.invoke(cli, ["commit", "-m", "initial"], env=_env(code_repo))
815
816 # Modify only one file; leave the other untouched.
817 (code_repo / "alpha.py").write_text("x = 99\n")
818
819 # Stage everything.
820 r = runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
821 assert r.exit_code == 0, r.output
822
823 # Only the changed file must be staged — NOT the unchanged beta.py.
824 from muse.plugins.code.stage import read_stage, StagedFileMap
825 stage = read_stage(code_repo)
826 assert "alpha.py" in stage, "modified file must be staged"
827 assert "beta.py" not in stage, "unchanged file must NOT appear in stage"
828
829
830 def test_add_dot_stages_deletions(
831 code_repo: pathlib.Path,
832 ) -> None:
833 """``muse code add .`` must stage tracked files that have been deleted from disk.
834
835 Regression test: before the fix, ``muse code add .`` (no flags) only walked
836 the working tree, so deleted files were silently omitted. Users had to know
837 to pass ``-A`` or explicitly name each deleted file — a significant ergonomic
838 gap vs ``git add .`` which has staged deletions since Git 2.0.
839 """
840 # code_repo already has auth.py and models.py committed.
841 os.remove(code_repo / "auth.py")
842
843 r = runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
844 assert r.exit_code == 0, r.output
845
846 from muse.plugins.code.stage import read_stage, StagedFileMap
847 stage = read_stage(code_repo)
848 assert "auth.py" in stage, "deleted tracked file must appear in stage with `muse code add .`"
849 assert stage["auth.py"]["mode"] == "D", "deleted file must have mode D"
850
851
852 def test_add_explicit_path_stages_deletion(
853 code_repo: pathlib.Path,
854 ) -> None:
855 """``muse code add <path>`` must stage a deletion when the file is gone from disk.
856
857 Mirrors ``git add <path>`` which stages the deletion regardless of whether
858 the file still exists on disk. Before the fix, naming a non-existent path
859 emitted ``❌ Path not found`` and silently skipped the deletion.
860 """
861 # code_repo already has auth.py committed — delete it from disk.
862 os.remove(code_repo / "auth.py")
863
864 r = runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
865 assert r.exit_code == 0, r.output
866
867 from muse.plugins.code.stage import read_stage
868 stage = read_stage(code_repo)
869 assert "auth.py" in stage, "deleted tracked file must appear in stage when named explicitly"
870 assert stage["auth.py"]["mode"] == "D", "deleted file must have mode D"
871
872
873 # ---------------------------------------------------------------------------
874 # Regression tests — _head_manifest branch resolution (Bug A)
875 #
876 # Written BEFORE the fix to document expected behaviour. Both tests verify
877 # that _head_manifest resolves the branch through the store abstraction
878 # (get_head_commit_id), not by reading the ref file directly.
879 # ---------------------------------------------------------------------------
880
881
882 class TestHeadManifestResolution:
883 """_head_manifest must use the store abstraction, not the raw ref file."""
884
885 def test_empty_branch_returns_empty_dict(
886 self, tmp_path: pathlib.Path
887 ) -> None:
888 """With no commits on the branch, _head_manifest returns {}."""
889 from muse.cli.commands.code_stage import _head_manifest
890
891 muse_dir = tmp_path / ".muse"
892 muse_dir.mkdir()
893 (muse_dir / "repo.json").write_text('{"repo_id":"test"}')
894 (muse_dir / "HEAD").write_text("ref: refs/heads/main")
895 (muse_dir / "refs" / "heads").mkdir(parents=True)
896 (muse_dir / "commits").mkdir()
897 (muse_dir / "snapshots").mkdir()
898 # No ref file written — branch has no commits.
899
900 result = _head_manifest(tmp_path)
901 assert result == {}
902
903 def test_branch_with_commit_returns_manifest(
904 self, tmp_path: pathlib.Path
905 ) -> None:
906 """With a real commit on the branch, _head_manifest returns its manifest."""
907 import datetime
908 from muse.cli.commands.code_stage import _head_manifest
909 from muse.core.store import write_commit, CommitRecord, SnapshotRecord
910 from muse.core.store import write_snapshot
911 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
912
913 muse_dir = tmp_path / ".muse"
914 muse_dir.mkdir()
915 (muse_dir / "repo.json").write_text('{"repo_id":"test"}')
916 (muse_dir / "HEAD").write_text("ref: refs/heads/main")
917 (muse_dir / "refs" / "heads").mkdir(parents=True)
918 (muse_dir / "commits").mkdir()
919 (muse_dir / "snapshots").mkdir()
920
921 _hello_id = fake_id("hello.py-content")
922 manifest = {"hello.py": _hello_id}
923 snap_id = compute_snapshot_id(manifest)
924 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest)
925 write_snapshot(tmp_path, snap)
926
927 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
928 commit_id = compute_commit_id(
929 repo_id="test",
930 parent_ids=[],
931 snapshot_id=snap_id,
932 message="init",
933 committed_at_iso=committed_at.isoformat(),
934 author="tester",)
935 commit = CommitRecord(
936 commit_id=commit_id,
937 repo_id="test",
938 created_on_branch="main",
939 snapshot_id=snap_id,
940 message="init",
941 committed_at=committed_at,
942 author="tester",
943 )
944 write_commit(tmp_path, commit)
945 (muse_dir / "refs" / "heads" / "main").write_text(commit_id)
946
947 result = _head_manifest(tmp_path)
948 assert result == {"hello.py": _hello_id}
949
950
951 # ---------------------------------------------------------------------------
952 # TestRegisterFlags
953 # ---------------------------------------------------------------------------
954
955
956 import argparse as _argparse
957
958
959 class TestRegisterFlags:
960 """register_add() and register_reset() wire --json / -j correctly."""
961
962 def _parse_add(self, *args: str) -> _argparse.Namespace:
963 from muse.cli.commands.code_stage import register_add
964 p = _argparse.ArgumentParser()
965 sub = p.add_subparsers()
966 register_add(sub)
967 return p.parse_args(["add", *args])
968
969 def _parse_reset(self, *args: str) -> _argparse.Namespace:
970 from muse.cli.commands.code_stage import register_reset
971 p = _argparse.ArgumentParser()
972 sub = p.add_subparsers()
973 register_reset(sub)
974 return p.parse_args(["reset", *args])
975
976 def test_add_default_json_out_is_false(self) -> None:
977 ns = self._parse_add("foo.py")
978 assert ns.json_out is False
979
980 def test_add_json_flag_sets_json_out(self) -> None:
981 ns = self._parse_add("--json", "foo.py")
982 assert ns.json_out is True
983
984 def test_add_j_shorthand_sets_json_out(self) -> None:
985 ns = self._parse_add("-j", "foo.py")
986 assert ns.json_out is True
987
988 def test_reset_default_json_out_is_false(self) -> None:
989 ns = self._parse_reset()
990 assert ns.json_out is False
991
992 def test_reset_json_flag_sets_json_out(self) -> None:
993 ns = self._parse_reset("--json")
994 assert ns.json_out is True
995
996 def test_reset_j_shorthand_sets_json_out(self) -> None:
997 ns = self._parse_reset("-j")
998 assert ns.json_out is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago