gabriel / muse public
rev_list.py python
633 lines 22.1 KB
Raw
sha256:85fc1542f909649acadf0b5fd3bc662f43d59eea76d799a069e76cbccd54f7ec feat: implement ^ref exclusion prefix syntax in rev-list an… Sonnet 5 patch 17 hours ago
1 """muse rev-list — emit a filtered stream of commit IDs.
2
3 ``rev-list`` is the raw commit-ID primitive that backs agent scripting,
4 counting, ancestry queries, and pipeline composition. Where ``muse log``
5 is the human-facing history viewer, ``rev-list`` is the machine-facing
6 stream — one commit ID per line (or a single integer for ``--count``).
7
8 Usage examples::
9
10 muse rev-list HEAD # all commits from HEAD to root
11 muse rev-list dev..feat/new # commits on feat/new not on dev
12 muse rev-list -n 10 HEAD # last 10 commit IDs
13 muse rev-list --count HEAD # single integer count
14 muse rev-list --first-parent HEAD # linear chain only (skip merge parents)
15 muse rev-list --no-merges HEAD # skip merge commits
16 muse rev-list --merges HEAD # only merge commits
17 muse rev-list --author alice HEAD # commits by alice (substring match)
18 muse rev-list --after 2026-01-01 HEAD # commits after a date (UTC)
19 muse rev-list --before 2026-12-31 HEAD # commits before a date (UTC)
20 muse rev-list --touches src/ HEAD # commits touching anything under src/
21 muse rev-list --reverse HEAD # oldest-first
22 muse rev-list --json HEAD # JSON array: {"commit_ids": [...]}
23
24 Range syntax (``A..B``)
25 Emits commits reachable from *B* but not reachable from *A*. Equivalent
26 to ``git rev-list A..B``. Useful for counting divergence between branches::
27
28 muse rev-list --count main..feat # how many commits ahead of main
29
30 Exclusion prefix (``^ref``)
31 Equivalent to ``A..B`` range syntax but composes across any number of
32 exclusions. ``muse rev-list X ^Y`` emits commits reachable from *X* but
33 not from *Y* — the same meaning as ``muse rev-list Y..X``::
34
35 muse rev-list HEAD ^main # same as: muse rev-list main..HEAD
36 muse rev-list feat ^dev ^hotfix/x # exclude anything reachable from dev OR hotfix/x
37
38 ``--count`` mode
39 Increments a counter rather than accumulating a list — memory is O(1)
40 with respect to history depth.
41
42 ``--touches <path>``
43 Path is matched as a prefix: ``src/`` matches any file whose POSIX path
44 starts with ``src/``, while ``src/foo.py`` matches only that exact file.
45 Uses a per-invocation manifest cache so each snapshot is read at most once.
46
47 Exit codes:
48 0 — success (including empty result for ``--count 0``).
49 1 — no commits matched (non-empty history but all filtered out).
50 2 — usage error (bad date, invalid ref, traversal attempt in ``--touches``).
51 3 — internal error (repository not found).
52 """
53
54 import argparse
55 import datetime
56 import json
57 import logging
58 import collections
59 import pathlib
60 import re
61 import sys
62 from typing import Callable, TypedDict
63
64 from muse.cli.config import get_limit
65 from muse.core.envelope import EnvelopeJson, make_envelope
66 from muse.core.errors import ExitCode
67 from muse.core.graph import ancestor_ids
68 from muse.core.repo import require_repo
69 from muse.core.refs import (
70 get_head_commit_id,
71 read_current_branch,
72 )
73 from muse.core.commits import (
74 CommitRecord,
75 commit_parent_ids,
76 read_commit,
77 resolve_commit_ref,
78 )
79 from muse.core.snapshots import get_commit_snapshot_manifest
80 from muse.core.validation import sanitize_display
81 from muse.core.timing import start_timer
82
83 logger = logging.getLogger(__name__)
84
85 # ---------------------------------------------------------------------------
86 # Types
87 # ---------------------------------------------------------------------------
88
89 type _FileManifest = dict[str, str]
90 type _ManifestCache = dict[str, _FileManifest]
91 type _Predicate = Callable[[CommitRecord], bool]
92
93 class _RevListJson(EnvelopeJson):
94 """JSON output for ID-list mode."""
95
96 commit_ids: list[str]
97
98 class _RevListCountJson(EnvelopeJson):
99 """JSON output for --count mode."""
100
101 count: int
102
103 # ---------------------------------------------------------------------------
104 # Internal helpers
105 # ---------------------------------------------------------------------------
106
107 def _parse_range(ref: str) -> tuple[str | None, str]:
108 """Parse a ref string into ``(exclude_ref, include_ref)``.
109
110 ``"A..B"`` → ``("A", "B")``. A plain ref with no ``..`` → ``(None, ref)``.
111
112 Args:
113 ref: Raw ref string from the CLI argument.
114
115 Returns:
116 A two-tuple ``(exclude, include)`` where ``exclude`` is ``None`` for
117 single-ref inputs.
118 """
119 if ".." in ref:
120 parts = ref.split("..", 1)
121 return parts[0].strip(), parts[1].strip()
122 return None, ref
123
124 def _parse_date(value: str) -> datetime.datetime:
125 """Parse *value* as a UTC date (``YYYY-MM-DD``) or ISO datetime.
126
127 Args:
128 value: Date string supplied via ``--after`` or ``--before``.
129
130 Returns:
131 A timezone-aware :class:`datetime.datetime` in UTC.
132
133 Raises:
134 ValueError: If *value* cannot be parsed as a recognised date format.
135 """
136 for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
137 try:
138 dt = datetime.datetime.strptime(value, fmt)
139 return dt.replace(tzinfo=datetime.timezone.utc)
140 except ValueError:
141 continue
142 raise ValueError(
143 f"Unrecognised date format: {value!r}. "
144 "Use YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS."
145 )
146
147 def _get_manifest(
148 root: pathlib.Path,
149 commit_id: str | None,
150 cache: _ManifestCache,
151 ) -> _FileManifest:
152 """Return the snapshot manifest for *commit_id*, using *cache* to avoid re-reads.
153
154 Each commit_id is fetched from disk at most once per *cache* lifetime.
155 An empty dict is returned (and cached) for ``None`` commit_ids so callers
156 can treat initial commits uniformly.
157
158 Args:
159 root: Repository root.
160 commit_id: Commit whose snapshot manifest to fetch, or ``None``.
161 cache: Shared manifest cache dict; mutated in place.
162
163 Returns:
164 The snapshot manifest mapping POSIX path → object_id.
165 """
166 if commit_id is None:
167 return {}
168 if commit_id not in cache:
169 cache[commit_id] = get_commit_snapshot_manifest(root, commit_id) or {}
170 return cache[commit_id]
171
172 def _commit_touches_path(
173 root: pathlib.Path,
174 commit: CommitRecord,
175 path: str,
176 cache: _ManifestCache,
177 ) -> bool:
178 """Return ``True`` if *commit* added, modified, or removed *path*.
179
180 *path* is matched as a prefix: ``"src/"`` matches any file under ``src/``,
181 while ``"src/foo.py"`` matches only that exact file. Trailing slashes are
182 normalised away before comparison.
183
184 Args:
185 root: Repository root.
186 commit: The commit to inspect.
187 path: POSIX path or prefix to match.
188 cache: Shared manifest cache; passed through to :func:`_get_manifest`.
189
190 Returns:
191 ``True`` when at least one file matching *path* changed between this
192 commit and its first parent.
193 """
194 norm = path.rstrip("/")
195 current = _get_manifest(root, commit.commit_id, cache)
196 parent = _get_manifest(root, commit.parent_commit_id, cache)
197 for p in set(current) | set(parent):
198 if p == norm or p.startswith(f"{norm}/"):
199 if current.get(p) != parent.get(p):
200 return True
201 return False
202
203 def _exclude_set(root: pathlib.Path, start_id: str | None) -> set[str]:
204 """Return the set of all commit IDs reachable from *start_id*.
205
206 Used to implement ``A..B`` range exclusion: compute ancestors of *A*,
207 then walk from *B* stopping at any commit in this set.
208 """
209 if start_id is None:
210 return set()
211 return ancestor_ids(root, start_id)
212
213 def _walk_from(
214 root: pathlib.Path,
215 start_id: str,
216 *,
217 exclude: set[str],
218 first_parent: bool,
219 max_count: int,
220 predicates: list[_Predicate],
221 count_mode: bool,
222 ) -> tuple[list[str], int]:
223 """BFS walk from *start_id*, applying filters, returning matching commit IDs.
224
225 In ``count_mode`` the function increments a counter rather than
226 accumulating IDs, keeping memory O(1) with respect to history depth.
227
228 Args:
229 root: Repository root.
230 start_id: Commit ID to start walking from.
231 exclude: Set of commit IDs to treat as a boundary — commits in
232 this set and their ancestors are not emitted.
233 first_parent: When ``True`` only follow ``parent_commit_id`` (the
234 first/linear parent), skipping ``parent2_commit_id``.
235 max_count: Stop after emitting this many matching commits. ``0``
236 means no limit.
237 predicates: List of filter callables; a commit is emitted only when
238 all predicates return ``True``.
239 count_mode: When ``True`` accumulate a counter instead of a list.
240
241 Returns:
242 A two-tuple ``(commit_ids, count)`` where ``commit_ids`` is the list
243 of matching commit IDs (empty in count_mode) and ``count`` is the
244 number of matching commits.
245 """
246 ids: list[str] = []
247 count = 0
248 visited: set[str] = set(exclude)
249 queue: collections.deque[str] = collections.deque([start_id])
250
251 while queue:
252 cid = queue.popleft()
253 if cid in visited:
254 continue
255 visited.add(cid)
256 try:
257 c = read_commit(root, cid)
258 except Exception:
259 continue
260 if all(pred(c) for pred in predicates):
261 count += 1
262 if not count_mode:
263 ids.append(c.commit_id)
264 if max_count > 0 and count >= max_count:
265 break
266 if first_parent:
267 if c.parent_commit_id:
268 queue.append(c.parent_commit_id)
269 else:
270 queue.extend(commit_parent_ids(c))
271
272 return ids, count
273
274 # ---------------------------------------------------------------------------
275 # CLI registration
276 # ---------------------------------------------------------------------------
277
278 def register(
279 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
280 ) -> None:
281 """Register the ``muse rev-list`` subcommand and its flags."""
282 parser = subparsers.add_parser(
283 "rev-list",
284 help="Emit a filtered stream of commit IDs.",
285 description=__doc__,
286 formatter_class=argparse.RawDescriptionHelpFormatter,
287 )
288 parser.add_argument(
289 "ref",
290 nargs="*",
291 default=None,
292 metavar="REF",
293 help=(
294 "Ref to walk from, or 'A..B' range (commits on B not on A). "
295 "Defaults to HEAD. A bare '-N' (e.g. '-3') is accepted anywhere "
296 "in this position as git-style shorthand for --max-count N."
297 ),
298 )
299 parser.add_argument(
300 "--max-count", "-n",
301 type=int,
302 default=0,
303 dest="max_count",
304 metavar="N",
305 help="Stop after emitting N commits. 0 = no limit (default).",
306 )
307 parser.add_argument(
308 "--count",
309 action="store_true",
310 help="Print a single integer — number of matching commits — instead of IDs.",
311 )
312 parser.add_argument(
313 "--first-parent",
314 action="store_true",
315 dest="first_parent",
316 help="Follow only the first-parent chain (skip merge parents).",
317 )
318 parser.add_argument(
319 "--no-merges",
320 action="store_true",
321 dest="no_merges",
322 help="Exclude merge commits (commits with two parents).",
323 )
324 parser.add_argument(
325 "--merges",
326 action="store_true",
327 help="Include only merge commits.",
328 )
329 parser.add_argument(
330 "--author",
331 default=None,
332 metavar="PATTERN",
333 help="Only commits whose author matches PATTERN (substring or regex).",
334 )
335 parser.add_argument(
336 "--after",
337 default=None,
338 metavar="DATE",
339 help="Only commits after DATE (YYYY-MM-DD, UTC).",
340 )
341 parser.add_argument(
342 "--before",
343 default=None,
344 metavar="DATE",
345 help="Only commits before DATE (YYYY-MM-DD, UTC).",
346 )
347 parser.add_argument(
348 "--touches",
349 default=None,
350 metavar="PATH",
351 help=(
352 "Only commits that changed PATH. Matched as a prefix: 'src/' "
353 "matches any file under src/."
354 ),
355 )
356 parser.add_argument(
357 "--reverse",
358 action="store_true",
359 help="Output commits oldest-first instead of newest-first.",
360 )
361 parser.add_argument(
362 "--json", "-j",
363 action="store_true",
364 dest="json_out",
365 help='Emit {"commit_ids": [...]} instead of newline-delimited IDs.',
366 )
367 parser.set_defaults(func=run)
368
369 # ---------------------------------------------------------------------------
370 # Command implementation
371 # ---------------------------------------------------------------------------
372
373 def run(args: argparse.Namespace) -> None:
374 """Emit a filtered stream of commit IDs.
375
376 Default output is one commit ID per line, newest-first. Pass ``--count``
377 to receive a single integer. Pass ``--json`` for a stable JSON envelope
378 suitable for agent pipeline consumption. Range syntax (``A..B``) emits
379 commits reachable from *B* but not from *A*.
380
381 Agent quickstart::
382
383 muse rev-list HEAD --json
384 muse rev-list -n 10 HEAD --json
385 muse rev-list --count main..feat --json
386 muse rev-list --author alice --after 2026-01-01 HEAD --json
387
388 JSON fields (ID-list mode)::
389
390 commit_ids list[str] Commit IDs matching the filters, newest-first
391
392 JSON fields (--count mode)::
393
394 count int Number of matching commits
395
396 Exit codes::
397
398 0 Success (including empty result in JSON mode).
399 1 No commits matched (non-empty history, all filtered; plain-text only).
400 2 Usage error (bad date, invalid ref, traversal in --touches).
401 3 Repository not found.
402 """
403 ref_tokens: list[str] = list(args.ref) if args.ref else []
404 max_count: int = args.max_count
405 count_mode: bool = args.count
406 first_parent: bool = args.first_parent
407 no_merges: bool = args.no_merges
408 merges_only: bool = args.merges
409 author_pat: str | None = args.author
410 after_str: str | None = args.after
411 before_str: str | None = args.before
412 touches_path: str | None = args.touches
413 reverse: bool = args.reverse
414 json_out: bool = args.json_out
415
416 elapsed = start_timer()
417
418 def _emit_error(msg: str, code: int, error_key: str = "error") -> None:
419 """Emit a structured error (JSON when --json, stderr otherwise) and exit."""
420 if json_out:
421 print(json.dumps({
422 "error": error_key,
423 "message": msg,
424 "commit_ids": [],
425 "count": 0,
426 "duration_ms": elapsed(),
427 "exit_code": code,
428 }))
429 else:
430 print(f"❌ {msg}", file=sys.stderr)
431 raise SystemExit(code)
432
433 # Support git-style bare "-N" shorthand (e.g. `muse rev-list -3`, same
434 # idiom as `git rev-list -3`) anywhere among the positional tokens —
435 # argparse has no negative-number-looking options registered, so "-3"
436 # reaches us as a plain positional rather than erroring. Also accept it
437 # alongside an explicit ref (`muse rev-list -1 HEAD`), matching git.
438 _bare_n_tokens = [t for t in ref_tokens if t.startswith("-") and t[1:].isdigit()]
439 if len(_bare_n_tokens) > 1:
440 _emit_error(
441 f"Only one '-N' commit-count shorthand is allowed, got {_bare_n_tokens}.",
442 ExitCode.USER_ERROR,
443 "ambiguous_count_shorthand",
444 )
445 if _bare_n_tokens:
446 max_count = int(_bare_n_tokens[0].lstrip("-"))
447 ref_tokens = [t for t in ref_tokens if t not in _bare_n_tokens]
448
449 # Support git-style "^ref" exclusion prefix tokens — any number of them,
450 # composing with each other and with a single plain/A..B include token
451 # (e.g. `muse rev-list A ^B ^C`, matching `git rev-list A ^B ^C`).
452 caret_exclude_refs: list[str] = [
453 t[1:] for t in ref_tokens if t.startswith("^") and len(t) > 1
454 ]
455 ref_tokens = [t for t in ref_tokens if not t.startswith("^")]
456
457 if len(ref_tokens) > 1:
458 _emit_error(
459 f"Too many refs given: {ref_tokens}. Expected at most one ref or 'A..B' range.",
460 ExitCode.USER_ERROR,
461 "too_many_refs",
462 )
463 ref_arg: str = ref_tokens[0] if ref_tokens else "HEAD"
464
465 # --merges and --no-merges are mutually exclusive.
466 if no_merges and merges_only:
467 _emit_error(
468 "--no-merges and --merges are mutually exclusive.",
469 ExitCode.USER_ERROR,
470 "mutually_exclusive_flags",
471 )
472
473 # Validate --touches path: reject traversal sequences.
474 if touches_path is not None:
475 norm = touches_path.replace("\\", "/")
476 if norm.startswith("/") or ".." in norm.split("/"):
477 _emit_error(
478 f"--touches path {sanitize_display(touches_path)!r} must be a "
479 "relative path with no traversal sequences.",
480 ExitCode.USER_ERROR,
481 "path_traversal",
482 )
483
484 # Parse date filters up-front so errors are reported before any I/O.
485 after_dt: datetime.datetime | None = None
486 before_dt: datetime.datetime | None = None
487 if after_str is not None:
488 try:
489 after_dt = _parse_date(after_str)
490 except ValueError as exc:
491 _emit_error(f"--after: {exc}", ExitCode.USER_ERROR, "bad_date")
492 if before_str is not None:
493 try:
494 before_dt = _parse_date(before_str)
495 except ValueError as exc:
496 _emit_error(f"--before: {exc}", ExitCode.USER_ERROR, "bad_date")
497
498 # Compile --author pattern. Treat compilation errors as literal substring
499 # fallback rather than crashing — this matches user expectation when they
500 # pass a name like "O'Brien" which contains no real regex metacharacters
501 # but might be malformed in edge cases.
502 author_re: re.Pattern[str] | None = None
503 if author_pat is not None:
504 try:
505 author_re = re.compile(author_pat, re.IGNORECASE)
506 except re.error:
507 # Literal substring fallback.
508 escaped = re.escape(author_pat)
509 author_re = re.compile(escaped, re.IGNORECASE)
510
511 root = require_repo()
512
513 # Resolve start and optional exclude ref.
514 exclude_ref_str, include_ref_str = _parse_range(ref_arg)
515
516 # Resolve include ref → commit ID.
517 try:
518 branch = read_current_branch(root)
519 except ValueError as exc:
520 _emit_error(str(exc), ExitCode.INTERNAL_ERROR, "internal_error")
521
522 include_commit = resolve_commit_ref(root, branch, include_ref_str)
523 if include_commit is None:
524 # Try treating the ref as a branch name directly.
525 tip = get_head_commit_id(root, include_ref_str)
526 if tip is not None:
527 include_commit = read_commit(root, tip)
528 if include_commit is None:
529 _emit_error(
530 f"Cannot resolve ref {sanitize_display(include_ref_str)!r}.",
531 ExitCode.USER_ERROR,
532 "bad_ref",
533 )
534
535 # Compute the exclusion set — union of the A..B range's A side (if any)
536 # and every "^ref" exclusion token. Both forms are equivalent ways to
537 # express the same thing: `rev-list A..B` == `rev-list B ^A`.
538 all_exclude_refs = list(caret_exclude_refs)
539 if exclude_ref_str:
540 all_exclude_refs.append(exclude_ref_str)
541
542 excl: set[str] = set()
543 for excl_ref_str in all_exclude_refs:
544 exclude_commit = resolve_commit_ref(root, branch, excl_ref_str)
545 if exclude_commit is None:
546 tip = get_head_commit_id(root, excl_ref_str)
547 if tip is not None:
548 exclude_commit = read_commit(root, tip)
549 if exclude_commit is None:
550 _emit_error(
551 f"Cannot resolve exclude ref {sanitize_display(excl_ref_str)!r}.",
552 ExitCode.USER_ERROR,
553 "bad_ref",
554 )
555 excl |= _exclude_set(root, exclude_commit.commit_id)
556
557 # Build filter predicates.
558 manifest_cache: _ManifestCache = {}
559 predicates: list[_Predicate] = []
560
561 if no_merges:
562 predicates.append(lambda c: c.parent2_commit_id is None)
563 if merges_only:
564 predicates.append(lambda c: c.parent2_commit_id is not None)
565 if author_re is not None:
566 _pat = author_re # capture for closure
567 predicates.append(lambda c: bool(_pat.search(c.author or "")))
568 if after_dt is not None:
569 _after = after_dt # capture for closure
570 # Parens required: `A if B else C > D` parses as `A if B else (C > D)`,
571 # not `(A if B else C) > D`. --before already uses parens; match it here.
572 predicates.append(
573 lambda c: (
574 c.committed_at.replace(tzinfo=datetime.timezone.utc)
575 if c.committed_at.tzinfo is None
576 else c.committed_at
577 ) > _after
578 )
579 if before_dt is not None:
580 _before = before_dt # capture for closure
581 predicates.append(
582 lambda c: (
583 c.committed_at.replace(tzinfo=datetime.timezone.utc)
584 if c.committed_at.tzinfo is None
585 else c.committed_at
586 ) < _before
587 )
588 if touches_path is not None:
589 _path = touches_path.rstrip("/")
590 _cache = manifest_cache
591 _root = root
592 predicates.append(
593 lambda c: _commit_touches_path(_root, c, _path, _cache)
594 )
595
596 # Walk the commit graph.
597 walk_cap = get_limit("max_walk_commits", root)
598 effective_max = max_count if max_count > 0 else walk_cap
599
600 ids, count = _walk_from(
601 root,
602 include_commit.commit_id,
603 exclude=excl,
604 first_parent=first_parent,
605 max_count=effective_max,
606 predicates=predicates,
607 count_mode=count_mode,
608 )
609
610 # Emit output.
611 if count_mode:
612 if json_out:
613 print(json.dumps(_RevListCountJson(**make_envelope(elapsed), count=count)))
614 else:
615 print(count)
616 return
617
618 if reverse:
619 ids = list(reversed(ids))
620
621 exit_code_val = 0 if ids else 1
622 if json_out:
623 print(json.dumps(_RevListJson(
624 **make_envelope(elapsed, exit_code=exit_code_val),
625 commit_ids=ids,
626 )))
627 return
628
629 if ids:
630 print("\n".join(ids))
631 else:
632 # Empty result — exit 1 so scripts can detect "no matches" vs "no repo".
633 raise SystemExit(1)
File History 1 commit
sha256:85fc1542f909649acadf0b5fd3bc662f43d59eea76d799a069e76cbccd54f7ec feat: implement ^ref exclusion prefix syntax in rev-list an… Sonnet 5 patch 17 hours ago