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