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