gabriel / muse public
test_cmd_commit.py python
1,154 lines 51.5 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 days ago
1 """Tests for ``muse commit``.
2
3 Coverage tiers
4 --------------
5 Unit — parser flags, pure-logic helpers, sanitization.
6 Integration — actual repo operations: commits, snapshots, reflog, harmony.
7 End-to-end — CLI invocations, text and JSON output paths.
8 Security — ANSI injection, author impersonation, provenance field caps.
9 Stress — 100 sequential commits, large manifests, concurrent writes.
10 """
11
12 from __future__ import annotations
13
14 import argparse
15 import json
16 import os
17 import pathlib
18 import subprocess
19 import threading
20 import time
21 from unittest.mock import patch
22
23 import pytest
24
25 from tests.cli_test_helper import CliRunner, InvokeResult
26 from muse.core.store import (
27 get_head_commit_id,
28 read_commit,
29 read_current_branch,
30 read_snapshot,
31 )
32
33 runner = CliRunner()
34
35 # ──────────────────────────────────────────────────────────────────────────────
36 # Helpers
37 # ──────────────────────────────────────────────────────────────────────────────
38
39
40 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
41 """Run a muse command in *repo* and return the result."""
42 saved = os.getcwd()
43 try:
44 os.chdir(repo)
45 return runner.invoke(None, args)
46 finally:
47 os.chdir(saved)
48
49
50 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
51 return _invoke(repo, ["commit", *extra])
52
53
54 def _init_repo(repo: pathlib.Path) -> InvokeResult:
55 repo.mkdir(parents=True, exist_ok=True)
56 return _invoke(repo, ["init"])
57
58
59 # ──────────────────────────────────────────────────────────────────────────────
60 # Fixtures
61 # ──────────────────────────────────────────────────────────────────────────────
62
63
64 @pytest.fixture()
65 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
66 """Initialised repo with one tracked file ready to commit."""
67 _init_repo(tmp_path)
68 (tmp_path / "a.py").write_text("x = 1\n")
69 return tmp_path
70
71
72 # ──────────────────────────────────────────────────────────────────────────────
73 # Unit — parser flags
74 # ──────────────────────────────────────────────────────────────────────────────
75
76
77 class TestRegisterFlags:
78 """All expected CLI flags are registered on the commit subcommand."""
79
80 def _parse(self, *args: str) -> argparse.Namespace:
81 from muse.cli.commands.commit import register
82
83 p = argparse.ArgumentParser()
84 sub = p.add_subparsers()
85 register(sub)
86 return p.parse_args(["commit", *args])
87
88 def test_message_flag(self) -> None:
89 ns = self._parse("-m", "hello")
90 assert ns.message == "hello"
91
92 def test_allow_empty_flag(self) -> None:
93 ns = self._parse("-m", "x", "--allow-empty")
94 assert ns.allow_empty is True
95
96 def test_dry_run_short_flag(self) -> None:
97 ns = self._parse("-m", "x", "-n")
98 assert ns.dry_run is True
99
100 def test_dry_run_long_flag(self) -> None:
101 ns = self._parse("-m", "x", "--dry-run")
102 assert ns.dry_run is True
103
104 def test_json_flag(self) -> None:
105 ns = self._parse("-m", "x", "--json")
106 assert ns.fmt == "json"
107
108 def test_format_text_default(self) -> None:
109 ns = self._parse("-m", "x")
110 assert ns.fmt == "text"
111
112 def test_agent_id_flag(self) -> None:
113 ns = self._parse("-m", "x", "--agent-id", "bot-1")
114 assert ns.agent_id == "bot-1"
115
116 def test_model_id_flag(self) -> None:
117 ns = self._parse("-m", "x", "--model-id", "claude-4")
118 assert ns.model_id == "claude-4"
119
120 def test_toolchain_id_flag(self) -> None:
121 ns = self._parse("-m", "x", "--toolchain-id", "cursor-v1")
122 assert ns.toolchain_id == "cursor-v1"
123
124 def test_section_flag(self) -> None:
125 ns = self._parse("-m", "x", "--section", "chorus")
126 assert ns.section == "chorus"
127
128 def test_track_flag(self) -> None:
129 ns = self._parse("-m", "x", "--track", "bass")
130 assert ns.track == "bass"
131
132 def test_emotion_flag(self) -> None:
133 ns = self._parse("-m", "x", "--emotion", "joyful")
134 assert ns.emotion == "joyful"
135
136 def test_author_flag(self) -> None:
137 ns = self._parse("-m", "x", "--author", "alice")
138 assert ns.author == "alice"
139
140 def test_sign_flag(self) -> None:
141 ns = self._parse("-m", "x", "--sign")
142 assert ns.sign is True
143
144
145 # ──────────────────────────────────────────────────────────────────────────────
146 # Unit — _MAX_FIELD_LEN constant
147 # ──────────────────────────────────────────────────────────────────────────────
148
149
150 class TestMaxFieldLen:
151 def test_constant_exists_and_is_256(self) -> None:
152 from muse.cli.commands.commit import _MAX_FIELD_LEN
153
154 assert _MAX_FIELD_LEN == 256
155
156 def test_no_separate_max_author_constant(self) -> None:
157 import muse.cli.commands.commit as m
158
159 assert not hasattr(m, "_MAX_AUTHOR"), "_MAX_AUTHOR should be replaced by _MAX_FIELD_LEN"
160 assert not hasattr(m, "_MAX_PROV"), "_MAX_PROV should be replaced by _MAX_FIELD_LEN"
161
162
163 # ──────────────────────────────────────────────────────────────────────────────
164 # Unit — dead-code removal
165 # ──────────────────────────────────────────────────────────────────────────────
166
167
168 class TestDeadCodeRemoved:
169 def test_read_branch_removed(self) -> None:
170 import muse.cli.commands.commit as m
171
172 assert not hasattr(m, "_read_branch"), (
173 "_read_branch was a dead wrapper; it should have been deleted"
174 )
175
176 def test_read_parent_id_removed(self) -> None:
177 import muse.cli.commands.commit as m
178
179 assert not hasattr(m, "_read_parent_id"), (
180 "_read_parent_id was a dead wrapper; it should have been deleted"
181 )
182
183
184 # ──────────────────────────────────────────────────────────────────────────────
185 # Unit — inline imports removed
186 # ──────────────────────────────────────────────────────────────────────────────
187
188
189 class TestNoInlineImports:
190 def test_sign_commit_record_is_module_level_import(self) -> None:
191 import inspect
192
193 import muse.cli.commands.commit as m
194
195 src = inspect.getsource(m.run)
196 assert "from muse.core.provenance import sign_commit_record" not in src, (
197 "sign_commit_record import must be at module level, not inside run()"
198 )
199
200 def test_no_inline_store_imports(self) -> None:
201 import inspect
202
203 import muse.cli.commands.commit as m
204
205 src = inspect.getsource(m.run)
206 assert "from muse.core.store import" not in src, (
207 "store imports inside run() should be at module level"
208 )
209
210
211 # ──────────────────────────────────────────────────────────────────────────────
212 # Integration — basic commit lifecycle
213 # ──────────────────────────────────────────────────────────────────────────────
214
215
216 class TestBasicCommit:
217 def test_first_commit_succeeds(self, repo: pathlib.Path) -> None:
218 result = _commit(repo, "-m", "init")
219 assert result.exit_code == 0
220 assert "init" in result.output
221
222 def test_commit_creates_commit_record(self, repo: pathlib.Path) -> None:
223 _commit(repo, "-m", "first")
224 branch = read_current_branch(repo)
225 cid = get_head_commit_id(repo, branch)
226 assert cid is not None
227 rec = read_commit(repo, cid)
228 assert rec is not None
229 assert rec.message == "first"
230
231 def test_commit_creates_snapshot(self, repo: pathlib.Path) -> None:
232 _commit(repo, "-m", "snap")
233 branch = read_current_branch(repo)
234 cid = get_head_commit_id(repo, branch)
235 assert cid is not None
236 rec = read_commit(repo, cid)
237 assert rec is not None
238 snap = read_snapshot(repo, rec.snapshot_id)
239 assert snap is not None
240 assert len(snap.manifest) >= 1
241
242 def test_commit_advances_branch_ref(self, repo: pathlib.Path) -> None:
243 _commit(repo, "-m", "first")
244 cid1 = get_head_commit_id(repo, "main")
245 (repo / "b.py").write_text("y = 2\n")
246 _commit(repo, "-m", "second")
247 cid2 = get_head_commit_id(repo, "main")
248 assert cid1 != cid2
249
250 def test_second_commit_has_parent(self, repo: pathlib.Path) -> None:
251 _commit(repo, "-m", "first")
252 cid1 = get_head_commit_id(repo, "main")
253 (repo / "b.py").write_text("y = 2\n")
254 _commit(repo, "-m", "second")
255 cid2 = get_head_commit_id(repo, "main")
256 assert cid2 is not None
257 rec2 = read_commit(repo, cid2)
258 assert rec2 is not None
259 assert rec2.parent_commit_id == cid1
260
261 def test_nothing_to_commit_exits_0(self, repo: pathlib.Path) -> None:
262 _commit(repo, "-m", "first")
263 result = _commit(repo, "-m", "second")
264 assert result.exit_code == 0
265 assert "Nothing to commit" in result.output
266
267 def test_metadata_section_stored(self, repo: pathlib.Path) -> None:
268 _commit(repo, "-m", "chorus", "--section", "chorus")
269 branch = read_current_branch(repo)
270 cid = get_head_commit_id(repo, branch)
271 assert cid is not None
272 rec = read_commit(repo, cid)
273 assert rec is not None
274 assert rec.metadata.get("section") == "chorus"
275
276 def test_metadata_track_stored(self, repo: pathlib.Path) -> None:
277 _commit(repo, "-m", "bass", "--track", "bass")
278 branch = read_current_branch(repo)
279 cid = get_head_commit_id(repo, branch)
280 assert cid is not None
281 rec = read_commit(repo, cid)
282 assert rec is not None
283 assert rec.metadata.get("track") == "bass"
284
285 def test_metadata_emotion_stored(self, repo: pathlib.Path) -> None:
286 _commit(repo, "-m", "joy", "--emotion", "joyful")
287 branch = read_current_branch(repo)
288 cid = get_head_commit_id(repo, branch)
289 assert cid is not None
290 rec = read_commit(repo, cid)
291 assert rec is not None
292 assert rec.metadata.get("emotion") == "joyful"
293
294
295 # ──────────────────────────────────────────────────────────────────────────────
296 # Integration — allow-empty
297 # ──────────────────────────────────────────────────────────────────────────────
298
299
300 class TestAllowEmpty:
301 def test_allow_empty_creates_commit(self, repo: pathlib.Path) -> None:
302 result = _commit(repo, "-m", "empty", "--allow-empty")
303 assert result.exit_code == 0
304
305 def test_allow_empty_without_message_warns(
306 self, repo: pathlib.Path, caplog: pytest.LogCaptureFixture
307 ) -> None:
308 import logging
309
310 with caplog.at_level(logging.WARNING, logger="muse.cli.commands.commit"):
311 _commit(repo, "--allow-empty")
312 assert any(
313 "empty message" in r.message or "--allow-empty" in r.message
314 for r in caplog.records
315 )
316
317 def test_allow_empty_without_message_exits_0(self, repo: pathlib.Path) -> None:
318 result = _commit(repo, "--allow-empty")
319 assert result.exit_code == 0
320
321 def test_allow_empty_json_message_is_empty_string(self, repo: pathlib.Path) -> None:
322 result = _commit(repo, "--allow-empty", "--json")
323 data = json.loads(result.output)
324 assert data["message"] == ""
325
326
327 # ──────────────────────────────────────────────────────────────────────────────
328 # Integration — validation errors
329 # ──────────────────────────────────────────────────────────────────────────────
330
331
332 class TestValidation:
333 def test_missing_message_exits_1(self, repo: pathlib.Path) -> None:
334 result = _commit(repo)
335 assert result.exit_code == 1
336
337 def test_missing_message_prints_hint(self, repo: pathlib.Path) -> None:
338 result = _commit(repo)
339 assert "-m" in result.output or "message" in result.output.lower()
340
341 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
342 result = _commit(repo, "-m", "x", "--format", "xml")
343 assert result.exit_code == 1
344
345 def test_unknown_format_sanitized_in_error(self, repo: pathlib.Path) -> None:
346 evil = "\x1b[31mred\x1b[0m"
347 result = _commit(repo, "-m", "x", "--format", evil)
348 assert "\x1b" not in result.output
349
350 def test_empty_tree_without_allow_empty_exits_1(self, tmp_path: pathlib.Path) -> None:
351 # Create a bare .muse structure with no tracked files at all (pre-init state).
352 # This is the only scenario where the "empty tree" guard fires, because
353 # muse init always writes .museattributes and .museignore as tracked files.
354 bare = tmp_path / "bare"
355 bare.mkdir()
356 (bare / ".muse").mkdir()
357 (bare / ".muse" / "HEAD").write_text("ref: refs/heads/main\n")
358 (bare / ".muse" / "refs").mkdir()
359 (bare / ".muse" / "refs" / "heads").mkdir()
360 (bare / ".muse" / "repo.json").write_text(
361 '{"repo_id": "' + "a" * 36 + '", "schema_version": 1, "domain": "code"}'
362 )
363 result = _invoke(bare, ["commit", "-m", "empty tree"])
364 # Either exits 1 (empty tree guard) or 0 (domain plugin tracks no files).
365 # The point is that it must not crash.
366 assert result.exit_code in (0, 1)
367
368
369 # ──────────────────────────────────────────────────────────────────────────────
370 # End-to-end — JSON output schema
371 # ──────────────────────────────────────────────────────────────────────────────
372
373
374 class TestJsonSchema:
375 """All keys agents depend on must be present in every JSON response."""
376
377 REQUIRED_KEYS = {
378 "commit_id",
379 "branch",
380 "snapshot_id",
381 "message",
382 "parent_commit_id",
383 "parent2_commit_id",
384 "committed_at",
385 "author",
386 "agent_id",
387 "sem_ver_bump",
388 "breaking_changes",
389 "files_changed",
390 "dry_run",
391 }
392
393 def test_first_commit_json_keys(self, repo: pathlib.Path) -> None:
394 result = _commit(repo, "-m", "first", "--json")
395 assert result.exit_code == 0
396 data = json.loads(result.output)
397 missing = self.REQUIRED_KEYS - set(data)
398 assert not missing, f"Missing keys: {missing}"
399
400 def test_parent_commit_id_null_on_first_commit(self, repo: pathlib.Path) -> None:
401 result = _commit(repo, "-m", "first", "--json")
402 data = json.loads(result.output)
403 assert data["parent_commit_id"] is None
404
405 def test_parent_commit_id_populated_on_second_commit(self, repo: pathlib.Path) -> None:
406 _commit(repo, "-m", "first")
407 cid1 = get_head_commit_id(repo, "main")
408 (repo / "b.py").write_text("y=2\n")
409 result = _commit(repo, "-m", "second", "--json")
410 data = json.loads(result.output)
411 assert data["parent_commit_id"] == cid1
412
413 def test_parent2_commit_id_null_on_regular_commit(self, repo: pathlib.Path) -> None:
414 result = _commit(repo, "-m", "first", "--json")
415 data = json.loads(result.output)
416 assert data["parent2_commit_id"] is None
417
418 def test_breaking_changes_is_list(self, repo: pathlib.Path) -> None:
419 result = _commit(repo, "-m", "first", "--json")
420 data = json.loads(result.output)
421 assert isinstance(data["breaking_changes"], list)
422
423 def test_sem_ver_bump_is_string(self, repo: pathlib.Path) -> None:
424 result = _commit(repo, "-m", "first", "--json")
425 data = json.loads(result.output)
426 assert isinstance(data["sem_ver_bump"], str)
427
428 def test_agent_id_default_empty_string(self, repo: pathlib.Path) -> None:
429 result = _commit(repo, "-m", "first", "--json")
430 data = json.loads(result.output)
431 assert data["agent_id"] == ""
432
433 def test_agent_id_from_flag(self, repo: pathlib.Path) -> None:
434 result = _commit(repo, "-m", "x", "--agent-id", "bot-42", "--json")
435 data = json.loads(result.output)
436 assert data["agent_id"] == "bot-42"
437
438 def test_agent_id_from_env(self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
439 monkeypatch.setenv("MUSE_AGENT_ID", "env-bot")
440 result = _invoke(repo, ["commit", "-m", "x", "--json"])
441 data = json.loads(result.output)
442 assert data["agent_id"] == "env-bot"
443
444 def test_dry_run_false_on_real_commit(self, repo: pathlib.Path) -> None:
445 result = _commit(repo, "-m", "x", "--json")
446 data = json.loads(result.output)
447 assert data["dry_run"] is False
448
449 def test_files_changed_structure(self, repo: pathlib.Path) -> None:
450 result = _commit(repo, "-m", "x", "--json")
451 data = json.loads(result.output)
452 fc = data["files_changed"]
453 assert isinstance(fc, dict)
454 assert {"added", "modified", "deleted", "total"} <= set(fc.keys())
455
456 def test_files_added_counted(self, repo: pathlib.Path) -> None:
457 result = _commit(repo, "-m", "x", "--json")
458 data = json.loads(result.output)
459 assert data["files_changed"]["added"] >= 1
460
461 def test_files_modified_counted(self, repo: pathlib.Path) -> None:
462 _commit(repo, "-m", "first")
463 (repo / "a.py").write_text("x = 99\n")
464 result = _commit(repo, "-m", "mod", "--json")
465 data = json.loads(result.output)
466 assert data["files_changed"]["modified"] == 1
467 assert data["files_changed"]["added"] == 0
468
469 def test_files_deleted_counted(self, repo: pathlib.Path) -> None:
470 (repo / "del.py").write_text("z = 3\n")
471 _commit(repo, "-m", "add del.py")
472 (repo / "del.py").unlink()
473 result = _commit(repo, "-m", "remove", "--json")
474 data = json.loads(result.output)
475 assert data["files_changed"]["deleted"] == 1
476
477 def test_committed_at_is_utc_iso(self, repo: pathlib.Path) -> None:
478 import datetime
479
480 result = _commit(repo, "-m", "x", "--json")
481 data = json.loads(result.output)
482 dt = datetime.datetime.fromisoformat(data["committed_at"])
483 assert dt.tzinfo is not None
484
485
486 # ──────────────────────────────────────────────────────────────────────────────
487 # End-to-end — dry-run
488 # ──────────────────────────────────────────────────────────────────────────────
489
490
491 class TestDryRun:
492 def test_dry_run_no_commit_written(self, repo: pathlib.Path) -> None:
493 result = _commit(repo, "-m", "dr", "--dry-run")
494 assert result.exit_code == 0
495 assert get_head_commit_id(repo, "main") is None
496
497 def test_dry_run_json_schema(self, repo: pathlib.Path) -> None:
498 result = _commit(repo, "-m", "dr", "--dry-run", "--json")
499 assert result.exit_code == 0
500 data = json.loads(result.output)
501 assert data["dry_run"] is True
502 assert data["clean"] is False
503 assert "commit_id" in data
504 assert "files_changed" in data
505
506 def test_dry_run_snapshot_id_stable(self, repo: pathlib.Path) -> None:
507 """Same tree content → same snapshot_id on repeated dry-runs."""
508 r1 = _commit(repo, "-m", "dr", "--dry-run", "--json")
509 r2 = _commit(repo, "-m", "dr", "--dry-run", "--json")
510 d1 = json.loads(r1.output)
511 d2 = json.loads(r2.output)
512 assert d1["snapshot_id"] == d2["snapshot_id"]
513
514 def test_dry_run_clean_tree_exits_1(self, repo: pathlib.Path) -> None:
515 _commit(repo, "-m", "first")
516 result = _commit(repo, "-m", "no changes", "--dry-run")
517 assert result.exit_code == 1
518
519 def test_dry_run_clean_tree_json_clean_flag(self, repo: pathlib.Path) -> None:
520 _commit(repo, "-m", "first")
521 result = _commit(repo, "-m", "no changes", "--dry-run", "--json")
522 data = json.loads(result.output)
523 assert data["clean"] is True
524
525 def test_dry_run_text_output_prefix(self, repo: pathlib.Path) -> None:
526 result = _commit(repo, "-m", "preview", "--dry-run")
527 assert "dry-run" in result.output
528
529 def test_dry_run_text_output_nothing_written_note(self, repo: pathlib.Path) -> None:
530 result = _commit(repo, "-m", "preview", "--dry-run")
531 assert "nothing written" in result.output
532
533 def test_dry_run_shows_sem_ver_in_json(self, repo: pathlib.Path) -> None:
534 result = _commit(repo, "-m", "dr", "--dry-run", "--json")
535 data = json.loads(result.output)
536 assert "sem_ver_bump" in data
537
538 def test_dry_run_does_not_advance_branch(self, repo: pathlib.Path) -> None:
539 _commit(repo, "-m", "first")
540 cid_before = get_head_commit_id(repo, "main")
541 (repo / "b.py").write_text("z=9\n")
542 _commit(repo, "-m", "second", "--dry-run")
543 cid_after = get_head_commit_id(repo, "main")
544 assert cid_before == cid_after
545
546 def test_dry_run_parent_commit_id_in_json(self, repo: pathlib.Path) -> None:
547 _commit(repo, "-m", "first")
548 cid1 = get_head_commit_id(repo, "main")
549 (repo / "b.py").write_text("z=9\n")
550 result = _commit(repo, "-m", "second", "--dry-run", "--json")
551 data = json.loads(result.output)
552 assert data["parent_commit_id"] == cid1
553
554
555 # ──────────────────────────────────────────────────────────────────────────────
556 # End-to-end — text output
557 # ──────────────────────────────────────────────────────────────────────────────
558
559
560 class TestTextOutput:
561 def test_text_shows_branch_and_short_id(self, repo: pathlib.Path) -> None:
562 import re
563
564 result = _commit(repo, "-m", "hello")
565 assert "main" in result.output
566 # Output format: "[main sha256:X...] message"
567 # The sha256: prefix is canonical — check for it directly.
568 assert re.search(r"sha256:[0-9a-f]+", result.output), (
569 f"No sha256:-prefixed commit ID found in: {result.output!r}"
570 )
571
572 def test_text_shows_message(self, repo: pathlib.Path) -> None:
573 result = _commit(repo, "-m", "verse melody")
574 assert "verse melody" in result.output
575
576 def test_text_shows_files_changed(self, repo: pathlib.Path) -> None:
577 result = _commit(repo, "-m", "x")
578 assert "file" in result.output
579
580 def test_text_nothing_to_commit_message(self, repo: pathlib.Path) -> None:
581 _commit(repo, "-m", "first")
582 result = _commit(repo, "-m", "second")
583 assert "Nothing to commit" in result.output
584
585
586 # ──────────────────────────────────────────────────────────────────────────────
587 # Security — ANSI injection prevention
588 # ──────────────────────────────────────────────────────────────────────────────
589
590
591 class TestSecurityAnsi:
592 """Text output must never emit raw ANSI escape sequences from user input."""
593
594 def _has_ansi(self, s: str) -> bool:
595 return "\x1b[" in s or "\x1b]" in s
596
597 def test_ansi_in_message_stripped_from_text_output(self, repo: pathlib.Path) -> None:
598 msg = "hello \x1b[31mred\x1b[0m world"
599 result = _commit(repo, "-m", msg)
600 assert not self._has_ansi(result.output), "ANSI in message leaked to text output"
601
602 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
603 result = _commit(repo, "-m", "x", "--format", "\x1b[31mxml\x1b[0m")
604 assert not self._has_ansi(result.output)
605
606 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
607 result = _commit(repo, "-m", "x", "--author", "\x1b[1mevil\x1b[0m")
608 assert not self._has_ansi(result.output)
609
610
611 # ──────────────────────────────────────────────────────────────────────────────
612 # Security — author / provenance field caps
613 # ──────────────────────────────────────────────────────────────────────────────
614
615
616 class TestSecurityProvenance:
617 def test_author_capped_at_256_chars(self, repo: pathlib.Path) -> None:
618 long_author = "a" * 500
619 _commit(repo, "-m", "x", "--author", long_author)
620 branch = read_current_branch(repo)
621 cid = get_head_commit_id(repo, branch)
622 assert cid is not None
623 rec = read_commit(repo, cid)
624 assert rec is not None
625 assert len(rec.author) <= 256
626
627 def test_agent_id_capped_at_256_chars(self, repo: pathlib.Path) -> None:
628 long_id = "b" * 500
629 _commit(repo, "-m", "x", "--agent-id", long_id)
630 branch = read_current_branch(repo)
631 cid = get_head_commit_id(repo, branch)
632 assert cid is not None
633 rec = read_commit(repo, cid)
634 assert rec is not None
635 assert len(rec.agent_id) <= 256
636
637 def test_author_control_chars_stripped(self, repo: pathlib.Path) -> None:
638 _commit(repo, "-m", "x", "--author", "alice\x00\x01\x02")
639 branch = read_current_branch(repo)
640 cid = get_head_commit_id(repo, branch)
641 assert cid is not None
642 rec = read_commit(repo, cid)
643 assert rec is not None
644 assert "\x00" not in rec.author
645 assert "\x01" not in rec.author
646
647 def test_author_override_emits_warning(
648 self, repo: pathlib.Path, caplog: pytest.LogCaptureFixture
649 ) -> None:
650 import logging
651
652 with caplog.at_level(logging.WARNING, logger="muse.cli.commands.commit"):
653 _commit(repo, "-m", "x", "--author", "evil-impersonator")
654 assert any(
655 "impersonation" in r.message or "--author" in r.message
656 for r in caplog.records
657 )
658
659 def test_agent_id_from_flag_overrides_env(
660 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
661 ) -> None:
662 monkeypatch.setenv("MUSE_AGENT_ID", "env-agent")
663 result = _invoke(repo, ["commit", "-m", "x", "--agent-id", "flag-agent", "--json"])
664 data = json.loads(result.output)
665 assert data["agent_id"] == "flag-agent"
666
667
668 # ──────────────────────────────────────────────────────────────────────────────
669 # Integration — merge-parent recording
670 # ──────────────────────────────────────────────────────────────────────────────
671
672
673 class TestMergeParent:
674 """When a merge commit is created, parent2_commit_id must be set."""
675
676 def test_merge_commit_has_two_parents(self, repo: pathlib.Path) -> None:
677 _commit(repo, "-m", "base")
678 _invoke(repo, ["branch", "feat"])
679 _invoke(repo, ["checkout", "feat"])
680 (repo / "feat.py").write_text("f = 1\n")
681 _commit(repo, "-m", "feat commit")
682 _invoke(repo, ["checkout", "main"])
683 (repo / "main_only.py").write_text("m = 1\n")
684 _commit(repo, "-m", "main commit")
685 _invoke(repo, ["merge", "feat"])
686 cid = get_head_commit_id(repo, "main")
687 assert cid is not None
688 rec = read_commit(repo, cid)
689 assert rec is not None
690 assert rec.parent2_commit_id is not None
691
692 def test_regular_commit_parent2_is_none(self, repo: pathlib.Path) -> None:
693 _commit(repo, "-m", "first")
694 (repo / "b.py").write_text("b=1\n")
695 _commit(repo, "-m", "second")
696 branch = read_current_branch(repo)
697 cid = get_head_commit_id(repo, branch)
698 assert cid is not None
699 rec = read_commit(repo, cid)
700 assert rec is not None
701 assert rec.parent2_commit_id is None
702
703
704 # ──────────────────────────────────────────────────────────────────────────────
705 # Integration — SemVer bump inference
706 # ──────────────────────────────────────────────────────────────────────────────
707
708
709 class TestSemVerBump:
710 def test_first_commit_sem_ver_bump_valid(self, repo: pathlib.Path) -> None:
711 _commit(repo, "-m", "init")
712 cid = get_head_commit_id(repo, "main")
713 assert cid is not None
714 rec = read_commit(repo, cid)
715 assert rec is not None
716 assert rec.sem_ver_bump in ("none", "patch", "minor", "major")
717
718 def test_json_sem_ver_bump_is_valid_value(self, repo: pathlib.Path) -> None:
719 result = _commit(repo, "-m", "x", "--json")
720 data = json.loads(result.output)
721 assert data["sem_ver_bump"] in ("none", "patch", "minor", "major")
722
723 def test_breaking_changes_list_in_record(self, repo: pathlib.Path) -> None:
724 _commit(repo, "-m", "first")
725 branch = read_current_branch(repo)
726 cid = get_head_commit_id(repo, branch)
727 assert cid is not None
728 rec = read_commit(repo, cid)
729 assert rec is not None
730 assert isinstance(rec.breaking_changes, list)
731
732
733 # ──────────────────────────────────────────────────────────────────────────────
734 # Integration — reflog
735 # ──────────────────────────────────────────────────────────────────────────────
736
737
738 class TestReflog:
739 def test_commit_appends_reflog_entry(self, repo: pathlib.Path) -> None:
740 from muse.core.reflog import read_reflog
741
742 _commit(repo, "-m", "logged")
743 entries = read_reflog(repo, "main")
744 assert len(entries) >= 1
745 assert any(
746 "logged" in e.operation or "commit" in e.operation for e in entries
747 )
748
749 def test_reflog_contains_commit_id(self, repo: pathlib.Path) -> None:
750 from muse.core.reflog import read_reflog
751
752 _commit(repo, "-m", "ref-entry")
753 cid = get_head_commit_id(repo, "main")
754 entries = read_reflog(repo, "main")
755 assert any(e.new_id == cid for e in entries)
756
757
758 # ──────────────────────────────────────────────────────────────────────────────
759 # Integration — stage cleared after commit
760 # ──────────────────────────────────────────────────────────────────────────────
761
762
763 class TestStageClearAfterCommit:
764 def test_stage_is_cleared(self, repo: pathlib.Path) -> None:
765 _invoke(repo, ["code", "add", "."])
766 _commit(repo, "-m", "staged")
767 stage_path = repo / ".muse" / "stage.json"
768 if stage_path.exists():
769 data = json.loads(stage_path.read_text())
770 assert data == {} or data.get("files") == {}
771
772
773 # ──────────────────────────────────────────────────────────────────────────────
774 # End-to-end — provenance env vars
775 # ──────────────────────────────────────────────────────────────────────────────
776
777
778 class TestProvenanceEnvVars:
779 def test_model_id_from_env(
780 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
781 ) -> None:
782 monkeypatch.setenv("MUSE_MODEL_ID", "gpt-5")
783 _invoke(repo, ["commit", "-m", "x"])
784 branch = read_current_branch(repo)
785 cid = get_head_commit_id(repo, branch)
786 assert cid is not None
787 rec = read_commit(repo, cid)
788 assert rec is not None
789 assert rec.model_id == "gpt-5"
790
791 def test_toolchain_id_from_env(
792 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
793 ) -> None:
794 monkeypatch.setenv("MUSE_TOOLCHAIN_ID", "cursor-v42")
795 _invoke(repo, ["commit", "-m", "x"])
796 branch = read_current_branch(repo)
797 cid = get_head_commit_id(repo, branch)
798 assert cid is not None
799 rec = read_commit(repo, cid)
800 assert rec is not None
801 assert rec.toolchain_id == "cursor-v42"
802
803 def test_prompt_hash_from_env(
804 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
805 ) -> None:
806 monkeypatch.setenv("MUSE_PROMPT_HASH", "abc123")
807 _invoke(repo, ["commit", "-m", "x"])
808 branch = read_current_branch(repo)
809 cid = get_head_commit_id(repo, branch)
810 assert cid is not None
811 rec = read_commit(repo, cid)
812 assert rec is not None
813 assert rec.prompt_hash == "abc123"
814
815 def test_flag_overrides_env_for_model_id(
816 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
817 ) -> None:
818 monkeypatch.setenv("MUSE_MODEL_ID", "env-model")
819 _invoke(repo, ["commit", "-m", "x", "--model-id", "flag-model"])
820 branch = read_current_branch(repo)
821 cid = get_head_commit_id(repo, branch)
822 assert cid is not None
823 rec = read_commit(repo, cid)
824 assert rec is not None
825 assert rec.model_id == "flag-model"
826
827
828 # ──────────────────────────────────────────────────────────────────────────────
829 # Integration — parent manifest not double-read
830 # ──────────────────────────────────────────────────────────────────────────────
831
832
833 class TestParentManifestSingleRead:
834 """
835 The parent snapshot must be loaded only once per commit, not twice.
836 We verify via call counts on read_snapshot.
837 """
838
839 def test_parent_snapshot_read_at_most_once(self, repo: pathlib.Path) -> None:
840 _commit(repo, "-m", "first")
841 (repo / "b.py").write_text("b=1\n")
842 call_count: list[int] = [0]
843 original_read_snapshot = read_snapshot
844
845 from muse.core.store import SnapshotRecord
846
847 def counting_read_snapshot(
848 root: pathlib.Path, sid: str
849 ) -> SnapshotRecord | None:
850 call_count[0] += 1
851 return original_read_snapshot(root, sid)
852
853 with patch(
854 "muse.cli.commands.commit.read_snapshot",
855 side_effect=counting_read_snapshot,
856 ):
857 _commit(repo, "-m", "second")
858
859 # Should be ≤1 (one read of the parent snapshot).
860 # Previously the bug caused 2 reads: one for structured_delta, one for file counts.
861 assert call_count[0] <= 1, (
862 f"read_snapshot called {call_count[0]} times; expected ≤1 (parent double-read bug)"
863 )
864
865
866 # ──────────────────────────────────────────────────────────────────────────────
867 # Stress — sequential commits
868 # ──────────────────────────────────────────────────────────────────────────────
869
870
871 @pytest.mark.slow
872 class TestStressSequential:
873 def test_100_commits_all_succeed(self, repo: pathlib.Path) -> None:
874 for i in range(100):
875 (repo / f"f{i:04d}.py").write_text(f"x = {i}\n")
876 result = _commit(repo, "-m", f"commit {i}")
877 assert result.exit_code == 0, f"Commit {i} failed: {result.output}"
878
879 def test_100_commits_branch_advances(self, repo: pathlib.Path) -> None:
880 seen_ids: set[str] = set()
881 for i in range(100):
882 (repo / f"g{i:04d}.py").write_text(f"y = {i}\n")
883 _commit(repo, "-m", f"c{i}")
884 cid = get_head_commit_id(repo, "main")
885 assert cid not in seen_ids, f"Duplicate commit ID at commit {i}"
886 if cid:
887 seen_ids.add(cid)
888 assert len(seen_ids) == 100
889
890
891 @pytest.mark.slow
892 class TestStressLargeManifest:
893 def test_500_file_commit_succeeds(self, repo: pathlib.Path) -> None:
894 for i in range(500):
895 (repo / f"h{i:04d}.py").write_text(f"z = {i}\n")
896 t0 = time.perf_counter()
897 result = _commit(repo, "-m", "big")
898 elapsed = (time.perf_counter() - t0) * 1000
899 assert result.exit_code == 0
900 assert elapsed < 3000, f"Commit too slow: {elapsed:.0f}ms"
901
902 def test_500_file_single_change_commit(self, repo: pathlib.Path) -> None:
903 for i in range(500):
904 (repo / f"k{i:04d}.py").write_text(f"a = {i}\n")
905 _commit(repo, "-m", "base")
906 (repo / "k0000.py").write_text("a = 999\n")
907 t0 = time.perf_counter()
908 result = _commit(repo, "-m", "one change")
909 elapsed = (time.perf_counter() - t0) * 1000
910 assert result.exit_code == 0
911 assert elapsed < 2000, f"Single-file commit too slow: {elapsed:.0f}ms"
912
913
914 # ──────────────────────────────────────────────────────────────────────────────
915 # Stress — concurrent commits to different repos
916 # ──────────────────────────────────────────────────────────────────────────────
917
918
919 @pytest.mark.slow
920 class TestStressConcurrent:
921 def test_concurrent_commits_to_separate_repos(self, tmp_path: pathlib.Path) -> None:
922 """16 threads each commit to their own isolated repo — no interference."""
923 errors: list[str] = []
924
925 def do_commit(idx: int) -> None:
926 repo_dir = tmp_path / f"repo_{idx}"
927 repo_dir.mkdir()
928 subprocess.run(
929 ["muse", "init"], cwd=str(repo_dir), capture_output=True
930 )
931 (repo_dir / "x.py").write_text(f"x = {idx}\n")
932 r = subprocess.run(
933 ["muse", "commit", "-m", f"c{idx}", "--json"],
934 cwd=str(repo_dir),
935 capture_output=True,
936 text=True,
937 )
938 if r.returncode != 0:
939 errors.append(f"repo_{idx}: {r.stderr}")
940 return
941 data = json.loads(r.stdout)
942 if "commit_id" not in data:
943 errors.append(f"repo_{idx}: no commit_id in output")
944
945 threads = [threading.Thread(target=do_commit, args=(i,)) for i in range(16)]
946 for t in threads:
947 t.start()
948 for t in threads:
949 t.join()
950
951
952 # ---------------------------------------------------------------------------
953 # Bug: muse commit must leave committed files on disk
954 #
955 # When a staged file's object is already in the store (e.g. because the file
956 # was staged, then deleted from disk by muse merge --abort, then committed
957 # via the stage index), write_object_from_path silently skips the write and
958 # the file is absent from disk even though HEAD's manifest includes it.
959 #
960 # Fix: after writing the commit, apply the committed manifest to disk so the
961 # working tree always matches HEAD.
962 # ---------------------------------------------------------------------------
963
964 class TestCommitRestoresFilesToDisk:
965
966 def test_added_file_exists_on_disk_after_commit(self, repo: pathlib.Path) -> None:
967 """A newly staged file must be present on disk after commit."""
968 # First commit so HEAD exists.
969 _invoke(repo, ["code", "add", "a.py"])
970 _commit(repo, "-m", "base")
971
972 # Stage a new file.
973 new_file = repo / "new.py"
974 new_file.write_text("# new\n")
975 _invoke(repo, ["code", "add", "new.py"])
976
977 # Simulate merge --abort: delete the file from disk (the bug scenario).
978 new_file.unlink()
979 assert not new_file.exists()
980
981 # Commit via the stage (object is in store from the add).
982 result = _commit(repo, "-m", "add new.py", "--json")
983 assert result.exit_code == 0, result.output
984
985 # The file must exist on disk — it's in HEAD's manifest.
986 assert new_file.exists(), "committed file must be present on disk after muse commit"
987 assert new_file.read_text() == "# new\n"
988
989 def test_modified_file_reflects_committed_content_after_commit(
990 self, repo: pathlib.Path
991 ) -> None:
992 """A staged modification must be visible on disk after commit."""
993 _invoke(repo, ["code", "add", "a.py"])
994 _commit(repo, "-m", "base")
995
996 # Stage a modification.
997 (repo / "a.py").write_text("x = 99\n")
998 _invoke(repo, ["code", "add", "a.py"])
999
1000 # Simulate merge --abort reverting the file.
1001 (repo / "a.py").write_text("x = 1\n")
1002
1003 # Commit: the stage has the new content's object_id (already in store).
1004 result = _commit(repo, "-m", "update a.py", "--json")
1005 assert result.exit_code == 0, result.output
1006
1007 # Disk must reflect what was committed, not the reverted content.
1008 assert (repo / "a.py").read_text() == "x = 99\n"
1009
1010
1011 # ──────────────────────────────────────────────────────────────────────────────
1012 # Bug: commit refuses when only staged deletions remain (empty snapshot)
1013 # ──────────────────────────────────────────────────────────────────────────────
1014
1015
1016 class TestCommitAllDeletions:
1017 """muse commit must succeed when the only staged changes are deletions.
1018
1019 The previous bug: plugin.snapshot() returns an empty manifest when all
1020 on-disk files are gone, and the guard ``if not manifest and not allow_empty``
1021 fired — refusing the commit with "nothing tracked". But staged deletions
1022 ARE meaningful changes; the snapshot is intentionally empty.
1023 """
1024
1025 def _committed_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
1026 """Init repo, add files, make a first commit. Returns the repo path."""
1027 _init_repo(tmp_path)
1028 (tmp_path / "a.txt").write_text("alpha\n")
1029 (tmp_path / "b.txt").write_text("beta\n")
1030 _invoke(tmp_path, ["code", "add", "."])
1031 _commit(tmp_path, "-m", "initial")
1032 return tmp_path
1033
1034 def test_commit_after_rm_all_succeeds(self, tmp_path: pathlib.Path) -> None:
1035 """muse commit must exit 0 after muse rm removes all tracked files."""
1036 repo = self._committed_repo(tmp_path)
1037 _invoke(repo, ["rm", "a.txt"])
1038 _invoke(repo, ["rm", "b.txt"])
1039 result = _commit(repo, "-m", "remove everything")
1040 assert result.exit_code == 0, result.output
1041
1042 def test_commit_after_rm_all_creates_second_commit(self, tmp_path: pathlib.Path) -> None:
1043 repo = self._committed_repo(tmp_path)
1044 _invoke(repo, ["rm", "a.txt"])
1045 _invoke(repo, ["rm", "b.txt"])
1046 _commit(repo, "-m", "remove everything")
1047 branch = read_current_branch(repo)
1048 commit_id = get_head_commit_id(repo, branch)
1049 assert commit_id is not None
1050 commit = read_commit(repo, commit_id)
1051 assert commit is not None
1052 assert commit.message == "remove everything"
1053
1054 def test_commit_after_rm_all_snapshot_is_empty(self, tmp_path: pathlib.Path) -> None:
1055 """The snapshot produced by an all-deletions commit must be empty."""
1056 repo = self._committed_repo(tmp_path)
1057 _invoke(repo, ["rm", "a.txt"])
1058 _invoke(repo, ["rm", "b.txt"])
1059 _commit(repo, "-m", "remove everything")
1060 branch = read_current_branch(repo)
1061 commit_id = get_head_commit_id(repo, branch)
1062 commit = read_commit(repo, commit_id)
1063 snap = read_snapshot(repo, commit.snapshot_id)
1064 assert snap is not None
1065 assert snap.manifest == {}
1066
1067 def test_commit_after_rm_one_file_leaves_one_in_snapshot(
1068 self, tmp_path: pathlib.Path
1069 ) -> None:
1070 """Removing one of two files produces a one-entry snapshot."""
1071 repo = self._committed_repo(tmp_path)
1072 _invoke(repo, ["rm", "a.txt"])
1073 _commit(repo, "-m", "remove a.txt")
1074 branch = read_current_branch(repo)
1075 commit_id = get_head_commit_id(repo, branch)
1076 commit = read_commit(repo, commit_id)
1077 snap = read_snapshot(repo, commit.snapshot_id)
1078 assert snap is not None
1079 assert "a.txt" not in snap.manifest
1080 assert "b.txt" in snap.manifest
1081
1082 def test_json_output_on_all_deletions_commit(self, tmp_path: pathlib.Path) -> None:
1083 """--json output must be valid and show exit_code 0 for an all-deletions commit."""
1084 repo = self._committed_repo(tmp_path)
1085 _invoke(repo, ["rm", "a.txt"])
1086 _invoke(repo, ["rm", "b.txt"])
1087 result = _commit(repo, "-m", "rm all", "--json")
1088 assert result.exit_code == 0, result.output
1089 data = json.loads(result.output)
1090 assert data.get("exit_code", data.get("code", 0)) == 0 or "commit_id" in data
1091
1092 def test_status_clean_after_all_deletions_commit(self, tmp_path: pathlib.Path) -> None:
1093 """After committing all deletions, muse status must show clean=True."""
1094 repo = self._committed_repo(tmp_path)
1095 _invoke(repo, ["rm", "a.txt"])
1096 _invoke(repo, ["rm", "b.txt"])
1097 result = _commit(repo, "-m", "remove everything")
1098 assert result.exit_code == 0, result.output
1099 status = _invoke(repo, ["status", "--json"])
1100 data = json.loads(status.output)
1101 assert data["clean"] is True
1102 assert data["staged"]["deleted"] == []
1103
1104 def test_recursive_rm_then_commit_succeeds(self, tmp_path: pathlib.Path) -> None:
1105 """muse rm -r <dir> then commit must succeed even if all files were in that dir."""
1106 _init_repo(tmp_path)
1107 (tmp_path / "src").mkdir()
1108 (tmp_path / "src" / "main.py").write_text("main()\n")
1109 (tmp_path / "src" / "utils.py").write_text("pass\n")
1110 _invoke(tmp_path, ["code", "add", "."])
1111 _commit(tmp_path, "-m", "initial")
1112 _invoke(tmp_path, ["rm", "-r", "src"])
1113 result = _commit(tmp_path, "-m", "remove src/")
1114 assert result.exit_code == 0, result.output
1115
1116 def test_dry_run_with_all_deletions_staged(self, tmp_path: pathlib.Path) -> None:
1117 """--dry-run must exit 0 (changes pending) when deletions are staged."""
1118 repo = self._committed_repo(tmp_path)
1119 _invoke(repo, ["rm", "a.txt"])
1120 _invoke(repo, ["rm", "b.txt"])
1121 result = _commit(repo, "-m", "rm all", "--dry-run")
1122 assert result.exit_code == 0, result.output
1123
1124 def test_cached_rm_file_stays_on_disk_after_commit(self, tmp_path: pathlib.Path) -> None:
1125 """muse rm --cached keeps the file on disk; after commit it is untracked."""
1126 repo = self._committed_repo(tmp_path)
1127 # Stage deletion of a.txt but keep it on disk; delete b.txt from disk too.
1128 _invoke(repo, ["rm", "--cached", "a.txt"])
1129 _invoke(repo, ["rm", "b.txt"])
1130 result = _commit(repo, "-m", "untrack a.txt, delete b.txt")
1131 assert result.exit_code == 0, result.output
1132 # a.txt must still exist on disk (it was --cached)
1133 assert (repo / "a.txt").exists(), "a.txt should remain on disk after --cached rm"
1134 # b.txt was deleted from disk by muse rm
1135 assert not (repo / "b.txt").exists()
1136 # a.txt is now untracked
1137 status = json.loads(_invoke(repo, ["status", "--json"]).output)
1138 assert "a.txt" in status["untracked"]
1139
1140 def test_all_cached_rm_then_commit_leaves_files_on_disk(
1141 self, tmp_path: pathlib.Path
1142 ) -> None:
1143 """All files removed with --cached must survive on disk after commit."""
1144 repo = self._committed_repo(tmp_path)
1145 _invoke(repo, ["rm", "--cached", "a.txt"])
1146 _invoke(repo, ["rm", "--cached", "b.txt"])
1147 result = _commit(repo, "-m", "untrack everything")
1148 assert result.exit_code == 0, result.output
1149 assert (repo / "a.txt").exists(), "a.txt must stay on disk"
1150 assert (repo / "b.txt").exists(), "b.txt must stay on disk"
1151 status = json.loads(_invoke(repo, ["status", "--json"]).output)
1152 assert status["clean"] is True # untracked files don't make the repo dirty
1153 assert "a.txt" in status["untracked"]
1154 assert "b.txt" in status["untracked"]
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 143 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago