gabriel / muse public
commits.py python
863 lines 31.5 KB
Raw
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9 Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump… Human 11 days ago
1 """muse.core.commits — commit layer for the Muse VCS.
2
3 Everything that reads, writes, or walks commit records lives here.
4
5 Public API
6 ----------
7 MissingParentError
8 Raised when a parent commit ID does not exist locally.
9
10 CommitDict
11 JSON-serialisable TypedDict for CommitRecord wire format.
12
13 CommitRecord
14 Immutable commit record dataclass with to_dict / from_dict.
15
16 CommitReadOk / CommitReadNotFound / CommitReadCorrupt / WalkResult
17 Typed result variants for read operations.
18
19 commit_exists / commit_path
20 Existence check and path helper.
21
22 write_commit / read_commit / overwrite_commit / update_commit_metadata
23 Core commit I/O.
24
25 read_commit_result
26 Result-typed read that distinguishes not-found from corrupt.
27
28 get_head_snapshot_id
29 Resolve a branch tip to its snapshot ID.
30
31 resolve_commit_ref
32 Resolve HEAD, tilde notation, branch names, or SHA prefixes.
33
34 find_commits_by_prefix / get_all_commits / get_commits_for_branch
35 Commit enumeration and lookup helpers.
36
37 walk_commits_between / walk_commits_between_result
38 Bounded history walks.
39 """
40 from __future__ import annotations
41
42 import datetime
43 import json as _json
44 import logging
45 import os
46 import pathlib
47 import re
48 import tempfile
49 from dataclasses import dataclass, field
50 from typing import Literal, TypedDict, TypeGuard
51
52 from muse.core.io import write_text_atomic # noqa: F401 — re-used by callers that import from here
53 from muse.core.object_store import objects_dir as _objects_dir
54 from muse.core.paths import ref_path as _ref_path, remotes_dir as _remotes_dir
55 from muse.core.record_helpers import (
56 _float_val,
57 _int_val,
58 _str_dict,
59 _str_list,
60 _str_or_none,
61 _str_val,
62 )
63 from muse.core.refs import get_head_commit_id, read_ref, resolve_any_ref
64 from muse.core.snapshot import compute_commit_id
65 from muse.core.types import (
66 Manifest,
67 Metadata,
68 MsgpackDict,
69 MsgpackValue,
70 SemVerBump,
71 long_id,
72 short_id,
73 )
74 from muse.core.validation import sanitize_glob_prefix, validate_ref_id
75 from muse.domain import StructuredDelta
76
77 logger = logging.getLogger(__name__)
78
79 # ---------------------------------------------------------------------------
80 # Commit-specific helpers (only used by CommitRecord)
81 # ---------------------------------------------------------------------------
82
83 def _as_structured_delta(
84 v: MsgpackDict,
85 ) -> TypeGuard[StructuredDelta]:
86 """Narrow a raw storage dict to :class:`StructuredDelta`.
87
88 ``StructuredDelta`` is ``TypedDict(total=False)`` — every field is
89 optional — so any ``dict`` is structurally compatible. This TypeGuard
90 exists solely to satisfy the type checker without ``# type: ignore``.
91 """
92 return True
93
94
95 def _sem_ver_bump_val(d: MsgpackDict) -> "SemVerBump":
96 """Extract and validate a ``sem_ver_bump`` field from a raw storage dict.
97
98 Falls back to ``"none"`` if the value is absent or not a recognised
99 Literal — guards against tampered or forward-versioned records.
100 """
101 val = _str_val(d, "sem_ver_bump", "none")
102 if val == "major":
103 return "major"
104 if val == "minor":
105 return "minor"
106 if val == "patch":
107 return "patch"
108 return "none"
109
110 # ---------------------------------------------------------------------------
111 # Exceptions
112 # ---------------------------------------------------------------------------
113
114 class MissingParentError(ValueError):
115 """Raised by :func:`write_commit` when a parent commit ID is referenced but
116 does not exist in the local object store.
117
118 A dangling parent pointer silently truncates history traversal — walks
119 stop at the gap rather than reaching the true root. Reject eagerly so the
120 corruption is surfaced at write time rather than discovered later during a
121 log, rebase, or push.
122 """
123
124 # ---------------------------------------------------------------------------
125 # Wire-format TypedDict
126 # ---------------------------------------------------------------------------
127
128 class CommitDict(TypedDict, total=False):
129 """JSON-serialisable representation of a CommitRecord.
130
131 ``structured_delta`` is the typed delta produced by the domain plugin's
132 ``diff()`` at commit time. ``None`` on the initial commit (no parent to
133 diff against).
134
135 ``sem_ver_bump`` and ``breaking_changes`` are semantic versioning
136 metadata. Absent (treated as ``"none"`` / ``[]``) for older records and
137 non-code domains.
138
139 Agent provenance fields (all optional, default ``""`` for older records):
140
141 ``agent_id`` Stable identity string for the committing agent or human
142 (e.g. ``"counterpoint-bot"`` or ``"gabriel"``).
143 ``model_id`` Model identifier when the author is an AI agent
144 (e.g. ``"claude-opus-4"``). Empty for human authors.
145 ``toolchain_id`` Toolchain that produced the commit
146 (e.g. ``"cursor-agent-v2"``).
147 ``prompt_hash`` SHA-256 of the instruction/prompt that triggered this
148 commit. Privacy-preserving: the hash identifies the
149 prompt without storing its content.
150 ``signature`` Base64url-encoded Ed25519 signature (no padding, 86 chars)
151 over the provenance payload (SHA-256 of commit_id + authorship
152 fields). Verifiable with
153 :func:`muse.core.provenance.verify_commit_ed25519` using the
154 embedded ``signer_public_key``.
155 ``signer_public_key`` Base64url-encoded raw Ed25519 public key bytes (32 bytes →
156 43 chars). Embedded in the commit so that verification is fully
157 offline — no hub lookup required.
158 ``signer_key_id`` ``sha256:<64-hex>`` fingerprint of the raw public key bytes.
159 """
160
161 commit_id: str
162 branch: str
163 snapshot_id: str
164 message: str
165 committed_at: str
166 parent_commit_id: str | None
167 parent2_commit_id: str | None
168 author: str
169 metadata: Metadata
170 structured_delta: StructuredDelta | None
171 sem_ver_bump: SemVerBump
172 breaking_changes: list[str]
173 agent_id: str
174 model_id: str
175 toolchain_id: str
176 prompt_hash: str
177 signature: str
178 signer_public_key: str
179 signer_key_id: str
180 reviewed_by: list[str]
181 test_runs: int
182 labels: list[str]
183 status: str
184 notes: list[str]
185 score: float | None
186
187 # ---------------------------------------------------------------------------
188 # CommitRecord dataclass
189 # ---------------------------------------------------------------------------
190
191 @dataclass
192 class CommitRecord:
193 """An immutable commit record stored as a JSON file under .muse/objects/.
194
195 ``sem_ver_bump`` and ``breaking_changes`` are populated by the commit command
196 when a code-domain delta is available. They default to ``"none"`` and ``[]``
197 for older records and non-code domains.
198
199 Agent provenance fields default to ``""`` so that existing JSON without
200 them deserialises without error. See :class:`CommitDict` for field semantics.
201 """
202
203 commit_id: str
204 branch: str
205 snapshot_id: str
206 message: str
207 committed_at: datetime.datetime
208 parent_commit_id: str | None = None
209 parent2_commit_id: str | None = None
210 author: str = ""
211 metadata: Metadata = field(default_factory=dict)
212 structured_delta: StructuredDelta | None = None
213 sem_ver_bump: SemVerBump = "none"
214 breaking_changes: list[str] = field(default_factory=list)
215 agent_id: str = ""
216 model_id: str = ""
217 toolchain_id: str = ""
218 prompt_hash: str = ""
219 signature: str = ""
220 signer_public_key: str = ""
221 signer_key_id: str = ""
222 reviewed_by: list[str] = field(default_factory=list)
223 test_runs: int = 0
224 labels: list[str] = field(default_factory=list)
225 status: str = ""
226 notes: list[str] = field(default_factory=list)
227 score: float | None = None
228
229 def to_dict(self) -> CommitDict:
230 return CommitDict(
231 commit_id=self.commit_id,
232 branch=self.branch,
233 snapshot_id=self.snapshot_id,
234 message=self.message,
235 committed_at=self.committed_at.isoformat(),
236 parent_commit_id=self.parent_commit_id,
237 parent2_commit_id=self.parent2_commit_id,
238 author=self.author,
239 metadata=dict(self.metadata),
240 structured_delta=self.structured_delta,
241 sem_ver_bump=self.sem_ver_bump,
242 breaking_changes=list(self.breaking_changes),
243 agent_id=self.agent_id,
244 model_id=self.model_id,
245 toolchain_id=self.toolchain_id,
246 prompt_hash=self.prompt_hash,
247 signature=self.signature,
248 signer_public_key=self.signer_public_key,
249 signer_key_id=self.signer_key_id,
250 reviewed_by=list(self.reviewed_by),
251 test_runs=self.test_runs,
252 labels=list(self.labels),
253 status=self.status,
254 notes=list(self.notes),
255 score=self.score,
256 )
257
258 @classmethod
259 def from_dict(cls, d: "MsgpackDict | CommitDict") -> "CommitRecord":
260 """Deserialise a :class:`CommitRecord` from a plain dict.
261
262 Accepts both the typed :class:`CommitDict` and any raw string-keyed
263 mapping (e.g. the result of ``json.loads`` on a stored object). Uses
264 typed accessor helpers (:func:`_str_val`, :func:`_str_or_none`, etc.)
265 so every field access is type-safe without ``# type: ignore``.
266 Runtime guards on the three ID fields fail loud — a corrupt commit_id
267 would propagate into path construction which is a security boundary.
268 """
269 committed_at_str = _str_val(d, "committed_at")
270 try:
271 committed_at = datetime.datetime.fromisoformat(committed_at_str)
272 except ValueError as exc:
273 raise ValueError(
274 f"Commit record has missing or unparseable committed_at "
275 f"({committed_at_str!r}): {exc}"
276 ) from exc
277
278 commit_id = _str_val(d, "commit_id")
279 if not commit_id:
280 raise TypeError(f"commit_id must be a non-empty str, got {d.get('commit_id')!r}")
281 snapshot_id = _str_val(d, "snapshot_id")
282 if not snapshot_id:
283 raise TypeError(f"snapshot_id must be a non-empty str, got {d.get('snapshot_id')!r}")
284 branch = _str_val(d, "branch") or _str_val(d, "created_on_branch")
285 if not branch:
286 raise TypeError(f"branch must be a non-empty str, got {d.get('branch')!r}")
287
288 raw_delta = d.get("structured_delta")
289 structured_delta: StructuredDelta | None = None
290 if isinstance(raw_delta, dict) and _as_structured_delta(raw_delta):
291 structured_delta = raw_delta
292
293 return cls(
294 commit_id=commit_id,
295 branch=branch,
296 snapshot_id=snapshot_id,
297 message=_str_val(d, "message"),
298 committed_at=committed_at,
299 parent_commit_id=_str_or_none(d, "parent_commit_id"),
300 parent2_commit_id=_str_or_none(d, "parent2_commit_id"),
301 author=_str_val(d, "author"),
302 metadata=_str_dict(d, "metadata"),
303 structured_delta=structured_delta,
304 sem_ver_bump=_sem_ver_bump_val(d),
305 breaking_changes=_str_list(d, "breaking_changes"),
306 agent_id=_str_val(d, "agent_id"),
307 model_id=_str_val(d, "model_id"),
308 toolchain_id=_str_val(d, "toolchain_id"),
309 prompt_hash=_str_val(d, "prompt_hash"),
310 signature=_str_val(d, "signature"),
311 signer_public_key=_str_val(d, "signer_public_key"),
312 signer_key_id=_str_val(d, "signer_key_id"),
313 reviewed_by=_str_list(d, "reviewed_by"),
314 test_runs=_int_val(d, "test_runs"),
315 labels=_str_list(d, "labels"),
316 status=_str_val(d, "status"),
317 notes=_str_list(d, "notes"),
318 score=_float_val(d, "score"),
319 )
320
321 # ---------------------------------------------------------------------------
322 # Result variant types
323 # ---------------------------------------------------------------------------
324
325 class CommitReadOk(TypedDict):
326 status: str
327 commit: CommitRecord
328
329 class CommitReadNotFound(TypedDict):
330 status: str
331
332 class CommitReadCorrupt(TypedDict):
333 status: str
334 path: str
335 error: str
336
337 class WalkResult(TypedDict):
338 """Result of a bounded history walk.
339
340 Returned by :func:`walk_commits_between_result` so callers can
341 distinguish between a complete walk and one that was truncated by the
342 safety cap.
343 """
344
345 commits: list[CommitRecord]
346 truncated: bool
347 count: int
348
349 # ---------------------------------------------------------------------------
350 # Path helper
351 # ---------------------------------------------------------------------------
352
353 def commit_path(repo_root: pathlib.Path, commit_id: str) -> pathlib.Path:
354 """Return the on-disk path for a commit record in the unified object store.
355
356 Path shape: ``.muse/objects/<algo>/<shard-2>/<hex-62>``
357 """
358 from muse.core.object_store import object_path as _object_path
359 return _object_path(repo_root, commit_id)
360
361 # ---------------------------------------------------------------------------
362 # Commit existence
363 # ---------------------------------------------------------------------------
364
365 _known_commits_dirs: set[str] = set()
366
367 def commit_exists(repo_root: pathlib.Path, commit_id: str) -> bool:
368 """Return ``True`` when the commit file for *commit_id* is present on disk."""
369 return commit_path(repo_root, commit_id).exists()
370
371 # ---------------------------------------------------------------------------
372 # Write
373 # ---------------------------------------------------------------------------
374
375 def _verify_commit_id(
376 record: CommitRecord, expected_id: str, path: pathlib.Path
377 ) -> None:
378 """Re-derive the commit ID from stored fields and assert it matches *expected_id*.
379
380 Raises:
381 OSError: If the recomputed ID does not match *expected_id*.
382 """
383 parent_ids: list[str] = []
384 if record.parent_commit_id:
385 parent_ids.append(record.parent_commit_id)
386 if record.parent2_commit_id:
387 parent_ids.append(record.parent2_commit_id)
388 recomputed = compute_commit_id(
389 parent_ids=parent_ids,
390 snapshot_id=record.snapshot_id,
391 message=record.message,
392 committed_at_iso=record.committed_at.isoformat(),
393 author=record.author or "",
394 signer_public_key=record.signer_public_key or "",
395 )
396 if recomputed != expected_id:
397 logger.critical(
398 "❌ Commit %s failed content-hash verification — "
399 "core fields are corrupt (snapshot_id, message, committed_at, or "
400 "parent IDs). Expected %s, recomputed %s. "
401 "Run `muse verify-pack` to audit the full store.",
402 expected_id,
403 expected_id,
404 recomputed,
405 )
406 raise OSError(
407 f"Commit {expected_id} failed content-hash verification. "
408 f"Core fields (snapshot_id, message, committed_at, parent IDs) "
409 f"have been silently corrupted in {path.name}. "
410 "Run `muse verify-pack` to audit the full store."
411 )
412
413
414 def write_commit(
415 repo_root: pathlib.Path,
416 commit: CommitRecord,
417 *,
418 skip_parent_check: bool = False,
419 sync: bool = True,
420 ) -> None:
421 """Persist a commit record to the unified object store.
422
423 Idempotent: if the file already exists and is a valid commit record, the
424 write is skipped. If the file exists but is *corrupt*, a CRITICAL is
425 logged and the file is overwritten with the incoming (good) record — the
426 store prefers a live good record over a corrupt existing one.
427
428 Args:
429 skip_parent_check: When ``True``, skip the parent-existence guard.
430 Use only for shallow clone boundary commits whose
431 parents are intentionally absent.
432
433 Raises:
434 OSError: If an existing record's ``commit_id`` field does not match
435 the filename (data-integrity violation — indicates a tampered
436 or severely corrupted store).
437 """
438 commit_path(repo_root, commit.commit_id).parent.mkdir(parents=True, exist_ok=True)
439 if not skip_parent_check:
440 for _field, _parent_id in (
441 ("parent_commit_id", commit.parent_commit_id),
442 ("parent2_commit_id", commit.parent2_commit_id),
443 ):
444 if _parent_id and not commit_exists(repo_root, _parent_id):
445 raise MissingParentError(
446 f"Refusing to write commit {commit.commit_id!r}: "
447 f"{_field} {_parent_id!r} does not exist in the local store. "
448 "Fetch the missing commits before retrying."
449 )
450 try:
451 _verify_commit_id(commit, commit.commit_id, pathlib.Path("<incoming>"))
452 except OSError as exc:
453 raise ValueError(
454 f"Refusing to write commit {commit.commit_id!r}: "
455 f"incoming record failed hash verification — {exc}"
456 ) from exc
457 path = commit_path(repo_root, commit.commit_id)
458 if path.exists():
459 logger.debug("⚠️ Commit %s already exists — skipped", short_id(commit.commit_id))
460 return
461 json_bytes = _json.dumps(commit.to_dict()).encode()
462 content = f"commit {len(json_bytes)}\x00".encode() + json_bytes
463 fd, tmp_str = tempfile.mkstemp(dir=path.parent, prefix=".muse-tmp-")
464 tmp = pathlib.Path(tmp_str)
465 try:
466 with os.fdopen(fd, "wb") as fh:
467 fh.write(content)
468 fh.flush()
469 if sync:
470 try:
471 os.fsync(fh.fileno())
472 except OSError:
473 pass
474 tmp.replace(path)
475 except OSError:
476 tmp.unlink(missing_ok=True)
477 raise
478 logger.debug("✅ Stored commit %s branch=%r", short_id(commit.commit_id), commit.branch)
479
480 # ---------------------------------------------------------------------------
481 # Read
482 # ---------------------------------------------------------------------------
483
484 def read_commit(repo_root: pathlib.Path, commit_id: str) -> CommitRecord | None:
485 """Load a commit record by ID, or ``None`` if it does not exist or is corrupt.
486
487 Every read re-verifies the commit ID by recomputing it from the stored
488 core fields (``snapshot_id``, ``message``, ``committed_at``, parent IDs).
489
490 Callers that need to distinguish "not found" from "corrupt" should use
491 :func:`read_commit_result` instead.
492 """
493 path = commit_path(repo_root, commit_id)
494 if not path.exists():
495 return None
496 try:
497 raw = path.read_bytes()
498 nl = raw.index(b"\x00")
499 record = CommitRecord.from_dict(_json.loads(raw[nl + 1:]))
500 _verify_commit_id(record, commit_id, path)
501 return record
502 except Exception as exc:
503 logger.critical("❌ Corrupt commit file %s (%s): %s", path, short_id(commit_id), exc)
504 return None
505
506
507 def commit_read_is_ok(
508 r: CommitReadOk | CommitReadNotFound | CommitReadCorrupt,
509 ) -> TypeGuard[CommitReadOk]:
510 """``True`` when *r* is a successful :func:`read_commit_result`."""
511 return r["status"] == "ok"
512
513
514 def commit_read_is_not_found(
515 r: CommitReadOk | CommitReadNotFound | CommitReadCorrupt,
516 ) -> TypeGuard[CommitReadNotFound]:
517 """``True`` when *r* represents a missing commit."""
518 return r["status"] == "not_found"
519
520
521 def commit_read_is_corrupt(
522 r: CommitReadOk | CommitReadNotFound | CommitReadCorrupt,
523 ) -> TypeGuard[CommitReadCorrupt]:
524 """``True`` when *r* represents a corrupt commit file."""
525 return r["status"] == "corrupt"
526
527
528 def read_commit_result(
529 repo_root: pathlib.Path, commit_id: str
530 ) -> CommitReadOk | CommitReadNotFound | CommitReadCorrupt:
531 """Load a commit record with a typed result that distinguishes all outcomes.
532
533 Returns one of:
534
535 * ``{"status": "ok", "commit": CommitRecord}``
536 * ``{"status": "not_found"}``
537 * ``{"status": "corrupt", "path": str, "error": str}``
538 """
539 path = commit_path(repo_root, commit_id)
540 if not path.exists():
541 return CommitReadNotFound(status="not_found")
542 try:
543 raw = path.read_bytes()
544 nl = raw.index(b"\x00")
545 record = CommitRecord.from_dict(_json.loads(raw[nl + 1:]))
546 _verify_commit_id(record, commit_id, path)
547 return CommitReadOk(status="ok", commit=record)
548 except Exception as exc:
549 logger.critical("❌ Corrupt commit file %s (%s): %s", path, short_id(commit_id), exc)
550 return CommitReadCorrupt(status="corrupt", path=str(path), error=str(exc))
551
552 # ---------------------------------------------------------------------------
553 # Mutation
554 # ---------------------------------------------------------------------------
555
556 def overwrite_commit(repo_root: pathlib.Path, commit: CommitRecord) -> None:
557 """Overwrite an existing commit record on disk (e.g. for annotation updates).
558
559 Unlike :func:`write_commit`, this function always writes the record even if
560 the file already exists. Use only for annotation fields
561 (``reviewed_by``, ``test_runs``, ``labels``, ``status``, ``notes``, ``score``)
562 that are semantically additive — never for changing history.
563 """
564 path = commit_path(repo_root, commit.commit_id)
565 path.parent.mkdir(parents=True, exist_ok=True)
566 json_bytes = _json.dumps(commit.to_dict()).encode()
567 content = f"commit {len(json_bytes)}\x00".encode() + json_bytes
568 fd, tmp_str = tempfile.mkstemp(dir=path.parent, prefix=".muse-tmp-")
569 tmp = pathlib.Path(tmp_str)
570 try:
571 with os.fdopen(fd, "wb") as fh:
572 fh.write(content)
573 tmp.replace(path)
574 except OSError:
575 tmp.unlink(missing_ok=True)
576 raise
577 logger.debug("✅ Updated annotation on commit %s", short_id(commit.commit_id))
578
579
580 def update_commit_metadata(
581 repo_root: pathlib.Path,
582 commit_id: str,
583 key: str,
584 value: str,
585 ) -> bool:
586 """Set a single string key in a commit's metadata dict.
587
588 Returns ``True`` on success, ``False`` if the commit is not found.
589 """
590 commit = read_commit(repo_root, commit_id)
591 if commit is None:
592 logger.warning("⚠️ Commit %s not found — cannot update metadata", commit_id)
593 return False
594 commit.metadata[key] = value
595 overwrite_commit(repo_root, commit)
596 logger.debug("✅ Set %s=%r on commit %s", key, value, short_id(commit_id))
597 return True
598
599 # ---------------------------------------------------------------------------
600 # Ref resolution helpers that depend on read_commit
601 # ---------------------------------------------------------------------------
602
603 def get_head_snapshot_id(
604 repo_root: pathlib.Path,
605 branch: str,
606 ) -> str | None:
607 """Return the snapshot_id at HEAD of *branch*, or ``None``."""
608 commit_id = resolve_any_ref(repo_root, branch)
609 if commit_id is None:
610 return None
611 commit = read_commit(repo_root, commit_id)
612 if commit is None:
613 return None
614 return commit.snapshot_id
615
616
617 def resolve_commit_ref(
618 repo_root: pathlib.Path,
619 branch: str,
620 ref: str | None,
621 ) -> CommitRecord | None:
622 """Resolve a commit reference to a ``CommitRecord``.
623
624 *ref* may be:
625 - ``None`` / ``"HEAD"`` — the most recent commit on *branch*.
626 - ``"HEAD~N"`` or ``"<sha>~N"`` — walk *N* first-parent steps back.
627 - A full or abbreviated commit SHA — resolved by prefix scan.
628
629 Performs a safe prefix scan (glob metacharacters stripped from *ref*) so
630 user-supplied references cannot glob the entire commits directory.
631 """
632 if ref is None or ref.upper() == "HEAD":
633 commit_id = get_head_commit_id(repo_root, branch)
634 if commit_id is None:
635 return None
636 return read_commit(repo_root, commit_id)
637
638 # @{N} / branch@{N} — resolve via the reflog before any other strategy.
639 try:
640 from muse.core.reflog import resolve_reflog_ref as _resolve_reflog_ref
641 reflog_id = _resolve_reflog_ref(ref, repo_root)
642 if reflog_id is not None:
643 return read_commit(repo_root, reflog_id)
644 except (FileNotFoundError, IndexError):
645 return None
646
647 _tilde_match = re.fullmatch(r"(.+?)~(\d+)", ref, re.IGNORECASE)
648 if _tilde_match:
649 base_ref, steps_str = _tilde_match.group(1), _tilde_match.group(2)
650 steps = int(steps_str)
651 base = resolve_commit_ref(repo_root, branch, base_ref if base_ref.upper() != "HEAD" else None)
652 if base is None:
653 return None
654 commit = base
655 for _ in range(steps):
656 if commit.parent_commit_id is None:
657 return None
658 next_commit = read_commit(repo_root, commit.parent_commit_id)
659 if next_commit is None:
660 return None
661 commit = next_commit
662 return commit
663
664 safe_ref = sanitize_glob_prefix(ref)
665
666 branch_ref = _ref_path(repo_root, safe_ref)
667 branch_commit_id = read_ref(branch_ref)
668 if branch_commit_id:
669 return read_commit(repo_root, branch_commit_id)
670
671 try:
672 validate_ref_id(safe_ref)
673 exact: CommitRecord | None = read_commit(repo_root, safe_ref)
674 if exact is not None:
675 return exact
676 except ValueError:
677 pass
678
679 bare_prefix = long_id(safe_ref, strip=True)
680 return _find_commit_by_prefix(repo_root, bare_prefix)
681
682
683 def _find_commit_by_prefix(
684 repo_root: pathlib.Path, prefix: str
685 ) -> CommitRecord | None:
686 """Find the first commit whose ID starts with *prefix*.
687
688 Glob metacharacters are stripped from *prefix* before use.
689 """
690 safe_prefix = sanitize_glob_prefix(prefix)
691 objects_dir = _objects_dir(repo_root)
692 if not objects_dir.exists():
693 return None
694 for path in objects_dir.glob(f"sha256/{safe_prefix[:2]}/*"):
695 if not path.is_file():
696 continue
697 hex_tail = path.name
698 full_hex = path.parent.name + hex_tail
699 if not full_hex.startswith(safe_prefix):
700 continue
701 commit_id = f"sha256:{full_hex}"
702 record = read_commit(repo_root, commit_id)
703 if record is not None:
704 return record
705 return None
706
707
708 def find_commits_by_prefix(
709 repo_root: pathlib.Path, prefix: str
710 ) -> list[CommitRecord]:
711 """Return all commits whose ID starts with *prefix*."""
712 safe_prefix = sanitize_glob_prefix(prefix)
713 objects_dir = _objects_dir(repo_root)
714 if not objects_dir.exists():
715 return []
716 results: list[CommitRecord] = []
717 for path in objects_dir.glob(f"sha256/{safe_prefix[:2]}/*"):
718 if not path.is_file():
719 continue
720 hex_tail = path.name
721 full_hex = path.parent.name + hex_tail
722 if not full_hex.startswith(safe_prefix):
723 continue
724 commit_id = f"sha256:{full_hex}"
725 record = read_commit(repo_root, commit_id)
726 if record is not None:
727 results.append(record)
728 return results
729
730
731 def _resolve_branch_commit_id(repo_root: pathlib.Path, branch: str) -> str | None:
732 """Resolve *branch* to a commit ID, handling both local and remote tracking refs.
733
734 Resolution order:
735 1. Local branch — ``.muse/refs/heads/<branch>``
736 2. Remote tracking ref — ``.muse/remotes/<remote>/<name>`` when *branch*
737 contains a ``/``.
738 3. Returns ``None`` when neither exists.
739 """
740 local = get_head_commit_id(repo_root, branch)
741 if local is not None:
742 return local
743 if "/" in branch:
744 remote, _, name = branch.partition("/")
745 if name:
746 ref_file = _remotes_dir(repo_root) / remote / name
747 return read_ref(ref_file)
748 return None
749
750
751 def get_commits_for_branch(
752 repo_root: pathlib.Path,
753 branch: str,
754 max_count: int = 0,
755 ) -> list[CommitRecord]:
756 """Return commits on *branch*, newest first, by walking the first-parent chain.
757
758 *branch* may be a local branch name (``"dev"``) or a remote tracking ref
759 (``"origin/dev"``).
760
761 Args:
762 repo_root: Repository root.
763 branch: Branch name or remote tracking ref to walk from HEAD.
764 max_count: Stop after this many commits. ``0`` means walk the entire chain.
765 """
766 from muse.core.graph import iter_ancestors # local to avoid circular import
767
768 commit_id = _resolve_branch_commit_id(repo_root, branch)
769 if not commit_id:
770 return []
771 cap: int | None = max_count if max_count > 0 else None
772 return list(iter_ancestors(repo_root, commit_id, first_parent_only=True, max_commits=cap))
773
774
775 def get_all_commits(repo_root: pathlib.Path) -> list[CommitRecord]:
776 """Return all commits in the store (order not guaranteed).
777
778 Corrupt commit files are skipped with a CRITICAL log entry.
779 """
780 objects_dir = _objects_dir(repo_root)
781 if not objects_dir.exists():
782 return []
783 results: list[CommitRecord] = []
784 for path in objects_dir.glob("sha256/*/*"):
785 if not path.is_file():
786 continue
787 raw = path.read_bytes()
788 if raw.startswith(b"blob ") or raw.startswith(b"snapshot "):
789 continue
790 if not raw.startswith(b"commit "):
791 # Could be a bare (header-less) blob from an older write path or
792 # a pre-migration raw blob whose ID is sha256(content) rather than
793 # the current blob_id formula sha256("blob <size>\0" + content).
794 import hashlib as _hashlib
795 from muse.core.types import blob_id as _blob_id
796 shard, rest = path.parent.name, path.name
797 path_id = f"sha256:{shard}{rest}"
798 if _blob_id(raw) == path_id:
799 # New-formula bare blob (path IS hash_blob(raw)) — safely skip.
800 logger.debug("Bare blob object at %s (no typed header) — skipped in commit listing", path)
801 elif f"sha256:{_hashlib.sha256(raw).hexdigest()}" == path_id:
802 # Old-formula raw blob (path IS sha256(raw)) — left behind by
803 # a migrate run that predates cleanup; safely skip.
804 logger.debug("Pre-migration bare blob at %s — skipped in commit listing", path)
805 else:
806 logger.critical(
807 "❌ Corrupt or unrecognized object at %s — skipped in commit listing", path
808 )
809 continue
810 try:
811 nl = raw.index(b"\x00")
812 record = CommitRecord.from_dict(_json.loads(raw[nl + 1:]))
813 results.append(record)
814 except Exception as exc:
815 logger.critical("❌ Corrupt commit file %s — skipped in listing: %s", path, exc)
816 return results
817
818 # ---------------------------------------------------------------------------
819 # Bounded history walks
820 # ---------------------------------------------------------------------------
821
822 def walk_commits_between(
823 repo_root: pathlib.Path,
824 to_commit_id: str,
825 from_commit_id: str | None = None,
826 max_commits: int = 10_000,
827 ) -> list[CommitRecord]:
828 """Return commits reachable from *to_commit_id*, stopping before *from_commit_id*.
829
830 .. note::
831 If the walk reaches *max_commits* before the chain is exhausted the
832 result is silently truncated. Use :func:`walk_commits_between_result`
833 when the caller must know whether truncation occurred.
834 """
835 return walk_commits_between_result(
836 repo_root, to_commit_id, from_commit_id, max_commits
837 )["commits"]
838
839
840 def walk_commits_between_result(
841 repo_root: pathlib.Path,
842 to_commit_id: str,
843 from_commit_id: str | None = None,
844 max_commits: int = 10_000,
845 ) -> WalkResult:
846 """Bounded history walk with explicit truncation signalling.
847
848 Returns a :class:`WalkResult` with ``"truncated": True`` when the safety
849 cap was reached before the chain was exhausted.
850 """
851 from muse.core.graph import iter_ancestors # local to avoid circular import
852
853 prune = (lambda cid: cid == from_commit_id) if from_commit_id else None
854 gathered = list(iter_ancestors(
855 repo_root,
856 to_commit_id,
857 first_parent_only=True,
858 prune=prune,
859 max_commits=max_commits + 1,
860 ))
861 truncated = len(gathered) > max_commits
862 commits = gathered[:max_commits]
863 return WalkResult(commits=commits, truncated=truncated, count=len(commits))
File History 3 commits
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9 Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump… Human 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 68 days ago
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f chore: bump version to 0.2.0rc15 to match musehub#113 fix release Sonnet 4.6 patch 68 days ago