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