gabriel / muse public
test_cmd_commit_graph.py python
740 lines 27.0 KB
Raw
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor ⚠ breaking 30 days ago
1 """Comprehensive tests for ``muse commit-graph``.
2
3 Coverage tiers
4 --------------
5 - Unit: _CommitNode schema, _DEFAULT_MAX
6 - Integration: linear chain, --tip, --max, --count, --first-parent, --stop-at,
7 --ancestry-path, text format, json shorthand
8 - Security: errors to stderr, no traceback on bad tip
9 - Stress: 50-commit chain traversal
10 """
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16
17 from muse.core.errors import ExitCode
18 from muse.core.ids import hash_commit, hash_snapshot
19 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
20 from tests.cli_test_helper import CliRunner, InvokeResult
21 from muse.core.paths import muse_dir
22
23 runner = CliRunner()
24
25 _DT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
33 repo = tmp_path / "repo"
34 dot_muse = muse_dir(repo)
35 for sub in ("objects", "commits", "snapshots", "refs/heads"):
36 (dot_muse / sub).mkdir(parents=True)
37 (dot_muse / "HEAD").write_text("ref: refs/heads/main")
38 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo", "domain": "code"}))
39 return repo
40
41
42 def _snap(repo: pathlib.Path) -> str:
43 """Write a snapshot with an empty manifest; return its content-addressed ID."""
44 sid = hash_snapshot({})
45 write_snapshot(repo, SnapshotRecord(
46 snapshot_id=sid,
47 manifest={},
48 created_at=_DT,
49 ))
50 return sid
51
52
53 def _commit(
54 repo: pathlib.Path,
55 snap_id: str,
56 *,
57 parent: str | None = None,
58 parent2: str | None = None,
59 message: str = "test",
60 ) -> str:
61 """Write a commit with a real content-addressed ID; return the commit ID."""
62 parent_ids = [p for p in [parent, parent2] if p is not None]
63 commit_id = hash_commit( parent_ids=parent_ids,
64 snapshot_id=snap_id,
65 message=message,
66 committed_at_iso=_DT.isoformat(),
67 )
68 write_commit(repo, CommitRecord(
69 commit_id=commit_id,
70 branch="main",
71 snapshot_id=snap_id,
72 message=message,
73 committed_at=_DT,
74 parent_commit_id=parent,
75 parent2_commit_id=parent2,
76 ))
77 return commit_id
78
79
80 def _set_head(repo: pathlib.Path, branch: str, commit_id: str) -> None:
81 ref = ref_path(repo, branch)
82 ref.parent.mkdir(parents=True, exist_ok=True)
83 ref.write_text(commit_id)
84 (head_path(repo)).write_text(f"ref: refs/heads/{branch}")
85
86
87 def _cg(repo: pathlib.Path, *args: str) -> InvokeResult:
88 from muse.cli.app import main as cli
89 return runner.invoke(
90 cli,
91 ["commit-graph", "--json", *args],
92 env={"MUSE_REPO_ROOT": str(repo)},
93 )
94
95
96 # ---------------------------------------------------------------------------
97 # Unit
98 # ---------------------------------------------------------------------------
99
100
101 class TestUnit:
102 def test_commit_node_fields(self) -> None:
103 from muse.cli.commands.commit_graph import _CommitNode
104 fields = set(_CommitNode.__annotations__.keys())
105 assert "commit_id" in fields
106 assert "parent_commit_id" in fields
107 assert "parent2_commit_id" in fields
108 assert "message" in fields
109 assert "snapshot_id" in fields
110 assert "branch" in fields
111 assert "committed_at" in fields
112 assert "author" in fields
113
114 def test_default_max(self) -> None:
115 from muse.cli.commands.commit_graph import _DEFAULT_MAX
116 assert _DEFAULT_MAX >= 1000
117
118 def test_ancestors_of_single_commit(self, tmp_path: pathlib.Path) -> None:
119 from muse.cli.commands.commit_graph import _ancestors_of
120 repo = _make_repo(tmp_path)
121 snap_id = _snap(repo)
122 cid = _commit(repo, snap_id)
123 result = _ancestors_of(repo, cid)
124 assert cid in result
125
126 def test_ancestors_of_linear_chain(self, tmp_path: pathlib.Path) -> None:
127 from muse.cli.commands.commit_graph import _ancestors_of
128 repo = _make_repo(tmp_path)
129 snap_id = _snap(repo)
130 c1 = _commit(repo, snap_id, message="c1")
131 c2 = _commit(repo, snap_id, parent=c1, message="c2")
132 c3 = _commit(repo, snap_id, parent=c2, message="c3")
133 result = _ancestors_of(repo, c3)
134 assert c1 in result
135 assert c2 in result
136 assert c3 in result
137
138 def test_ancestors_of_merge_commit_follows_both_parents(self, tmp_path: pathlib.Path) -> None:
139 from muse.cli.commands.commit_graph import _ancestors_of
140 repo = _make_repo(tmp_path)
141 snap_id = _snap(repo)
142 base = _commit(repo, snap_id, message="base")
143 left = _commit(repo, snap_id, parent=base, message="left")
144 right = _commit(repo, snap_id, parent=base, message="right")
145 merge = _commit(repo, snap_id, parent=left, parent2=right, message="merge")
146 result = _ancestors_of(repo, merge)
147 assert base in result
148 assert left in result
149 assert right in result
150 assert merge in result
151
152 def test_ancestors_of_missing_commit_returns_empty(self, tmp_path: pathlib.Path) -> None:
153 from muse.cli.commands.commit_graph import _ancestors_of
154 from muse.core.types import blob_id
155 repo = _make_repo(tmp_path)
156 # Use blob_id to produce a well-formed sha256: ID that doesn't exist as a commit.
157 missing = blob_id(b"this commit does not exist")
158 # Missing commit: read_commit returns None, so it is skipped → empty set.
159 result = _ancestors_of(repo, missing)
160 assert missing not in result
161 assert len(result) == 0
162
163
164 # ---------------------------------------------------------------------------
165 # Integration — JSON format
166 # ---------------------------------------------------------------------------
167
168
169 class TestJsonFormat:
170 def test_linear_two_commits(self, tmp_path: pathlib.Path) -> None:
171 repo = _make_repo(tmp_path)
172 snap_id = _snap(repo)
173 c1 = _commit(repo, snap_id, message="c1")
174 c2 = _commit(repo, snap_id, parent=c1, message="c2")
175 _set_head(repo, "main", c2)
176 result = _cg(repo)
177 assert result.exit_code == 0
178 data = json.loads(result.output)
179 assert data["count"] == 2
180 ids = {c["commit_id"] for c in data["commits"]}
181 assert {c1, c2} == ids
182
183 def test_tip_is_present_in_output(self, tmp_path: pathlib.Path) -> None:
184 repo = _make_repo(tmp_path)
185 snap_id = _snap(repo)
186 cid = _commit(repo, snap_id)
187 _set_head(repo, "main", cid)
188 data = json.loads(_cg(repo).output)
189 assert data["tip"] == cid
190
191 def test_explicit_tip(self, tmp_path: pathlib.Path) -> None:
192 repo = _make_repo(tmp_path)
193 snap_id = _snap(repo)
194 cid = _commit(repo, snap_id)
195 result = _cg(repo, "--tip", cid)
196 assert result.exit_code == 0
197 data = json.loads(result.output)
198 assert data["tip"] == cid
199
200 def test_json_shorthand(self, tmp_path: pathlib.Path) -> None:
201 repo = _make_repo(tmp_path)
202 snap_id = _snap(repo)
203 cid = _commit(repo, snap_id)
204 _set_head(repo, "main", cid)
205 result = _cg(repo, "--json")
206 assert result.exit_code == 0
207 assert "commits" in json.loads(result.output)
208
209 def test_truncated_flag_when_limited(self, tmp_path: pathlib.Path) -> None:
210 repo = _make_repo(tmp_path)
211 snap_id = _snap(repo)
212 c1 = _commit(repo, snap_id, message="c1")
213 c2 = _commit(repo, snap_id, parent=c1, message="c2")
214 _set_head(repo, "main", c2)
215 data = json.loads(_cg(repo, "--max", "1").output)
216 assert data["truncated"] is True
217
218
219 # ---------------------------------------------------------------------------
220 # Integration — --count
221 # ---------------------------------------------------------------------------
222
223
224 class TestCountOnly:
225 def test_count_returns_integer(self, tmp_path: pathlib.Path) -> None:
226 repo = _make_repo(tmp_path)
227 snap_id = _snap(repo)
228 cid = _commit(repo, snap_id)
229 _set_head(repo, "main", cid)
230 data = json.loads(_cg(repo, "--count").output)
231 assert data["count"] == 1
232 assert "commits" not in data
233
234 def test_count_reflects_chain_length(self, tmp_path: pathlib.Path) -> None:
235 repo = _make_repo(tmp_path)
236 snap_id = _snap(repo)
237 c1 = _commit(repo, snap_id, message="c1")
238 c2 = _commit(repo, snap_id, parent=c1, message="c2")
239 c3 = _commit(repo, snap_id, parent=c2, message="c3")
240 _set_head(repo, "main", c3)
241 data = json.loads(_cg(repo, "--count").output)
242 assert data["count"] == 3
243
244
245 # ---------------------------------------------------------------------------
246 # Integration — --first-parent
247 # ---------------------------------------------------------------------------
248
249
250 class TestFirstParent:
251 def test_first_parent_skips_merge_parent(self, tmp_path: pathlib.Path) -> None:
252 repo = _make_repo(tmp_path)
253 snap_id = _snap(repo)
254 p1 = _commit(repo, snap_id, message="p1")
255 p2 = _commit(repo, snap_id, message="p2")
256 merge = _commit(repo, snap_id, parent=p1, parent2=p2, message="merge")
257 _set_head(repo, "main", merge)
258 data = json.loads(_cg(repo, "--first-parent").output)
259 ids = {c["commit_id"] for c in data["commits"]}
260 assert p2 not in ids
261 assert p1 in ids
262 assert merge in ids
263
264
265 # ---------------------------------------------------------------------------
266 # Integration — --stop-at
267 # ---------------------------------------------------------------------------
268
269
270 class TestStopAt:
271 def test_stop_at_excludes_old_commits(self, tmp_path: pathlib.Path) -> None:
272 repo = _make_repo(tmp_path)
273 snap_id = _snap(repo)
274 c1 = _commit(repo, snap_id, message="c1")
275 c2 = _commit(repo, snap_id, parent=c1, message="c2")
276 c3 = _commit(repo, snap_id, parent=c2, message="c3")
277 _set_head(repo, "main", c3)
278 data = json.loads(_cg(repo, "--stop-at", c2).output)
279 ids = {c["commit_id"] for c in data["commits"]}
280 assert c2 not in ids
281 assert c1 not in ids
282 assert c3 in ids
283
284
285 # ---------------------------------------------------------------------------
286 # Integration — text format
287 # ---------------------------------------------------------------------------
288
289
290 class TestTextFormat:
291 def test_text_one_id_per_line(self, tmp_path: pathlib.Path) -> None:
292 repo = _make_repo(tmp_path)
293 snap_id = _snap(repo)
294 cid = _commit(repo, snap_id)
295 _set_head(repo, "main", cid)
296 from muse.cli.app import main as cli
297 result = runner.invoke(
298 cli,
299 ["commit-graph"],
300 env={"MUSE_REPO_ROOT": str(repo)},
301 )
302 assert result.exit_code == 0
303 assert cid in result.output
304
305
306 # ---------------------------------------------------------------------------
307 # Error cases
308 # ---------------------------------------------------------------------------
309
310
311 class TestErrors:
312 def test_no_commits_errors(self, tmp_path: pathlib.Path) -> None:
313 repo = _make_repo(tmp_path)
314 result = _cg(repo)
315 assert result.exit_code == ExitCode.USER_ERROR
316
317 def test_tip_not_found_errors(self, tmp_path: pathlib.Path) -> None:
318 repo = _make_repo(tmp_path)
319 result = _cg(repo, "--tip", f"dead{'beef' * 15}")
320 assert result.exit_code == ExitCode.USER_ERROR
321
322 def test_ancestry_path_without_stop_at_errors(self, tmp_path: pathlib.Path) -> None:
323 repo = _make_repo(tmp_path)
324 snap_id = _snap(repo)
325 cid = _commit(repo, snap_id)
326 _set_head(repo, "main", cid)
327 result = _cg(repo, "--ancestry-path")
328 assert result.exit_code == ExitCode.USER_ERROR
329
330 def test_no_traceback_on_bad_tip(self, tmp_path: pathlib.Path) -> None:
331 repo = _make_repo(tmp_path)
332 result = _cg(repo, "--tip", "bad")
333 assert "Traceback" not in result.output
334
335
336 # ---------------------------------------------------------------------------
337 # Stress
338 # ---------------------------------------------------------------------------
339
340
341 class TestSecurity:
342 def test_format_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
343 repo = _make_repo(tmp_path)
344 r = _cg(repo, "--format", "xml")
345 assert r.exit_code != 0
346 assert r.stdout_bytes == b""
347 assert "error" in r.stderr.lower()
348
349 def test_no_traceback_on_bad_format(self, tmp_path: pathlib.Path) -> None:
350 repo = _make_repo(tmp_path)
351 r = _cg(repo, "--format", "bad")
352 assert "Traceback" not in r.output
353 assert "Traceback" not in r.stderr
354
355 def test_ansi_in_tip_rejected_gracefully(self, tmp_path: pathlib.Path) -> None:
356 """An ANSI-injected tip ID must not crash; it's not a valid commit."""
357 repo = _make_repo(tmp_path)
358 r = _cg(repo, "--tip", "\x1b[31mbad\x1b[0m")
359 assert "Traceback" not in r.output
360 assert "Traceback" not in r.stderr
361
362 def test_json_shorthand_flag(self, tmp_path: pathlib.Path) -> None:
363 repo = _make_repo(tmp_path)
364 snap_id = _snap(repo)
365 cid = _commit(repo, snap_id)
366 _set_head(repo, "main", cid)
367 r = _cg(repo, "--json")
368 assert r.exit_code == 0
369 d = json.loads(r.output)
370 assert "commits" in d
371
372
373 class TestStress:
374 def test_50_commit_linear_chain(self, tmp_path: pathlib.Path) -> None:
375 repo = _make_repo(tmp_path)
376 snap_id = _snap(repo)
377 prev: str | None = None
378 for i in range(50):
379 prev = _commit(repo, snap_id, parent=prev, message=f"commit {i}")
380 assert prev is not None
381 _set_head(repo, "main", prev)
382 data = json.loads(_cg(repo, "--count").output)
383 assert data["count"] == 50
384
385 def test_branching_dag_100_commits(self, tmp_path: pathlib.Path) -> None:
386 """10-branch DAG — --ancestry-path + --first-parent should complete."""
387 repo = _make_repo(tmp_path)
388 snap_id = _snap(repo)
389 base = _commit(repo, snap_id, message="base")
390 tips: list[str] = []
391 for i in range(10):
392 tip = _commit(repo, snap_id, parent=base, message=f"branch {i}")
393 tips.append(tip)
394 merge = _commit(repo, snap_id, parent=tips[-1], parent2=tips[-2], message="merge")
395 _set_head(repo, "main", merge)
396 r = _cg(repo, "--json")
397 assert r.exit_code == 0
398 d = json.loads(r.output)
399 commit_ids = {c["commit_id"] for c in d["commits"]}
400 assert merge in commit_ids
401 assert base in commit_ids
402
403 def test_200_sequential_calls(self, tmp_path: pathlib.Path) -> None:
404 repo = _make_repo(tmp_path)
405 snap_id = _snap(repo)
406 cid = _commit(repo, snap_id)
407 _set_head(repo, "main", cid)
408 for i in range(200):
409 r = _cg(repo)
410 assert r.exit_code == 0, f"failed at {i}"
411
412
413 # ---------------------------------------------------------------------------
414 # Supercharge — duration_ms, exit_code, agent provenance in nodes
415 # ---------------------------------------------------------------------------
416
417 _FULL_TOP_KEYS = frozenset({"tip", "count", "truncated", "commits",
418 "duration_ms", "exit_code"})
419 _FULL_COUNT_KEYS = frozenset({"tip", "count", "truncated",
420 "duration_ms", "exit_code"})
421 _FULL_NODE_KEYS = frozenset({
422 "commit_id", "parent_commit_id", "parent2_commit_id",
423 "message", "branch", "committed_at", "snapshot_id", "author",
424 "agent_id", "model_id", "sem_ver_bump", "breaking_changes",
425 })
426
427
428 def _commit_with_provenance(
429 repo: pathlib.Path,
430 snap_id: str,
431 *,
432 parent: str | None = None,
433 message: str = "test",
434 agent_id: str = "claude-code",
435 model_id: str = "claude-sonnet-4-6",
436 sem_ver_bump: str = "minor",
437 ) -> str:
438 """Write a commit with full agent provenance; return the commit ID."""
439 parent_ids = [parent] if parent else []
440 commit_id = hash_commit( parent_ids=parent_ids,
441 snapshot_id=snap_id,
442 message=message,
443 committed_at_iso=_DT.isoformat(),
444 )
445 write_commit(repo, CommitRecord(
446 commit_id=commit_id,
447 branch="main",
448 snapshot_id=snap_id,
449 message=message,
450 committed_at=_DT,
451 parent_commit_id=parent,
452 agent_id=agent_id,
453 model_id=model_id,
454 sem_ver_bump=sem_ver_bump,
455 breaking_changes=[],
456 ))
457 return commit_id
458
459
460 class TestElapsed:
461 """Every JSON output path must include ``duration_ms`` as a float."""
462
463 def test_full_json_has_elapsed(self, tmp_path: pathlib.Path) -> None:
464 repo = _make_repo(tmp_path)
465 snap_id = _snap(repo)
466 cid = _commit(repo, snap_id)
467 _set_head(repo, "main", cid)
468 r = _cg(repo)
469 assert r.exit_code == 0
470 data = json.loads(r.output)
471 assert "duration_ms" in data, "duration_ms missing from full JSON"
472 assert isinstance(data["duration_ms"], float)
473 assert data["duration_ms"] >= 0.0
474
475 def test_count_only_has_elapsed(self, tmp_path: pathlib.Path) -> None:
476 repo = _make_repo(tmp_path)
477 snap_id = _snap(repo)
478 cid = _commit(repo, snap_id)
479 _set_head(repo, "main", cid)
480 r = _cg(repo, "--count")
481 assert r.exit_code == 0
482 data = json.loads(r.output)
483 assert "duration_ms" in data, "duration_ms missing from --count JSON"
484 assert isinstance(data["duration_ms"], float)
485
486 def test_elapsed_is_non_negative(self, tmp_path: pathlib.Path) -> None:
487 repo = _make_repo(tmp_path)
488 snap_id = _snap(repo)
489 cid = _commit(repo, snap_id)
490 _set_head(repo, "main", cid)
491 r = _cg(repo)
492 data = json.loads(r.output)
493 assert data["duration_ms"] >= 0.0
494
495
496 class TestExitCode:
497 """Every JSON output path must include ``exit_code`` mirroring the process exit."""
498
499 def test_full_json_exit_code_0(self, tmp_path: pathlib.Path) -> None:
500 repo = _make_repo(tmp_path)
501 snap_id = _snap(repo)
502 cid = _commit(repo, snap_id)
503 _set_head(repo, "main", cid)
504 r = _cg(repo)
505 assert r.exit_code == 0
506 data = json.loads(r.output)
507 assert data["exit_code"] == 0
508
509 def test_count_only_exit_code_0(self, tmp_path: pathlib.Path) -> None:
510 repo = _make_repo(tmp_path)
511 snap_id = _snap(repo)
512 cid = _commit(repo, snap_id)
513 _set_head(repo, "main", cid)
514 r = _cg(repo, "--count")
515 assert r.exit_code == 0
516 data = json.loads(r.output)
517 assert data["exit_code"] == 0
518
519
520 class TestJsonSchemaComplete:
521 """Full key-set present in both top-level and node objects."""
522
523 def test_top_level_keys_complete(self, tmp_path: pathlib.Path) -> None:
524 repo = _make_repo(tmp_path)
525 snap_id = _snap(repo)
526 cid = _commit(repo, snap_id)
527 _set_head(repo, "main", cid)
528 r = _cg(repo)
529 data = json.loads(r.output)
530 missing = _FULL_TOP_KEYS - data.keys()
531 assert not missing, f"Top-level JSON missing keys: {missing}"
532
533 def test_count_keys_complete(self, tmp_path: pathlib.Path) -> None:
534 repo = _make_repo(tmp_path)
535 snap_id = _snap(repo)
536 cid = _commit(repo, snap_id)
537 _set_head(repo, "main", cid)
538 r = _cg(repo, "--count")
539 data = json.loads(r.output)
540 missing = _FULL_COUNT_KEYS - data.keys()
541 assert not missing, f"--count JSON missing keys: {missing}"
542
543 def test_node_provenance_keys_complete(self, tmp_path: pathlib.Path) -> None:
544 """Each commit node must expose agent_id, model_id, sem_ver_bump, breaking_changes."""
545 repo = _make_repo(tmp_path)
546 snap_id = _snap(repo)
547 cid = _commit_with_provenance(repo, snap_id)
548 _set_head(repo, "main", cid)
549 r = _cg(repo)
550 data = json.loads(r.output)
551 assert data["commits"], "Expected at least one node"
552 node = data["commits"][0]
553 missing = _FULL_NODE_KEYS - node.keys()
554 assert not missing, f"Node missing keys: {missing}"
555
556
557 class TestNodeProvenance:
558 """agent_id, model_id, sem_ver_bump, breaking_changes are surfaced per node."""
559
560 def test_agent_id_in_node(self, tmp_path: pathlib.Path) -> None:
561 repo = _make_repo(tmp_path)
562 snap_id = _snap(repo)
563 cid = _commit_with_provenance(repo, snap_id, agent_id="agentception/worker")
564 _set_head(repo, "main", cid)
565 r = _cg(repo)
566 node = json.loads(r.output)["commits"][0]
567 assert node["agent_id"] == "agentception/worker"
568
569 def test_model_id_in_node(self, tmp_path: pathlib.Path) -> None:
570 repo = _make_repo(tmp_path)
571 snap_id = _snap(repo)
572 cid = _commit_with_provenance(repo, snap_id, model_id="claude-opus-4-6")
573 _set_head(repo, "main", cid)
574 r = _cg(repo)
575 node = json.loads(r.output)["commits"][0]
576 assert node["model_id"] == "claude-opus-4-6"
577
578 def test_sem_ver_bump_in_node(self, tmp_path: pathlib.Path) -> None:
579 repo = _make_repo(tmp_path)
580 snap_id = _snap(repo)
581 cid = _commit_with_provenance(repo, snap_id, sem_ver_bump="major")
582 _set_head(repo, "main", cid)
583 r = _cg(repo)
584 node = json.loads(r.output)["commits"][0]
585 assert node["sem_ver_bump"] == "major"
586
587 def test_breaking_changes_is_list(self, tmp_path: pathlib.Path) -> None:
588 repo = _make_repo(tmp_path)
589 snap_id = _snap(repo)
590 cid = _commit_with_provenance(repo, snap_id)
591 _set_head(repo, "main", cid)
592 r = _cg(repo)
593 node = json.loads(r.output)["commits"][0]
594 assert isinstance(node["breaking_changes"], list)
595
596 def test_human_commit_has_empty_agent_id(self, tmp_path: pathlib.Path) -> None:
597 """A commit without agent provenance must still have the key, value empty string."""
598 repo = _make_repo(tmp_path)
599 snap_id = _snap(repo)
600 cid = _commit(repo, snap_id, message="human commit")
601 _set_head(repo, "main", cid)
602 r = _cg(repo)
603 node = json.loads(r.output)["commits"][0]
604 assert "agent_id" in node
605 assert node["agent_id"] == ""
606
607 def test_chain_preserves_provenance_per_node(self, tmp_path: pathlib.Path) -> None:
608 """Each node in a multi-commit chain retains its own provenance."""
609 repo = _make_repo(tmp_path)
610 snap_id = _snap(repo)
611 c1 = _commit_with_provenance(repo, snap_id, agent_id="bot-a", model_id="m-a")
612 c2 = _commit_with_provenance(repo, snap_id, parent=c1, agent_id="bot-b", model_id="m-b")
613 _set_head(repo, "main", c2)
614 r = _cg(repo)
615 nodes = {n["commit_id"]: n for n in json.loads(r.output)["commits"]}
616 assert nodes[c1]["agent_id"] == "bot-a"
617 assert nodes[c1]["model_id"] == "m-a"
618 assert nodes[c2]["agent_id"] == "bot-b"
619 assert nodes[c2]["model_id"] == "m-b"
620
621
622 class TestDataIntegrity:
623 """Nodes returned by commit-graph must match CommitRecords on disk."""
624
625 def test_commit_id_matches_disk(self, tmp_path: pathlib.Path) -> None:
626 from muse.core.store import read_commit
627 repo = _make_repo(tmp_path)
628 snap_id = _snap(repo)
629 cid = _commit(repo, snap_id)
630 _set_head(repo, "main", cid)
631 r = _cg(repo)
632 node = json.loads(r.output)["commits"][0]
633 record = read_commit(repo, node["commit_id"])
634 assert record is not None
635 assert record.commit_id == node["commit_id"]
636
637 def test_snapshot_id_matches_disk(self, tmp_path: pathlib.Path) -> None:
638 from muse.core.store import read_commit
639 repo = _make_repo(tmp_path)
640 snap_id = _snap(repo)
641 cid = _commit(repo, snap_id)
642 _set_head(repo, "main", cid)
643 r = _cg(repo)
644 node = json.loads(r.output)["commits"][0]
645 record = read_commit(repo, node["commit_id"])
646 assert record is not None
647 assert record.snapshot_id == node["snapshot_id"]
648
649 def test_parent_chain_matches_disk(self, tmp_path: pathlib.Path) -> None:
650 from muse.core.store import read_commit
651 repo = _make_repo(tmp_path)
652 snap_id = _snap(repo)
653 c1 = _commit(repo, snap_id, message="root")
654 c2 = _commit(repo, snap_id, parent=c1, message="child")
655 _set_head(repo, "main", c2)
656 r = _cg(repo)
657 nodes = {n["commit_id"]: n for n in json.loads(r.output)["commits"]}
658 disk_c2 = read_commit(repo, c2)
659 assert disk_c2 is not None
660 assert nodes[c2]["parent_commit_id"] == disk_c2.parent_commit_id
661
662 def test_count_matches_node_list_length(self, tmp_path: pathlib.Path) -> None:
663 repo = _make_repo(tmp_path)
664 snap_id = _snap(repo)
665 c1 = _commit(repo, snap_id)
666 c2 = _commit(repo, snap_id, parent=c1)
667 c3 = _commit(repo, snap_id, parent=c2)
668 _set_head(repo, "main", c3)
669 r = _cg(repo)
670 data = json.loads(r.output)
671 assert data["count"] == len(data["commits"]) == 3
672
673
674 class TestPerformance:
675 """Large graph walks must complete within acceptable time."""
676
677 def test_1000_commit_chain_under_5s(self, tmp_path: pathlib.Path) -> None:
678 import time
679 repo = _make_repo(tmp_path)
680 snap_id = _snap(repo)
681 parent: str | None = None
682 for i in range(1000):
683 parent = _commit(repo, snap_id, parent=parent, message=f"c{i}")
684 _set_head(repo, "main", parent) # type: ignore[arg-type]
685 start = time.monotonic()
686 r = _cg(repo)
687 elapsed = time.monotonic() - start
688 assert r.exit_code == 0
689 data = json.loads(r.output)
690 assert data["count"] == 1000
691 assert elapsed < 5.0, f"1000-commit walk took {elapsed:.2f}s"
692
693 def test_duration_ms_plausible(self, tmp_path: pathlib.Path) -> None:
694 import time
695 repo = _make_repo(tmp_path)
696 snap_id = _snap(repo)
697 parent: str | None = None
698 for i in range(20):
699 parent = _commit(repo, snap_id, parent=parent, message=f"c{i}")
700 _set_head(repo, "main", parent) # type: ignore[arg-type]
701 start = time.monotonic()
702 r = _cg(repo)
703 wall = time.monotonic() - start
704 data = json.loads(r.output)
705 assert data["duration_ms"] <= (wall + 0.5) * 1000
706
707
708 # ---------------------------------------------------------------------------
709 # Flag registration tests
710 # ---------------------------------------------------------------------------
711
712 import argparse as _argparse
713 from muse.cli.commands.commit_graph import register as _register_commit_graph
714 from muse.core.paths import head_path, ref_path
715
716
717 def _parse_cg(*args: str) -> _argparse.Namespace:
718 root_p = _argparse.ArgumentParser()
719 subs = root_p.add_subparsers(dest="cmd")
720 _register_commit_graph(subs)
721 return root_p.parse_args(["commit-graph", *args])
722
723
724 class TestRegisterFlags:
725 def test_default_json_out_is_false(self) -> None:
726 ns = _parse_cg()
727 assert ns.json_out is False
728
729 def test_json_flag_sets_json_out(self) -> None:
730 ns = _parse_cg("--json")
731 assert ns.json_out is True
732
733 def test_j_shorthand_sets_json_out(self) -> None:
734 ns = _parse_cg("-j")
735 assert ns.json_out is True
736
737 def test_format_flag_no_longer_exists(self) -> None:
738 import pytest
739 with pytest.raises(SystemExit):
740 _parse_cg("--format", "json")
File History 1 commit
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago