memdebug 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,705 @@
1
+ """Restore a markdown/git memory store to a snapshot.
2
+
3
+ This is the only code in memdebug that changes a memory store, so it is built to fail safe:
4
+
5
+ * PLANNING WRITES NOTHING. A plan is a read-only comparison of the snapshot with the repository. Applying
6
+ re-plans from scratch and refuses if anything differs from the plan the person confirmed.
7
+ * EXACT BYTES, NOT SNAPSHOT TEXT. A snapshot holds normalised text (line endings changed, undecodable bytes
8
+ replaced, long text cut). Files are restored from the original git blob whose text matches the snapshot.
9
+ Snapshot text is only a fallback, and is refused when it is cut, marked too large, or had undecodable bytes.
10
+ * NOTHING IS LOST. Anything a rollback would overwrite or delete that git does not already hold (an uncommitted
11
+ edit, an untracked file) is first saved under refs/memdebug/backups/ as a commit, so it can be recovered.
12
+ * HISTORY IS NEVER REWRITTEN. Changes that must be committed become one new commit on top of the branch. It is
13
+ built with git plumbing and a private index file, so no hook, filter, textconv or attribute from the
14
+ (untrusted) repository can run, and the branch moves only if nobody committed in the meantime.
15
+ * IT REFUSES unsafe states: a detached HEAD, staged changes, a merge or rebase in progress, git lock files,
16
+ and any path that is a link, junction, device name, or differs from another path only by case.
17
+ * IT UNDOES ITSELF. Every file written is journaled; if any step fails, files, branch and index are put back.
18
+
19
+ Files are written through a temporary file in the same folder and an atomic rename, never through a link.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import re
27
+ import secrets
28
+ import shutil
29
+ import stat
30
+ import tempfile
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime, timezone
33
+ from typing import Sequence
34
+
35
+ from ..errors import RestoreError
36
+ from ..models import Snapshot
37
+ from ..textsafe import safe_text
38
+ from .markdown_git import (
39
+ _RAW_RE,
40
+ _SHA_RE,
41
+ MAX_FILE_BYTES,
42
+ MarkdownGitAdapter,
43
+ _BlobReader,
44
+ _is_reparse_point,
45
+ _text_from_bytes,
46
+ _valid_relpath,
47
+ )
48
+
49
+ MAX_ITEMS = 5_000
50
+ MAX_TOTAL_BYTES = 200_000_000
51
+ MAX_HISTORY_PER_FILE = 200
52
+ HISTORY_COMMITS = 3000 # commits looked through, per batch of files, for the version a snapshot held
53
+ BATCH = 100 # files per git command, so command lines stay short on every system
54
+ COMMIT_NAME = "memdebug"
55
+ COMMIT_EMAIL = "memdebug@localhost"
56
+ _BRANCH_RE = re.compile(r"^refs/heads/[A-Za-z0-9][A-Za-z0-9._/\-]{0,200}\Z")
57
+ _CUT_MARKER = re.compile(r"\.\.\.\[cut: \d+ chars, sha256 [0-9a-f]{16}\]\Z")
58
+ _TOO_LARGE = "[file too large to read:"
59
+ _IN_PROGRESS = ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "REBASE_HEAD", "rebase-merge", "rebase-apply",
60
+ "sequencer", "BISECT_LOG", "index.lock", "HEAD.lock", "shallow.lock")
61
+ _REGULAR = ("100644", "100755")
62
+ _O_BINARY = getattr(os, "O_BINARY", 0)
63
+ _O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
64
+
65
+
66
+ @dataclass
67
+ class Item:
68
+ path: str
69
+ action: str # restore: change a file back; recreate: put back a deleted file; remove: delete a file added since
70
+ source: str # "git": the original bytes; "snapshot": rebuilt from the snapshot's text; "none": a removal
71
+ live_bytes: bytes | None
72
+ live_text: str | None
73
+ target_bytes: bytes | None
74
+ target_text: str | None
75
+ target_sha: str | None # the git blob the bytes came from, if any
76
+ head_mode: str | None # mode in HEAD; None when git does not track the file
77
+ head_bytes: bytes | None
78
+ commit: bool # part of the new commit
79
+ backup: bool # live content is not in git, so it is saved first
80
+ note: str = ""
81
+ head_sha: str | None = None # the committed blob, so a failed apply can put the index back
82
+
83
+
84
+ @dataclass
85
+ class Plan:
86
+ snapshot_id: str
87
+ store: str
88
+ branch: str | None = None
89
+ head: str | None = None
90
+ items: list[Item] = field(default_factory=list)
91
+ unchanged: int = 0
92
+ kept_new: list[str] = field(default_factory=list) # added since the snapshot, and left alone
93
+ skipped: list[tuple[str, str]] = field(default_factory=list)
94
+ warnings: list[str] = field(default_factory=list)
95
+ blockers: list[str] = field(default_factory=list) # reasons it cannot be applied now
96
+ plan_id: str = ""
97
+
98
+ @property
99
+ def commits(self) -> bool:
100
+ return any(i.commit for i in self.items)
101
+
102
+
103
+ @dataclass
104
+ class Outcome:
105
+ snapshot_id: str
106
+ branch: str | None
107
+ previous_head: str | None
108
+ commit: str | None
109
+ backup_ref: str | None
110
+ items: list[Item]
111
+
112
+
113
+ def _digest(data: bytes | None) -> str | None:
114
+ return None if data is None else hashlib.sha256(data).hexdigest()
115
+
116
+
117
+ def _plan_id(plan: Plan) -> str:
118
+ body = {"snapshot": plan.snapshot_id, "store": plan.store, "head": plan.head, "branch": plan.branch,
119
+ "items": [[i.path, i.action, i.source, _digest(i.target_bytes), _digest(i.live_bytes)] for i in plan.items]}
120
+ return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
121
+
122
+
123
+ def _is_link(info: os.stat_result) -> bool:
124
+ return stat.S_ISLNK(info.st_mode) or _is_reparse_point(info)
125
+
126
+
127
+ def snapshot_text_problem(text: str) -> str | None:
128
+ """Why this snapshot text must not be written back into a file, or None if it is faithful."""
129
+ if text.startswith(_TOO_LARGE):
130
+ return "the snapshot only holds a size marker for this file"
131
+ if _CUT_MARKER.search(text):
132
+ return "the snapshot holds only the start of this long file"
133
+ if "\ufffd" in text:
134
+ return "the original file had bytes that are not valid text, which the snapshot could not keep"
135
+ return None
136
+
137
+
138
+ class Restorer:
139
+ def __init__(self, adapter: MarkdownGitAdapter):
140
+ self._adapter = adapter
141
+ self._git = adapter.git
142
+ self._root = adapter.root
143
+
144
+ # -- git helpers --------------------------------------------------------------------------------------------
145
+
146
+ def _run(self, args: list[str], *, stdin: bytes | None = None, env: dict[str, str] | None = None,
147
+ limit: int = 4_000_000) -> tuple[int, bytes, str]:
148
+ with self._git.spawn(args, interactive=stdin is not None, env=env) as run:
149
+ if stdin is not None:
150
+ try:
151
+ run.inp.write(stdin)
152
+ run.inp.close()
153
+ except OSError:
154
+ pass # git exited early; its exit code and message explain why
155
+ data = run.out.read(limit + 1)
156
+ if len(data) > limit:
157
+ run.stop()
158
+ raise RestoreError("git produced unexpectedly large output")
159
+ code = run.proc.wait()
160
+ if run.timed_out:
161
+ raise RestoreError(f"git timed out after {self._git.timeout:g} seconds")
162
+ return code, data, run.stderr_text()
163
+
164
+ def _must(self, args: list[str], what: str, **kw) -> bytes:
165
+ code, data, err = self._run(args, **kw)
166
+ if code != 0:
167
+ raise RestoreError(f"git could not {what}: {safe_text(err, 200)}")
168
+ return data
169
+
170
+ # -- planning (read only) ---------------------------------------------------------------------------------
171
+
172
+ def _git_state(self, plan: Plan) -> None:
173
+ code, out, _ = self._run(["rev-parse", "--verify", "-q", "HEAD"])
174
+ head = out.decode("ascii", "replace").strip()
175
+ if code != 0 or not _SHA_RE.match(head):
176
+ plan.blockers.append("the repository has no commits yet")
177
+ return
178
+ plan.head = head
179
+ code, out, _ = self._run(["symbolic-ref", "-q", "HEAD"])
180
+ branch = out.decode("utf-8", "replace").strip()
181
+ if code != 0:
182
+ plan.blockers.append("HEAD is detached; check out a branch first")
183
+ elif not _BRANCH_RE.match(branch) or ".." in branch or branch.endswith((".lock", "/", ".")) or "//" in branch:
184
+ plan.blockers.append("the branch name is not one this tool will write to")
185
+ else:
186
+ plan.branch = branch
187
+ names = list(_IN_PROGRESS) + ([f"{plan.branch}.lock"] if plan.branch else [])
188
+ args = ["rev-parse"] + [part for name in names for part in ("--git-path", name)]
189
+ code, out, _ = self._run(args)
190
+ where = out.decode("utf-8", "replace").splitlines()
191
+ if code == 0 and len(where) == len(names):
192
+ for name, location in zip(names, where, strict=True):
193
+ if os.path.lexists(os.path.join(str(self._root), location.strip())):
194
+ plan.blockers.append(f"git is busy or mid-operation ({safe_text(name, 40)} exists); finish or abort it first")
195
+ else:
196
+ plan.blockers.append("git could not say whether it is busy")
197
+ code, _, _ = self._run(["diff-index", "--cached", "--quiet", "HEAD", "--"])
198
+ if code == 1:
199
+ plan.blockers.append("there are staged changes; commit or unstage them first")
200
+ elif code != 0:
201
+ plan.blockers.append("git could not compare the index with HEAD")
202
+
203
+ def _inspect(self, relpath: str) -> tuple[str, bytes | None]:
204
+ """Read a working-tree file without following links: ("ok", bytes), ("missing", None), ("large", None) or
205
+ ("unsafe", None)."""
206
+ current = str(self._root)
207
+ parts = relpath.split("/")
208
+ for part in parts[:-1]:
209
+ current = os.path.join(current, part)
210
+ try:
211
+ info = os.lstat(current)
212
+ except FileNotFoundError:
213
+ return "missing", None
214
+ except OSError:
215
+ return "unsafe", None
216
+ if _is_link(info) or not stat.S_ISDIR(info.st_mode):
217
+ return "unsafe", None
218
+ full = os.path.join(current, parts[-1])
219
+ try:
220
+ info = os.lstat(full)
221
+ except FileNotFoundError:
222
+ return "missing", None
223
+ except OSError:
224
+ return "unsafe", None
225
+ if _is_link(info) or not stat.S_ISREG(info.st_mode):
226
+ return "unsafe", None
227
+ if info.st_size > MAX_FILE_BYTES:
228
+ return "large", None
229
+ try:
230
+ fd = os.open(full, os.O_RDONLY | _O_BINARY | _O_NOFOLLOW | getattr(os, "O_NONBLOCK", 0))
231
+ except OSError:
232
+ return "unsafe", None
233
+ try:
234
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
235
+ return "unsafe", None
236
+ with os.fdopen(fd, "rb", closefd=False) as handle:
237
+ data = handle.read(MAX_FILE_BYTES + 1)
238
+ except OSError:
239
+ return "unsafe", None
240
+ finally:
241
+ os.close(fd)
242
+ return ("large", None) if len(data) > MAX_FILE_BYTES else ("ok", data)
243
+
244
+ def _head_entries(self, paths: list[str]) -> dict[str, tuple[str, str]]:
245
+ """Mode and object id of each path as HEAD has it (("other", "") for anything that is not a plain file)."""
246
+ found: dict[str, tuple[str, str]] = {}
247
+ for start in range(0, len(paths), BATCH):
248
+ chunk = paths[start:start + BATCH]
249
+ code, out, _ = self._run(["ls-tree", "-z", "HEAD", "--", *chunk])
250
+ if code != 0:
251
+ continue
252
+ for record in out.split(b"\0"):
253
+ match = re.match(rb"^(\d{6}) (\w+) ([0-9a-f]{40,64})\t(.+)\Z", record, re.S)
254
+ if match:
255
+ path = match.group(4).decode("utf-8", "replace")
256
+ found[path] = (match.group(1).decode(), match.group(3).decode()) if match.group(2) == b"blob" else ("other", "")
257
+ return found
258
+
259
+ def _history_blobs(self, paths: list[str], plan: Plan) -> dict[str, list[str]]:
260
+ """For each path, the ids of the versions git has held, newest first. One git command per batch of files."""
261
+ wanted = set(paths)
262
+ versions: dict[str, list[str]] = {path: [] for path in paths}
263
+ for start in range(0, len(paths), BATCH):
264
+ chunk = paths[start:start + BATCH]
265
+ try:
266
+ code, out, _ = self._run(["log", "--format=", "--raw", "--no-renames", "--no-abbrev", "--no-color", "--no-ext-diff",
267
+ "-n", str(HISTORY_COMMITS), "HEAD", "--", *chunk], limit=32_000_000)
268
+ except RestoreError:
269
+ plan.warnings.append("the history was too large to search for original versions; "
270
+ "files are rebuilt from the snapshot's text where needed")
271
+ continue
272
+ if code != 0:
273
+ continue
274
+ for line in out.decode("utf-8", "replace").splitlines():
275
+ match = _RAW_RE.match(line)
276
+ if not match:
277
+ continue
278
+ path, new_sha, status = match.group(6), match.group(4), match.group(5)
279
+ if path in wanted and status in ("A", "M") and set(new_sha) != {"0"} and len(versions[path]) < MAX_HISTORY_PER_FILE:
280
+ versions[path].append(new_sha)
281
+ return versions
282
+
283
+ @staticmethod
284
+ def _exact_source(blobs: _BlobReader, candidates: list[str], wanted: str) -> tuple[str, bytes] | None:
285
+ """The newest git version of this file whose text equals the snapshot's text, with its exact bytes."""
286
+ seen: set[str] = set()
287
+ for sha in candidates:
288
+ if sha in seen:
289
+ continue
290
+ seen.add(sha)
291
+ data = blobs.raw(sha)
292
+ if data is not None and _text_from_bytes(data) == wanted:
293
+ return sha, data
294
+ return None
295
+
296
+ def plan(self, snapshot: Snapshot, *, only: Sequence[str] = (), remove_added: bool = False) -> Plan:
297
+ info = snapshot.info
298
+ adapter = self._adapter
299
+ if info.backend != adapter.name or info.scope != {"store": adapter.store}:
300
+ raise RestoreError("that snapshot was taken from a different memory store")
301
+ plan = Plan(snapshot_id=info.id, store=adapter.store)
302
+ self._git_state(plan)
303
+ by_path = {m.id: m for m in snapshot.memories}
304
+ chosen: set[str] | None = None
305
+ if only:
306
+ chosen = set()
307
+ for name in only:
308
+ rel = _valid_relpath(name, adapter.suffixes)
309
+ if rel is None:
310
+ raise RestoreError(f"not a usable memory file name: {safe_text(name, 60)}")
311
+ chosen.add(rel)
312
+
313
+ live_ids: set[str] = set()
314
+ live_complete = False
315
+ if remove_added:
316
+ if not info.complete:
317
+ plan.warnings.append("the snapshot may be incomplete, so files added since cannot be told apart; none will be removed")
318
+ else:
319
+ live = adapter.list_memories({"store": adapter.store})
320
+ live_complete = live.complete
321
+ live_ids = {m.id for m in live.memories}
322
+ if not live.complete:
323
+ plan.warnings.append("some folders could not be read, so files added since cannot be listed; none will be removed")
324
+ if chosen is not None:
325
+ unknown = sorted(c for c in chosen if c not in by_path and c not in live_ids)
326
+ if unknown:
327
+ raise RestoreError("not in the snapshot (nor in the store): " + ", ".join(safe_text(u, 60) for u in unknown[:5]))
328
+
329
+ total = 0
330
+ differing: list[tuple[str, str, bytes | None, str | None]] = []
331
+ for path in sorted(by_path):
332
+ memory = by_path[path]
333
+ rel = _valid_relpath(path, adapter.suffixes)
334
+ if rel is None:
335
+ plan.skipped.append((safe_text(path, 60), "the file name is not safe to write"))
336
+ continue
337
+ if chosen is not None and rel not in chosen:
338
+ continue
339
+ if adapter.subdir and not rel.startswith(adapter.subdir + "/"):
340
+ plan.skipped.append((rel, "outside the folder this store covers"))
341
+ continue
342
+ if memory.scope != {"store": adapter.store}:
343
+ plan.skipped.append((rel, "belongs to another store"))
344
+ continue
345
+ status, live_bytes = self._inspect(rel)
346
+ if status == "unsafe":
347
+ plan.skipped.append((rel, "exists but is a link, a folder or not a plain file; never written"))
348
+ continue
349
+ if status == "large":
350
+ plan.skipped.append((rel, "the file in the store is too large to handle"))
351
+ continue
352
+ live_text = None if live_bytes is None else _text_from_bytes(live_bytes)
353
+ if live_text == memory.text:
354
+ plan.unchanged += 1
355
+ continue
356
+ differing.append((rel, memory.text, live_bytes, live_text))
357
+
358
+ removals: list[tuple[str, bytes]] = []
359
+ if remove_added and live_complete:
360
+ for rel in sorted(live_ids - set(by_path)):
361
+ if chosen is not None and rel not in chosen:
362
+ continue
363
+ status, live_bytes = self._inspect(rel)
364
+ if status != "ok":
365
+ plan.skipped.append((rel, "added since, but cannot be safely read, so it is left alone"))
366
+ continue
367
+ removals.append((rel, live_bytes or b""))
368
+ elif not remove_added:
369
+ live = adapter.list_memories({"store": adapter.store})
370
+ plan.kept_new = sorted(m.id for m in live.memories if m.id not in by_path and (chosen is None or m.id in chosen))
371
+
372
+ wanted_paths = [d[0] for d in differing]
373
+ heads = self._head_entries(wanted_paths + [r[0] for r in removals])
374
+ history = self._history_blobs(wanted_paths, plan) if wanted_paths else {}
375
+ with _BlobReader(self._git) as blobs:
376
+ for rel, text, live_bytes, live_text in differing:
377
+ item = self._plan_write(blobs, rel, text, live_bytes, live_text, plan, heads.get(rel), history.get(rel, []))
378
+ if item is not None:
379
+ total += len(item.target_bytes or b"") + len(item.live_bytes or b"")
380
+ plan.items.append(item)
381
+ for rel, live_bytes in removals:
382
+ item = self._plan_remove(blobs, rel, live_bytes, heads.get(rel))
383
+ if item is not None:
384
+ total += len(item.live_bytes or b"")
385
+ plan.items.append(item)
386
+
387
+ if len(plan.items) > MAX_ITEMS or total > MAX_TOTAL_BYTES:
388
+ plan.blockers.append("this rollback touches too many or too large files to do in one step; use --only")
389
+ folded: dict[str, str] = {}
390
+ for item in plan.items:
391
+ other = folded.setdefault(item.path.casefold(), item.path)
392
+ if other != item.path:
393
+ plan.blockers.append(f"{safe_text(item.path, 60)} and {safe_text(other, 60)} differ only by letter case")
394
+ plan.items.sort(key=lambda i: i.path)
395
+ plan.plan_id = _plan_id(plan)
396
+ return plan
397
+
398
+ def _plan_write(self, blobs: _BlobReader, rel: str, text: str, live_bytes: bytes | None, live_text: str | None,
399
+ plan: Plan, entry: tuple[str, str] | None, candidates: list[str]) -> Item | None:
400
+ if entry is not None and entry[0] not in _REGULAR:
401
+ plan.skipped.append((rel, "git tracks it as a link or submodule; never written"))
402
+ return None
403
+ head_mode, head_bytes, head_sha = None, None, None
404
+ if entry is not None:
405
+ head_mode, head_sha = entry[0], entry[1]
406
+ head_bytes = blobs.raw(entry[1])
407
+ if head_bytes is None:
408
+ plan.skipped.append((rel, "the committed version could not be read"))
409
+ return None
410
+ found = self._exact_source(blobs, candidates, text)
411
+ if found is not None:
412
+ sha, data = found
413
+ source, note = "git", f"original bytes from git {sha[:10]}"
414
+ else:
415
+ problem = snapshot_text_problem(text)
416
+ if problem is not None:
417
+ plan.skipped.append((rel, f"no matching version in git history, and {problem}"))
418
+ return None
419
+ sha, data, source = None, text.encode("utf-8"), "snapshot"
420
+ note = "rebuilt from the snapshot's text (no matching version in git; line endings are normalised)"
421
+ action = "restore" if live_bytes is not None else "recreate"
422
+ commit = head_bytes is None or data != head_bytes
423
+ backup = live_bytes is not None and (head_bytes is None or live_bytes != head_bytes)
424
+ if backup:
425
+ note += "; replaces an uncommitted edit (saved first)"
426
+ return Item(rel, action, source, live_bytes, live_text, data, text, sha, head_mode, head_bytes, commit, backup, note,
427
+ head_sha)
428
+
429
+ def _plan_remove(self, blobs: _BlobReader, rel: str, live_bytes: bytes | None, entry: tuple[str, str] | None) -> Item | None:
430
+ head_mode, head_bytes, head_sha = None, None, None
431
+ if entry is not None:
432
+ if entry[0] not in _REGULAR:
433
+ return None
434
+ head_mode, head_sha, head_bytes = entry[0], entry[1], blobs.raw(entry[1])
435
+ if head_bytes is None:
436
+ return None
437
+ backup = live_bytes is not None and (head_bytes is None or live_bytes != head_bytes)
438
+ note = "added since the snapshot" + ("; never committed, saved first" if backup and head_bytes is None
439
+ else "; has uncommitted edits, saved first" if backup else "")
440
+ return Item(rel, "remove", "none", live_bytes, None if live_bytes is None else _text_from_bytes(live_bytes),
441
+ None, None, None, head_mode, head_bytes, commit=head_bytes is not None, backup=backup, note=note,
442
+ head_sha=head_sha)
443
+
444
+ # -- applying -------------------------------------------------------------------------------------------------
445
+
446
+ def apply(self, snapshot: Snapshot, *, expected_plan_id: str, only: Sequence[str] = (), remove_added: bool = False,
447
+ now: datetime | None = None) -> Outcome:
448
+ """Carry out the plan the person confirmed. Re-plans first and refuses if anything has changed since."""
449
+ now = now or datetime.now(timezone.utc)
450
+ plan = self.plan(snapshot, only=only, remove_added=remove_added)
451
+ if plan.blockers:
452
+ raise RestoreError("cannot roll back right now: " + "; ".join(plan.blockers) + ". Nothing was changed.")
453
+ if plan.plan_id != expected_plan_id:
454
+ raise RestoreError("the repository changed after the plan was made. Nothing was changed; run it again.")
455
+ if not plan.items:
456
+ return Outcome(plan.snapshot_id, plan.branch, plan.head, None, None, [])
457
+ assert plan.head is not None and plan.branch is not None
458
+
459
+ for item in plan.items: # every write is checked before the first one happens
460
+ if item.action != "remove":
461
+ self._check_target(item.path)
462
+ elif item.live_bytes is not None:
463
+ self._check_target(item.path, must_exist=True)
464
+
465
+ backup_ref = self._save_backup([i for i in plan.items if i.backup], plan, now) if any(i.backup for i in plan.items) else None
466
+
467
+ entries: list[tuple[str, str | None, str]] = [] # (mode, blob id or None to delete, path)
468
+ undo_entries: list[tuple[str, str | None, str]] = []
469
+ for item in plan.items:
470
+ if not item.commit:
471
+ continue
472
+ if item.action == "remove":
473
+ entries.append(("0", None, item.path))
474
+ else:
475
+ sha = item.target_sha or self._store_blob(item.target_bytes or b"")
476
+ entries.append((item.head_mode or "100644", sha, item.path))
477
+ undo_entries.append((item.head_mode or "0", item.head_sha, item.path) if item.head_sha else ("0", None, item.path))
478
+ commit = self._build_commit(plan, entries, now) if entries else None
479
+
480
+ journal: list[tuple[str, bytes | None]] = []
481
+ created_dirs: list[str] = []
482
+ moved = {"ref": False, "index": False}
483
+ try:
484
+ if commit is not None:
485
+ self._must(["update-ref", "-m", f"memdebug: restore to {plan.snapshot_id}", plan.branch, commit, plan.head],
486
+ "move the branch", env=self._identity(now))
487
+ moved["ref"] = True
488
+ self._must(["update-index", "-z", "--index-info"], "update the index", stdin=self._index_info(plan, entries))
489
+ moved["index"] = True
490
+ for item in plan.items:
491
+ if item.action == "remove":
492
+ self._remove_file(item.path)
493
+ journal.append((item.path, item.live_bytes))
494
+ else:
495
+ created_dirs += self._write_file(item.path, item.target_bytes or b"")
496
+ journal.append((item.path, item.live_bytes))
497
+ self._verify(plan, commit)
498
+ except BaseException as failure:
499
+ problems = self._undo(plan, journal, created_dirs, moved, undo_entries, commit)
500
+ where = f" Your previous content is saved in {backup_ref}." if backup_ref else ""
501
+ if problems:
502
+ raise RestoreError(
503
+ f"the rollback failed ({safe_text(failure, 160)}) and could not be fully undone "
504
+ f"({'; '.join(problems)}). The branch was at {plan.head}.{where}") from failure
505
+ if isinstance(failure, (KeyboardInterrupt, SystemExit)):
506
+ raise
507
+ raise RestoreError(f"the rollback failed ({safe_text(failure, 200)}) and was undone; nothing was changed.") from failure
508
+ return Outcome(plan.snapshot_id, plan.branch, plan.head, commit, backup_ref, plan.items)
509
+
510
+ @staticmethod
511
+ def _index_info(plan: Plan, entries: list[tuple[str, str | None, str]]) -> bytes:
512
+ zeros = "0" * len(plan.head or "0" * 40)
513
+ return b"".join(f"{mode} {sha or zeros}\t{path}\0".encode("utf-8") for mode, sha, path in entries)
514
+
515
+ @staticmethod
516
+ def _identity(now: datetime) -> dict[str, str]:
517
+ when = f"{int(now.timestamp())} +0000"
518
+ return {"GIT_AUTHOR_NAME": COMMIT_NAME, "GIT_AUTHOR_EMAIL": COMMIT_EMAIL, "GIT_AUTHOR_DATE": when,
519
+ "GIT_COMMITTER_NAME": COMMIT_NAME, "GIT_COMMITTER_EMAIL": COMMIT_EMAIL, "GIT_COMMITTER_DATE": when}
520
+
521
+ def _store_blob(self, data: bytes) -> str:
522
+ sha = self._must(["hash-object", "-w", "--no-filters", "--stdin"], "store a file", stdin=data).decode().strip()
523
+ if not _SHA_RE.match(sha):
524
+ raise RestoreError("git returned an unexpected object id")
525
+ return sha
526
+
527
+ def _in_private_index(self, work) -> str:
528
+ """Run `work(env)` with a private index file (so the real index and working tree are untouched); returns its result."""
529
+ folder = tempfile.mkdtemp(prefix="memdebug-")
530
+ try:
531
+ return work({"GIT_INDEX_FILE": os.path.join(folder, "index")})
532
+ finally:
533
+ shutil.rmtree(folder, ignore_errors=True)
534
+
535
+ def _build_commit(self, plan: Plan, entries: list[tuple[str, str | None, str]], now: datetime) -> str:
536
+ head = plan.head
537
+ if head is None:
538
+ raise RestoreError("there is no commit to build on")
539
+ paths = [f"{item.action} {item.path}" for item in plan.items if item.commit]
540
+ message = (f"memdebug: restore {len(paths)} memory file{'' if len(paths) == 1 else 's'} to snapshot "
541
+ f"{plan.snapshot_id}\n\n" + "\n".join(paths[:100]) + ("\n..." if len(paths) > 100 else ""))
542
+
543
+ def work(env: dict[str, str]) -> str:
544
+ self._must(["read-tree", head], "read the current tree", env=env)
545
+ self._must(["update-index", "-z", "--index-info"], "prepare the new tree", env=env,
546
+ stdin=self._index_info(plan, entries))
547
+ tree = self._must(["write-tree"], "write the new tree", env=env).decode().strip()
548
+ commit = self._must(["commit-tree", tree, "-p", head, "-m", message], "create the commit",
549
+ env={**env, **self._identity(now)}).decode().strip()
550
+ if not _SHA_RE.match(tree) or not _SHA_RE.match(commit):
551
+ raise RestoreError("git returned an unexpected object id")
552
+ return commit
553
+
554
+ return self._in_private_index(work)
555
+
556
+ def _save_backup(self, items: list[Item], plan: Plan, now: datetime) -> str:
557
+ """Keep what is about to be overwritten or deleted, as a commit nothing else points to."""
558
+ ref = f"refs/memdebug/backups/{now:%Y%m%dT%H%M%SZ}-{secrets.token_hex(4)}"
559
+ entries: list[tuple[str, str | None, str]] = [("100644", self._store_blob(item.live_bytes or b""), item.path) for item in items]
560
+
561
+ def work(env: dict[str, str]) -> str:
562
+ self._must(["update-index", "-z", "--index-info"], "prepare the backup", env=env,
563
+ stdin=self._index_info(plan, entries))
564
+ tree = self._must(["write-tree"], "write the backup", env=env).decode().strip()
565
+ message = f"memdebug: content replaced by the rollback to {plan.snapshot_id}\n\n" + "\n".join(i.path for i in items[:100])
566
+ return self._must(["commit-tree", tree, "-m", message], "create the backup",
567
+ env={**env, **self._identity(now)}).decode().strip()
568
+
569
+ commit = self._in_private_index(work)
570
+ self._must(["update-ref", ref, commit, "0" * len(commit)], "keep the backup", env=self._identity(now))
571
+ return ref
572
+
573
+ # -- working tree -----------------------------------------------------------------------------------------------
574
+
575
+ def _check_target(self, rel: str, must_exist: bool = False) -> None:
576
+ current = str(self._root)
577
+ parts = rel.split("/")
578
+ for part in parts[:-1]:
579
+ current = os.path.join(current, part)
580
+ try:
581
+ info = os.lstat(current)
582
+ except FileNotFoundError:
583
+ if must_exist:
584
+ raise RestoreError(f"{safe_text(rel, 60)}: a folder disappeared") from None
585
+ break
586
+ if _is_link(info) or not stat.S_ISDIR(info.st_mode):
587
+ raise RestoreError(f"{safe_text(rel, 60)}: a folder on the way is a link or not a folder")
588
+ else:
589
+ try:
590
+ names = os.listdir(current)
591
+ except OSError as exc:
592
+ raise RestoreError(f"{safe_text(rel, 60)}: cannot read its folder ({exc.strerror})") from exc
593
+ name = parts[-1]
594
+ if name not in names and any(other.casefold() == name.casefold() for other in names):
595
+ raise RestoreError(f"{safe_text(rel, 60)}: another file in that folder differs only by letter case")
596
+ target = os.path.join(current, name)
597
+ try:
598
+ info = os.lstat(target)
599
+ except FileNotFoundError:
600
+ if must_exist:
601
+ raise RestoreError(f"{safe_text(rel, 60)}: the file disappeared") from None
602
+ return
603
+ if _is_link(info) or not stat.S_ISREG(info.st_mode):
604
+ raise RestoreError(f"{safe_text(rel, 60)}: not a plain file")
605
+
606
+ def _write_file(self, rel: str, data: bytes) -> list[str]:
607
+ """Replace or create a file through a temporary file and an atomic rename. Returns the folders it created."""
608
+ created: list[str] = []
609
+ try:
610
+ current = str(self._root)
611
+ parts = rel.split("/")
612
+ for part in parts[:-1]:
613
+ current = os.path.join(current, part)
614
+ try:
615
+ info = os.lstat(current)
616
+ except FileNotFoundError:
617
+ os.mkdir(current)
618
+ created.append(current)
619
+ info = os.lstat(current)
620
+ if _is_link(info) or not stat.S_ISDIR(info.st_mode):
621
+ raise RestoreError(f"{safe_text(rel, 60)}: a folder on the way is a link or not a folder")
622
+ target = os.path.join(current, parts[-1])
623
+ mode = None
624
+ try:
625
+ info = os.lstat(target)
626
+ if _is_link(info) or not stat.S_ISREG(info.st_mode):
627
+ raise RestoreError(f"{safe_text(rel, 60)}: not a plain file")
628
+ mode = stat.S_IMODE(info.st_mode)
629
+ except FileNotFoundError:
630
+ pass
631
+ temporary = os.path.join(current, f".memdebug-{secrets.token_hex(8)}.tmp")
632
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | _O_BINARY | _O_NOFOLLOW, 0o666)
633
+ try:
634
+ with os.fdopen(fd, "wb") as handle:
635
+ handle.write(data)
636
+ handle.flush()
637
+ os.fsync(handle.fileno())
638
+ if mode is not None and os.name == "posix":
639
+ os.chmod(temporary, mode)
640
+ os.replace(temporary, target)
641
+ except BaseException:
642
+ try:
643
+ os.unlink(temporary)
644
+ except OSError:
645
+ pass
646
+ raise
647
+ return created
648
+ except BaseException:
649
+ for folder in reversed(created):
650
+ try:
651
+ os.rmdir(folder)
652
+ except OSError:
653
+ pass
654
+ raise
655
+
656
+ def _remove_file(self, rel: str) -> None:
657
+ self._check_target(rel, must_exist=True)
658
+ os.unlink(os.path.join(str(self._root), *rel.split("/")))
659
+
660
+ def _verify(self, plan: Plan, commit: str | None) -> None:
661
+ """The store must now hold exactly what the plan said, and git must agree with the working tree."""
662
+ for item in plan.items:
663
+ status, data = self._inspect(item.path)
664
+ if item.action == "remove":
665
+ if status != "missing":
666
+ raise RestoreError(f"{safe_text(item.path, 60)} is still there after removal")
667
+ elif status != "ok" or _text_from_bytes(data or b"") != item.target_text:
668
+ raise RestoreError(f"{safe_text(item.path, 60)} does not hold the restored text after writing")
669
+ if commit is not None:
670
+ code, out, _ = self._run(["rev-parse", "--verify", "-q", "HEAD"])
671
+ if code != 0 or out.decode().strip() != commit:
672
+ raise RestoreError("the branch does not point at the new commit")
673
+ code, _, _ = self._run(["diff-index", "--cached", "--quiet", "HEAD", "--"])
674
+ if code != 0:
675
+ raise RestoreError("the index does not match the new commit")
676
+
677
+ def _undo(self, plan: Plan, journal, created_dirs, moved, undo_entries, commit) -> list[str]:
678
+ """Put everything back, newest step first. Returns what could not be undone."""
679
+ problems: list[str] = []
680
+ for path, old in reversed(journal):
681
+ try:
682
+ if old is None:
683
+ full = os.path.join(str(self._root), *path.split("/"))
684
+ if os.path.lexists(full):
685
+ os.unlink(full)
686
+ else:
687
+ self._write_file(path, old)
688
+ except Exception as exc:
689
+ problems.append(f"{safe_text(path, 60)}: {safe_text(exc, 80)}")
690
+ for folder in reversed(created_dirs):
691
+ try:
692
+ os.rmdir(folder)
693
+ except OSError:
694
+ pass
695
+ if moved["index"]:
696
+ try:
697
+ self._must(["update-index", "-z", "--index-info"], "restore the index", stdin=self._index_info(plan, undo_entries))
698
+ except Exception as exc:
699
+ problems.append(f"index: {safe_text(exc, 80)}")
700
+ if moved["ref"] and commit is not None and plan.branch and plan.head:
701
+ try:
702
+ self._must(["update-ref", "-m", "memdebug: undo failed rollback", plan.branch, plan.head, commit], "restore the branch")
703
+ except Exception as exc:
704
+ problems.append(f"branch: {safe_text(exc, 80)}")
705
+ return problems