reflog.py
python
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