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