gabriel / muse public
reflog.py python
497 lines 16.8 KB
Raw
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217 chore(timeline): remove unused RationalRate import in entity.py Human minor ⚠ breaking 8 days 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
60 from muse.core.paths import logs_dir as _logs_dir_path, reflog_branch_path as _reflog_branch_path, reflog_heads_dir as _reflog_heads_dir
61
62 logger = logging.getLogger(__name__)
63
64 _LOG_DIR_NAME = "logs"
65
66 #: Maximum reflog file size that will be read. Files larger than this are
67 #: truncated with a warning rather than loaded entirely into memory.
68 _MAX_REFLOG_BYTES: int = 10 * 1024 * 1024 # 10 MiB
69
70 # ---------------------------------------------------------------------------
71 # Types
72 # ---------------------------------------------------------------------------
73
74 @dataclass(frozen=True)
75 class ReflogEntry:
76 """One line of a reflog."""
77
78 old_id: str
79 new_id: str
80 author: str
81 timestamp: datetime.datetime
82 operation: str
83
84 # ---------------------------------------------------------------------------
85 # Path helpers
86 # ---------------------------------------------------------------------------
87
88 def _head_log_path(repo_root: pathlib.Path) -> pathlib.Path:
89 return _logs_dir_path(repo_root) / "HEAD"
90
91 def _ref_log_path(repo_root: pathlib.Path, branch: str) -> pathlib.Path:
92 return _reflog_branch_path(repo_root, branch)
93
94 # ---------------------------------------------------------------------------
95 # Internal sanitization helpers
96 # ---------------------------------------------------------------------------
97
98 def _sanitize_author(s: str) -> str:
99 """Strip characters from *s* that would corrupt reflog line structure.
100
101 Strips ``\\n``, ``\\r`` (prevent line injection) and ``\\t`` (the metadata
102 / operation tab-separator). The author field is the free-form portion
103 of the metadata half-line, so embedded tabs would corrupt the parse.
104 """
105 return s.replace("\n", "").replace("\r", "").replace("\t", "")
106
107 def _sanitize_operation(s: str) -> str:
108 """Strip newlines from *s* to prevent line injection in the operation field.
109
110 The operation is the post-tab half of each reflog line. Embedded
111 newlines would inject fake entries into the log file.
112 """
113 return s.replace("\n", "").replace("\r", "")
114
115 # ---------------------------------------------------------------------------
116 # Write
117 # ---------------------------------------------------------------------------
118
119 def append_reflog(
120 repo_root: pathlib.Path,
121 branch: str,
122 old_id: str | None,
123 new_id: str,
124 author: str,
125 operation: str,
126 ) -> None:
127 """Append one entry to both the branch log and the HEAD log.
128
129 Args:
130 repo_root: Root of the Muse repository.
131 branch: Branch whose ref is moving (e.g. ``"main"``).
132 old_id: Previous commit SHA-256 (``None`` for initial commit).
133 new_id: New commit SHA-256.
134 author: ``"Name <email>"`` or a short label. Newlines and tabs
135 are stripped to prevent log corruption.
136 operation: Human-readable description of the operation. Newlines
137 are stripped to prevent line injection.
138 """
139 effective_old = old_id or NULL_COMMIT_ID
140 safe_author = _sanitize_author(author)
141 safe_operation = _sanitize_operation(operation)
142 now = datetime.datetime.now(tz=datetime.timezone.utc)
143 ts = int(now.timestamp())
144 tz_offset = "+0000" # we always store UTC; offset is for display parity
145
146 line = (
147 f"{effective_old} {new_id} {safe_author} {ts} {tz_offset}\t{safe_operation}\n"
148 )
149
150 for log_path in (_ref_log_path(repo_root, branch), _head_log_path(repo_root)):
151 log_path.parent.mkdir(parents=True, exist_ok=True)
152 with log_path.open("a", encoding="utf-8") as fh:
153 fh.write(line)
154
155 # ---------------------------------------------------------------------------
156 # Read
157 # ---------------------------------------------------------------------------
158
159 def _parse_line(line: str) -> ReflogEntry | None:
160 """Parse one reflog line; return None on malformed input.
161
162 The format is::
163
164 <old_id> <new_id> <author…> <ts_unix> <tz_offset> TAB <operation>
165
166 The tab character is the only guaranteed delimiter between the metadata
167 tokens and the free-form operation string.
168 """
169 line = line.rstrip("\n")
170 parts = line.split("\t", 1)
171 if len(parts) != 2:
172 return None
173 meta, operation = parts
174 tokens = meta.split()
175 if len(tokens) < 5:
176 return None
177 old_id, new_id = tokens[0], tokens[1]
178 # author may contain spaces — everything between tokens[2] and the last
179 # two tokens (timestamp, tz_offset) is the author.
180 ts_str = tokens[-2]
181 author = " ".join(tokens[2:-2])
182 try:
183 ts = datetime.datetime.fromtimestamp(int(ts_str), tz=datetime.timezone.utc)
184 except (ValueError, OSError):
185 ts = datetime.datetime.now(tz=datetime.timezone.utc)
186 return ReflogEntry(
187 old_id=old_id,
188 new_id=new_id,
189 author=author,
190 timestamp=ts,
191 operation=operation,
192 )
193
194 def read_reflog(
195 repo_root: pathlib.Path,
196 branch: str | None = None,
197 limit: int = 100,
198 ) -> list[ReflogEntry]:
199 """Return reflog entries newest-first, up to *limit*.
200
201 Args:
202 repo_root: Root of the Muse repository.
203 branch: Branch name, or ``None`` to read the HEAD log.
204 limit: Maximum number of entries to return.
205
206 Notes:
207 Files larger than ``_MAX_REFLOG_BYTES`` are rejected with a warning
208 to prevent OOM from maliciously large reflog files. The ``limit``
209 parameter is applied after parsing — the function stops as soon as
210 *limit* valid entries have been collected.
211 """
212 log_path = (
213 _head_log_path(repo_root)
214 if branch is None
215 else _ref_log_path(repo_root, branch)
216 )
217 if not log_path.exists():
218 return []
219 try:
220 size = log_path.stat().st_size
221 if size > _MAX_REFLOG_BYTES:
222 logger.warning(
223 "⚠️ Reflog %s is %.1f MiB — exceeds cap of %d MiB; "
224 "results may be incomplete. Consider pruning old entries.",
225 log_path,
226 size / (1024 * 1024),
227 _MAX_REFLOG_BYTES // (1024 * 1024),
228 )
229 lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
230 except OSError as exc:
231 logger.warning("⚠️ Could not read reflog %s: %s", log_path, exc)
232 return []
233 entries: list[ReflogEntry] = []
234 for line in reversed(lines):
235 if not line.strip():
236 continue
237 entry = _parse_line(line)
238 if entry is not None:
239 entries.append(entry)
240 if len(entries) >= limit:
241 break
242 return entries
243
244 def list_reflog_refs(repo_root: pathlib.Path) -> list[str]:
245 """Return branch names that have a reflog, sorted.
246
247 Symlinks are excluded — only regular files are returned so that a
248 crafted symlink inside ``.muse/logs/refs/heads/`` cannot be used to
249 enumerate arbitrary filesystem paths.
250 """
251 refs_dir = _reflog_heads_dir(repo_root)
252 if not refs_dir.exists():
253 return []
254 return sorted(
255 p.name
256 for p in refs_dir.iterdir()
257 if p.is_file() and not p.is_symlink()
258 )
259
260 # ---------------------------------------------------------------------------
261 # Expire
262 # ---------------------------------------------------------------------------
263
264 def expire_reflog(
265 repo_root: pathlib.Path,
266 branch: str | None,
267 expire_days: int,
268 *,
269 dry_run: bool = False,
270 ) -> tuple[int, int]:
271 """Remove reflog entries older than *expire_days* from one log file.
272
273 Args:
274 repo_root: Root of the Muse repository.
275 branch: Branch name whose log to expire, or ``None`` for the
276 HEAD log.
277 expire_days: Entries whose timestamp is older than this many days
278 are removed.
279 dry_run: When ``True``, compute counts but write nothing.
280
281 Returns:
282 ``(expired_count, kept_count)`` — both counts refer to the log file
283 for *branch* (or HEAD).
284
285 The write is atomic: content is first written to a sibling temp file and
286 then renamed into place with ``os.replace``. If ``os.replace`` raises,
287 the original log is unchanged. An empty log after expiry is removed
288 rather than left as a zero-byte stub.
289 """
290 log_path = (
291 _head_log_path(repo_root)
292 if branch is None
293 else _ref_log_path(repo_root, branch)
294 )
295 if not log_path.exists():
296 return 0, 0
297
298 try:
299 raw = log_path.read_text(encoding="utf-8", errors="replace")
300 except OSError as exc:
301 logger.warning("⚠️ reflog expire: could not read %s: %s", log_path, exc)
302 return 0, 0
303
304 cutoff_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) - expire_days * 86400
305
306 kept_lines: list[str] = []
307 expired = 0
308 kept = 0
309 for line in raw.splitlines(keepends=True):
310 stripped = line.strip()
311 if not stripped:
312 continue
313 entry = _parse_line(stripped)
314 if entry is None:
315 kept_lines.append(line) # malformed — keep it
316 kept += 1
317 continue
318 entry_ts = int(entry.timestamp.timestamp())
319 if entry_ts < cutoff_ts:
320 expired += 1
321 else:
322 kept_lines.append(line)
323 kept += 1
324
325 if dry_run:
326 return expired, kept
327
328 if not kept_lines:
329 # All entries expired — remove the file rather than leave a zero-byte stub.
330 try:
331 log_path.unlink()
332 except OSError as exc:
333 logger.warning("⚠️ reflog expire: could not remove %s: %s", log_path, exc)
334 return expired, kept
335
336 # Atomic write: tmp sibling → os.replace → target.
337 tmp_path = log_path.with_suffix(".lock")
338 try:
339 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
340 os.replace(tmp_path, log_path)
341 except OSError as exc:
342 logger.warning("⚠️ reflog expire: atomic write failed for %s: %s", log_path, exc)
343 # Clean up the tmp file if it was written; original is still intact.
344 try:
345 tmp_path.unlink(missing_ok=True)
346 except OSError:
347 pass
348 raise
349
350 return expired, kept
351
352 # ---------------------------------------------------------------------------
353 # Delete
354 # ---------------------------------------------------------------------------
355
356 def delete_reflog_entry(
357 repo_root: pathlib.Path,
358 branch: str | None,
359 index: int | None,
360 ) -> tuple[int, int]:
361 """Remove one entry (by index) or all entries from a reflog file.
362
363 Args:
364 repo_root: Root of the Muse repository.
365 branch: Branch name, or ``None`` for the HEAD log.
366 index: 0-based index from newest (``@{0}`` = newest).
367 Pass ``None`` to delete **all** entries.
368
369 Returns:
370 ``(deleted_count, remaining_count)``.
371
372 Raises:
373 FileNotFoundError: Log file does not exist (only when index is not None).
374 IndexError: *index* is out of range for the log.
375
376 The write is atomic when entries remain: content is written to a sibling
377 ``.lock`` temp file and renamed into place with ``os.replace``. An empty
378 log is deleted rather than left as a zero-byte stub.
379 """
380 log_path = (
381 _head_log_path(repo_root)
382 if branch is None
383 else _ref_log_path(repo_root, branch)
384 )
385
386 if not log_path.exists():
387 if index is None:
388 return 0, 0 # --all on missing log is graceful
389 raise FileNotFoundError(log_path)
390
391 raw = log_path.read_text(encoding="utf-8", errors="replace")
392 # Preserve chronological order (oldest first); skip blank lines.
393 all_lines = [ln for ln in raw.splitlines(keepends=True) if ln.strip()]
394 total = len(all_lines)
395
396 if index is None:
397 # Delete everything.
398 try:
399 log_path.unlink()
400 except OSError as exc:
401 logger.warning("⚠️ reflog delete --all: could not remove %s: %s", log_path, exc)
402 return total, 0
403
404 # index is 0-based from newest; line position from oldest-first order.
405 if index < 0 or index >= total:
406 raise IndexError(index, total)
407
408 line_pos = total - 1 - index
409 kept_lines = all_lines[:line_pos] + all_lines[line_pos + 1:]
410
411 if not kept_lines:
412 try:
413 log_path.unlink()
414 except OSError as exc:
415 logger.warning("⚠️ reflog delete: could not remove %s: %s", log_path, exc)
416 return 1, 0
417
418 # Atomic write.
419 tmp_path = log_path.with_suffix(".lock")
420 try:
421 tmp_path.write_text("".join(kept_lines), encoding="utf-8")
422 os.replace(tmp_path, log_path)
423 except OSError as exc:
424 logger.warning("⚠️ reflog delete: atomic write failed for %s: %s", log_path, exc)
425 try:
426 tmp_path.unlink(missing_ok=True)
427 except OSError:
428 pass
429 raise
430
431 return 1, len(kept_lines)
432
433 # ---------------------------------------------------------------------------
434 # @{N} ref resolution
435 # ---------------------------------------------------------------------------
436
437 #: Matches @{N}, branch@{N}, or refs/heads/branch@{N}.
438 #: Group "branch": the part before @{N}, or empty/absent for HEAD.
439 _AT_N_RE = re.compile(
440 r"^(?:(?:refs/heads/)?(?P<branch>[^@{}\s]+))?@\{(?P<index>\d+)\}$"
441 )
442
443
444 def resolve_reflog_ref(spec: str, repo_root: pathlib.Path) -> str | None:
445 """Resolve an ``@{N}`` ref specifier to the commit ID it names.
446
447 Accepted forms:
448
449 - ``@{N}`` — HEAD log, index N (0 = newest)
450 - ``<branch>@{N}`` — named branch log, index N
451 - ``refs/heads/<branch>@{N}`` — same, full ref form
452
453 Args:
454 spec: The ref specifier to parse.
455 repo_root: Root of the Muse repository.
456
457 Returns:
458 The ``new_id`` (SHA-256 commit ID) from the matching reflog entry,
459 or ``None`` when *spec* does not match the ``@{N}`` pattern.
460
461 Raises:
462 FileNotFoundError: The referenced log file does not exist.
463 IndexError: *N* is out of range; args are ``(index, total)``.
464 """
465 m = _AT_N_RE.match(spec)
466 if m is None:
467 return None
468
469 branch_part: str | None = m.group("branch") or None
470 index: int = int(m.group("index"))
471
472 # Determine which log file to read.
473 if branch_part is None:
474 # @{N} — HEAD log
475 branch: str | None = None
476 else:
477 # Strip refs/heads/ if present (already consumed by the regex but
478 # the branch group holds only the plain name after our pattern).
479 branch = branch_part
480
481 log_path = (
482 _head_log_path(repo_root)
483 if branch is None
484 else _ref_log_path(repo_root, branch)
485 )
486
487 if not log_path.exists():
488 raise FileNotFoundError(log_path)
489
490 # read_reflog returns newest-first, so entries[0] is @{0}.
491 entries = read_reflog(repo_root, branch=branch, limit=10_000)
492 total = len(entries)
493
494 if index >= total:
495 raise IndexError(index, total)
496
497 return entries[index].new_id
File History 1 commit
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217 chore(timeline): remove unused RationalRate import in entity.py Human minor 8 days ago