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