gabriel / muse public
commit_graph.py python
304 lines 9.6 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago
1 """muse commit-graph — emit the commit DAG as JSON.
2
3 Walks the commit graph from a tip commit (defaulting to HEAD) and emits
4 every reachable commit as a JSON array of nodes, suitable for agent
5 consumption, visualization, and graph analysis.
6
7 New flags extend the original BFS walk:
8
9 - ``--count`` — emit only the integer count, not the full node list.
10 - ``--first-parent`` — follow only ``parent_commit_id``, ignoring merge parents.
11 Produces a strict linear history.
12 - ``--ancestry-path`` — when used with ``--stop-at``, restricts output to
13 commits that are on a *direct ancestry path* between the tip and the
14 stop-at commit. Commits that are reachable from the tip but not
15 ancestors of ``--stop-at`` are excluded.
16
17 Output (JSON, default)::
18
19 {
20 "tip": "<sha256>",
21 "count": 42,
22 "truncated": false,
23 "commits": [
24 {
25 "commit_id": "<sha256>",
26 "parent_commit_id": "<sha256> | null",
27 "parent2_commit_id": null,
28 "message": "Add verse melody",
29 "branch": "main",
30 "committed_at": "2026-03-18T12:00:00+00:00",
31 "snapshot_id": "<sha256>",
32 "author": "gabriel",
33 "agent_id": "claude-code",
34 "model_id": "claude-sonnet-4-6",
35 "sem_ver_bump": "minor",
36 "breaking_changes": []
37 },
38 ...
39 ],
40 "duration_ms": 0.042,
41 "exit_code": 0
42 }
43
44 With ``--count``::
45
46 {"tip": "<sha256>", "count": 42, "truncated": false,
47 "duration_ms": 0.001, "exit_code": 0}
48
49 ``agent_id`` / ``model_id``
50 Provenance fields from ``muse commit --agent-id / --model-id``.
51 Empty string when the commit was made without agent flags.
52 ``sem_ver_bump``
53 Semantic-version classification: ``"none"``, ``"patch"``, ``"minor"``,
54 or ``"major"``. Lets agents filter history for breaking or significant
55 changes.
56 ``duration_ms``
57 Wall-clock time from argument parsing to output.
58 ``exit_code``
59 Mirrors the process exit code (always ``0`` in the success paths).
60
61 Output contract
62 ---------------
63
64 - Exit 0: graph emitted.
65 - Exit 1: tip commit not found; ``--ancestry-path`` used without ``--stop-at``;
66 unknown ``--format`` value.
67 """
68
69 import argparse
70 import json
71 import logging
72 import pathlib
73 import sys
74 from typing import TypedDict
75
76 from muse.core.errors import ExitCode
77 from muse.core.graph import ancestor_ids, iter_ancestors
78 from muse.core.repo import require_repo
79 from muse.core.refs import (
80 get_head_commit_id,
81 read_current_branch,
82 )
83 from muse.core.commits import read_commit
84 from muse.core.timing import start_timer
85 from muse.core.envelope import EnvelopeJson, make_envelope
86
87 logger = logging.getLogger(__name__)
88
89 _DEFAULT_MAX = 10_000
90
91 class _CommitNode(TypedDict):
92 commit_id: str
93 parent_commit_id: str | None
94 parent2_commit_id: str | None
95 message: str
96 branch: str
97 committed_at: str
98 snapshot_id: str
99 author: str
100 # Agent provenance — empty string when committed by a human without --agent-id.
101 agent_id: str
102 model_id: str
103 sem_ver_bump: str # "none" | "patch" | "minor" | "major"
104 breaking_changes: list[str]
105
106 class _CommitGraphCountJson(EnvelopeJson):
107 """JSON output for ``muse commit-graph --count-only``."""
108
109 tip: str
110 count: int
111 truncated: bool
112
113 class _CommitGraphJson(EnvelopeJson):
114 """JSON output for ``muse commit-graph --json``."""
115
116 tip: str
117 count: int
118 truncated: bool
119 commits: list[_CommitNode]
120
121 _ANCESTRY_PATH_MAX = 100_000 # hard ceiling for --ancestry-path BFS
122
123 def _ancestors_of(root: pathlib.Path, start: str) -> set[str]:
124 """Return the set of all commit IDs reachable from *start* (inclusive).
125
126 Capped at ``_ANCESTRY_PATH_MAX`` to prevent unbounded I/O on very large repos.
127 """
128 return ancestor_ids(root, start, max_commits=_ANCESTRY_PATH_MAX)
129
130 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
131 """Register the commit-graph subcommand."""
132 parser = subparsers.add_parser(
133 "commit-graph",
134 help="Emit commit DAG as JSON.",
135 description=__doc__,
136 )
137 parser.add_argument(
138 "--tip",
139 default=None,
140 metavar="COMMIT_ID",
141 help="Commit ID to start from (default: HEAD).",
142 )
143 parser.add_argument(
144 "--stop-at",
145 default=None,
146 dest="stop_at",
147 metavar="COMMIT_ID",
148 help="Stop BFS at this commit ID (exclusive).",
149 )
150 parser.add_argument(
151 "--max", "-n",
152 type=int,
153 default=_DEFAULT_MAX,
154 dest="max_commits",
155 metavar="N",
156 help=f"Maximum commits to traverse (default: {_DEFAULT_MAX}).",
157 )
158 parser.add_argument(
159 "--count", "-c",
160 action="store_true",
161 dest="count_only",
162 help="Emit only the integer commit count, not the full node list.",
163 )
164 parser.add_argument(
165 "--first-parent", "-1",
166 action="store_true",
167 dest="first_parent",
168 help="Follow only first-parent links, producing a linear history.",
169 )
170 parser.add_argument(
171 "--ancestry-path", "-a",
172 action="store_true",
173 dest="ancestry_path",
174 help="With --stop-at: restrict output to commits on the direct ancestry path.",
175 )
176 parser.add_argument(
177 "--json", "-j", action="store_true", dest="json_out",
178 help="Shorthand for --format json."
179 )
180 parser.set_defaults(func=run)
181
182 def run(args: argparse.Namespace) -> None:
183 """Emit the commit DAG reachable from a tip commit.
184
185 BFS-walks from the tip following ``parent_commit_id`` (and
186 ``parent2_commit_id`` unless ``--first-parent``). Use ``--stop-at`` to
187 exclude a commit and its ancestors — useful for computing commits on a
188 branch since it diverged from another.
189
190 Agent quickstart
191 ----------------
192 ::
193
194 muse commit-graph --json
195 muse commit-graph --tip dev --stop-at main --json
196 muse commit-graph --first-parent --count --json
197
198 JSON fields
199 -----------
200 tip Commit ID the walk started from.
201 stop_at Stop-at commit ID (``null`` if not given).
202 total Total number of commits in the result.
203 truncated ``true`` if ``--max`` was reached before the full history.
204 commits List of commit objects: ``commit_id``, ``message``,
205 ``committed_at``, ``parent_commit_id``, ``parent2_commit_id``.
206
207 Exit codes
208 ----------
209 0 Walk complete.
210 1 Invalid arguments or ancestry-path without stop-at.
211 2 Not inside a Muse repository.
212 """
213 elapsed = start_timer()
214 json_out: bool = args.json_out
215 tip: str | None = args.tip
216 stop_at: str | None = args.stop_at
217 max_commits: int = args.max_commits
218 count_only: bool = args.count_only
219 first_parent: bool = args.first_parent
220 ancestry_path: bool = args.ancestry_path
221 if ancestry_path and stop_at is None:
222 print(
223 json.dumps({"error": "--ancestry-path requires --stop-at to be set."}),
224 file=sys.stderr,
225 )
226 raise SystemExit(ExitCode.USER_ERROR)
227
228 root = require_repo()
229
230 if tip is None:
231 branch = read_current_branch(root)
232 tip = get_head_commit_id(root, branch)
233 if tip is None:
234 print(json.dumps({"error": "No commits on current branch."}), file=sys.stderr)
235 raise SystemExit(ExitCode.USER_ERROR)
236
237 try:
238 tip_record = read_commit(root, tip)
239 except ValueError:
240 tip_record = None
241 if tip_record is None:
242 print(json.dumps({"error": f"Tip commit not found: {tip}"}), file=sys.stderr)
243 raise SystemExit(ExitCode.USER_ERROR)
244
245 # For --ancestry-path, pre-compute ancestors of stop_at so we can filter.
246 stop_ancestors: set[str] = set()
247 if ancestry_path and stop_at is not None:
248 stop_ancestors = _ancestors_of(root, stop_at)
249
250 stop_set: set[str] = {stop_at} if stop_at else set()
251 nodes: list[_CommitNode] = []
252
253 for record in iter_ancestors(
254 root, tip, first_parent_only=first_parent, exclude=stop_set, max_commits=max_commits
255 ):
256 # --ancestry-path: skip commits not on the path to stop_at.
257 if ancestry_path and record.commit_id not in stop_ancestors and record.commit_id != tip:
258 continue
259
260 nodes.append(
261 _CommitNode(
262 commit_id=record.commit_id,
263 parent_commit_id=record.parent_commit_id,
264 parent2_commit_id=record.parent2_commit_id,
265 message=record.message,
266 branch=record.branch,
267 committed_at=record.committed_at.isoformat(),
268 snapshot_id=record.snapshot_id,
269 author=record.author,
270 agent_id=record.agent_id or "",
271 model_id=record.model_id or "",
272 sem_ver_bump=record.sem_ver_bump or "none",
273 breaking_changes=list(record.breaking_changes or []),
274 )
275 )
276
277 truncated = len(nodes) >= max_commits
278
279 if count_only:
280 print(json.dumps(_CommitGraphCountJson(
281 **make_envelope(elapsed),
282 tip=tip,
283 count=len(nodes),
284 truncated=truncated,
285 )))
286 return
287
288 if not json_out:
289 if truncated:
290 print(
291 f"# TRUNCATED at {max_commits:,} commits — "
292 "pass --max-commits N to raise the cap"
293 )
294 for node in nodes:
295 print(node["commit_id"])
296 return
297
298 print(json.dumps(_CommitGraphJson(
299 **make_envelope(elapsed),
300 tip=tip,
301 count=len(nodes),
302 truncated=len(nodes) >= max_commits,
303 commits=nodes,
304 )))
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago