gabriel / muse public
test_code_stage.py python
730 lines 28.4 KB
5b27712b fix: muse code add skipped unchanged-vs-HEAD check, staging all files Gabriel Cardona <gabriel@tellurstori.com> 1d 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 pathlib
56
57 import pytest
58
59 from tests.cli_test_helper import CliRunner
60
61 cli = None # argparse migration — CliRunner ignores this arg
62 runner = CliRunner()
63
64
65 # ---------------------------------------------------------------------------
66 # Unit tests — pure functions
67 # ---------------------------------------------------------------------------
68
69
70 class TestSplitIntoHunks:
71 """Unit tests for _split_into_hunks (no I/O)."""
72
73 def _run(self, diff_text: str) -> list[list[str]]:
74 from muse.cli.commands.code_stage import _split_into_hunks
75 lines = [l + "\n" for l in diff_text.splitlines()]
76 return _split_into_hunks(lines)
77
78 def test_empty_diff_returns_no_hunks(self) -> None:
79 assert self._run("") == []
80
81 def test_single_hunk(self) -> None:
82 diff = (
83 "--- a/foo.py\n"
84 "+++ b/foo.py\n"
85 "@@ -1,2 +1,3 @@\n"
86 " def f():\n"
87 "- pass\n"
88 "+ return 1\n"
89 )
90 hunks = self._run(diff)
91 assert len(hunks) == 1
92 assert any("@@" in l for l in hunks[0])
93
94 def test_multi_hunk_has_header_on_each(self) -> None:
95 diff = (
96 "--- a/foo.py\n"
97 "+++ b/foo.py\n"
98 "@@ -1,2 +1,3 @@\n"
99 " line1\n"
100 "-old\n"
101 "+new\n"
102 "@@ -10,2 +11,3 @@\n"
103 " line10\n"
104 "-old10\n"
105 "+new10\n"
106 )
107 hunks = self._run(diff)
108 assert len(hunks) == 2
109 # Each hunk starts with the file header (--- / +++), then @@
110 for h in hunks:
111 assert any(l.startswith("---") for l in h)
112 assert any(l.startswith("+++") for l in h)
113 assert any(l.startswith("@@") for l in h)
114
115 def test_no_header_lines_before_first_hunk_is_still_valid(self) -> None:
116 diff = (
117 "@@ -1,1 +1,1 @@\n"
118 "-old\n"
119 "+new\n"
120 )
121 hunks = self._run(diff)
122 assert len(hunks) == 1
123
124
125 class TestApplyHunksToBytes:
126 """Unit tests for _apply_hunks_to_bytes."""
127
128 def _run(self, before: str, diff_text: str, accept_all: bool = True) -> str:
129 from muse.cli.commands.code_stage import _split_into_hunks, _apply_hunks_to_bytes
130
131 before_lines = before.splitlines(keepends=True)
132 after_lines = diff_text.splitlines(keepends=True)
133
134 import difflib
135 diff = list(difflib.unified_diff(
136 before_lines, after_lines, fromfile="a/f", tofile="b/f", lineterm=""
137 ))
138 diff_nl = [l + "\n" for l in diff]
139 hunks = _split_into_hunks(diff_nl)
140
141 accepted = hunks if accept_all else []
142 result = _apply_hunks_to_bytes(before.encode(), accepted)
143 return result.decode()
144
145 def test_accept_all_hunks_produces_after_content(self) -> None:
146 before = "def f():\n pass\n"
147 after = "def f():\n return 1\n"
148 result = self._run(before, after, accept_all=True)
149 assert "return 1" in result
150
151 def test_accept_no_hunks_preserves_original(self) -> None:
152 before = "def f():\n pass\n"
153 after = "def f():\n return 1\n"
154 result = self._run(before, after, accept_all=False)
155 assert result == before
156
157 def test_new_file_from_empty(self) -> None:
158 """Staging a new file from empty before-bytes produces after-content."""
159 before = ""
160 after = "x = 1\ny = 2\n"
161 result = self._run(before, after, accept_all=True)
162 assert "x = 1" in result
163
164 def test_binary_safe_with_replacement(self) -> None:
165 from muse.cli.commands.code_stage import _apply_hunks_to_bytes
166 result = _apply_hunks_to_bytes(b"\xff\xfe", [])
167 assert isinstance(result, bytes)
168
169
170 class TestInferMode:
171 """Unit tests for _infer_mode."""
172
173 def _run(self, rel: str, head: dict[str, str], exists: bool) -> str:
174 from muse.cli.commands.code_stage import _infer_mode
175 return _infer_mode(rel, head, exists)
176
177 def test_existing_tracked_is_M(self) -> None:
178 assert self._run("src/a.py", {"src/a.py": "abc"}, True) == "M"
179
180 def test_new_untracked_is_A(self) -> None:
181 assert self._run("src/new.py", {}, True) == "A"
182
183 def test_missing_from_disk_is_D(self) -> None:
184 assert self._run("src/gone.py", {"src/gone.py": "abc"}, False) == "D"
185
186 def test_missing_and_not_tracked_is_D(self) -> None:
187 # Shouldn't normally occur, but must not crash.
188 assert self._run("ghost.py", {}, False) == "D"
189
190
191 class TestColorizeHunk:
192 """Unit tests for _colorize_hunk."""
193
194 def test_added_lines_get_green(self) -> None:
195 from muse.cli.commands.code_stage import _colorize_hunk
196 result = _colorize_hunk(["+new line\n"])
197 assert "\x1b[32m" in result # green
198
199 def test_removed_lines_get_red(self) -> None:
200 from muse.cli.commands.code_stage import _colorize_hunk
201 result = _colorize_hunk(["-old line\n"])
202 assert "\x1b[31m" in result # red
203
204 def test_file_header_not_colored(self) -> None:
205 from muse.cli.commands.code_stage import _colorize_hunk
206 result = _colorize_hunk(["--- a/foo.py\n", "+++ b/foo.py\n"])
207 # file header lines should not get red/green
208 assert "\x1b[31m" not in result
209 assert "\x1b[32m" not in result
210
211 def test_at_at_header_gets_cyan(self) -> None:
212 from muse.cli.commands.code_stage import _colorize_hunk
213 result = _colorize_hunk(["@@ -1,2 +1,3 @@\n"])
214 assert "\x1b[36m" in result # cyan
215
216
217 # ---------------------------------------------------------------------------
218 # Fixtures
219 # ---------------------------------------------------------------------------
220
221
222 def _env(root: pathlib.Path) -> dict[str, str]:
223 return {"MUSE_REPO_ROOT": str(root)}
224
225
226 @pytest.fixture()
227 def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
228 """Initialise a fresh code-domain Muse repo with one initial commit."""
229 monkeypatch.chdir(tmp_path)
230
231 result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
232 assert result.exit_code == 0, result.output
233
234 (tmp_path / "auth.py").write_text("def authenticate():\n pass\n")
235 (tmp_path / "models.py").write_text("class User:\n pass\n")
236
237 r = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path))
238 assert r.exit_code == 0, r.output
239
240 return tmp_path
241
242
243 # ---------------------------------------------------------------------------
244 # muse code add — integration tests
245 # ---------------------------------------------------------------------------
246
247
248 class TestCodeAdd:
249 def test_stage_modified_file_is_mode_M(self, code_repo: pathlib.Path) -> None:
250 (code_repo / "auth.py").write_text("def authenticate():\n return True\n")
251 result = runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
252 assert result.exit_code == 0, result.output
253 assert "Staged 1 file" in result.output
254
255 stage = json.loads((code_repo / ".muse" / "code" / "stage.json").read_text())
256 assert stage["entries"]["auth.py"]["mode"] == "M"
257
258 def test_stage_new_file_is_mode_A(self, code_repo: pathlib.Path) -> None:
259 (code_repo / "new_module.py").write_text("x = 1\n")
260 runner.invoke(cli, ["code", "add", "new_module.py"], env=_env(code_repo))
261 stage = json.loads((code_repo / ".muse" / "code" / "stage.json").read_text())
262 assert stage["entries"]["new_module.py"]["mode"] == "A"
263
264 def test_stage_dot_stages_everything(self, code_repo: pathlib.Path) -> None:
265 (code_repo / "auth.py").write_text("# changed\n")
266 runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
267 stage = json.loads((code_repo / ".muse" / "code" / "stage.json").read_text())
268 assert "auth.py" in stage["entries"]
269
270 def test_stage_A_includes_new_files(self, code_repo: pathlib.Path) -> None:
271 (code_repo / "auth.py").write_text("# changed\n")
272 (code_repo / "new.py").write_text("x = 1\n")
273 runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
274 stage = json.loads((code_repo / ".muse" / "code" / "stage.json").read_text())
275 assert "auth.py" in stage["entries"]
276 assert "new.py" in stage["entries"]
277
278 def test_stage_u_excludes_new_untracked_files(
279 self, code_repo: pathlib.Path
280 ) -> None:
281 """-u stages only tracked files; new/untracked files are NOT staged."""
282 (code_repo / "auth.py").write_text("# tracked change\n")
283 (code_repo / "brand_new.py").write_text("x = 1\n")
284
285 runner.invoke(cli, ["code", "add", "-u"], env=_env(code_repo))
286
287 stage_file = code_repo / ".muse" / "code" / "stage.json"
288 assert stage_file.exists()
289 stage = json.loads(stage_file.read_text())
290 assert "auth.py" in stage["entries"]
291 assert "brand_new.py" not in stage["entries"]
292
293 def test_stage_u_includes_deleted_files(self, code_repo: pathlib.Path) -> None:
294 (code_repo / "models.py").unlink()
295 runner.invoke(cli, ["code", "add", "-u"], env=_env(code_repo))
296 stage = json.loads((code_repo / ".muse" / "code" / "stage.json").read_text())
297 assert "models.py" in stage["entries"]
298 assert stage["entries"]["models.py"]["mode"] == "D"
299
300 def test_stage_directory_expands_recursively(
301 self, code_repo: pathlib.Path
302 ) -> None:
303 src = code_repo / "src"
304 src.mkdir()
305 (src / "a.py").write_text("x = 1\n")
306 (src / "b.py").write_text("y = 2\n")
307
308 runner.invoke(cli, ["code", "add", "src"], env=_env(code_repo))
309 stage = json.loads((code_repo / ".muse" / "code" / "stage.json").read_text())
310 assert "src/a.py" in stage["entries"]
311 assert "src/b.py" in stage["entries"]
312
313 def test_dry_run_does_not_write_stage(self, code_repo: pathlib.Path) -> None:
314 (code_repo / "auth.py").write_text("# dry\n")
315 runner.invoke(
316 cli, ["code", "add", "--dry-run", "auth.py"], env=_env(code_repo)
317 )
318 assert not (code_repo / ".muse" / "code" / "stage.json").exists()
319
320 def test_dry_run_output_shows_files(self, code_repo: pathlib.Path) -> None:
321 (code_repo / "auth.py").write_text("# dry\n")
322 result = runner.invoke(
323 cli, ["code", "add", "--dry-run", "auth.py"], env=_env(code_repo)
324 )
325 assert "auth.py" in result.output
326
327 def test_verbose_shows_per_file_output(self, code_repo: pathlib.Path) -> None:
328 (code_repo / "auth.py").write_text("# verbose\n")
329 result = runner.invoke(
330 cli, ["code", "add", "-v", "auth.py"], env=_env(code_repo)
331 )
332 assert result.exit_code == 0
333 assert "auth.py" in result.output
334
335 def test_restage_updates_object_id(self, code_repo: pathlib.Path) -> None:
336 """Staging a file twice with different content updates the object_id."""
337 (code_repo / "auth.py").write_text("# version 1\n")
338 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
339 stage_v1 = json.loads(
340 (code_repo / ".muse" / "code" / "stage.json").read_text()
341 )
342 oid_v1 = stage_v1["entries"]["auth.py"]["object_id"]
343
344 (code_repo / "auth.py").write_text("# version 2\n")
345 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
346 stage_v2 = json.loads(
347 (code_repo / ".muse" / "code" / "stage.json").read_text()
348 )
349 oid_v2 = stage_v2["entries"]["auth.py"]["object_id"]
350
351 assert oid_v1 != oid_v2
352
353 def test_staging_unchanged_file_is_idempotent(
354 self, code_repo: pathlib.Path
355 ) -> None:
356 """Staging a file that has not changed since last staging is a no-op."""
357 (code_repo / "auth.py").write_text("# same\n")
358 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
359 result = runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
360 assert result.exit_code == 0
361 assert "already up to date" in result.output
362
363 def test_nonexistent_path_exits_error(self, code_repo: pathlib.Path) -> None:
364 result = runner.invoke(
365 cli, ["code", "add", "does_not_exist.py"], env=_env(code_repo)
366 )
367 assert result.exit_code != 0
368
369 def test_wrong_domain_exits_error(
370 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
371 ) -> None:
372 monkeypatch.chdir(tmp_path)
373 runner.invoke(cli, ["init", "--domain", "midi"], env=_env(tmp_path))
374 result = runner.invoke(cli, ["code", "add", "file.py"], env=_env(tmp_path))
375 assert result.exit_code != 0
376
377
378 # ---------------------------------------------------------------------------
379 # Stage-aware commit
380 # ---------------------------------------------------------------------------
381
382
383 class TestStageAwareCommit:
384 def test_only_staged_file_is_committed(self, code_repo: pathlib.Path) -> None:
385 (code_repo / "auth.py").write_text("def authenticate():\n return True\n")
386 (code_repo / "models.py").write_text("class User:\n name = 'anon'\n")
387
388 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
389
390 r = runner.invoke(
391 cli, ["commit", "-m", "auth only", "--format", "json"],
392 env=_env(code_repo),
393 )
394 assert r.exit_code == 0, r.output
395 data = json.loads(r.output.strip())
396
397 from muse.core.store import read_commit, read_snapshot
398 from muse.core.object_store import read_object
399
400 commit = read_commit(code_repo, data["commit_id"])
401 assert commit is not None
402 snap = read_snapshot(code_repo, commit.snapshot_id)
403 assert snap is not None
404
405 auth_bytes = read_object(code_repo, snap.manifest["auth.py"])
406 assert auth_bytes is not None
407 assert b"return True" in auth_bytes
408
409 models_bytes = read_object(code_repo, snap.manifest["models.py"])
410 assert models_bytes is not None
411 # models.py was NOT staged — should have old content (pass, not name='anon')
412 assert b"name = 'anon'" not in models_bytes
413 assert b"pass" in models_bytes
414
415 def test_stage_cleared_after_commit(self, code_repo: pathlib.Path) -> None:
416 (code_repo / "auth.py").write_text("# cleared after commit\n")
417 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
418
419 stage_file = code_repo / ".muse" / "code" / "stage.json"
420 assert stage_file.exists()
421
422 runner.invoke(cli, ["commit", "-m", "clear stage test"], env=_env(code_repo))
423 assert not stage_file.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 = json.loads(
563 (code_repo / ".muse" / "code" / "stage.json").read_text()
564 )
565 assert "auth.py" not in stage["entries"]
566 assert "models.py" in stage["entries"]
567
568 def test_reset_HEAD_syntax(self, code_repo: pathlib.Path) -> None:
569 (code_repo / "auth.py").write_text("# head\n")
570 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
571 result = runner.invoke(
572 cli, ["code", "reset", "HEAD", "auth.py"], env=_env(code_repo)
573 )
574 assert result.exit_code == 0
575 assert not (code_repo / ".muse" / "code" / "stage.json").exists()
576
577 def test_reset_no_args_clears_all(self, code_repo: pathlib.Path) -> None:
578 (code_repo / "auth.py").write_text("# a\n")
579 (code_repo / "models.py").write_text("# b\n")
580 runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
581 result = runner.invoke(cli, ["code", "reset"], env=_env(code_repo))
582 assert result.exit_code == 0
583 assert not (code_repo / ".muse" / "code" / "stage.json").exists()
584
585 def test_reset_when_nothing_staged(self, code_repo: pathlib.Path) -> None:
586 result = runner.invoke(cli, ["code", "reset"], env=_env(code_repo))
587 assert result.exit_code == 0
588 assert "Nothing staged" in result.output
589
590 def test_reset_nonexistent_file_does_not_crash(
591 self, code_repo: pathlib.Path
592 ) -> None:
593 (code_repo / "auth.py").write_text("# staged\n")
594 runner.invoke(cli, ["code", "add", "auth.py"], env=_env(code_repo))
595 result = runner.invoke(
596 cli, ["code", "reset", "not_in_stage.py"], env=_env(code_repo)
597 )
598 assert result.exit_code == 0
599 assert "not staged" in result.output
600
601
602 # ---------------------------------------------------------------------------
603 # Resilience
604 # ---------------------------------------------------------------------------
605
606
607 class TestResilience:
608 def test_corrupt_stage_json_returns_empty(
609 self, code_repo: pathlib.Path
610 ) -> None:
611 """Corrupt stage.json must degrade gracefully — returns {} on read."""
612 from muse.plugins.code.stage import read_stage
613
614 stage_dir = code_repo / ".muse" / "code"
615 stage_dir.mkdir(parents=True, exist_ok=True)
616 (stage_dir / "stage.json").write_text("NOT VALID JSON }{")
617
618 entries = read_stage(code_repo)
619 assert entries == {}
620
621 def test_truncated_stage_json_returns_empty(
622 self, code_repo: pathlib.Path
623 ) -> None:
624 from muse.plugins.code.stage import read_stage
625
626 stage_dir = code_repo / ".muse" / "code"
627 stage_dir.mkdir(parents=True, exist_ok=True)
628 (stage_dir / "stage.json").write_bytes(b"\x00\x01\x02")
629
630 entries = read_stage(code_repo)
631 assert entries == {}
632
633 def test_missing_stage_returns_empty(self, code_repo: pathlib.Path) -> None:
634 from muse.plugins.code.stage import read_stage
635
636 entries = read_stage(code_repo)
637 assert entries == {}
638
639 def test_write_empty_entries_removes_file(
640 self, code_repo: pathlib.Path
641 ) -> None:
642 from muse.plugins.code.stage import write_stage, stage_path
643
644 path = stage_path(code_repo)
645 path.parent.mkdir(parents=True, exist_ok=True)
646 path.write_text('{"version":1,"entries":{}}')
647
648 write_stage(code_repo, {})
649 assert not path.exists()
650
651 def test_clear_stage_idempotent(self, code_repo: pathlib.Path) -> None:
652 from muse.plugins.code.stage import clear_stage
653
654 clear_stage(code_repo) # no stage to clear — must not raise
655 clear_stage(code_repo) # idempotent
656
657
658 # ---------------------------------------------------------------------------
659 # Stress test
660 # ---------------------------------------------------------------------------
661
662
663 class TestStageStress:
664 def test_stage_100_files(
665 self, code_repo: pathlib.Path
666 ) -> None:
667 """Staging 100 files must complete without error and write all entries."""
668 for i in range(100):
669 (code_repo / f"module_{i:03d}.py").write_text(f"X_{i} = {i}\n")
670
671 result = runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
672 assert result.exit_code == 0, result.output
673
674 stage = json.loads(
675 (code_repo / ".muse" / "code" / "stage.json").read_text()
676 )
677 # 100 new files + 2 original tracked files (auth.py, models.py)
678 assert len(stage["entries"]) >= 100
679
680 def test_commit_100_staged_files(
681 self, code_repo: pathlib.Path
682 ) -> None:
683 """Committing 100 staged files produces a correct manifest."""
684 for i in range(100):
685 (code_repo / f"mod_{i:03d}.py").write_text(f"V = {i}\n")
686
687 runner.invoke(cli, ["code", "add", "-A"], env=_env(code_repo))
688 r = runner.invoke(
689 cli, ["commit", "-m", "100 files", "--format", "json"],
690 env=_env(code_repo),
691 )
692 assert r.exit_code == 0, r.output
693 data = json.loads(r.output.strip())
694
695 from muse.core.store import read_commit, read_snapshot
696 commit = read_commit(code_repo, data["commit_id"])
697 assert commit is not None
698 snap = read_snapshot(code_repo, commit.snapshot_id)
699 assert snap is not None
700 assert len(snap.manifest) >= 100
701
702
703 def test_add_dot_does_not_stage_unchanged_files(
704 code_repo: pathlib.Path,
705 ) -> None:
706 """``muse code add .`` must only stage files whose content differs from HEAD.
707
708 Regression test for the bug where ``muse code add .`` staged every file in
709 the working tree regardless of whether it had changed, because the
710 "skip-if-already-staged" guard was only consulted (and only correct) after a
711 second ``add`` run. On a fresh stage the check was vacuously false for all
712 files, so even unchanged files were staged.
713 """
714 # Make an initial commit so HEAD has a manifest.
715 (code_repo / "alpha.py").write_text("x = 1\n")
716 (code_repo / "beta.py").write_text("y = 2\n")
717 runner.invoke(cli, ["commit", "-m", "initial"], env=_env(code_repo))
718
719 # Modify only one file; leave the other untouched.
720 (code_repo / "alpha.py").write_text("x = 99\n")
721
722 # Stage everything.
723 r = runner.invoke(cli, ["code", "add", "."], env=_env(code_repo))
724 assert r.exit_code == 0, r.output
725
726 # Only the changed file must be staged — NOT the unchanged beta.py.
727 from muse.plugins.code.stage import read_stage
728 stage = read_stage(code_repo)
729 assert "alpha.py" in stage, "modified file must be staged"
730 assert "beta.py" not in stage, "unchanged file must NOT appear in stage"