gabriel / muse public
reflog.py python
1,066 lines 38.2 KB
Raw
sha256:f7bf283e6cd64892d61560b1d4508b52612b0d8cd2c7d02a17fcdbbd3ae75b66 feat(branch): reflog copy/rename parity with git, standardi… Sonnet 5 patch 5 hours ago
1 """Reflog — record every HEAD and branch-ref movement.
2
3 The reflog is an append-only per-ref journal that records every time a branch
4 pointer moves, regardless of cause: commit, checkout, merge, reset, cherry-pick,
5 shelf pop. It is a safety net — if a ``muse reset --hard`` goes wrong the
6 reflog tells you exactly what ``HEAD`` was before.
7
8 Storage layout::
9
10 .muse/logs/
11 HEAD — log for the symbolic HEAD pointer
12 refs/
13 heads/
14 main — log for the main branch ref
15 dev — log for the dev branch ref
16 …
17
18 Each log file is a plain-text append-only sequence of lines::
19
20 <old_id> <new_id> <author> <timestamp_unix> <tz_offset> \\t<operation>
21
22 This format mirrors Git's reflog so tooling that understands both can be
23 built without translation.
24
25 Fields
26 ------
27 old_id 64-hex SHA-256 or ``0000…0000`` when there is no predecessor
28 (initial commit on a branch, new branch creation).
29 new_id 64-hex SHA-256 of the new commit.
30 author ``Name <email>`` or a short label for automated operations.
31 timestamp_unix Unix seconds as a decimal integer.
32 tz_offset UTC offset in ``+HHMM`` / ``-HHMM`` form.
33 operation Free-form description, e.g. ``commit: add verse``,
34 ``checkout: moving from main to dev``,
35 ``reset: moving to <sha12>``.
36
37 Security model
38 --------------
39 ``author`` and ``operation`` are sanitized before being written to disk:
40 newlines (``\\n``, ``\\r``) are stripped to prevent line injection, and tabs
41 (``\\t``) are stripped from ``author`` to prevent corruption of the
42 tab-delimited parse boundary.
43
44 ``list_reflog_refs`` skips symlinks when enumerating branch logs — symlinks
45 that escape the ``.muse/logs/refs/heads/`` directory cannot be followed.
46
47 ``read_reflog`` enforces a ``_MAX_REFLOG_BYTES`` file-size cap before reading
48 to prevent OOM from excessively large or maliciously crafted log files.
49 """
50
51 import datetime
52 import logging
53 import os
54 import pathlib
55 import re
56 import tempfile
57 from dataclasses import dataclass
58
59 from muse.core.types import MUSE_DIR, NULL_COMMIT_ID, long_id
60 from muse.core.paths import (
61 logs_dir as _logs_dir_path,
62 reflog_branch_path as _reflog_branch_path,
63 reflog_heads_dir as _reflog_heads_dir,
64 reflog_remote_path as _reflog_remote_path,
65 reflog_remotes_dir as _reflog_remotes_dir,
66 reflog_tag_path as _reflog_tag_path,
67 reflog_tags_dir as _reflog_tags_dir,
68 )
69
70 logger = logging.getLogger(__name__)
71
72 _LOG_DIR_NAME = "logs"
73
74 #: Maximum reflog file size that will be read. Files larger than this are
75 #: truncated with a warning rather than loaded entirely into memory.
76 _MAX_REFLOG_BYTES: int = 10 * 1024 * 1024 # 10 MiB
77
78 # ---------------------------------------------------------------------------
79 # Types
80 # ---------------------------------------------------------------------------
81
82 @dataclass(frozen=True)
83 class ReflogEntry:
84 """One line of a reflog."""
85
86 old_id: str
87 new_id: str
88 author: str
89 timestamp: datetime.datetime
90 operation: str
91
92 # ---------------------------------------------------------------------------
93 # Path helpers
94 # ---------------------------------------------------------------------------
95
96 def _head_log_path(repo_root: pathlib.Path) -> pathlib.Path:
97 return _logs_dir_path(repo_root) / "HEAD"
98
99 def _ref_log_path(repo_root: pathlib.Path, branch: str) -> pathlib.Path:
100 return _reflog_branch_path(repo_root, branch)
101
102 # ---------------------------------------------------------------------------
103 # Internal sanitization helpers
104 # ---------------------------------------------------------------------------
105
106 def _sanitize_author(s: str) -> str:
107 """Strip characters from *s* that would corrupt reflog line structure.
108
109 Strips ``\\n``, ``\\r`` (prevent line injection) and ``\\t`` (the metadata
110 / operation tab-separator). The author field is the free-form portion
111 of the metadata half-line, so embedded tabs would corrupt the parse.
112 """
113 return s.replace("\n", "").replace("\r", "").replace("\t", "")
114
115 def _sanitize_operation(s: str) -> str:
116 """Strip newlines from *s* to prevent line injection in the operation field.
117
118 The operation is the post-tab half of each reflog line. Embedded
119 newlines would inject fake entries into the log file.
120 """
121 return s.replace("\n", "").replace("\r", "")
122
123 # ---------------------------------------------------------------------------
124 # Write
125 # ---------------------------------------------------------------------------
126
127 def append_reflog(
128 repo_root: pathlib.Path,
129 branch: str,
130 old_id: str | None,
131 new_id: str,
132 author: str,
133 operation: str,
134 ) -> None:
135 """Append one entry to both the branch log and the HEAD log.
136
137 Args:
138 repo_root: Root of the Muse repository.
139 branch: Branch whose ref is moving (e.g. ``"main"``).
140 old_id: Previous commit SHA-256 (``None`` for initial commit).
141 new_id: New commit SHA-256.
142 author: ``"Name <email>"`` or a short label. Newlines and tabs
143 are stripped to prevent log corruption.
144 operation: Human-readable description of the operation. Newlines
145 are stripped to prevent line injection.
146 """
147 effective_old = old_id or NULL_COMMIT_ID
148 safe_author = _sanitize_author(author)
149 safe_operation = _sanitize_operation(operation)
150 now = datetime.datetime.now(tz=datetime.timezone.utc)
151 ts = int(now.timestamp())
152 tz_offset = "+0000" # we always store UTC; offset is for display parity
153
154 line = (
155 f"{effective_old} {new_id} {safe_author} {ts} {tz_offset}\t{safe_operation}\n"
156 )
157
158 for log_path in (_ref_log_path(repo_root, branch), _head_log_path(repo_root)):
159 log_path.parent.mkdir(parents=True, exist_ok=True)
160 with log_path.open("a", encoding="utf-8") as fh:
161 fh.write(line)
162
163 def append_head_only_reflog(
164 repo_root: pathlib.Path,
165 old_id: str | None,
166 new_id: str,
167 author: str,
168 operation: str,
169 ) -> None:
170 """Append one entry to the HEAD log only — not any branch log.
171
172 For operations that already wrote their own, differently-worded entry
173 directly to a branch log (e.g. renaming the checked-out branch, where
174 :func:`rename_branch_reflog` already wrote a "renamed from" entry to the
175 branch's own log) but still need a HEAD-log entry recording the same
176 event from HEAD's perspective. Using :func:`append_reflog` here would
177 double-write the branch log with a near-duplicate entry.
178 """
179 effective_old = old_id or NULL_COMMIT_ID
180 safe_author = _sanitize_author(author)
181 safe_operation = _sanitize_operation(operation)
182 now = datetime.datetime.now(tz=datetime.timezone.utc)
183 ts = int(now.timestamp())
184 line = f"{effective_old} {new_id} {safe_author} {ts} +0000\t{safe_operation}\n"
185
186 log_path = _head_log_path(repo_root)
187 log_path.parent.mkdir(parents=True, exist_ok=True)
188 with log_path.open("a", encoding="utf-8") as fh:
189 fh.write(line)
190
191 def copy_branch_reflog(
192 repo_root: pathlib.Path,
193 src_branch: str,
194 dst_branch: str,
195 new_id: str,
196 author: str,
197 operation: str,
198 ) -> None:
199 """Copy *src_branch*'s reflog to *dst_branch*, appending one new entry.
200
201 Mirrors ``git branch -c``: the destination log becomes the source log's
202 raw content (byte-for-byte, if the source has one) with one new entry
203 appended after it. Reflog files are stored oldest-first on disk —
204 :func:`read_reflog` reverses them for display — so appending here is
205 what makes the new entry the *newest*, i.e. it reads as ``@{0}`` in
206 ``muse reflog --branch dst_branch``. The appended entry's ``old_id`` is
207 the null commit ID (the destination ref has no predecessor of its own);
208 ``new_id`` is the source's tip.
209
210 The destination log is always replaced wholesale, discarding whatever
211 was there before — correct for both the safe (``-c``) and force (``-C``)
212 forms, since force-copy is expected to fully overwrite an existing
213 destination branch's history too.
214
215 If the source branch has no reflog (e.g. a brand-new repo with no
216 commits yet), the destination log contains just the one prepended entry.
217
218 The write is atomic (tmp sibling file + ``os.replace``) so a crash
219 never leaves a half-written log; callers should write the branch ref
220 itself first so an interruption between the two leaves, at worst, a
221 valid branch with no reflog yet (never a reflog with no branch).
222 """
223 safe_author = _sanitize_author(author)
224 safe_operation = _sanitize_operation(operation)
225 now = datetime.datetime.now(tz=datetime.timezone.utc)
226 ts = int(now.timestamp())
227 new_line = f"{NULL_COMMIT_ID} {new_id} {safe_author} {ts} +0000\t{safe_operation}\n"
228
229 src_path = _ref_log_path(repo_root, src_branch)
230 src_content = src_path.read_bytes() if src_path.is_file() else b""
231
232 dst_path = _ref_log_path(repo_root, dst_branch)
233 dst_path.parent.mkdir(parents=True, exist_ok=True)
234 fd, tmp_name = tempfile.mkstemp(dir=dst_path.parent, prefix=".reflog_copy_")
235 try:
236 with os.fdopen(fd, "wb") as fh:
237 fh.write(src_content)
238 fh.write(new_line.encode("utf-8"))
239 os.replace(tmp_name, dst_path)
240 except Exception:
241 try:
242 os.unlink(tmp_name)
243 except OSError:
244 pass
245 raise
246
247 def rename_branch_reflog(
248 repo_root: pathlib.Path,
249 old_branch: str,
250 new_branch: str,
251 tip: str,
252 author: str,
253 operation: str,
254 ) -> None:
255 """Move *old_branch*'s reflog to *new_branch*, appending a rename entry.
256
257 Mirrors ``git branch -m``: the destination log becomes the source log's
258 raw content with one new entry appended after it (see
259 :func:`copy_branch_reflog` for why appending, not prepending, is what
260 makes the new entry read as ``@{0}``). Unlike :func:`copy_branch_reflog`,
261 the appended entry's ``old_id`` and ``new_id`` are both *tip* — a rename
262 doesn't move the commit the branch points at, only its name, so there is
263 no real pointer movement to record beyond the rename itself.
264
265 The destination log is written and ``os.replace``d into place *before*
266 the source log is removed, so a failure partway through (disk full,
267 permission error) leaves the original log intact at the old path rather
268 than losing history — the source is only unlinked once the destination
269 write has fully succeeded. Any prior log already at the destination
270 path is discarded, matching force-rename (``-M``) semantics as well as
271 the plain (``-m``) case, which the caller has already validated won't
272 silently clobber an existing branch.
273
274 Used for both ``-m`` and force ``-M`` — the caller is responsible for
275 writing a HEAD-log entry too when the renamed branch is currently
276 checked out, since that's a HEAD-specific concern this function (which
277 only touches the branch's own log) doesn't need to know about.
278 """
279 safe_author = _sanitize_author(author)
280 safe_operation = _sanitize_operation(operation)
281 now = datetime.datetime.now(tz=datetime.timezone.utc)
282 ts = int(now.timestamp())
283 new_line = f"{tip} {tip} {safe_author} {ts} +0000\t{safe_operation}\n"
284
285 src_path = _ref_log_path(repo_root, old_branch)
286 src_content = src_path.read_bytes() if src_path.is_file() else b""
287
288 dst_path = _ref_log_path(repo_root, new_branch)
289 dst_path.parent.mkdir(parents=True, exist_ok=True)
290 fd, tmp_name = tempfile.mkstemp(dir=dst_path.parent, prefix=".reflog_rename_")
291 try:
292 with os.fdopen(fd, "wb") as fh:
293 fh.write(src_content)
294 fh.write(new_line.encode("utf-8"))
295 os.replace(tmp_name, dst_path)
296 except Exception:
297 try:
298 os.unlink(tmp_name)
299 except OSError:
300 pass
301 raise
302
303 if src_path != dst_path and src_path.is_file():
304 src_path.unlink(missing_ok=True)
305
306 def append_tag_reflog(
307 repo_root: pathlib.Path,
308 tag: str,
309 old_id: str | None,
310 new_id: str,
311 author: str,
312 operation: str,
313 ) -> None:
314 """Append one entry to *tag*'s own reflog (muse#54 item 5).
315
316 Unlike :func:`append_reflog`, this does **not** also write to the HEAD
317 log — creating or re-pointing a version tag doesn't move HEAD, so it has
318 no business appearing in ``muse reflog``'s default (HEAD) view. Use
319 ``muse reflog --tag <name>`` to see a tag's own history.
320
321 Args:
322 repo_root: Root of the Muse repository.
323 tag: Version tag whose ref is moving (e.g. ``"v1.0.0"``).
324 old_id: Previous commit SHA-256 (``None`` for a brand-new tag).
325 new_id: New commit SHA-256.
326 author: ``"Name <email>"`` or a short label.
327 operation: Human-readable description of the operation.
328 """
329 effective_old = old_id or NULL_COMMIT_ID
330 safe_author = _sanitize_author(author)
331 safe_operation = _sanitize_operation(operation)
332 now = datetime.datetime.now(tz=datetime.timezone.utc)
333 ts = int(now.timestamp())
334 tz_offset = "+0000"
335
336 line = (
337 f"{effective_old} {new_id} {safe_author} {ts} {tz_offset}\t{safe_operation}\n"
338 )
339
340 log_path = _reflog_tag_path(repo_root, tag)
341 log_path.parent.mkdir(parents=True, exist_ok=True)
342 with log_path.open("a", encoding="utf-8") as fh:
343 fh.write(line)
344
345 # ---------------------------------------------------------------------------
346 # Read
347 # ---------------------------------------------------------------------------
348
349 def _parse_line(line: str) -> ReflogEntry | None:
350 """Parse one reflog line; return None on malformed input.
351
352 The format is::
353
354 <old_id> <new_id> <author…> <ts_unix> <tz_offset> TAB <operation>
355
356 The tab character is the only guaranteed delimiter between the metadata
357 tokens and the free-form operation string.
358 """
359 line = line.rstrip("\n")
360 parts = line.split("\t", 1)
361 if len(parts) != 2:
362 return None
363 meta, operation = parts
364 tokens = meta.split()
365 if len(tokens) < 5:
366 return None
367 old_id, new_id = tokens[0], tokens[1]
368 # author may contain spaces — everything between tokens[2] and the last
369 # two tokens (timestamp, tz_offset) is the author.
370 ts_str = tokens[-2]
371 author = " ".join(tokens[2:-2])
372 try:
373 ts = datetime.datetime.fromtimestamp(int(ts_str), tz=datetime.timezone.utc)
374 except (ValueError, OSError):
375 ts = datetime.datetime.now(tz=datetime.timezone.utc)
376 return ReflogEntry(
377 old_id=old_id,
378 new_id=new_id,
379 author=author,
380 timestamp=ts,
381 operation=operation,
382 )
383
384 def read_reflog(
385 repo_root: pathlib.Path,
386 branch: str | None = None,
387 limit: int = 100,
388 ) -> list[ReflogEntry]:
389 """Return reflog entries newest-first, up to *limit*.
390
391 Args:
392 repo_root: Root of the Muse repository.
393 branch: Branch name, or ``None`` to read the HEAD log.
394 limit: Maximum number of entries to return.
395
396 Notes:
397 Files larger than ``_MAX_REFLOG_BYTES`` are rejected with a warning
398 to prevent OOM from maliciously large reflog files. The ``limit``
399 parameter is applied after parsing — the function stops as soon as
400 *limit* valid entries have been collected.
401 """
402 log_path = (
403 _head_log_path(repo_root)
404 if branch is None
405 else _ref_log_path(repo_root, branch)
406 )
407 if not log_path.exists():
408 return []
409 try:
410 size = log_path.stat().st_size
411 if size > _MAX_REFLOG_BYTES:
412 logger.warning(
413 "⚠️ Reflog %s is %.1f MiB — exceeds cap of %d MiB; "
414 "results may be incomplete. Consider pruning old entries.",
415 log_path,
416 size / (1024 * 1024),
417 _MAX_REFLOG_BYTES // (1024 * 1024),
418 )
419 lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
420 except OSError as exc:
421 logger.warning("⚠️ Could not read reflog %s: %s", log_path, exc)
422 return []
423 entries: list[ReflogEntry] = []
424 for line in reversed(lines):
425 if not line.strip():
426 continue
427 entry = _parse_line(line)
428 if entry is not None:
429 entries.append(entry)
430 if len(entries) >= limit:
431 break
432 return entries
433
434 def read_tag_reflog(
435 repo_root: pathlib.Path,
436 tag: str,
437 limit: int = 100,
438 ) -> list[ReflogEntry]:
439 """Return *tag*'s reflog entries newest-first, up to *limit*.
440
441 Same format, size cap, and malformed-line handling as :func:`read_reflog`
442 — reads from the tag's own namespace (``.muse/logs/refs/tags/``) instead.
443 """
444 log_path = _reflog_tag_path(repo_root, tag)
445 if not log_path.exists():
446 return []
447 try:
448 size = log_path.stat().st_size
449 if size > _MAX_REFLOG_BYTES:
450 logger.warning(
451 "⚠️ Reflog %s is %.1f MiB — exceeds cap of %d MiB; "
452 "results may be incomplete. Consider pruning old entries.",
453 log_path,
454 size / (1024 * 1024),
455 _MAX_REFLOG_BYTES // (1024 * 1024),
456 )
457 lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
458 except OSError as exc:
459 logger.warning("⚠️ Could not read reflog %s: %s", log_path, exc)
460 return []
461 entries: list[ReflogEntry] = []
462 for line in reversed(lines):
463 if not line.strip():
464 continue
465 entry = _parse_line(line)
466 if entry is not None:
467 entries.append(entry)
468 if len(entries) >= limit:
469 break
470 return entries
471
472 def list_reflog_refs(repo_root: pathlib.Path) -> list[str]:
473 """Return branch names that have a reflog, sorted.
474
475 Symlinks are excluded — only regular files are returned so that a
476 crafted symlink inside ``.muse/logs/refs/heads/`` cannot be used to
477 enumerate arbitrary filesystem paths.
478 """
479 refs_dir = _reflog_heads_dir(repo_root)
480 if not refs_dir.exists():
481 return []
482 return sorted(
483 p.name
484 for p in refs_dir.iterdir()
485 if p.is_file() and not p.is_symlink()
486 )
487
488 # ---------------------------------------------------------------------------
489 # Remote sharing (muse#54 item 4)
490 # ---------------------------------------------------------------------------
491
492 def reflog_entries_to_wire(entries: list[ReflogEntry]) -> list[dict]:
493 """Convert local :class:`ReflogEntry` objects to wire-ready dicts.
494
495 IDs are normalized to canonical ``sha256:<64-hex>`` form via
496 :func:`long_id` at the wire boundary — the on-disk file stores bare
497 hex, but every other ID-bearing JSON output in this codebase (``muse
498 reflog --json``, etc.) emits the canonical form, and the wire payload
499 should match that convention rather than leaking the storage format.
500 """
501 return [
502 {
503 "old_id": long_id(e.old_id),
504 "new_id": long_id(e.new_id),
505 "author": e.author,
506 "timestamp": e.timestamp.isoformat(),
507 "operation": e.operation,
508 }
509 for e in entries
510 ]
511
512
513 def merge_remote_reflog_entries(
514 repo_root: pathlib.Path,
515 remote: str,
516 branch: str,
517 wire_entries: list[dict],
518 ) -> int:
519 """Merge entries fetched from a remote into its own tracking namespace.
520
521 Writes to ``.muse/logs/refs/remotes/<remote>/<branch>`` — never directly
522 into the local branch's own reflog — so a remote's history can merge in
523 without colliding with purely local activity (muse#54 item 4's
524 "namespaced by remote" requirement).
525
526 Append-only and idempotent: *wire_entries* may safely include entries
527 already present (e.g. on a repeat pull) — each is compared against the
528 exact raw line it would produce, and only genuinely new lines are
529 appended, preserving *wire_entries*' given order.
530
531 Args:
532 repo_root: Root of the Muse repository.
533 remote: Name of the remote the entries came from (e.g. ``"origin"``).
534 branch: Branch these entries belong to.
535 wire_entries: Dicts shaped like :func:`reflog_entries_to_wire`'s
536 output — ``old_id``/``new_id`` (either bare hex or
537 ``sha256:``-prefixed), ``author``, ``timestamp`` (ISO 8601),
538 ``operation``.
539
540 Returns:
541 The number of newly appended entries.
542 """
543 if not wire_entries:
544 return 0
545
546 log_path = _reflog_remote_path(repo_root, remote, branch)
547 existing_lines: set[str] = set()
548 if log_path.exists():
549 try:
550 existing_lines = set(log_path.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True))
551 except OSError as exc:
552 logger.warning("⚠️ reflog merge: could not read %s: %s", log_path, exc)
553 return 0
554
555 new_lines: list[str] = []
556 for e in wire_entries:
557 old_id = long_id(str(e.get("old_id", "") or NULL_COMMIT_ID), strip=True)
558 new_id = long_id(str(e.get("new_id", "")), strip=True)
559 if not new_id:
560 continue
561 author = _sanitize_author(str(e.get("author", "") or ""))
562 operation = _sanitize_operation(str(e.get("operation", "") or ""))
563 raw_ts = e.get("timestamp", "")
564 try:
565 ts = datetime.datetime.fromisoformat(str(raw_ts))
566 except (ValueError, TypeError):
567 ts = datetime.datetime.now(tz=datetime.timezone.utc)
568 unix_ts = int(ts.timestamp())
569 line = f"{old_id} {new_id} {author} {unix_ts} +0000\t{operation}\n"
570 if line not in existing_lines:
571 new_lines.append(line)
572 existing_lines.add(line)
573
574 if not new_lines:
575 return 0
576
577 log_path.parent.mkdir(parents=True, exist_ok=True)
578 try:
579 with log_path.open("a", encoding="utf-8") as f:
580 f.writelines(new_lines)
581 except OSError as exc:
582 logger.warning("⚠️ reflog merge: could not write %s: %s", log_path, exc)
583 return 0
584
585 return len(new_lines)
586
587
588 def read_remote_reflog(
589 repo_root: pathlib.Path,
590 remote: str,
591 branch: str,
592 limit: int = 100,
593 ) -> list[ReflogEntry]:
594 """Return newest-first entries from a remote's tracking log for *branch*.
595
596 Same format/size-cap/malformed-line handling as :func:`read_reflog` —
597 reads from ``.muse/logs/refs/remotes/<remote>/<branch>`` instead.
598 """
599 log_path = _reflog_remote_path(repo_root, remote, branch)
600 if not log_path.exists():
601 return []
602 try:
603 size = log_path.stat().st_size
604 if size > _MAX_REFLOG_BYTES:
605 logger.warning(
606 "⚠️ Reflog %s is %.1f MiB — exceeds cap of %d MiB; "
607 "results may be incomplete. Consider pruning old entries.",
608 log_path,
609 size / (1024 * 1024),
610 _MAX_REFLOG_BYTES // (1024 * 1024),
611 )
612 lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
613 except OSError as exc:
614 logger.warning("⚠️ Could not read reflog %s: %s", log_path, exc)
615 return []
616 entries: list[ReflogEntry] = []
617 for line in reversed(lines):
618 if not line.strip():
619 continue
620 entry = _parse_line(line)
621 if entry is not None:
622 entries.append(entry)
623 if len(entries) >= limit:
624 break
625 return entries
626
627 # ---------------------------------------------------------------------------
628 # Expire
629 # ---------------------------------------------------------------------------
630
631 def expire_reflog(
632 repo_root: pathlib.Path,
633 branch: str | None,
634 expire_days: int,
635 *,
636 dry_run: bool = False,
637 ) -> tuple[int, int]:
638 """Remove reflog entries older than *expire_days* from one log file.
639
640 Args:
641 repo_root: Root of the Muse repository.
642 branch: Branch name whose log to expire, or ``None`` for the
643 HEAD log.
644 expire_days: Entries whose timestamp is older than this many days
645 are removed.
646 dry_run: When ``True``, compute counts but write nothing.
647
648 Returns:
649 ``(expired_count, kept_count)`` — both counts refer to the log file
650 for *branch* (or HEAD).
651
652 The write is atomic: content is first written to a sibling temp file and
653 then renamed into place with ``os.replace``. If ``os.replace`` raises,
654 the original log is unchanged. An empty log after expiry is removed
655 rather than left as a zero-byte stub.
656 """
657 log_path = (
658 _head_log_path(repo_root)
659 if branch is None
660 else _ref_log_path(repo_root, branch)
661 )
662 if not log_path.exists():
663 return 0, 0
664
665 try:
666 raw = log_path.read_text(encoding="utf-8", errors="replace")
667 except OSError as exc:
668 logger.warning("⚠️ reflog expire: could not read %s: %s", log_path, exc)
669 return 0, 0
670
671 cutoff_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) - expire_days * 86400
672
673 kept_lines: list[str] = []
674 expired = 0
675 kept = 0
676 for line in raw.splitlines(keepends=True):
677 stripped = line.strip()
678 if not stripped:
679 continue
680 entry = _parse_line(stripped)
681 if entry is None:
682 kept_lines.append(line) # malformed — keep it
683 kept += 1
684 continue
685 entry_ts = int(entry.timestamp.timestamp())
686 if entry_ts < cutoff_ts:
687 expired += 1
688 else:
689 kept_lines.append(line)
690 kept += 1
691
692 if dry_run:
693 return expired, kept
694
695 if not kept_lines:
696 # All entries expired — remove the file rather than leave a zero-byte stub.
697 try:
698 log_path.unlink()
699 except OSError as exc:
700 logger.warning("⚠️ reflog expire: could not remove %s: %s", log_path, exc)
701 return expired, kept
702
703 # Atomic write: tmp sibling → os.replace → target.
704 tmp_path = log_path.with_suffix(".lock")
705 try:
706 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
707 os.replace(tmp_path, log_path)
708 except OSError as exc:
709 logger.warning("⚠️ reflog expire: atomic write failed for %s: %s", log_path, exc)
710 # Clean up the tmp file if it was written; original is still intact.
711 try:
712 tmp_path.unlink(missing_ok=True)
713 except OSError:
714 pass
715 raise
716
717 return expired, kept
718
719 # ---------------------------------------------------------------------------
720 # Compaction (muse#54 item 3)
721 # ---------------------------------------------------------------------------
722
723 def categorize_operation(operation: str) -> str:
724 """Bucket an operation string by its leading ``<category>: ...`` prefix.
725
726 Every reflog writer in this codebase formats operations as
727 ``"<category>: <detail>"`` or ``"<category> (<mode>): <detail>"``
728 (e.g. ``"commit: add verse"``, ``"reset (hard): moving to sha256:..."``).
729 Operations with no ``:`` are bucketed under their own full text verbatim
730 (defensive fallback, not expected to occur in practice).
731 """
732 head = operation.split(":", 1)[0] if ":" in operation else operation
733 head = head.split("(", 1)[0]
734 return head.strip().lower()
735
736
737 def compact_reflog(
738 repo_root: pathlib.Path,
739 branch: str | None = None,
740 *,
741 min_run: int = 2,
742 dry_run: bool = False,
743 ) -> tuple[int, int]:
744 """Merge consecutive same-category entries into one summary entry.
745
746 Scans the log file in storage order (oldest first) and merges each
747 maximal run of *min_run* or more ADJACENT entries sharing the same
748 operation category (per :func:`categorize_operation`) into a single
749 synthesized entry spanning the run: ``old_id`` from the run's first
750 entry, ``new_id``/timestamp from its last, ``author`` preserved when
751 every entry in the run shares one author, else ``"various"``. Entries
752 outside any qualifying run are left untouched — the same spirit as
753 ``muse rebase --squash`` only squashing a contiguous range, not the
754 whole history. Malformed lines always break a run and are kept as-is.
755
756 Args:
757 repo_root: Root of the Muse repository.
758 branch: Branch name to compact, or ``None`` for the HEAD log.
759 min_run: Minimum consecutive same-category run length to merge
760 (default 2 — a lone entry is never "compacted").
761 dry_run: When ``True``, compute counts but write nothing.
762
763 Returns:
764 ``(removed_count, final_count)`` — ``removed_count`` is how many
765 entries were eliminated by merging
766 (``original_total - final_count``).
767
768 The write is atomic, following the same tmp-file + ``os.replace``
769 pattern as :func:`expire_reflog`.
770 """
771 log_path = (
772 _head_log_path(repo_root)
773 if branch is None
774 else _ref_log_path(repo_root, branch)
775 )
776 if not log_path.exists():
777 return 0, 0
778
779 try:
780 raw = log_path.read_text(encoding="utf-8", errors="replace")
781 except OSError as exc:
782 logger.warning("⚠️ reflog compact: could not read %s: %s", log_path, exc)
783 return 0, 0
784
785 parsed: list[tuple[str, ReflogEntry | None]] = []
786 for line in raw.splitlines(keepends=True):
787 stripped = line.strip()
788 if not stripped:
789 continue
790 parsed.append((line, _parse_line(stripped)))
791
792 original_total = len(parsed)
793
794 # Group into runs of consecutive same-category entries; malformed lines
795 # always terminate the current run and pass through untouched.
796 output_items: list[list[tuple[str, ReflogEntry]] | str] = []
797 current_run: list[tuple[str, ReflogEntry]] = []
798 current_category: str | None = None
799
800 def _flush() -> None:
801 nonlocal current_run, current_category
802 if current_run:
803 output_items.append(current_run)
804 current_run = []
805 current_category = None
806
807 for line, entry in parsed:
808 if entry is None:
809 _flush()
810 output_items.append(line)
811 continue
812 category = categorize_operation(entry.operation)
813 if current_category is not None and category == current_category:
814 current_run.append((line, entry))
815 else:
816 _flush()
817 current_run = [(line, entry)]
818 current_category = category
819 _flush()
820
821 new_lines: list[str] = []
822 removed = 0
823 for item in output_items:
824 if isinstance(item, str):
825 new_lines.append(item)
826 continue
827 run = item
828 if len(run) < min_run:
829 new_lines.extend(line for line, _entry in run)
830 continue
831 first_entry = run[0][1]
832 last_entry = run[-1][1]
833 authors = {e.author for _, e in run}
834 merged_author = next(iter(authors)) if len(authors) == 1 else "various"
835 category = categorize_operation(last_entry.operation)
836 merged_operation = f"{category} (compacted {len(run)} entries)"
837 safe_author = _sanitize_author(merged_author)
838 safe_operation = _sanitize_operation(merged_operation)
839 ts = int(last_entry.timestamp.timestamp())
840 new_lines.append(
841 f"{first_entry.old_id} {last_entry.new_id} {safe_author} {ts} +0000\t{safe_operation}\n"
842 )
843 removed += len(run) - 1
844
845 final_count = original_total - removed
846
847 if dry_run or removed == 0:
848 return removed, final_count
849
850 if not new_lines:
851 try:
852 log_path.unlink()
853 except OSError as exc:
854 logger.warning("⚠️ reflog compact: could not remove %s: %s", log_path, exc)
855 return removed, final_count
856
857 tmp_path = log_path.with_suffix(".lock")
858 try:
859 tmp_path.write_text("".join(new_lines), encoding="utf-8")
860 os.replace(tmp_path, log_path)
861 except OSError as exc:
862 logger.warning("⚠️ reflog compact: atomic write failed for %s: %s", log_path, exc)
863 try:
864 tmp_path.unlink(missing_ok=True)
865 except OSError:
866 pass
867 raise
868
869 return removed, final_count
870
871 # ---------------------------------------------------------------------------
872 # Delete
873 # ---------------------------------------------------------------------------
874
875 def delete_reflog_entry(
876 repo_root: pathlib.Path,
877 branch: str | None,
878 index: int | None,
879 ) -> tuple[int, int]:
880 """Remove one entry (by index) or all entries from a reflog file.
881
882 Args:
883 repo_root: Root of the Muse repository.
884 branch: Branch name, or ``None`` for the HEAD log.
885 index: 0-based index from newest (``@{0}`` = newest).
886 Pass ``None`` to delete **all** entries.
887
888 Returns:
889 ``(deleted_count, remaining_count)``.
890
891 Raises:
892 FileNotFoundError: Log file does not exist (only when index is not None).
893 IndexError: *index* is out of range for the log.
894
895 The write is atomic when entries remain: content is written to a sibling
896 ``.lock`` temp file and renamed into place with ``os.replace``. An empty
897 log is deleted rather than left as a zero-byte stub.
898 """
899 log_path = (
900 _head_log_path(repo_root)
901 if branch is None
902 else _ref_log_path(repo_root, branch)
903 )
904
905 if not log_path.exists():
906 if index is None:
907 return 0, 0 # --all on missing log is graceful
908 raise FileNotFoundError(log_path)
909
910 raw = log_path.read_text(encoding="utf-8", errors="replace")
911 # Preserve chronological order (oldest first); skip blank lines.
912 all_lines = [ln for ln in raw.splitlines(keepends=True) if ln.strip()]
913 total = len(all_lines)
914
915 if index is None:
916 # Delete everything.
917 try:
918 log_path.unlink()
919 except OSError as exc:
920 logger.warning("⚠️ reflog delete --all: could not remove %s: %s", log_path, exc)
921 return total, 0
922
923 # index is 0-based from newest; line position from oldest-first order.
924 if index < 0 or index >= total:
925 raise IndexError(index, total)
926
927 line_pos = total - 1 - index
928 kept_lines = all_lines[:line_pos] + all_lines[line_pos + 1:]
929
930 if not kept_lines:
931 try:
932 log_path.unlink()
933 except OSError as exc:
934 logger.warning("⚠️ reflog delete: could not remove %s: %s", log_path, exc)
935 return 1, 0
936
937 # Atomic write.
938 tmp_path = log_path.with_suffix(".lock")
939 try:
940 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
941 os.replace(tmp_path, log_path)
942 except OSError as exc:
943 logger.warning("⚠️ reflog delete: atomic write failed for %s: %s", log_path, exc)
944 try:
945 tmp_path.unlink(missing_ok=True)
946 except OSError:
947 pass
948 raise
949
950 return 1, len(kept_lines)
951
952 # ---------------------------------------------------------------------------
953 # @{N} ref resolution
954 # ---------------------------------------------------------------------------
955
956 #: Matches @{N}, @{<timespec>}, branch@{N}, or refs/heads/branch@{N}.
957 #: Group "branch": the part before @{...}, or empty/absent for HEAD.
958 #: Group "inner": whatever is inside the braces — either a plain integer
959 #: index or a relative-time spec (muse#54 item 1), disambiguated in
960 #: ``resolve_reflog_ref`` itself.
961 _AT_N_RE = re.compile(
962 r"^(?:(?:refs/heads/)?(?P<branch>[^@{}\s]+))?@\{(?P<inner>[^}]+)\}$"
963 )
964
965 #: Matches "N.unit.ago" relative-time specs, e.g. "1.hour.ago", "2.days.ago".
966 _TIME_AGO_RE = re.compile(
967 r"^(?P<num>\d+)\.(?P<unit>second|minute|hour|day|week)s?\.ago$"
968 )
969
970 _TIME_UNIT_SECONDS = {
971 "second": 1, "minute": 60, "hour": 3600, "day": 86_400, "week": 604_800,
972 }
973
974
975 def _parse_time_cutoff(inner: str) -> datetime.datetime | None:
976 """Parse a relative-time ``@{...}`` spec into an absolute UTC cutoff.
977
978 Supported forms (muse#54 item 1):
979
980 - ``N.unit.ago`` — unit is one of second/minute/hour/day/week, singular
981 or plural (e.g. ``"1.hour.ago"``, ``"2.days.ago"``).
982 - ``yesterday`` — start of today (UTC midnight), i.e. "before midnight
983 last night".
984
985 Returns ``None`` for anything that isn't a recognized time spec —
986 callers then try the plain integer-index form instead.
987 """
988 now = datetime.datetime.now(datetime.timezone.utc)
989 if inner == "yesterday":
990 return now.replace(hour=0, minute=0, second=0, microsecond=0)
991 m = _TIME_AGO_RE.match(inner)
992 if m is None:
993 return None
994 num = int(m.group("num"))
995 unit_seconds = _TIME_UNIT_SECONDS[m.group("unit")]
996 return now - datetime.timedelta(seconds=num * unit_seconds)
997
998
999 def resolve_reflog_ref(spec: str, repo_root: pathlib.Path) -> str | None:
1000 """Resolve an ``@{N}`` or ``@{<timespec>}`` ref specifier to a commit ID.
1001
1002 Accepted forms:
1003
1004 - ``@{N}`` — HEAD log, index N (0 = newest)
1005 - ``<branch>@{N}`` — named branch log, index N
1006 - ``refs/heads/<branch>@{N}`` — same, full ref form
1007 - ``@{1.hour.ago}``, ``@{2.days.ago}``, ``@{yesterday}`` — most recent
1008 entry at or before that point in time (muse#54 item 1), on HEAD or a
1009 named branch log the same way as the index form.
1010
1011 Args:
1012 spec: The ref specifier to parse.
1013 repo_root: Root of the Muse repository.
1014
1015 Returns:
1016 The ``new_id`` (SHA-256 commit ID) from the matching reflog entry,
1017 or ``None`` when *spec* does not match any recognized ``@{...}``
1018 pattern, or (for a time spec) no entry is old enough.
1019
1020 Raises:
1021 FileNotFoundError: The referenced log file does not exist.
1022 IndexError: *N* is out of range; args are ``(index, total)``.
1023 """
1024 m = _AT_N_RE.match(spec)
1025 if m is None:
1026 return None
1027
1028 branch_part: str | None = m.group("branch") or None
1029 inner: str = m.group("inner")
1030
1031 # Determine which log file to read.
1032 if branch_part is None:
1033 # @{...} — HEAD log
1034 branch: str | None = None
1035 else:
1036 # Strip refs/heads/ if present (already consumed by the regex but
1037 # the branch group holds only the plain name after our pattern).
1038 branch = branch_part
1039
1040 log_path = (
1041 _head_log_path(repo_root)
1042 if branch is None
1043 else _ref_log_path(repo_root, branch)
1044 )
1045
1046 if not log_path.exists():
1047 raise FileNotFoundError(log_path)
1048
1049 # read_reflog returns newest-first, so entries[0] is @{0}.
1050 entries = read_reflog(repo_root, branch=branch, limit=10_000)
1051 total = len(entries)
1052
1053 if inner.isdigit():
1054 index = int(inner)
1055 if index >= total:
1056 raise IndexError(index, total)
1057 return entries[index].new_id
1058
1059 cutoff = _parse_time_cutoff(inner)
1060 if cutoff is None:
1061 return None
1062
1063 for entry in entries:
1064 if entry.timestamp <= cutoff:
1065 return entry.new_id
1066 return None
File History 1 commit
sha256:f7bf283e6cd64892d61560b1d4508b52612b0d8cd2c7d02a17fcdbbd3ae75b66 feat(branch): reflog copy/rename parity with git, standardi… Sonnet 5 patch 5 hours ago