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.
- memdebug/__init__.py +3 -0
- memdebug/__main__.py +3 -0
- memdebug/adapters/__init__.py +0 -0
- memdebug/adapters/base.py +37 -0
- memdebug/adapters/common.py +69 -0
- memdebug/adapters/folder.py +96 -0
- memdebug/adapters/markdown_git.py +731 -0
- memdebug/adapters/mem0.py +324 -0
- memdebug/adapters/openwebui.py +209 -0
- memdebug/adapters/restore.py +705 -0
- memdebug/agents.py +128 -0
- memdebug/backends.py +29 -0
- memdebug/cli.py +857 -0
- memdebug/demo.py +190 -0
- memdebug/describe.py +34 -0
- memdebug/diff.py +91 -0
- memdebug/docker_source.py +164 -0
- memdebug/errors.py +29 -0
- memdebug/hints.py +145 -0
- memdebug/ledger.py +700 -0
- memdebug/models.py +157 -0
- memdebug/monitor.py +246 -0
- memdebug/paths.py +19 -0
- memdebug/reconcile.py +83 -0
- memdebug/report.py +279 -0
- memdebug/rollback_flow.py +69 -0
- memdebug/selftest.py +482 -0
- memdebug/stores.py +276 -0
- memdebug/sync.py +177 -0
- memdebug/textsafe.py +73 -0
- memdebug/viewer/__init__.py +1 -0
- memdebug/viewer/html.py +136 -0
- memdebug/viewer/pages.py +569 -0
- memdebug/viewer/redline.py +242 -0
- memdebug/viewer/server.py +432 -0
- memdebug/viewer/style.py +218 -0
- memdebug/witness.py +199 -0
- memdebug-0.2.0.dist-info/METADATA +206 -0
- memdebug-0.2.0.dist-info/RECORD +43 -0
- memdebug-0.2.0.dist-info/WHEEL +4 -0
- memdebug-0.2.0.dist-info/entry_points.txt +2 -0
- memdebug-0.2.0.dist-info/licenses/LICENSE +202 -0
- memdebug-0.2.0.dist-info/licenses/NOTICE +4 -0
|
@@ -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
|