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