reflog.py python
272 lines 9.1 KB
Raw
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