gabriel / muse public
test_cmd_commit.py python
1,238 lines 54.8 KB
Raw
sha256:4d09a52c06fbc389006963ad1e5ca6ee48c3cb72799f1a322561035b263db67d merge conflict resolve Human patch 3 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.refs import (
27 get_head_commit_id,
28 read_current_branch,
29 )
30 from muse.core.commits import read_commit
31 from muse.core.snapshots import read_snapshot
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 _invoke(repo, ["code", "add", "."])
52 return _invoke(repo, ["commit", *extra])
53
54
55 def _init_repo(repo: pathlib.Path) -> InvokeResult:
56 repo.mkdir(parents=True, exist_ok=True)
57 return _invoke(repo, ["init"])
58
59
60 # ──────────────────────────────────────────────────────────────────────────────
61 # Fixtures
62 # ──────────────────────────────────────────────────────────────────────────────
63
64
65 @pytest.fixture()
66 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
67 """Initialised repo with one tracked file ready to commit."""
68 _init_repo(tmp_path)
69 (tmp_path / "a.py").write_text("x = 1\n")
70 return tmp_path
71
72
73 # ──────────────────────────────────────────────────────────────────────────────
74 # Unit — parser flags
75 # ──────────────────────────────────────────────────────────────────────────────
76
77
78 class TestRegisterFlags:
79 """All expected CLI flags are registered on the commit subcommand."""
80
81 def _parse(self, *args: str) -> argparse.Namespace:
82 from muse.cli.commands.commit import register
83
84 p = argparse.ArgumentParser()
85 sub = p.add_subparsers()
86 register(sub)
87 return p.parse_args(["commit", *args])
88
89 def test_message_flag(self) -> None:
90 ns = self._parse("-m", "hello")
91 assert ns.message == "hello"
92
93 def test_allow_empty_flag(self) -> None:
94 ns = self._parse("-m", "x", "--allow-empty")
95 assert ns.allow_empty is True
96
97 def test_dry_run_short_flag(self) -> None:
98 ns = self._parse("-m", "x", "-n")
99 assert ns.dry_run is True
100
101 def test_dry_run_long_flag(self) -> None:
102 ns = self._parse("-m", "x", "--dry-run")
103 assert ns.dry_run is True
104
105 def test_json_flag(self) -> None:
106 ns = self._parse("-m", "x", "--json")
107 assert ns.json_out is True
108
109 def test_j_shorthand(self) -> None:
110 ns = self._parse("-m", "x", "-j")
111 assert ns.json_out is True
112
113 def test_default_json_out_is_false(self) -> None:
114 ns = self._parse("-m", "x")
115 assert ns.json_out is False
116
117 def test_agent_id_flag(self) -> None:
118 ns = self._parse("-m", "x", "--agent-id", "bot-1")
119 assert ns.agent_id == "bot-1"
120
121 def test_model_id_flag(self) -> None:
122 ns = self._parse("-m", "x", "--model-id", "claude-4")
123 assert ns.model_id == "claude-4"
124
125 def test_toolchain_id_flag(self) -> None:
126 ns = self._parse("-m", "x", "--toolchain-id", "cursor-v1")
127 assert ns.toolchain_id == "cursor-v1"
128
129 def test_section_flag(self) -> None:
130 ns = self._parse("-m", "x", "--section", "chorus")
131 assert ns.section == "chorus"
132
133 def test_track_flag(self) -> None:
134 ns = self._parse("-m", "x", "--track", "bass")
135 assert ns.track == "bass"
136
137 def test_emotion_flag(self) -> None:
138 ns = self._parse("-m", "x", "--emotion", "joyful")
139 assert ns.emotion == "joyful"
140
141 def test_author_flag(self) -> None:
142 ns = self._parse("-m", "x", "--author", "alice")
143 assert ns.author == "alice"
144
145 def test_sign_flag(self) -> None:
146 ns = self._parse("-m", "x", "--sign")
147 assert ns.sign is True
148
149
150 # ──────────────────────────────────────────────────────────────────────────────
151 # Unit — _MAX_FIELD_LEN constant
152 # ──────────────────────────────────────────────────────────────────────────────
153
154
155 class TestMaxFieldLen:
156 def test_constant_exists_and_is_256(self) -> None:
157 from muse.cli.commands.commit import _MAX_FIELD_LEN
158
159 assert _MAX_FIELD_LEN == 256
160
161 def test_no_separate_max_author_constant(self) -> None:
162 import muse.cli.commands.commit as m
163
164 assert not hasattr(m, "_MAX_AUTHOR"), "_MAX_AUTHOR should be replaced by _MAX_FIELD_LEN"
165 assert not hasattr(m, "_MAX_PROV"), "_MAX_PROV should be replaced by _MAX_FIELD_LEN"
166
167
168 # ──────────────────────────────────────────────────────────────────────────────
169 # Unit — dead-code removal
170 # ──────────────────────────────────────────────────────────────────────────────
171
172
173 class TestDeadCodeRemoved:
174 def test_read_branch_removed(self) -> None:
175 import muse.cli.commands.commit as m
176
177 assert not hasattr(m, "_read_branch"), (
178 "_read_branch was a dead wrapper; it should have been deleted"
179 )
180
181 def test_read_parent_id_removed(self) -> None:
182 import muse.cli.commands.commit as m
183
184 assert not hasattr(m, "_read_parent_id"), (
185 "_read_parent_id was a dead wrapper; it should have been deleted"
186 )
187
188
189 # ──────────────────────────────────────────────────────────────────────────────
190 # Unit — inline imports removed
191 # ──────────────────────────────────────────────────────────────────────────────
192
193
194 class TestNoInlineImports:
195 def test_sign_commit_record_is_module_level_import(self) -> None:
196 import inspect
197
198 import muse.cli.commands.commit as m
199
200 src = inspect.getsource(m.run)
201 assert "from muse.core.provenance import sign_commit_record" not in src, (
202 "sign_commit_record import must be at module level, not inside run()"
203 )
204
205 def test_no_inline_store_imports(self) -> None:
206 import inspect
207
208 import muse.cli.commands.commit as m
209
210 src = inspect.getsource(m.run)
211 assert "from muse.core.store import" not in src, (
212 "store imports inside run() should be at module level"
213 )
214
215
216 # ──────────────────────────────────────────────────────────────────────────────
217 # Integration — basic commit lifecycle
218 # ──────────────────────────────────────────────────────────────────────────────
219
220
221 class TestBasicCommit:
222 def test_first_commit_succeeds(self, repo: pathlib.Path) -> None:
223 result = _commit(repo, "-m", "init")
224 assert result.exit_code == 0
225 assert "init" in result.output
226
227 def test_commit_creates_commit_record(self, repo: pathlib.Path) -> None:
228 _commit(repo, "-m", "first")
229 branch = read_current_branch(repo)
230 cid = get_head_commit_id(repo, branch)
231 assert cid is not None
232 rec = read_commit(repo, cid)
233 assert rec is not None
234 assert rec.message == "first"
235
236 def test_commit_creates_snapshot(self, repo: pathlib.Path) -> None:
237 _commit(repo, "-m", "snap")
238 branch = read_current_branch(repo)
239 cid = get_head_commit_id(repo, branch)
240 assert cid is not None
241 rec = read_commit(repo, cid)
242 assert rec is not None
243 snap = read_snapshot(repo, rec.snapshot_id)
244 assert snap is not None
245 assert len(snap.manifest) >= 1
246
247 def test_commit_advances_branch_ref(self, repo: pathlib.Path) -> None:
248 _commit(repo, "-m", "first")
249 cid1 = get_head_commit_id(repo, "main")
250 (repo / "b.py").write_text("y = 2\n")
251 _commit(repo, "-m", "second")
252 cid2 = get_head_commit_id(repo, "main")
253 assert cid1 != cid2
254
255 def test_second_commit_has_parent(self, repo: pathlib.Path) -> None:
256 _commit(repo, "-m", "first")
257 cid1 = get_head_commit_id(repo, "main")
258 (repo / "b.py").write_text("y = 2\n")
259 _commit(repo, "-m", "second")
260 cid2 = get_head_commit_id(repo, "main")
261 assert cid2 is not None
262 rec2 = read_commit(repo, cid2)
263 assert rec2 is not None
264 assert rec2.parent_commit_id == cid1
265
266 def test_nothing_to_commit_exits_0(self, repo: pathlib.Path) -> None:
267 _commit(repo, "-m", "first")
268 result = _commit(repo, "-m", "second")
269 assert result.exit_code == 0
270 assert "Nothing to commit" in result.output
271
272 def test_metadata_section_stored(self, repo: pathlib.Path) -> None:
273 _commit(repo, "-m", "chorus", "--section", "chorus")
274 branch = read_current_branch(repo)
275 cid = get_head_commit_id(repo, branch)
276 assert cid is not None
277 rec = read_commit(repo, cid)
278 assert rec is not None
279 assert rec.metadata.get("section") == "chorus"
280
281 def test_metadata_track_stored(self, repo: pathlib.Path) -> None:
282 _commit(repo, "-m", "bass", "--track", "bass")
283 branch = read_current_branch(repo)
284 cid = get_head_commit_id(repo, branch)
285 assert cid is not None
286 rec = read_commit(repo, cid)
287 assert rec is not None
288 assert rec.metadata.get("track") == "bass"
289
290 def test_metadata_emotion_stored(self, repo: pathlib.Path) -> None:
291 _commit(repo, "-m", "joy", "--emotion", "joyful")
292 branch = read_current_branch(repo)
293 cid = get_head_commit_id(repo, branch)
294 assert cid is not None
295 rec = read_commit(repo, cid)
296 assert rec is not None
297 assert rec.metadata.get("emotion") == "joyful"
298
299
300 # ──────────────────────────────────────────────────────────────────────────────
301 # Integration — allow-empty
302 # ──────────────────────────────────────────────────────────────────────────────
303
304
305 class TestAllowEmpty:
306 def test_allow_empty_creates_commit(self, repo: pathlib.Path) -> None:
307 result = _commit(repo, "-m", "empty", "--allow-empty")
308 assert result.exit_code == 0
309
310 def test_allow_empty_without_message_warns(
311 self, repo: pathlib.Path, caplog: pytest.LogCaptureFixture
312 ) -> None:
313 import logging
314
315 with caplog.at_level(logging.WARNING, logger="muse.cli.commands.commit"):
316 _commit(repo, "--allow-empty")
317 assert any(
318 "empty message" in r.message or "--allow-empty" in r.message
319 for r in caplog.records
320 )
321
322 def test_allow_empty_without_message_exits_0(self, repo: pathlib.Path) -> None:
323 result = _commit(repo, "--allow-empty")
324 assert result.exit_code == 0
325
326 def test_allow_empty_json_message_is_empty_string(self, repo: pathlib.Path) -> None:
327 result = _commit(repo, "--allow-empty", "--json")
328 data = json.loads(result.output)
329 assert data["message"] == ""
330
331
332 # ──────────────────────────────────────────────────────────────────────────────
333 # Integration — validation errors
334 # ──────────────────────────────────────────────────────────────────────────────
335
336
337 class TestValidation:
338 def test_missing_message_exits_1(self, repo: pathlib.Path) -> None:
339 result = _commit(repo)
340 assert result.exit_code == 1
341
342 def test_missing_message_prints_hint(self, repo: pathlib.Path) -> None:
343 result = _commit(repo)
344 assert "-m" in result.stderr or "message" in result.stderr.lower()
345
346 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
347 result = _commit(repo, "-m", "x", "--no-such-flag")
348 assert result.exit_code != 0
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 muse_dir(bare).mkdir()
357 (head_path(bare)).write_text("ref: refs/heads/main\n")
358 (muse_dir(bare) / "refs").mkdir()
359 (heads_dir(bare)).mkdir()
360 (repo_json_path(bare)).write_text(
361 f'{{"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_message_flag_sanitized(self, repo: pathlib.Path) -> None:
603 result = _commit(repo, "-m", "\x1b[31mmalicious\x1b[0m message")
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[1mmalicious\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", "malicious-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 = muse_dir(repo) / "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_bare_hex_gets_prefixed(
804 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
805 ) -> None:
806 bare = "a" * 64
807 monkeypatch.setenv("MUSE_PROMPT_HASH", bare)
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 == f"sha256:{bare}"
815
816 def test_prompt_hash_already_prefixed_unchanged(
817 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
818 ) -> None:
819 prefixed = f"sha256:{'b' * 64}"
820 monkeypatch.setenv("MUSE_PROMPT_HASH", prefixed)
821 _invoke(repo, ["commit", "-m", "x"])
822 branch = read_current_branch(repo)
823 cid = get_head_commit_id(repo, branch)
824 assert cid is not None
825 rec = read_commit(repo, cid)
826 assert rec is not None
827 assert rec.prompt_hash == prefixed
828
829 def test_prompt_hash_invalid_not_stored(
830 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
831 ) -> None:
832 monkeypatch.setenv("MUSE_PROMPT_HASH", "abc123")
833 _invoke(repo, ["commit", "-m", "x"])
834 branch = read_current_branch(repo)
835 cid = get_head_commit_id(repo, branch)
836 assert cid is not None
837 rec = read_commit(repo, cid)
838 assert rec is not None
839 assert rec.prompt_hash == ""
840
841 def test_flag_overrides_env_for_model_id(
842 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
843 ) -> None:
844 monkeypatch.setenv("MUSE_MODEL_ID", "env-model")
845 _invoke(repo, ["commit", "-m", "x", "--model-id", "flag-model"])
846 branch = read_current_branch(repo)
847 cid = get_head_commit_id(repo, branch)
848 assert cid is not None
849 rec = read_commit(repo, cid)
850 assert rec is not None
851 assert rec.model_id == "flag-model"
852
853
854 # ──────────────────────────────────────────────────────────────────────────────
855 # Integration — parent manifest not double-read
856 # ──────────────────────────────────────────────────────────────────────────────
857
858
859 class TestParentManifestSingleRead:
860 """
861 The parent snapshot must be loaded only once per commit, not twice.
862 We verify via call counts on read_snapshot.
863 """
864
865 def test_parent_snapshot_read_at_most_once(self, repo: pathlib.Path) -> None:
866 _commit(repo, "-m", "first")
867 (repo / "b.py").write_text("b=1\n")
868 call_count: list[int] = [0]
869 original_read_snapshot = read_snapshot
870
871 from muse.core.snapshots import SnapshotRecord
872
873 def counting_read_snapshot(
874 root: pathlib.Path, sid: str
875 ) -> SnapshotRecord | None:
876 call_count[0] += 1
877 return original_read_snapshot(root, sid)
878
879 with patch(
880 "muse.cli.commands.commit.read_snapshot",
881 side_effect=counting_read_snapshot,
882 ):
883 _commit(repo, "-m", "second")
884
885 # Should be ≤1 (one read of the parent snapshot).
886 # Previously the bug caused 2 reads: one for structured_delta, one for file counts.
887 assert call_count[0] <= 1, (
888 f"read_snapshot called {call_count[0]} times; expected ≤1 (parent double-read bug)"
889 )
890
891
892 # ──────────────────────────────────────────────────────────────────────────────
893 # Stress — sequential commits
894 # ──────────────────────────────────────────────────────────────────────────────
895
896
897 @pytest.mark.slow
898 class TestStressSequential:
899 def test_100_commits_all_succeed(self, repo: pathlib.Path) -> None:
900 for i in range(100):
901 (repo / f"f{i:04d}.py").write_text(f"x = {i}\n")
902 result = _commit(repo, "-m", f"commit {i}")
903 assert result.exit_code == 0, f"Commit {i} failed: {result.output}"
904
905 def test_100_commits_branch_advances(self, repo: pathlib.Path) -> None:
906 seen_ids: set[str] = set()
907 for i in range(100):
908 (repo / f"g{i:04d}.py").write_text(f"y = {i}\n")
909 _commit(repo, "-m", f"c{i}")
910 cid = get_head_commit_id(repo, "main")
911 assert cid not in seen_ids, f"Duplicate commit ID at commit {i}"
912 if cid:
913 seen_ids.add(cid)
914 assert len(seen_ids) == 100
915
916
917 @pytest.mark.slow
918 class TestStressLargeManifest:
919 def test_500_file_commit_succeeds(self, repo: pathlib.Path) -> None:
920 for i in range(500):
921 (repo / f"h{i:04d}.py").write_text(f"z = {i}\n")
922 t0 = time.perf_counter()
923 result = _commit(repo, "-m", "big")
924 elapsed = (time.perf_counter() - t0) * 1000
925 assert result.exit_code == 0
926 assert elapsed < 3000, f"Commit too slow: {elapsed:.0f}ms"
927
928 def test_500_file_single_change_commit(self, repo: pathlib.Path) -> None:
929 for i in range(500):
930 (repo / f"k{i:04d}.py").write_text(f"a = {i}\n")
931 _commit(repo, "-m", "base")
932 (repo / "k0000.py").write_text("a = 999\n")
933 t0 = time.perf_counter()
934 result = _commit(repo, "-m", "one change")
935 elapsed = (time.perf_counter() - t0) * 1000
936 assert result.exit_code == 0
937 assert elapsed < 2000, f"Single-file commit too slow: {elapsed:.0f}ms"
938
939
940 # ──────────────────────────────────────────────────────────────────────────────
941 # Stress — concurrent commits to different repos
942 # ──────────────────────────────────────────────────────────────────────────────
943
944
945 @pytest.mark.slow
946 class TestStressConcurrent:
947 def test_concurrent_commits_to_separate_repos(self, tmp_path: pathlib.Path) -> None:
948 """16 threads each commit to their own isolated repo — no interference."""
949 errors: list[str] = []
950
951 def do_commit(idx: int) -> None:
952 repo_dir = tmp_path / f"repo_{idx}"
953 repo_dir.mkdir()
954 subprocess.run(
955 ["muse", "init"], cwd=str(repo_dir), capture_output=True
956 )
957 (repo_dir / "x.py").write_text(f"x = {idx}\n")
958 r = subprocess.run(
959 ["muse", "commit", "-m", f"c{idx}", "--json"],
960 cwd=str(repo_dir),
961 capture_output=True,
962 text=True,
963 )
964 if r.returncode != 0:
965 errors.append(f"repo_{idx}: {r.stderr}")
966 return
967 data = json.loads(r.stdout)
968 if "commit_id" not in data:
969 errors.append(f"repo_{idx}: no commit_id in output")
970
971 threads = [threading.Thread(target=do_commit, args=(i,)) for i in range(16)]
972 for t in threads:
973 t.start()
974 for t in threads:
975 t.join()
976
977
978 # ---------------------------------------------------------------------------
979 # commit must NOT touch the working tree (Git-compatible behaviour)
980 #
981 # muse commit writes to the object store and advances the branch ref.
982 # It must never call apply_manifest — that belongs only in checkout/merge/pull.
983 # Unstaged changes on disk survive a commit unchanged.
984 # See tests/test_commit_workdir_preservation.py for the full regression suite.
985 # ---------------------------------------------------------------------------
986
987
988 # ──────────────────────────────────────────────────────────────────────────────
989 # Bug: commit refuses when only staged deletions remain (empty snapshot)
990 # ──────────────────────────────────────────────────────────────────────────────
991
992
993 class TestCommitAllDeletions:
994 """muse commit must succeed when the only staged changes are deletions.
995
996 The previous bug: plugin.snapshot() returns an empty manifest when all
997 on-disk files are gone, and the guard ``if not manifest and not allow_empty``
998 fired — refusing the commit with "nothing tracked". But staged deletions
999 ARE meaningful changes; the snapshot is intentionally empty.
1000 """
1001
1002 def _committed_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
1003 """Init repo, add files, make a first commit. Returns the repo path."""
1004 _init_repo(tmp_path)
1005 (tmp_path / "a.txt").write_text("alpha\n")
1006 (tmp_path / "b.txt").write_text("beta\n")
1007 _invoke(tmp_path, ["code", "add", "."])
1008 _commit(tmp_path, "-m", "initial")
1009 return tmp_path
1010
1011 def test_commit_after_rm_all_succeeds(self, tmp_path: pathlib.Path) -> None:
1012 """muse commit must exit 0 after muse rm removes all tracked files."""
1013 repo = self._committed_repo(tmp_path)
1014 _invoke(repo, ["rm", "a.txt"])
1015 _invoke(repo, ["rm", "b.txt"])
1016 result = _commit(repo, "-m", "remove everything")
1017 assert result.exit_code == 0, result.output
1018
1019 def test_commit_after_rm_all_creates_second_commit(self, tmp_path: pathlib.Path) -> None:
1020 repo = self._committed_repo(tmp_path)
1021 _invoke(repo, ["rm", "a.txt"])
1022 _invoke(repo, ["rm", "b.txt"])
1023 _commit(repo, "-m", "remove everything")
1024 branch = read_current_branch(repo)
1025 commit_id = get_head_commit_id(repo, branch)
1026 assert commit_id is not None
1027 commit = read_commit(repo, commit_id)
1028 assert commit is not None
1029 assert commit.message == "remove everything"
1030
1031 def test_commit_after_rm_all_snapshot_is_empty(self, tmp_path: pathlib.Path) -> None:
1032 """The snapshot produced by an all-deletions commit must be empty."""
1033 repo = self._committed_repo(tmp_path)
1034 _invoke(repo, ["rm", "a.txt"])
1035 _invoke(repo, ["rm", "b.txt"])
1036 _commit(repo, "-m", "remove everything")
1037 branch = read_current_branch(repo)
1038 commit_id = get_head_commit_id(repo, branch)
1039 commit = read_commit(repo, commit_id)
1040 snap = read_snapshot(repo, commit.snapshot_id)
1041 assert snap is not None
1042 # muse init creates .museattributes and .museignore — these are tracked
1043 # alongside user files and remain in the snapshot after user files are removed.
1044 assert "a.txt" not in snap.manifest
1045 assert "b.txt" not in snap.manifest
1046
1047 def test_commit_after_rm_one_file_leaves_one_in_snapshot(
1048 self, tmp_path: pathlib.Path
1049 ) -> None:
1050 """Removing one of two files produces a one-entry snapshot."""
1051 repo = self._committed_repo(tmp_path)
1052 _invoke(repo, ["rm", "a.txt"])
1053 _commit(repo, "-m", "remove a.txt")
1054 branch = read_current_branch(repo)
1055 commit_id = get_head_commit_id(repo, branch)
1056 commit = read_commit(repo, commit_id)
1057 snap = read_snapshot(repo, commit.snapshot_id)
1058 assert snap is not None
1059 assert "a.txt" not in snap.manifest
1060 assert "b.txt" in snap.manifest
1061
1062 def test_json_output_on_all_deletions_commit(self, tmp_path: pathlib.Path) -> None:
1063 """--json output must be valid and show exit_code 0 for an all-deletions commit."""
1064 repo = self._committed_repo(tmp_path)
1065 _invoke(repo, ["rm", "a.txt"])
1066 _invoke(repo, ["rm", "b.txt"])
1067 result = _commit(repo, "-m", "rm all", "--json")
1068 assert result.exit_code == 0, result.output
1069 data = json.loads(result.output)
1070 assert data.get("exit_code", data.get("code", 0)) == 0 or "commit_id" in data
1071
1072 def test_status_clean_after_all_deletions_commit(self, tmp_path: pathlib.Path) -> None:
1073 """After committing all deletions, muse status must show clean=True."""
1074 repo = self._committed_repo(tmp_path)
1075 _invoke(repo, ["rm", "a.txt"])
1076 _invoke(repo, ["rm", "b.txt"])
1077 result = _commit(repo, "-m", "remove everything")
1078 assert result.exit_code == 0, result.output
1079 status = _invoke(repo, ["status", "--json"])
1080 data = json.loads(status.output)
1081 assert data["clean"] is True
1082 assert data["staged"]["deleted"] == []
1083
1084 def test_recursive_rm_then_commit_succeeds(self, tmp_path: pathlib.Path) -> None:
1085 """muse rm -r <dir> then commit must succeed even if all files were in that dir."""
1086 _init_repo(tmp_path)
1087 (tmp_path / "src").mkdir()
1088 (tmp_path / "src" / "main.py").write_text("main()\n")
1089 (tmp_path / "src" / "utils.py").write_text("pass\n")
1090 _invoke(tmp_path, ["code", "add", "."])
1091 _commit(tmp_path, "-m", "initial")
1092 _invoke(tmp_path, ["rm", "-r", "src"])
1093 result = _commit(tmp_path, "-m", "remove src/")
1094 assert result.exit_code == 0, result.output
1095
1096 def test_dry_run_with_all_deletions_staged(self, tmp_path: pathlib.Path) -> None:
1097 """--dry-run must exit 0 (changes pending) when deletions are staged."""
1098 repo = self._committed_repo(tmp_path)
1099 _invoke(repo, ["rm", "a.txt"])
1100 _invoke(repo, ["rm", "b.txt"])
1101 result = _commit(repo, "-m", "rm all", "--dry-run")
1102 assert result.exit_code == 0, result.output
1103
1104 def test_cached_rm_file_stays_on_disk_after_commit(self, tmp_path: pathlib.Path) -> None:
1105 """muse rm --cached keeps the file on disk; after commit it is untracked."""
1106 repo = self._committed_repo(tmp_path)
1107 # Stage deletion of a.txt but keep it on disk; delete b.txt from disk too.
1108 _invoke(repo, ["rm", "--cached", "a.txt"])
1109 _invoke(repo, ["rm", "b.txt"])
1110 result = _commit(repo, "-m", "untrack a.txt, delete b.txt")
1111 assert result.exit_code == 0, result.output
1112 # a.txt must still exist on disk (it was --cached)
1113 assert (repo / "a.txt").exists(), "a.txt should remain on disk after --cached rm"
1114 # b.txt was deleted from disk by muse rm
1115 assert not (repo / "b.txt").exists()
1116 # a.txt is now untracked
1117 status = json.loads(_invoke(repo, ["status", "--json"]).output)
1118 assert "a.txt" in status["untracked"]
1119
1120 def test_all_cached_rm_then_commit_leaves_files_on_disk(
1121 self, tmp_path: pathlib.Path
1122 ) -> None:
1123 """All files removed with --cached must survive on disk after commit."""
1124 repo = self._committed_repo(tmp_path)
1125 _invoke(repo, ["rm", "--cached", "a.txt"])
1126 _invoke(repo, ["rm", "--cached", "b.txt"])
1127 result = _commit(repo, "-m", "untrack everything")
1128 assert result.exit_code == 0, result.output
1129 assert (repo / "a.txt").exists(), "a.txt must stay on disk"
1130 assert (repo / "b.txt").exists(), "b.txt must stay on disk"
1131 status = json.loads(_invoke(repo, ["status", "--json"]).output)
1132 # Untracked files make the repo dirty (mirrors git behaviour)
1133 assert status["clean"] is False
1134 assert "a.txt" in status["untracked"]
1135 assert "b.txt" in status["untracked"]
1136
1137
1138 # ---------------------------------------------------------------------------
1139 # Flag registration tests
1140 # ---------------------------------------------------------------------------
1141
1142 import argparse as _argparse
1143 from muse.cli.commands.commit import register as _register_commit
1144 from muse.core.paths import head_path, heads_dir, muse_dir, repo_json_path
1145
1146
1147 def _parse_commit(*args: str) -> _argparse.Namespace:
1148 root_p = _argparse.ArgumentParser()
1149 subs = root_p.add_subparsers(dest="cmd")
1150 _register_commit(subs)
1151 return root_p.parse_args(["commit", *args])
1152
1153
1154 class TestRegisterFlags:
1155 def test_default_json_out_is_false(self) -> None:
1156 ns = _parse_commit("-m", "msg")
1157 assert ns.json_out is False
1158
1159 def test_json_flag_sets_json_out(self) -> None:
1160 ns = _parse_commit("-m", "msg", "--json")
1161 assert ns.json_out is True
1162
1163 def test_j_shorthand_sets_json_out(self) -> None:
1164 ns = _parse_commit("-m", "msg", "-j")
1165 assert ns.json_out is True
1166
1167 def test_m_shorthand_for_message(self) -> None:
1168 ns = _parse_commit("-m", "hello")
1169 assert ns.message == "hello"
1170
1171
1172 # ---------------------------------------------------------------------------
1173 # Genesis commit — structured_delta must be populated (TDD)
1174 # ---------------------------------------------------------------------------
1175
1176
1177 class TestGenesisStructuredDelta:
1178 """The very first commit (no parent) must produce a structured_delta with
1179 insert ops for every tracked symbol, so indexers can record the birth op
1180 as ``add`` rather than ``modify``.
1181
1182 Prior to the fix, ``structured_delta`` was ``None`` for genesis commits
1183 because the diff path was guarded by ``if parent_id is not None``.
1184 """
1185
1186 def test_genesis_commit_has_structured_delta(self, repo: pathlib.Path) -> None:
1187 """structured_delta must not be None on the first commit."""
1188 _invoke(repo, ["code", "add", "."])
1189 result = _commit(repo, "-m", "init: genesis", "--json")
1190 assert result.exit_code == 0, result.output
1191 data = json.loads(result.output)
1192 cid = data.get("commit_id")
1193 assert cid is not None
1194 rec = read_commit(repo, cid)
1195 assert rec is not None
1196 assert rec.structured_delta is not None, (
1197 "Genesis commit must carry a structured_delta so indexers can "
1198 "record symbol births as op=add"
1199 )
1200
1201 def test_genesis_structured_delta_has_insert_ops(self, repo: pathlib.Path) -> None:
1202 """All symbols in a genesis commit must have op=insert (not replace)."""
1203 _invoke(repo, ["code", "add", "."])
1204 result = _commit(repo, "-m", "init", "--json")
1205 data = json.loads(result.output)
1206 cid = data["commit_id"]
1207 rec = read_commit(repo, cid)
1208 assert rec is not None
1209 delta = rec.structured_delta
1210 assert delta is not None
1211 ops = delta.get("ops", [])
1212 assert ops, "Genesis delta must have at least one op"
1213 op_types = {op.get("op") for op in ops}
1214 assert "insert" in op_types, (
1215 f"Expected insert ops in genesis delta; got: {op_types}"
1216 )
1217 assert "replace" not in op_types, (
1218 f"Genesis delta must not contain replace ops; got: {op_types}"
1219 )
1220
1221 def test_genesis_structured_delta_content_ids_present(
1222 self, repo: pathlib.Path
1223 ) -> None:
1224 """Each insert op in the genesis delta must carry a new_content_id."""
1225 _invoke(repo, ["code", "add", "."])
1226 result = _commit(repo, "-m", "init", "--json")
1227 data = json.loads(result.output)
1228 cid = data["commit_id"]
1229 rec = read_commit(repo, cid)
1230 assert rec is not None
1231 delta = rec.structured_delta
1232 assert delta is not None
1233 ops = delta.get("ops", [])
1234 for op in ops:
1235 if op.get("op") == "insert":
1236 assert op.get("new_content_id") or op.get("content_id"), (
1237 f"Insert op missing content id: {op}"
1238 )
File History 1 commit
sha256:4d09a52c06fbc389006963ad1e5ca6ee48c3cb72799f1a322561035b263db67d merge conflict resolve Human patch 3 days ago