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