commit_graph.py python
284 lines 8.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 34 days ago
1 """muse plumbing 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, equivalent to ``git log --first-parent``.
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": ""
33 },
34 ...
35 ]
36 }
37
38 With ``--count``::
39
40 {"tip": "<sha256>", "count": 42}
41
42 Plumbing contract
43 -----------------
44
45 - Exit 0: graph emitted.
46 - Exit 1: tip commit not found; ``--ancestry-path`` used without ``--stop-at``;
47 unknown ``--format`` value.
48 """
49
50 from __future__ import annotations
51
52 import argparse
53 import json
54 import logging
55 import pathlib
56 import sys
57 from collections import deque
58 from typing import TypedDict
59
60 from muse.core.errors import ExitCode
61 from muse.core.repo import require_repo
62 from muse.core.store import get_head_commit_id, read_commit, read_current_branch
63
64 logger = logging.getLogger(__name__)
65
66 _DEFAULT_MAX = 10_000
67 _FORMAT_CHOICES = ("json", "text")
68
69
70 class _CommitNode(TypedDict):
71 commit_id: str
72 parent_commit_id: str | None
73 parent2_commit_id: str | None
74 message: str
75 branch: str
76 committed_at: str
77 snapshot_id: str
78 author: str
79
80
81 _ANCESTRY_PATH_MAX = 100_000 # hard ceiling for --ancestry-path BFS
82
83
84 def _ancestors_of(root: pathlib.Path, start: str) -> set[str]:
85 """Return the set of all commit IDs reachable from *start* (inclusive).
86
87 Used by ``--ancestry-path`` to identify which commits lie on a direct
88 path between the tip and the stop-at commit. Capped at
89 ``_ANCESTRY_PATH_MAX`` to prevent unbounded I/O on very large repos.
90 """
91 visited: set[str] = set()
92 queue: deque[str] = deque([start])
93 while queue and len(visited) < _ANCESTRY_PATH_MAX:
94 cid = queue.popleft()
95 if cid in visited:
96 continue
97 visited.add(cid)
98 record = read_commit(root, cid)
99 if record is None:
100 continue
101 if record.parent_commit_id:
102 queue.append(record.parent_commit_id)
103 if record.parent2_commit_id:
104 queue.append(record.parent2_commit_id)
105 return visited
106
107
108 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
109 """Register the commit-graph subcommand."""
110 parser = subparsers.add_parser(
111 "commit-graph",
112 help="Emit commit DAG as JSON.",
113 description=__doc__,
114 )
115 parser.add_argument(
116 "--tip",
117 default=None,
118 metavar="COMMIT_ID",
119 help="Commit ID to start from (default: HEAD).",
120 )
121 parser.add_argument(
122 "--stop-at",
123 default=None,
124 dest="stop_at",
125 metavar="COMMIT_ID",
126 help="Stop BFS at this commit ID (exclusive).",
127 )
128 parser.add_argument(
129 "--max", "-n",
130 type=int,
131 default=_DEFAULT_MAX,
132 dest="max_commits",
133 metavar="N",
134 help=f"Maximum commits to traverse (default: {_DEFAULT_MAX}).",
135 )
136 parser.add_argument(
137 "--count", "-c",
138 action="store_true",
139 dest="count_only",
140 help="Emit only the integer commit count, not the full node list.",
141 )
142 parser.add_argument(
143 "--first-parent", "-1",
144 action="store_true",
145 dest="first_parent",
146 help="Follow only first-parent links, producing a linear history.",
147 )
148 parser.add_argument(
149 "--ancestry-path", "-a",
150 action="store_true",
151 dest="ancestry_path",
152 help="With --stop-at: restrict output to commits on the direct ancestry path.",
153 )
154 parser.add_argument(
155 "--format", "-f",
156 dest="fmt",
157 default="json",
158 metavar="FORMAT",
159 help="Output format: json or text (one ID per line). (default: json)",
160 )
161 parser.add_argument(
162 "--json", action="store_const", const="json", dest="fmt",
163 help="Shorthand for --format json."
164 )
165 parser.set_defaults(func=run)
166
167
168 def run(args: argparse.Namespace) -> None:
169 """Emit the commit DAG reachable from a tip commit.
170
171 Performs a BFS walk from the tip, following ``parent_commit_id`` and
172 (unless ``--first-parent``) ``parent2_commit_id`` pointers. The
173 ``--stop-at`` commit and its ancestors are excluded — useful for
174 computing the commits in a branch since it diverged from another.
175 """
176 fmt: str = args.fmt
177 tip: str | None = args.tip
178 stop_at: str | None = args.stop_at
179 max_commits: int = args.max_commits
180 count_only: bool = args.count_only
181 first_parent: bool = args.first_parent
182 ancestry_path: bool = args.ancestry_path
183
184 if fmt not in _FORMAT_CHOICES:
185 print(
186 json.dumps({
187 "error": f"Unknown format {fmt!r}. "
188 f"Valid choices: {', '.join(_FORMAT_CHOICES)}"
189 }),
190 file=sys.stderr,
191 )
192 raise SystemExit(ExitCode.USER_ERROR)
193
194 if ancestry_path and stop_at is None:
195 print(
196 json.dumps({"error": "--ancestry-path requires --stop-at to be set."}),
197 file=sys.stderr,
198 )
199 raise SystemExit(ExitCode.USER_ERROR)
200
201 root = require_repo()
202
203 if tip is None:
204 branch = read_current_branch(root)
205 tip = get_head_commit_id(root, branch)
206 if tip is None:
207 print(json.dumps({"error": "No commits on current branch."}), file=sys.stderr)
208 raise SystemExit(ExitCode.USER_ERROR)
209
210 if read_commit(root, tip) is None:
211 print(json.dumps({"error": f"Tip commit not found: {tip}"}), file=sys.stderr)
212 raise SystemExit(ExitCode.USER_ERROR)
213
214 # For --ancestry-path, pre-compute ancestors of stop_at so we can filter.
215 stop_ancestors: set[str] = set()
216 if ancestry_path and stop_at is not None:
217 stop_ancestors = _ancestors_of(root, stop_at)
218
219 stop_set: set[str] = {stop_at} if stop_at else set()
220 visited: set[str] = set()
221 queue: deque[str] = deque([tip])
222 nodes: list[_CommitNode] = []
223
224 while queue and len(nodes) < max_commits:
225 cid = queue.popleft()
226 if cid in visited or cid in stop_set:
227 continue
228 visited.add(cid)
229 record = read_commit(root, cid)
230 if record is None:
231 continue
232
233 # --ancestry-path: skip commits not on the path to stop_at.
234 if ancestry_path and cid not in stop_ancestors and cid != tip:
235 if record.parent_commit_id:
236 queue.append(record.parent_commit_id)
237 if not first_parent and record.parent2_commit_id:
238 queue.append(record.parent2_commit_id)
239 continue
240
241 nodes.append(
242 _CommitNode(
243 commit_id=record.commit_id,
244 parent_commit_id=record.parent_commit_id,
245 parent2_commit_id=record.parent2_commit_id,
246 message=record.message,
247 branch=record.branch,
248 committed_at=record.committed_at.isoformat(),
249 snapshot_id=record.snapshot_id,
250 author=record.author,
251 )
252 )
253 if record.parent_commit_id:
254 queue.append(record.parent_commit_id)
255 if not first_parent and record.parent2_commit_id:
256 queue.append(record.parent2_commit_id)
257
258 truncated = len(nodes) >= max_commits
259
260 if count_only:
261 print(json.dumps({"tip": tip, "count": len(nodes), "truncated": truncated}))
262 return
263
264 if fmt == "text":
265 if truncated:
266 print(
267 f"# TRUNCATED at {max_commits:,} commits — "
268 "pass --max-commits N to raise the cap"
269 )
270 for node in nodes:
271 print(node["commit_id"])
272 return
273
274 print(
275 json.dumps(
276 {
277 "tip": tip,
278 "count": len(nodes),
279 "truncated": len(nodes) >= max_commits,
280 "commits": nodes,
281 },
282 indent=2,
283 )
284 )
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 34 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 34 days ago