gabriel / muse public
patch_id.py python
335 lines 11.5 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """``muse patch-id [<ref>]`` — content-based commit identity.
2
3 Computes a stable SHA-256 hash of a commit's diff content — independent of
4 commit ID, author, timestamp, branch, or merge history. Two commits that
5 make the same logical change produce the same patch-id, enabling reliable
6 cherry-pick detection and duplicate patch identification.
7
8 Algorithm
9 ---------
10 1. Resolve the commit and its parent's snapshot manifests.
11 2. For each file changed between parent and commit (sorted alphabetically),
12 compute the unified diff.
13 3. Extract only the ``+`` and ``-`` content lines (skip ``@@`` context headers
14 and ``---``/``+++`` file headers).
15 4. Feed those lines — in sorted-file order — into a SHA-256 digest.
16 5. The 64-char hex digest is the patch-id.
17
18 With ``--stable``, each content line is stripped of trailing whitespace before
19 hashing so that cosmetic whitespace changes don't produce a new patch-id.
20
21 Output formats
22 --------------
23 Default text::
24
25 <patch_id> <commit_id>
26
27 JSON (``--json``)::
28
29 {
30 "commit_id": "<sha256>",
31 "patch_id": "<64-char hex>",
32 "subject": "feat: add something",
33 "files_changed": 3,
34 "stable": false,
35 "duration_ms": 2.1,
36 "exit_code": 0
37 }
38
39 Exit codes::
40
41 0 — success
42 1 — user error: bad ref, ANSI in ref, empty repo
43 2 — not a Muse repository
44
45 Examples::
46
47 muse patch-id HEAD
48 muse patch-id HEAD --json
49 muse patch-id HEAD --stable
50 muse patch-id <commit-id> --json
51 muse patch-id main --json
52 """
53
54 import argparse
55 import difflib
56 import hashlib
57 import json as _json
58 import logging
59 import pathlib
60 import sys
61 from typing import TypedDict
62
63 from muse.core.errors import ExitCode
64 from muse.core.object_store import read_object
65 from muse.core.repo import read_repo_id, require_repo
66 from muse.core.refs import read_ref
67 from muse.core.store import (
68 CommitRecord,
69 get_head_commit_id,
70 read_commit,
71 read_current_branch,
72 read_snapshot,
73 resolve_commit_ref,
74 )
75 from muse.core.envelope import EnvelopeJson, make_envelope
76 from muse.core.validation import sanitize_display
77 from muse.core.types import Manifest, long_id
78 from muse.core.paths import ref_path as _ref_path
79 from muse.core.timing import start_timer
80
81 logger = logging.getLogger(__name__)
82
83 # ---------------------------------------------------------------------------
84 # Wire-format TypedDicts
85 # ---------------------------------------------------------------------------
86
87 class _PatchIdJson(EnvelopeJson):
88 """Stable JSON envelope for ``muse patch-id --json`` output."""
89 commit_id: str
90 patch_id: str
91 subject: str
92 files_changed: int
93 stable: bool
94
95 # ---------------------------------------------------------------------------
96 # Internal helpers
97 # ---------------------------------------------------------------------------
98
99 def _compute_patch_id(
100 root: pathlib.Path,
101 base_manifest: Manifest,
102 target_manifest: Manifest,
103 *,
104 stable: bool,
105 ) -> str:
106 """Compute a patch-id from the diff between two manifests.
107
108 The patch-id is the SHA-256 of the sorted-file unified diff content lines
109 (``+`` and ``-`` lines only, not ``@@`` or file headers).
110
111 Args:
112 root: Absolute repo root (for object store reads).
113 base_manifest: Parent commit manifest (path → object_id).
114 target_manifest: This commit manifest (path → object_id).
115 stable: When True, strip trailing whitespace from each content
116 line before hashing so cosmetic whitespace changes are
117 ignored.
118
119 Returns:
120 ``sha256:``-prefixed SHA-256 string.
121 """
122 h = hashlib.sha256()
123
124 base_paths = set(base_manifest)
125 target_paths = set(target_manifest)
126 changed = sorted(
127 (target_paths - base_paths) # added
128 | (base_paths - target_paths) # removed
129 | { # modified
130 p for p in base_paths & target_paths
131 if base_manifest[p] != target_manifest[p]
132 }
133 )
134
135 for path in changed:
136 # Read base lines.
137 if path in base_manifest:
138 raw = read_object(root, base_manifest[path])
139 base_lines = raw.decode("utf-8", errors="replace").splitlines() if raw else []
140 else:
141 base_lines = []
142
143 # Read target lines.
144 if path in target_manifest:
145 raw = read_object(root, target_manifest[path])
146 target_lines = raw.decode("utf-8", errors="replace").splitlines() if raw else []
147 else:
148 target_lines = []
149
150 # Generate unified diff and extract content lines (+/-) only.
151 for line in difflib.unified_diff(
152 base_lines, target_lines,
153 fromfile=f"a/{path}", tofile=f"b/{path}",
154 lineterm="",
155 ):
156 if line.startswith("+") or line.startswith("-"):
157 if stable:
158 line = line.rstrip()
159 h.update(line.encode("utf-8", errors="replace"))
160 h.update(b"\n")
161
162 return long_id(h.hexdigest())
163
164 def _resolve_commit(root: pathlib.Path, treeish: str) -> CommitRecord:
165 """Resolve *treeish* to a CommitRecord.
166
167 Args:
168 root: Absolute repo root.
169 treeish: Branch name, commit ID, or ``"HEAD"``.
170
171 Returns:
172 CommitRecord.
173
174 Raises:
175 SystemExit(USER_ERROR): ref not found or empty repo.
176 """
177 try:
178 branch = read_current_branch(root)
179 repo_id = read_repo_id(root)
180
181 if treeish.upper() == "HEAD":
182 commit_id = get_head_commit_id(root, branch)
183 if not commit_id:
184 print("❌ Repository has no commits yet.", file=sys.stderr)
185 raise SystemExit(ExitCode.USER_ERROR)
186 commit = read_commit(root, commit_id)
187 else:
188 # Try branch name first.
189 branch_ref = _ref_path(root, treeish)
190 commit_id = read_ref(branch_ref)
191 if commit_id is not None:
192 commit = read_commit(root, commit_id)
193 else:
194 commit = resolve_commit_ref(root, repo_id, branch, treeish)
195
196 if commit is None:
197 print(
198 f"❌ '{sanitize_display(treeish)}' is not a known branch or commit ID.",
199 file=sys.stderr,
200 )
201 raise SystemExit(ExitCode.USER_ERROR)
202
203 return commit
204 except SystemExit:
205 raise
206 except Exception as exc:
207 print(f"❌ Failed to resolve '{sanitize_display(treeish)}': {exc}", file=sys.stderr)
208 raise SystemExit(ExitCode.USER_ERROR)
209
210 # ---------------------------------------------------------------------------
211 # Registration
212 # ---------------------------------------------------------------------------
213
214 def register(
215 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
216 ) -> None:
217 """Register the ``muse patch-id`` subcommand."""
218 parser = subparsers.add_parser(
219 "patch-id",
220 help="Compute a stable content-based hash of a commit's diff.",
221 description=__doc__,
222 formatter_class=argparse.RawDescriptionHelpFormatter,
223 )
224 parser.add_argument(
225 "treeish",
226 metavar="REF",
227 nargs="?",
228 default="HEAD",
229 help="Commit ID, branch name, or HEAD (default: HEAD).",
230 )
231 parser.add_argument(
232 "--stable",
233 action="store_true",
234 dest="stable",
235 help=(
236 "Strip trailing whitespace from each diff line before hashing "
237 "so cosmetic whitespace changes don't produce a new patch-id."
238 ),
239 )
240 parser.add_argument(
241 "--json", "-j",
242 action="store_true",
243 dest="json_out",
244 help="Emit machine-readable JSON on stdout.",
245 )
246 parser.set_defaults(func=run)
247
248 # ---------------------------------------------------------------------------
249 # Run
250 # ---------------------------------------------------------------------------
251
252 def run(args: argparse.Namespace) -> None:
253 """Compute the patch-id for a given commit.
254
255 Hashes the diff between a commit and its parent using a content-stable
256 algorithm. Identical patches applied to different bases produce the same
257 patch-id — useful for deduplication and cherry-pick detection.
258
259 Agent quickstart
260 ----------------
261 ::
262
263 muse patch-id --json
264 muse patch-id HEAD~3 --json
265 muse patch-id feat/billing --stable --json
266
267 JSON fields
268 -----------
269 commit_id Commit ID that was analysed.
270 patch_id Content-stable patch fingerprint (sha256: prefixed).
271 subject First line of the commit message.
272 files_changed Number of files that changed relative to the parent.
273 stable ``true`` when ``--stable`` was passed.
274
275 Exit codes
276 ----------
277 0 Success.
278 1 Bad ref, ANSI in ref, or empty repository.
279 2 Not inside a Muse repository.
280 """
281 elapsed = start_timer()
282 treeish: str = args.treeish or "HEAD"
283 stable: bool = args.stable
284 json_out: bool = args.json_out
285
286 root = require_repo()
287
288 # ── Reject ANSI / control characters in the ref ───────────────────────────
289 if any(ord(c) < 32 for c in treeish):
290 print(
291 f"❌ Invalid ref '{sanitize_display(treeish)}': control characters not allowed.",
292 file=sys.stderr,
293 )
294 raise SystemExit(ExitCode.USER_ERROR)
295
296 # ── Resolve the commit ────────────────────────────────────────────────────
297 commit = _resolve_commit(root, treeish)
298
299 # ── Get parent manifest ───────────────────────────────────────────────────
300 base_manifest: Manifest = {}
301 if commit.parent_commit_id:
302 parent = read_commit(root, commit.parent_commit_id)
303 if parent:
304 snap = read_snapshot(root, parent.snapshot_id)
305 if snap:
306 base_manifest = dict(snap.manifest)
307
308 # ── Get this commit's manifest ────────────────────────────────────────────
309 snap = read_snapshot(root, commit.snapshot_id)
310 target_manifest: Manifest = dict(snap.manifest) if snap else {}
311
312 # ── Count changed files (added + removed + modified) ──────────────────────
313 base_paths = set(base_manifest)
314 target_paths = set(target_manifest)
315 files_changed = len(
316 (target_paths - base_paths)
317 | (base_paths - target_paths)
318 | {p for p in base_paths & target_paths if base_manifest[p] != target_manifest[p]}
319 )
320
321 # ── Compute patch-id ──────────────────────────────────────────────────────
322 patch_id = _compute_patch_id(root, base_manifest, target_manifest, stable=stable)
323
324 # ── Output ───────────────────────────────────────────────────────────────
325 if json_out:
326 print(_json.dumps(_PatchIdJson(
327 **make_envelope(elapsed),
328 commit_id=commit.commit_id,
329 patch_id=patch_id,
330 subject=commit.message.splitlines()[0] if commit.message else "",
331 files_changed=files_changed,
332 stable=stable,
333 )))
334 else:
335 print(f"{patch_id} {commit.commit_id}")
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago