gabriel / muse public
describe.py python
184 lines 6.4 KB
Raw
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 72 days ago
1 """Tag-based commit description for ``muse describe``.
2
3 Walks backward from a commit through its ancestor graph and finds the nearest
4 tag. Returns a human-readable ``<tag>~N`` label where N is the hop count from
5 the tag's commit to the described commit. N=0 means the commit is exactly on
6 the tag.
7
8 The walk is a BFS that visits each ancestor at most once (cycle-safe). It
9 stops as soon as the first matching tag is found, so it is O(commits between
10 tag and HEAD) — not O(all commits).
11
12 If no matching tag is found, ``DescribeResult.tag`` is ``None`` and ``name``
13 falls back to the short SHA.
14
15 Walk budget::
16
17 _MAX_WALK = 50_000 commits. When the budget is exhausted the walk stops
18 and the nearest tag found so far (if any) is returned. The budget check
19 uses ``>=`` so exactly 50 000 commits are visited — not 50 001.
20
21 Tag-selection tie-breaking::
22
23 When multiple tags point to the same commit the lexicographically greatest
24 tag name wins. This is consistent with Git's ``--tags`` behaviour for
25 lightweight tags.
26
27 Pattern matching::
28
29 ``match_pattern`` is a ``fnmatch``-style glob applied to tag names before
30 they are eligible for selection. Only tags whose name matches the pattern
31 are considered. When ``None`` (the default) all tags are eligible.
32 """
33
34 import fnmatch
35 import logging
36 import pathlib
37 from typing import TypedDict
38
39 from muse.core.types import split_id
40 from muse.core.commits import read_commit
41 from muse.core.tags import get_all_tags
42
43 type _StrMap = dict[str, str]
44 logger = logging.getLogger(__name__)
45
46 _MAX_WALK = 50_000
47
48 class DescribeResult(TypedDict):
49 """Result of describing a commit by its nearest tag.
50
51 Fields:
52 commit_id: Full SHA-256 hex of the described commit.
53 tag: Nearest tag name, or ``None`` if no tag was found.
54 distance: Hop count from the tag's commit to this commit.
55 short_sha: First ``abbrev`` characters of ``commit_id``.
56 name: Human-readable description string.
57 exact: ``True`` when ``distance == 0`` and ``tag`` is not ``None``.
58 """
59
60 commit_id: str
61 tag: str | None
62 distance: int
63 short_sha: str
64 name: str
65 exact: bool
66
67 def describe_commit(
68 root: pathlib.Path,
69 repo_id: str,
70 commit_id: str,
71 *,
72 long_format: bool = False,
73 match_pattern: str | None = None,
74 first_parent: bool = False,
75 abbrev: int = 12,
76 exact_match: bool = False,
77 ) -> DescribeResult:
78 """Return a human-readable description of *commit_id*.
79
80 Walks backward from *commit_id* through the parent chain using BFS and
81 finds the nearest tag that satisfies the given constraints. The
82 description is ``<tag>~N`` where N is the number of hops from the tag's
83 commit to *commit_id*.
84
85 When *long_format* is ``True`` the name always includes the distance and
86 short SHA even when N=0, matching Git's ``--long`` behaviour::
87
88 v1.0.0-0-sha256:abc12345 # long: on the tag itself (distance=0)
89 v1.0.0~3-sha256:abc12345 # long: 3 hops past the tag
90
91 When *exact_match* is ``True`` only distance-0 hits are accepted; if no
92 exact tag match exists ``tag`` is ``None`` and the walk returns the short
93 SHA fallback immediately.
94
95 When *first_parent* is ``True`` only the first parent of each merge commit
96 is followed, limiting the walk to the main-line ancestry.
97
98 When *match_pattern* is given, only tag names matching the
99 ``fnmatch``-style glob are considered.
100
101 Args:
102 root: Repository root directory.
103 repo_id: Repository content ID (used to look up tags).
104 commit_id: Starting commit to describe (typically HEAD).
105 long_format: Always include distance and short SHA in the name.
106 match_pattern: ``fnmatch`` glob to filter eligible tag names.
107 first_parent: Follow only the first-parent chain (skip second parents).
108 abbrev: Length of the short SHA suffix (default 12).
109 exact_match: Only accept distance-0 hits; return SHA fallback otherwise.
110
111 Returns:
112 A :class:`DescribeResult` with the nearest tag name, hop count, and
113 formatted description string.
114 """
115 algo, hex_str = split_id(commit_id)
116 short_sha = f"{algo}:{hex_str[:abbrev]}"
117
118 _no_tag = DescribeResult(
119 commit_id=commit_id,
120 tag=None,
121 distance=0,
122 short_sha=short_sha,
123 name=short_sha,
124 exact=False,
125 )
126
127 # Build commit_id → tag_name map. Multiple tags on the same commit:
128 # keep the lexicographically greatest name (stable tie-break).
129 all_tags = get_all_tags(root, repo_id)
130 tag_by_commit: _StrMap = {}
131 for t in all_tags:
132 if match_pattern is not None and not fnmatch.fnmatch(t.tag, match_pattern):
133 continue
134 existing = tag_by_commit.get(t.commit_id)
135 if existing is None or t.tag > existing:
136 tag_by_commit[t.commit_id] = t.tag
137
138 if not tag_by_commit:
139 return _no_tag
140
141 # Walk backward through the parent chain tracking hop distance per node.
142 from muse.core.graph import walk_dag
143
144 distances: dict[str, int] = {commit_id: 0}
145
146 def _adjacency(cid: str) -> list[str]:
147 rec = read_commit(root, cid)
148 if rec is None:
149 return []
150 parents = [p for p in (rec.parent_commit_id, rec.parent2_commit_id) if p]
151 if first_parent:
152 parents = parents[:1]
153 dist = distances.get(cid, 0)
154 for p in parents:
155 if p not in distances:
156 distances[p] = dist + 1
157 return parents
158
159 nodes_yielded = 0
160 for cid in walk_dag(commit_id, _adjacency, max_nodes=_MAX_WALK):
161 nodes_yielded += 1
162 dist = distances.get(cid, 0)
163 if cid in tag_by_commit:
164 if exact_match and dist != 0:
165 return _no_tag
166 tag_name = tag_by_commit[cid]
167 if long_format:
168 name = f"{tag_name}-{dist}-{short_sha}"
169 elif dist == 0:
170 name = tag_name
171 else:
172 name = f"{tag_name}~{dist}"
173 return DescribeResult(
174 commit_id=commit_id,
175 tag=tag_name,
176 distance=dist,
177 short_sha=short_sha,
178 name=name,
179 exact=(dist == 0),
180 )
181
182 if nodes_yielded >= _MAX_WALK:
183 logger.warning("⚠️ describe: reached %d-commit walk limit", _MAX_WALK)
184 return _no_tag
File History 1 commit
sha256:9ca3eeef4f199d5e8d0b21e50c384a2468e2c2477bd9b157f29fd0f7f06fddcd feat: add muse reflog expire subcommand and reflog.expire-d… Sonnet 4.6 patch 72 days ago