reflog.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 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 | stash 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 | from __future__ import annotations |
| 51 | |
| 52 | import datetime |
| 53 | import logging |
| 54 | import pathlib |
| 55 | from dataclasses import dataclass |
| 56 | |
| 57 | logger = logging.getLogger(__name__) |
| 58 | |
| 59 | _NULL_ID = "0" * 64 |
| 60 | _LOG_DIR_NAME = "logs" |
| 61 | |
| 62 | #: Maximum reflog file size that will be read. Files larger than this are |
| 63 | #: truncated with a warning rather than loaded entirely into memory. |
| 64 | _MAX_REFLOG_BYTES: int = 10 * 1024 * 1024 # 10 MiB |
| 65 | |
| 66 | |
| 67 | # --------------------------------------------------------------------------- |
| 68 | # Types |
| 69 | # --------------------------------------------------------------------------- |
| 70 | |
| 71 | |
| 72 | @dataclass(frozen=True) |
| 73 | class ReflogEntry: |
| 74 | """One line of a reflog.""" |
| 75 | |
| 76 | old_id: str |
| 77 | new_id: str |
| 78 | author: str |
| 79 | timestamp: datetime.datetime |
| 80 | operation: str |
| 81 | |
| 82 | |
| 83 | # --------------------------------------------------------------------------- |
| 84 | # Path helpers |
| 85 | # --------------------------------------------------------------------------- |
| 86 | |
| 87 | |
| 88 | def _logs_dir(repo_root: pathlib.Path) -> pathlib.Path: |
| 89 | return repo_root / ".muse" / _LOG_DIR_NAME |
| 90 | |
| 91 | |
| 92 | def _head_log_path(repo_root: pathlib.Path) -> pathlib.Path: |
| 93 | return _logs_dir(repo_root) / "HEAD" |
| 94 | |
| 95 | |
| 96 | def _ref_log_path(repo_root: pathlib.Path, branch: str) -> pathlib.Path: |
| 97 | return _logs_dir(repo_root) / "refs" / "heads" / branch |
| 98 | |
| 99 | |
| 100 | # --------------------------------------------------------------------------- |
| 101 | # Internal sanitization helpers |
| 102 | # --------------------------------------------------------------------------- |
| 103 | |
| 104 | |
| 105 | def _sanitize_author(s: str) -> str: |
| 106 | """Strip characters from *s* that would corrupt reflog line structure. |
| 107 | |
| 108 | Strips ``\\n``, ``\\r`` (prevent line injection) and ``\\t`` (the metadata |
| 109 | / operation tab-separator). The author field is the free-form portion |
| 110 | of the metadata half-line, so embedded tabs would corrupt the parse. |
| 111 | """ |
| 112 | return s.replace("\n", "").replace("\r", "").replace("\t", "") |
| 113 | |
| 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 | # --------------------------------------------------------------------------- |
| 125 | # Write |
| 126 | # --------------------------------------------------------------------------- |
| 127 | |
| 128 | |
| 129 | def append_reflog( |
| 130 | repo_root: pathlib.Path, |
| 131 | branch: str, |
| 132 | old_id: str | None, |
| 133 | new_id: str, |
| 134 | author: str, |
| 135 | operation: str, |
| 136 | ) -> None: |
| 137 | """Append one entry to both the branch log and the HEAD log. |
| 138 | |
| 139 | Args: |
| 140 | repo_root: Root of the Muse repository. |
| 141 | branch: Branch whose ref is moving (e.g. ``"main"``). |
| 142 | old_id: Previous commit SHA-256 (``None`` for initial commit). |
| 143 | new_id: New commit SHA-256. |
| 144 | author: ``"Name <email>"`` or a short label. Newlines and tabs |
| 145 | are stripped to prevent log corruption. |
| 146 | operation: Human-readable description of the operation. Newlines |
| 147 | are stripped to prevent line injection. |
| 148 | """ |
| 149 | effective_old = old_id or _NULL_ID |
| 150 | safe_author = _sanitize_author(author) |
| 151 | safe_operation = _sanitize_operation(operation) |
| 152 | now = datetime.datetime.now(tz=datetime.timezone.utc) |
| 153 | ts = int(now.timestamp()) |
| 154 | tz_offset = "+0000" # we always store UTC; offset is for display parity |
| 155 | |
| 156 | line = ( |
| 157 | f"{effective_old} {new_id} {safe_author} {ts} {tz_offset}\t{safe_operation}\n" |
| 158 | ) |
| 159 | |
| 160 | for log_path in (_ref_log_path(repo_root, branch), _head_log_path(repo_root)): |
| 161 | log_path.parent.mkdir(parents=True, exist_ok=True) |
| 162 | with log_path.open("a", encoding="utf-8") as fh: |
| 163 | fh.write(line) |
| 164 | |
| 165 | |
| 166 | # --------------------------------------------------------------------------- |
| 167 | # Read |
| 168 | # --------------------------------------------------------------------------- |
| 169 | |
| 170 | |
| 171 | def _parse_line(line: str) -> ReflogEntry | None: |
| 172 | """Parse one reflog line; return None on malformed input. |
| 173 | |
| 174 | The format is:: |
| 175 | |
| 176 | <old_id> <new_id> <author…> <ts_unix> <tz_offset> TAB <operation> |
| 177 | |
| 178 | The tab character is the only guaranteed delimiter between the metadata |
| 179 | tokens and the free-form operation string. |
| 180 | """ |
| 181 | line = line.rstrip("\n") |
| 182 | parts = line.split("\t", 1) |
| 183 | if len(parts) != 2: |
| 184 | return None |
| 185 | meta, operation = parts |
| 186 | tokens = meta.split() |
| 187 | if len(tokens) < 5: |
| 188 | return None |
| 189 | old_id, new_id = tokens[0], tokens[1] |
| 190 | # author may contain spaces — everything between tokens[2] and the last |
| 191 | # two tokens (timestamp, tz_offset) is the author. |
| 192 | ts_str = tokens[-2] |
| 193 | author = " ".join(tokens[2:-2]) |
| 194 | try: |
| 195 | ts = datetime.datetime.fromtimestamp(int(ts_str), tz=datetime.timezone.utc) |
| 196 | except (ValueError, OSError): |
| 197 | ts = datetime.datetime.now(tz=datetime.timezone.utc) |
| 198 | return ReflogEntry( |
| 199 | old_id=old_id, |
| 200 | new_id=new_id, |
| 201 | author=author, |
| 202 | timestamp=ts, |
| 203 | operation=operation, |
| 204 | ) |
| 205 | |
| 206 | |
| 207 | def read_reflog( |
| 208 | repo_root: pathlib.Path, |
| 209 | branch: str | None = None, |
| 210 | limit: int = 100, |
| 211 | ) -> list[ReflogEntry]: |
| 212 | """Return reflog entries newest-first, up to *limit*. |
| 213 | |
| 214 | Args: |
| 215 | repo_root: Root of the Muse repository. |
| 216 | branch: Branch name, or ``None`` to read the HEAD log. |
| 217 | limit: Maximum number of entries to return. |
| 218 | |
| 219 | Notes: |
| 220 | Files larger than ``_MAX_REFLOG_BYTES`` are rejected with a warning |
| 221 | to prevent OOM from maliciously large reflog files. The ``limit`` |
| 222 | parameter is applied after parsing — the function stops as soon as |
| 223 | *limit* valid entries have been collected. |
| 224 | """ |
| 225 | log_path = ( |
| 226 | _head_log_path(repo_root) |
| 227 | if branch is None |
| 228 | else _ref_log_path(repo_root, branch) |
| 229 | ) |
| 230 | if not log_path.exists(): |
| 231 | return [] |
| 232 | try: |
| 233 | size = log_path.stat().st_size |
| 234 | if size > _MAX_REFLOG_BYTES: |
| 235 | logger.warning( |
| 236 | "⚠️ Reflog %s is %.1f MiB — exceeds cap of %d MiB; " |
| 237 | "results may be incomplete. Consider pruning old entries.", |
| 238 | log_path, |
| 239 | size / (1024 * 1024), |
| 240 | _MAX_REFLOG_BYTES // (1024 * 1024), |
| 241 | ) |
| 242 | lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() |
| 243 | except OSError as exc: |
| 244 | logger.warning("⚠️ Could not read reflog %s: %s", log_path, exc) |
| 245 | return [] |
| 246 | entries: list[ReflogEntry] = [] |
| 247 | for line in reversed(lines): |
| 248 | if not line.strip(): |
| 249 | continue |
| 250 | entry = _parse_line(line) |
| 251 | if entry is not None: |
| 252 | entries.append(entry) |
| 253 | if len(entries) >= limit: |
| 254 | break |
| 255 | return entries |
| 256 | |
| 257 | |
| 258 | def list_reflog_refs(repo_root: pathlib.Path) -> list[str]: |
| 259 | """Return branch names that have a reflog, sorted. |
| 260 | |
| 261 | Symlinks are excluded — only regular files are returned so that a |
| 262 | crafted symlink inside ``.muse/logs/refs/heads/`` cannot be used to |
| 263 | enumerate arbitrary filesystem paths. |
| 264 | """ |
| 265 | refs_dir = _logs_dir(repo_root) / "refs" / "heads" |
| 266 | if not refs_dir.exists(): |
| 267 | return [] |
| 268 | return sorted( |
| 269 | p.name |
| 270 | for p in refs_dir.iterdir() |
| 271 | if p.is_file() and not p.is_symlink() |
| 272 | ) |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
32 days ago