"""Reflog — record every HEAD and branch-ref movement. The reflog is an append-only per-ref journal that records every time a branch pointer moves, regardless of cause: commit, checkout, merge, reset, cherry-pick, shelf pop. It is a safety net — if a ``muse reset --hard`` goes wrong the reflog tells you exactly what ``HEAD`` was before. Storage layout:: .muse/logs/ HEAD — log for the symbolic HEAD pointer refs/ heads/ main — log for the main branch ref dev — log for the dev branch ref … Each log file is a plain-text append-only sequence of lines:: \\t This format mirrors Git's reflog so tooling that understands both can be built without translation. Fields ------ old_id 64-hex SHA-256 or ``0000…0000`` when there is no predecessor (initial commit on a branch, new branch creation). new_id 64-hex SHA-256 of the new commit. author ``Name `` or a short label for automated operations. timestamp_unix Unix seconds as a decimal integer. tz_offset UTC offset in ``+HHMM`` / ``-HHMM`` form. operation Free-form description, e.g. ``commit: add verse``, ``checkout: moving from main to dev``, ``reset: moving to ``. Security model -------------- ``author`` and ``operation`` are sanitized before being written to disk: newlines (``\\n``, ``\\r``) are stripped to prevent line injection, and tabs (``\\t``) are stripped from ``author`` to prevent corruption of the tab-delimited parse boundary. ``list_reflog_refs`` skips symlinks when enumerating branch logs — symlinks that escape the ``.muse/logs/refs/heads/`` directory cannot be followed. ``read_reflog`` enforces a ``_MAX_REFLOG_BYTES`` file-size cap before reading to prevent OOM from excessively large or maliciously crafted log files. """ import datetime import logging import os import pathlib import re import tempfile from dataclasses import dataclass from muse.core.types import MUSE_DIR, NULL_COMMIT_ID 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 logger = logging.getLogger(__name__) _LOG_DIR_NAME = "logs" #: Maximum reflog file size that will be read. Files larger than this are #: truncated with a warning rather than loaded entirely into memory. _MAX_REFLOG_BYTES: int = 10 * 1024 * 1024 # 10 MiB # --------------------------------------------------------------------------- # Types # --------------------------------------------------------------------------- @dataclass(frozen=True) class ReflogEntry: """One line of a reflog.""" old_id: str new_id: str author: str timestamp: datetime.datetime operation: str # --------------------------------------------------------------------------- # Path helpers # --------------------------------------------------------------------------- def _head_log_path(repo_root: pathlib.Path) -> pathlib.Path: return _logs_dir_path(repo_root) / "HEAD" def _ref_log_path(repo_root: pathlib.Path, branch: str) -> pathlib.Path: return _reflog_branch_path(repo_root, branch) # --------------------------------------------------------------------------- # Internal sanitization helpers # --------------------------------------------------------------------------- def _sanitize_author(s: str) -> str: """Strip characters from *s* that would corrupt reflog line structure. Strips ``\\n``, ``\\r`` (prevent line injection) and ``\\t`` (the metadata / operation tab-separator). The author field is the free-form portion of the metadata half-line, so embedded tabs would corrupt the parse. """ return s.replace("\n", "").replace("\r", "").replace("\t", "") def _sanitize_operation(s: str) -> str: """Strip newlines from *s* to prevent line injection in the operation field. The operation is the post-tab half of each reflog line. Embedded newlines would inject fake entries into the log file. """ return s.replace("\n", "").replace("\r", "") # --------------------------------------------------------------------------- # Write # --------------------------------------------------------------------------- def append_reflog( repo_root: pathlib.Path, branch: str, old_id: str | None, new_id: str, author: str, operation: str, ) -> None: """Append one entry to both the branch log and the HEAD log. Args: repo_root: Root of the Muse repository. branch: Branch whose ref is moving (e.g. ``"main"``). old_id: Previous commit SHA-256 (``None`` for initial commit). new_id: New commit SHA-256. author: ``"Name "`` or a short label. Newlines and tabs are stripped to prevent log corruption. operation: Human-readable description of the operation. Newlines are stripped to prevent line injection. """ effective_old = old_id or NULL_COMMIT_ID safe_author = _sanitize_author(author) safe_operation = _sanitize_operation(operation) now = datetime.datetime.now(tz=datetime.timezone.utc) ts = int(now.timestamp()) tz_offset = "+0000" # we always store UTC; offset is for display parity line = ( f"{effective_old} {new_id} {safe_author} {ts} {tz_offset}\t{safe_operation}\n" ) for log_path in (_ref_log_path(repo_root, branch), _head_log_path(repo_root)): log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8") as fh: fh.write(line) # --------------------------------------------------------------------------- # Read # --------------------------------------------------------------------------- def _parse_line(line: str) -> ReflogEntry | None: """Parse one reflog line; return None on malformed input. The format is:: TAB The tab character is the only guaranteed delimiter between the metadata tokens and the free-form operation string. """ line = line.rstrip("\n") parts = line.split("\t", 1) if len(parts) != 2: return None meta, operation = parts tokens = meta.split() if len(tokens) < 5: return None old_id, new_id = tokens[0], tokens[1] # author may contain spaces — everything between tokens[2] and the last # two tokens (timestamp, tz_offset) is the author. ts_str = tokens[-2] author = " ".join(tokens[2:-2]) try: ts = datetime.datetime.fromtimestamp(int(ts_str), tz=datetime.timezone.utc) except (ValueError, OSError): ts = datetime.datetime.now(tz=datetime.timezone.utc) return ReflogEntry( old_id=old_id, new_id=new_id, author=author, timestamp=ts, operation=operation, ) def read_reflog( repo_root: pathlib.Path, branch: str | None = None, limit: int = 100, ) -> list[ReflogEntry]: """Return reflog entries newest-first, up to *limit*. Args: repo_root: Root of the Muse repository. branch: Branch name, or ``None`` to read the HEAD log. limit: Maximum number of entries to return. Notes: Files larger than ``_MAX_REFLOG_BYTES`` are rejected with a warning to prevent OOM from maliciously large reflog files. The ``limit`` parameter is applied after parsing — the function stops as soon as *limit* valid entries have been collected. """ log_path = ( _head_log_path(repo_root) if branch is None else _ref_log_path(repo_root, branch) ) if not log_path.exists(): return [] try: size = log_path.stat().st_size if size > _MAX_REFLOG_BYTES: logger.warning( "⚠️ Reflog %s is %.1f MiB — exceeds cap of %d MiB; " "results may be incomplete. Consider pruning old entries.", log_path, size / (1024 * 1024), _MAX_REFLOG_BYTES // (1024 * 1024), ) lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() except OSError as exc: logger.warning("⚠️ Could not read reflog %s: %s", log_path, exc) return [] entries: list[ReflogEntry] = [] for line in reversed(lines): if not line.strip(): continue entry = _parse_line(line) if entry is not None: entries.append(entry) if len(entries) >= limit: break return entries def list_reflog_refs(repo_root: pathlib.Path) -> list[str]: """Return branch names that have a reflog, sorted. Symlinks are excluded — only regular files are returned so that a crafted symlink inside ``.muse/logs/refs/heads/`` cannot be used to enumerate arbitrary filesystem paths. """ refs_dir = _reflog_heads_dir(repo_root) if not refs_dir.exists(): return [] return sorted( p.name for p in refs_dir.iterdir() if p.is_file() and not p.is_symlink() ) # --------------------------------------------------------------------------- # Expire # --------------------------------------------------------------------------- def expire_reflog( repo_root: pathlib.Path, branch: str | None, expire_days: int, *, dry_run: bool = False, ) -> tuple[int, int]: """Remove reflog entries older than *expire_days* from one log file. Args: repo_root: Root of the Muse repository. branch: Branch name whose log to expire, or ``None`` for the HEAD log. expire_days: Entries whose timestamp is older than this many days are removed. dry_run: When ``True``, compute counts but write nothing. Returns: ``(expired_count, kept_count)`` — both counts refer to the log file for *branch* (or HEAD). The write is atomic: content is first written to a sibling temp file and then renamed into place with ``os.replace``. If ``os.replace`` raises, the original log is unchanged. An empty log after expiry is removed rather than left as a zero-byte stub. """ log_path = ( _head_log_path(repo_root) if branch is None else _ref_log_path(repo_root, branch) ) if not log_path.exists(): return 0, 0 try: raw = log_path.read_text(encoding="utf-8", errors="replace") except OSError as exc: logger.warning("⚠️ reflog expire: could not read %s: %s", log_path, exc) return 0, 0 cutoff_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp()) - expire_days * 86400 kept_lines: list[str] = [] expired = 0 kept = 0 for line in raw.splitlines(keepends=True): stripped = line.strip() if not stripped: continue entry = _parse_line(stripped) if entry is None: kept_lines.append(line) # malformed — keep it kept += 1 continue entry_ts = int(entry.timestamp.timestamp()) if entry_ts < cutoff_ts: expired += 1 else: kept_lines.append(line) kept += 1 if dry_run: return expired, kept if not kept_lines: # All entries expired — remove the file rather than leave a zero-byte stub. try: log_path.unlink() except OSError as exc: logger.warning("⚠️ reflog expire: could not remove %s: %s", log_path, exc) return expired, kept # Atomic write: tmp sibling → os.replace → target. tmp_path = log_path.with_suffix(".lock") try: tmp_path.write_text("".join(kept_lines), encoding="utf-8") os.replace(tmp_path, log_path) except OSError as exc: logger.warning("⚠️ reflog expire: atomic write failed for %s: %s", log_path, exc) # Clean up the tmp file if it was written; original is still intact. try: tmp_path.unlink(missing_ok=True) except OSError: pass raise return expired, kept # --------------------------------------------------------------------------- # Delete # --------------------------------------------------------------------------- def delete_reflog_entry( repo_root: pathlib.Path, branch: str | None, index: int | None, ) -> tuple[int, int]: """Remove one entry (by index) or all entries from a reflog file. Args: repo_root: Root of the Muse repository. branch: Branch name, or ``None`` for the HEAD log. index: 0-based index from newest (``@{0}`` = newest). Pass ``None`` to delete **all** entries. Returns: ``(deleted_count, remaining_count)``. Raises: FileNotFoundError: Log file does not exist (only when index is not None). IndexError: *index* is out of range for the log. The write is atomic when entries remain: content is written to a sibling ``.lock`` temp file and renamed into place with ``os.replace``. An empty log is deleted rather than left as a zero-byte stub. """ log_path = ( _head_log_path(repo_root) if branch is None else _ref_log_path(repo_root, branch) ) if not log_path.exists(): if index is None: return 0, 0 # --all on missing log is graceful raise FileNotFoundError(log_path) raw = log_path.read_text(encoding="utf-8", errors="replace") # Preserve chronological order (oldest first); skip blank lines. all_lines = [ln for ln in raw.splitlines(keepends=True) if ln.strip()] total = len(all_lines) if index is None: # Delete everything. try: log_path.unlink() except OSError as exc: logger.warning("⚠️ reflog delete --all: could not remove %s: %s", log_path, exc) return total, 0 # index is 0-based from newest; line position from oldest-first order. if index < 0 or index >= total: raise IndexError(index, total) line_pos = total - 1 - index kept_lines = all_lines[:line_pos] + all_lines[line_pos + 1:] if not kept_lines: try: log_path.unlink() except OSError as exc: logger.warning("⚠️ reflog delete: could not remove %s: %s", log_path, exc) return 1, 0 # Atomic write. tmp_path = log_path.with_suffix(".lock") try: tmp_path.write_text("".join(kept_lines), encoding="utf-8") os.replace(tmp_path, log_path) except OSError as exc: logger.warning("⚠️ reflog delete: atomic write failed for %s: %s", log_path, exc) try: tmp_path.unlink(missing_ok=True) except OSError: pass raise return 1, len(kept_lines) # --------------------------------------------------------------------------- # @{N} ref resolution # --------------------------------------------------------------------------- #: Matches @{N}, branch@{N}, or refs/heads/branch@{N}. #: Group "branch": the part before @{N}, or empty/absent for HEAD. _AT_N_RE = re.compile( r"^(?:(?:refs/heads/)?(?P[^@{}\s]+))?@\{(?P\d+)\}$" ) def resolve_reflog_ref(spec: str, repo_root: pathlib.Path) -> str | None: """Resolve an ``@{N}`` ref specifier to the commit ID it names. Accepted forms: - ``@{N}`` — HEAD log, index N (0 = newest) - ``@{N}`` — named branch log, index N - ``refs/heads/@{N}`` — same, full ref form Args: spec: The ref specifier to parse. repo_root: Root of the Muse repository. Returns: The ``new_id`` (SHA-256 commit ID) from the matching reflog entry, or ``None`` when *spec* does not match the ``@{N}`` pattern. Raises: FileNotFoundError: The referenced log file does not exist. IndexError: *N* is out of range; args are ``(index, total)``. """ m = _AT_N_RE.match(spec) if m is None: return None branch_part: str | None = m.group("branch") or None index: int = int(m.group("index")) # Determine which log file to read. if branch_part is None: # @{N} — HEAD log branch: str | None = None else: # Strip refs/heads/ if present (already consumed by the regex but # the branch group holds only the plain name after our pattern). branch = branch_part log_path = ( _head_log_path(repo_root) if branch is None else _ref_log_path(repo_root, branch) ) if not log_path.exists(): raise FileNotFoundError(log_path) # read_reflog returns newest-first, so entries[0] is @{0}. entries = read_reflog(repo_root, branch=branch, limit=10_000) total = len(entries) if index >= total: raise IndexError(index, total) return entries[index].new_id