gabriel / muse public
test_cmd_commit.py python
952 lines 42.0 KB
Raw
sha256:4d09a52c06fbc389006963ad1e5ca6ee48c3cb72799f1a322561035b263db67d merge conflict resolve Human patch 41 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, rerere.
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 set(fc.keys()) == {"added", "modified", "deleted"}
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 <8-hex-char>] message"
567 # Extract all 8-char hex tokens (stripping punctuation like ']').
568 tokens = [re.sub(r"[^0-9a-f]", "", w) for w in result.output.split()]
569 assert any(len(t) == 8 for t in tokens), (
570 f"No 8-char hex commit ID found in: {result.output!r}"
571 )
572
573 def test_text_shows_message(self, repo: pathlib.Path) -> None:
574 result = _commit(repo, "-m", "verse melody")
575 assert "verse melody" in result.output
576
577 def test_text_shows_files_changed(self, repo: pathlib.Path) -> None:
578 result = _commit(repo, "-m", "x")
579 assert "file" in result.output
580
581 def test_text_nothing_to_commit_message(self, repo: pathlib.Path) -> None:
582 _commit(repo, "-m", "first")
583 result = _commit(repo, "-m", "second")
584 assert "Nothing to commit" in result.output
585
586
587 # ──────────────────────────────────────────────────────────────────────────────
588 # Security — ANSI injection prevention
589 # ──────────────────────────────────────────────────────────────────────────────
590
591
592 class TestSecurityAnsi:
593 """Text output must never emit raw ANSI escape sequences from user input."""
594
595 def _has_ansi(self, s: str) -> bool:
596 return "\x1b[" in s or "\x1b]" in s
597
598 def test_ansi_in_message_stripped_from_text_output(self, repo: pathlib.Path) -> None:
599 msg = "hello \x1b[31mred\x1b[0m world"
600 result = _commit(repo, "-m", msg)
601 assert not self._has_ansi(result.output), "ANSI in message leaked to text output"
602
603 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
604 result = _commit(repo, "-m", "x", "--format", "\x1b[31mxml\x1b[0m")
605 assert not self._has_ansi(result.output)
606
607 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
608 result = _commit(repo, "-m", "x", "--author", "\x1b[1mevil\x1b[0m")
609 assert not self._has_ansi(result.output)
610
611
612 # ──────────────────────────────────────────────────────────────────────────────
613 # Security — author / provenance field caps
614 # ──────────────────────────────────────────────────────────────────────────────
615
616
617 class TestSecurityProvenance:
618 def test_author_capped_at_256_chars(self, repo: pathlib.Path) -> None:
619 long_author = "a" * 500
620 _commit(repo, "-m", "x", "--author", long_author)
621 branch = read_current_branch(repo)
622 cid = get_head_commit_id(repo, branch)
623 assert cid is not None
624 rec = read_commit(repo, cid)
625 assert rec is not None
626 assert len(rec.author) <= 256
627
628 def test_agent_id_capped_at_256_chars(self, repo: pathlib.Path) -> None:
629 long_id = "b" * 500
630 _commit(repo, "-m", "x", "--agent-id", long_id)
631 branch = read_current_branch(repo)
632 cid = get_head_commit_id(repo, branch)
633 assert cid is not None
634 rec = read_commit(repo, cid)
635 assert rec is not None
636 assert len(rec.agent_id) <= 256
637
638 def test_author_control_chars_stripped(self, repo: pathlib.Path) -> None:
639 _commit(repo, "-m", "x", "--author", "alice\x00\x01\x02")
640 branch = read_current_branch(repo)
641 cid = get_head_commit_id(repo, branch)
642 assert cid is not None
643 rec = read_commit(repo, cid)
644 assert rec is not None
645 assert "\x00" not in rec.author
646 assert "\x01" not in rec.author
647
648 def test_author_override_emits_warning(
649 self, repo: pathlib.Path, caplog: pytest.LogCaptureFixture
650 ) -> None:
651 import logging
652
653 with caplog.at_level(logging.WARNING, logger="muse.cli.commands.commit"):
654 _commit(repo, "-m", "x", "--author", "evil-impersonator")
655 assert any(
656 "impersonation" in r.message or "--author" in r.message
657 for r in caplog.records
658 )
659
660 def test_agent_id_from_flag_overrides_env(
661 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
662 ) -> None:
663 monkeypatch.setenv("MUSE_AGENT_ID", "env-agent")
664 result = _invoke(repo, ["commit", "-m", "x", "--agent-id", "flag-agent", "--json"])
665 data = json.loads(result.output)
666 assert data["agent_id"] == "flag-agent"
667
668
669 # ──────────────────────────────────────────────────────────────────────────────
670 # Integration — merge-parent recording
671 # ──────────────────────────────────────────────────────────────────────────────
672
673
674 class TestMergeParent:
675 """When a merge commit is created, parent2_commit_id must be set."""
676
677 def test_merge_commit_has_two_parents(self, repo: pathlib.Path) -> None:
678 _commit(repo, "-m", "base")
679 _invoke(repo, ["branch", "feat"])
680 _invoke(repo, ["checkout", "feat"])
681 (repo / "feat.py").write_text("f = 1\n")
682 _commit(repo, "-m", "feat commit")
683 _invoke(repo, ["checkout", "main"])
684 (repo / "main_only.py").write_text("m = 1\n")
685 _commit(repo, "-m", "main commit")
686 _invoke(repo, ["merge", "feat"])
687 cid = get_head_commit_id(repo, "main")
688 assert cid is not None
689 rec = read_commit(repo, cid)
690 assert rec is not None
691 assert rec.parent2_commit_id is not None
692
693 def test_regular_commit_parent2_is_none(self, repo: pathlib.Path) -> None:
694 _commit(repo, "-m", "first")
695 (repo / "b.py").write_text("b=1\n")
696 _commit(repo, "-m", "second")
697 branch = read_current_branch(repo)
698 cid = get_head_commit_id(repo, branch)
699 assert cid is not None
700 rec = read_commit(repo, cid)
701 assert rec is not None
702 assert rec.parent2_commit_id is None
703
704
705 # ──────────────────────────────────────────────────────────────────────────────
706 # Integration — SemVer bump inference
707 # ──────────────────────────────────────────────────────────────────────────────
708
709
710 class TestSemVerBump:
711 def test_first_commit_sem_ver_bump_valid(self, repo: pathlib.Path) -> None:
712 _commit(repo, "-m", "init")
713 cid = get_head_commit_id(repo, "main")
714 assert cid is not None
715 rec = read_commit(repo, cid)
716 assert rec is not None
717 assert rec.sem_ver_bump in ("none", "patch", "minor", "major")
718
719 def test_json_sem_ver_bump_is_valid_value(self, repo: pathlib.Path) -> None:
720 result = _commit(repo, "-m", "x", "--json")
721 data = json.loads(result.output)
722 assert data["sem_ver_bump"] in ("none", "patch", "minor", "major")
723
724 def test_breaking_changes_list_in_record(self, repo: pathlib.Path) -> None:
725 _commit(repo, "-m", "first")
726 branch = read_current_branch(repo)
727 cid = get_head_commit_id(repo, branch)
728 assert cid is not None
729 rec = read_commit(repo, cid)
730 assert rec is not None
731 assert isinstance(rec.breaking_changes, list)
732
733
734 # ──────────────────────────────────────────────────────────────────────────────
735 # Integration — reflog
736 # ──────────────────────────────────────────────────────────────────────────────
737
738
739 class TestReflog:
740 def test_commit_appends_reflog_entry(self, repo: pathlib.Path) -> None:
741 from muse.core.reflog import read_reflog
742
743 _commit(repo, "-m", "logged")
744 entries = read_reflog(repo, "main")
745 assert len(entries) >= 1
746 assert any(
747 "logged" in e.operation or "commit" in e.operation for e in entries
748 )
749
750 def test_reflog_contains_commit_id(self, repo: pathlib.Path) -> None:
751 from muse.core.reflog import read_reflog
752
753 _commit(repo, "-m", "ref-entry")
754 cid = get_head_commit_id(repo, "main")
755 entries = read_reflog(repo, "main")
756 assert any(e.new_id == cid for e in entries)
757
758
759 # ──────────────────────────────────────────────────────────────────────────────
760 # Integration — stage cleared after commit
761 # ──────────────────────────────────────────────────────────────────────────────
762
763
764 class TestStageClearAfterCommit:
765 def test_stage_is_cleared(self, repo: pathlib.Path) -> None:
766 _invoke(repo, ["code", "add", "."])
767 _commit(repo, "-m", "staged")
768 stage_path = repo / ".muse" / "stage.json"
769 if stage_path.exists():
770 data = json.loads(stage_path.read_text())
771 assert data == {} or data.get("files") == {}
772
773
774 # ──────────────────────────────────────────────────────────────────────────────
775 # End-to-end — provenance env vars
776 # ──────────────────────────────────────────────────────────────────────────────
777
778
779 class TestProvenanceEnvVars:
780 def test_model_id_from_env(
781 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
782 ) -> None:
783 monkeypatch.setenv("MUSE_MODEL_ID", "gpt-5")
784 _invoke(repo, ["commit", "-m", "x"])
785 branch = read_current_branch(repo)
786 cid = get_head_commit_id(repo, branch)
787 assert cid is not None
788 rec = read_commit(repo, cid)
789 assert rec is not None
790 assert rec.model_id == "gpt-5"
791
792 def test_toolchain_id_from_env(
793 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
794 ) -> None:
795 monkeypatch.setenv("MUSE_TOOLCHAIN_ID", "cursor-v42")
796 _invoke(repo, ["commit", "-m", "x"])
797 branch = read_current_branch(repo)
798 cid = get_head_commit_id(repo, branch)
799 assert cid is not None
800 rec = read_commit(repo, cid)
801 assert rec is not None
802 assert rec.toolchain_id == "cursor-v42"
803
804 def test_prompt_hash_from_env(
805 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
806 ) -> None:
807 monkeypatch.setenv("MUSE_PROMPT_HASH", "abc123")
808 _invoke(repo, ["commit", "-m", "x"])
809 branch = read_current_branch(repo)
810 cid = get_head_commit_id(repo, branch)
811 assert cid is not None
812 rec = read_commit(repo, cid)
813 assert rec is not None
814 assert rec.prompt_hash == "abc123"
815
816 def test_flag_overrides_env_for_model_id(
817 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
818 ) -> None:
819 monkeypatch.setenv("MUSE_MODEL_ID", "env-model")
820 _invoke(repo, ["commit", "-m", "x", "--model-id", "flag-model"])
821 branch = read_current_branch(repo)
822 cid = get_head_commit_id(repo, branch)
823 assert cid is not None
824 rec = read_commit(repo, cid)
825 assert rec is not None
826 assert rec.model_id == "flag-model"
827
828
829 # ──────────────────────────────────────────────────────────────────────────────
830 # Integration — parent manifest not double-read
831 # ──────────────────────────────────────────────────────────────────────────────
832
833
834 class TestParentManifestSingleRead:
835 """
836 The parent snapshot must be loaded only once per commit, not twice.
837 We verify via call counts on read_snapshot.
838 """
839
840 def test_parent_snapshot_read_at_most_once(self, repo: pathlib.Path) -> None:
841 _commit(repo, "-m", "first")
842 (repo / "b.py").write_text("b=1\n")
843 call_count: list[int] = [0]
844 original_read_snapshot = read_snapshot
845
846 from muse.core.store import SnapshotRecord
847
848 def counting_read_snapshot(
849 root: pathlib.Path, sid: str
850 ) -> SnapshotRecord | None:
851 call_count[0] += 1
852 return original_read_snapshot(root, sid)
853
854 with patch(
855 "muse.cli.commands.commit.read_snapshot",
856 side_effect=counting_read_snapshot,
857 ):
858 _commit(repo, "-m", "second")
859
860 # Should be ≤1 (one read of the parent snapshot).
861 # Previously the bug caused 2 reads: one for structured_delta, one for file counts.
862 assert call_count[0] <= 1, (
863 f"read_snapshot called {call_count[0]} times; expected ≤1 (parent double-read bug)"
864 )
865
866
867 # ──────────────────────────────────────────────────────────────────────────────
868 # Stress — sequential commits
869 # ──────────────────────────────────────────────────────────────────────────────
870
871
872 @pytest.mark.slow
873 class TestStressSequential:
874 def test_100_commits_all_succeed(self, repo: pathlib.Path) -> None:
875 for i in range(100):
876 (repo / f"f{i:04d}.py").write_text(f"x = {i}\n")
877 result = _commit(repo, "-m", f"commit {i}")
878 assert result.exit_code == 0, f"Commit {i} failed: {result.output}"
879
880 def test_100_commits_branch_advances(self, repo: pathlib.Path) -> None:
881 seen_ids: set[str] = set()
882 for i in range(100):
883 (repo / f"g{i:04d}.py").write_text(f"y = {i}\n")
884 _commit(repo, "-m", f"c{i}")
885 cid = get_head_commit_id(repo, "main")
886 assert cid not in seen_ids, f"Duplicate commit ID at commit {i}"
887 if cid:
888 seen_ids.add(cid)
889 assert len(seen_ids) == 100
890
891
892 @pytest.mark.slow
893 class TestStressLargeManifest:
894 def test_500_file_commit_succeeds(self, repo: pathlib.Path) -> None:
895 for i in range(500):
896 (repo / f"h{i:04d}.py").write_text(f"z = {i}\n")
897 t0 = time.perf_counter()
898 result = _commit(repo, "-m", "big")
899 elapsed = (time.perf_counter() - t0) * 1000
900 assert result.exit_code == 0
901 assert elapsed < 3000, f"Commit too slow: {elapsed:.0f}ms"
902
903 def test_500_file_single_change_commit(self, repo: pathlib.Path) -> None:
904 for i in range(500):
905 (repo / f"k{i:04d}.py").write_text(f"a = {i}\n")
906 _commit(repo, "-m", "base")
907 (repo / "k0000.py").write_text("a = 999\n")
908 t0 = time.perf_counter()
909 result = _commit(repo, "-m", "one change")
910 elapsed = (time.perf_counter() - t0) * 1000
911 assert result.exit_code == 0
912 assert elapsed < 2000, f"Single-file commit too slow: {elapsed:.0f}ms"
913
914
915 # ──────────────────────────────────────────────────────────────────────────────
916 # Stress — concurrent commits to different repos
917 # ──────────────────────────────────────────────────────────────────────────────
918
919
920 @pytest.mark.slow
921 class TestStressConcurrent:
922 def test_concurrent_commits_to_separate_repos(self, tmp_path: pathlib.Path) -> None:
923 """16 threads each commit to their own isolated repo — no interference."""
924 errors: list[str] = []
925
926 def do_commit(idx: int) -> None:
927 repo_dir = tmp_path / f"repo_{idx}"
928 repo_dir.mkdir()
929 subprocess.run(
930 ["muse", "init"], cwd=str(repo_dir), capture_output=True
931 )
932 (repo_dir / "x.py").write_text(f"x = {idx}\n")
933 r = subprocess.run(
934 ["muse", "commit", "-m", f"c{idx}", "--json"],
935 cwd=str(repo_dir),
936 capture_output=True,
937 text=True,
938 )
939 if r.returncode != 0:
940 errors.append(f"repo_{idx}: {r.stderr}")
941 return
942 data = json.loads(r.stdout)
943 if "commit_id" not in data:
944 errors.append(f"repo_{idx}: no commit_id in output")
945
946 threads = [threading.Thread(target=do_commit, args=(i,)) for i in range(16)]
947 for t in threads:
948 t.start()
949 for t in threads:
950 t.join()
951
952 assert not errors, "Concurrent commit errors:\n" + "\n".join(errors)
File History 1 commit
sha256:4d09a52c06fbc389006963ad1e5ca6ee48c3cb72799f1a322561035b263db67d merge conflict resolve Human patch 41 days ago