gitundo 0.1.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.
- gitundo/__init__.py +31 -0
- gitundo/__main__.py +8 -0
- gitundo/cli.py +583 -0
- gitundo/core.py +949 -0
- gitundo/py.typed +0 -0
- gitundo-0.1.0.dist-info/METADATA +360 -0
- gitundo-0.1.0.dist-info/RECORD +11 -0
- gitundo-0.1.0.dist-info/WHEEL +5 -0
- gitundo-0.1.0.dist-info/entry_points.txt +2 -0
- gitundo-0.1.0.dist-info/licenses/LICENSE +21 -0
- gitundo-0.1.0.dist-info/top_level.txt +1 -0
gitundo/core.py
ADDED
|
@@ -0,0 +1,949 @@
|
|
|
1
|
+
"""Core engine for gitundo.
|
|
2
|
+
|
|
3
|
+
Everything here is a plain function of a repository on disk and is deliberately
|
|
4
|
+
UI-free so it can be reused by the CLI, the shell guard, and the test-suite.
|
|
5
|
+
|
|
6
|
+
Mental model
|
|
7
|
+
------------
|
|
8
|
+
* A **checkpoint** is an ordinary git commit that stores a complete snapshot of
|
|
9
|
+
your working state: every tracked file as it is on disk right now, plus every
|
|
10
|
+
untracked (non-ignored) file. It is stored on a hidden ref,
|
|
11
|
+
``refs/gitundo/checkpoints``, so git itself does the deduplication, and it
|
|
12
|
+
never touches your branches, index, tags or history.
|
|
13
|
+
* Checkpoints are useful *because* git normally only protects committed work.
|
|
14
|
+
If you blast away uncommitted changes with ``reset --hard`` / ``clean -fdx``
|
|
15
|
+
/ a bad rebase, gitundo can bring them back.
|
|
16
|
+
* ``restore`` writes checkpoint files back into the working tree only. Committed
|
|
17
|
+
history is never rewritten or deleted, and local edits are never silently
|
|
18
|
+
overwritten (they are parked as ``<file>.gitundo-keep`` unless ``--hard``).
|
|
19
|
+
|
|
20
|
+
Every checkpoint commit is authored by ``gitundo`` (timestamp ``@0``) so it is
|
|
21
|
+
instantly recognisable and never impersonates you. Its message carries
|
|
22
|
+
structured ``key: value`` metadata lines::
|
|
23
|
+
|
|
24
|
+
snapshot-of: <oid> # HEAD at capture time
|
|
25
|
+
gitundo-tag: <name> # optional human tag
|
|
26
|
+
created-by: gitundo
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
from . import CHECKPOINT_REF, SNAPSHOT_AUTHOR, SNAPSHOT_EMAIL
|
|
41
|
+
|
|
42
|
+
GIT = shutil.which("git") or "git"
|
|
43
|
+
|
|
44
|
+
# Well-known SHA-1 of git's empty tree object (always usable in diffs).
|
|
45
|
+
EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
46
|
+
|
|
47
|
+
_MAX_BLOB_COMPARE = 64 * 1024 * 1024 # don't byte-compare files above this size
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
# exceptions
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GitUndoError(Exception):
|
|
56
|
+
"""Base error; str(e) is user-presentable."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NotARepository(GitUndoError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class NoCheckpoints(GitUndoError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NothingToSnapshot(GitUndoError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
# process plumbing
|
|
73
|
+
# --------------------------------------------------------------------------- #
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run(
|
|
77
|
+
args: list[str],
|
|
78
|
+
repo: Path,
|
|
79
|
+
*,
|
|
80
|
+
check: bool = True,
|
|
81
|
+
text: bool = True,
|
|
82
|
+
input_bytes: Optional[bytes] = None,
|
|
83
|
+
env_extra: Optional[dict[str, str]] = None,
|
|
84
|
+
) -> subprocess.CompletedProcess:
|
|
85
|
+
"""Run git inside *repo* with the environment scrubbed of anything that
|
|
86
|
+
could redirect git into a *different* repository (GIT_DIR & friends)."""
|
|
87
|
+
env = os.environ.copy()
|
|
88
|
+
for key in (
|
|
89
|
+
"GIT_DIR",
|
|
90
|
+
"GIT_WORK_TREE",
|
|
91
|
+
"GIT_INDEX_FILE",
|
|
92
|
+
"GIT_OBJECT_DIRECTORY",
|
|
93
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
94
|
+
"GIT_COMMON_DIR",
|
|
95
|
+
"GIT_NAMESPACE",
|
|
96
|
+
"GIT_PREFIX",
|
|
97
|
+
"GIT_CEILING_DIRECTORIES",
|
|
98
|
+
):
|
|
99
|
+
env.pop(key, None)
|
|
100
|
+
if env_extra:
|
|
101
|
+
env.update(env_extra)
|
|
102
|
+
# Providing bytes on stdin requires binary pipes regardless of `text`.
|
|
103
|
+
if input_bytes is not None:
|
|
104
|
+
text = False
|
|
105
|
+
return subprocess.run(
|
|
106
|
+
[GIT, *args],
|
|
107
|
+
cwd=str(repo),
|
|
108
|
+
env=env,
|
|
109
|
+
text=text,
|
|
110
|
+
input=input_bytes,
|
|
111
|
+
capture_output=True,
|
|
112
|
+
check=check,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _out(args: list[str], repo: Path, *, check: bool = True, env_extra=None) -> str:
|
|
117
|
+
return _run(args, repo=repo, check=check, env_extra=env_extra).stdout
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _bytes(args: list[str], repo: Path, *, check: bool = True, input_bytes=None) -> bytes:
|
|
121
|
+
return _run(args, repo=repo, check=check, input_bytes=input_bytes, text=False).stdout
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _zsplit(raw: str) -> list[str]:
|
|
125
|
+
return [p for p in raw.split("\x00") if p]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
# discovery
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_repo(path: Path | str = ".") -> Path:
|
|
134
|
+
"""Absolute path of the repository root containing *path* (or raise)."""
|
|
135
|
+
p = Path(path or os.getcwd()).resolve()
|
|
136
|
+
try:
|
|
137
|
+
proc = _run(["rev-parse", "--show-toplevel"], repo=p, check=False)
|
|
138
|
+
except OSError: # path does not exist
|
|
139
|
+
raise NotARepository(
|
|
140
|
+
f"'{p}' does not exist\n(run `gitundo` inside a git repository)"
|
|
141
|
+
) from None
|
|
142
|
+
if proc.returncode != 0:
|
|
143
|
+
raise NotARepository(
|
|
144
|
+
f"'{p}' is not inside a git repository\n"
|
|
145
|
+
"(run `git init` first, or point gitundo at a repo)"
|
|
146
|
+
)
|
|
147
|
+
return Path(proc.stdout.strip())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def is_repository(path: Path | str = ".") -> bool:
|
|
151
|
+
try:
|
|
152
|
+
resolve_repo(path)
|
|
153
|
+
return True
|
|
154
|
+
except GitUndoError:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def head_oid(repo: Path) -> str:
|
|
159
|
+
return _out(["rev-parse", "--verify", "-q", "HEAD"], repo, check=False).strip()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def empty_tree_oid(repo: Path) -> str:
|
|
163
|
+
"""An empty tree object that is guaranteed to exist in this repository."""
|
|
164
|
+
if _run(["cat-file", "-e", EMPTY_TREE], repo, check=False).returncode == 0:
|
|
165
|
+
return EMPTY_TREE
|
|
166
|
+
made = _out(["mktree"], repo, check=False).strip()
|
|
167
|
+
return made or EMPTY_TREE
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
# checkpoint model
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class Checkpoint:
|
|
177
|
+
oid: str
|
|
178
|
+
subject: str
|
|
179
|
+
body: str
|
|
180
|
+
author_name: str
|
|
181
|
+
author_email: str
|
|
182
|
+
commit_time: int
|
|
183
|
+
snapshot_of: str = ""
|
|
184
|
+
tag: str = ""
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def when(self) -> str:
|
|
188
|
+
return datetime.fromtimestamp(self.commit_time, tz=timezone.utc).strftime(
|
|
189
|
+
"%Y-%m-%d %H:%M:%S UTC"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def short(self) -> str:
|
|
193
|
+
return self.oid[:7]
|
|
194
|
+
|
|
195
|
+
def describe(self) -> str:
|
|
196
|
+
if self.tag:
|
|
197
|
+
return f"{self.tag} ({self.short()})"
|
|
198
|
+
return self.short()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_meta(body: str) -> tuple[str, str]:
|
|
202
|
+
tag, snap = "", ""
|
|
203
|
+
for line in body.splitlines():
|
|
204
|
+
if line.startswith("gitundo-tag:"):
|
|
205
|
+
tag = line.split(":", 1)[1].strip()
|
|
206
|
+
elif line.startswith("snapshot-of:"):
|
|
207
|
+
snap = line.split(":", 1)[1].strip()
|
|
208
|
+
return tag, snap
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def read_checkpoints(repo: Path) -> list[Checkpoint]:
|
|
212
|
+
"""All checkpoints reachable from the checkpoint ref, oldest → newest.
|
|
213
|
+
|
|
214
|
+
The ref always points at the newest checkpoint and checkpoints form a
|
|
215
|
+
linear chain (each one's parent is the previous checkpoint), so ``git log
|
|
216
|
+
--reverse`` gives the authoritative chronological order -- which matters
|
|
217
|
+
because several snapshots taken within the same second share a timestamp.
|
|
218
|
+
"""
|
|
219
|
+
proc = _run(
|
|
220
|
+
["log", "--reverse", "--format=%H%x1f%an%x1f%ae%x1f%ct%x1f%B%x1e", CHECKPOINT_REF],
|
|
221
|
+
repo=repo,
|
|
222
|
+
check=False,
|
|
223
|
+
)
|
|
224
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
225
|
+
return []
|
|
226
|
+
out: list[Checkpoint] = []
|
|
227
|
+
for record in proc.stdout.split("\x1e"):
|
|
228
|
+
record = record.strip("\n")
|
|
229
|
+
if not record.strip():
|
|
230
|
+
continue
|
|
231
|
+
fields = record.split("\x1f")
|
|
232
|
+
if len(fields) < 5:
|
|
233
|
+
continue
|
|
234
|
+
oid, author, email, ct, body = fields[:5]
|
|
235
|
+
body = body.strip()
|
|
236
|
+
tag, snap = _parse_meta(body)
|
|
237
|
+
out.append(
|
|
238
|
+
Checkpoint(
|
|
239
|
+
oid=oid,
|
|
240
|
+
subject=body.splitlines()[0].strip() if body else "(no message)",
|
|
241
|
+
body=body,
|
|
242
|
+
author_name=author,
|
|
243
|
+
author_email=email,
|
|
244
|
+
commit_time=int(ct or 0),
|
|
245
|
+
snapshot_of=snap,
|
|
246
|
+
tag=tag,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
_by_oid = _tags_by_oid(repo)
|
|
250
|
+
for cp in out:
|
|
251
|
+
cp.tag = cp.tag or _by_oid.get(cp.oid, "")
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _to_int(s: str) -> Optional[int]:
|
|
256
|
+
try:
|
|
257
|
+
return int(s)
|
|
258
|
+
except ValueError:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def find_checkpoint(
|
|
263
|
+
repo: Path, selector: Optional[str], cps: Optional[list[Checkpoint]] = None
|
|
264
|
+
) -> Optional[Checkpoint]:
|
|
265
|
+
"""Resolve a selector: ``latest`` (default), an int ``N`` = "N snapshots
|
|
266
|
+
ago" (0 = latest), a tag name, or an object-id prefix.
|
|
267
|
+
|
|
268
|
+
Numeric selectors only mean "N snapshots ago" when N is within range; an
|
|
269
|
+
out-of-range integer (e.g. an all-digit object-id prefix) falls through to
|
|
270
|
+
tag / id-prefix matching.
|
|
271
|
+
"""
|
|
272
|
+
cps = read_checkpoints(repo) if cps is None else cps
|
|
273
|
+
if not cps:
|
|
274
|
+
return None
|
|
275
|
+
sel = (selector or "latest").strip().lower()
|
|
276
|
+
if sel in ("latest", "last", "head", "@", "0"):
|
|
277
|
+
return cps[-1]
|
|
278
|
+
n = _to_int(sel)
|
|
279
|
+
if n is not None and 0 < n <= len(cps):
|
|
280
|
+
return cps[-(n + 1)]
|
|
281
|
+
for c in reversed(cps):
|
|
282
|
+
if c.tag and c.tag.lower() == sel:
|
|
283
|
+
return c
|
|
284
|
+
for c in reversed(cps):
|
|
285
|
+
if c.oid.startswith(sel) or c.short().startswith(sel):
|
|
286
|
+
return c
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _need(
|
|
291
|
+
repo: Path, selector: Optional[str], cps: Optional[list[Checkpoint]] = None
|
|
292
|
+
) -> Checkpoint:
|
|
293
|
+
cp = find_checkpoint(repo, selector, cps)
|
|
294
|
+
if cp is None:
|
|
295
|
+
raise NoCheckpoints(
|
|
296
|
+
f"no checkpoint matches '{selector}'"
|
|
297
|
+
if selector
|
|
298
|
+
else "no checkpoints yet — run `gitundo snap` (or `gitundo help`)"
|
|
299
|
+
)
|
|
300
|
+
return cp
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# --------------------------------------------------------------------------- #
|
|
304
|
+
# snapshot
|
|
305
|
+
# --------------------------------------------------------------------------- #
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _state_lists(repo: Path) -> tuple[list[str], list[str], list[str]]:
|
|
309
|
+
staged = _zsplit(_out(["diff", "--cached", "--name-only", "-z"], repo, check=False))
|
|
310
|
+
unstaged = _zsplit(_out(["diff", "--name-only", "-z"], repo, check=False))
|
|
311
|
+
untracked = _zsplit(
|
|
312
|
+
_out(["ls-files", "--others", "--exclude-standard", "-z"], repo, check=False)
|
|
313
|
+
)
|
|
314
|
+
return staged, unstaged, untracked
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _build_tree(
|
|
318
|
+
repo: Path,
|
|
319
|
+
staged: list[str] | None = None,
|
|
320
|
+
unstaged: list[str] | None = None,
|
|
321
|
+
untracked: list[str] | None = None,
|
|
322
|
+
) -> str:
|
|
323
|
+
"""Tree object representing *everything on disk right now* (tracked files as
|
|
324
|
+
they exist, staged or not, plus untracked-but-not-ignored files), built on a
|
|
325
|
+
throwaway index that never touches the real one."""
|
|
326
|
+
if staged is None or unstaged is None or untracked is None:
|
|
327
|
+
staged, unstaged, untracked = _state_lists(repo)
|
|
328
|
+
touched = sorted(set(staged) | set(unstaged) | set(untracked))
|
|
329
|
+
tmp = repo / f".gitundo-tmp-index-{os.getpid()}"
|
|
330
|
+
env = {"GIT_INDEX_FILE": str(tmp)}
|
|
331
|
+
try:
|
|
332
|
+
head = head_oid(repo)
|
|
333
|
+
if head:
|
|
334
|
+
_out(["read-tree", head], repo, env_extra=env)
|
|
335
|
+
else:
|
|
336
|
+
_out(["read-tree", empty_tree_oid(repo)], repo, env_extra=env)
|
|
337
|
+
if touched:
|
|
338
|
+
# present files are added (content read from disk), deleted files
|
|
339
|
+
# are removed -- exactly the state of the working directory.
|
|
340
|
+
payload = b"".join(p.encode("utf-8", "surrogateescape") + b"\0" for p in touched)
|
|
341
|
+
_run(
|
|
342
|
+
["update-index", "-z", "--add", "--remove", "--stdin"],
|
|
343
|
+
repo,
|
|
344
|
+
env_extra=env,
|
|
345
|
+
input_bytes=payload,
|
|
346
|
+
check=False,
|
|
347
|
+
)
|
|
348
|
+
return _out(["write-tree"], repo, env_extra=env).strip()
|
|
349
|
+
finally:
|
|
350
|
+
try:
|
|
351
|
+
tmp.unlink()
|
|
352
|
+
except OSError:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _message(message: Optional[str], repo: Path) -> str:
|
|
357
|
+
head = head_oid(repo)
|
|
358
|
+
lines = [(message or "checkpoint").replace("\n", " ").strip() or "checkpoint"]
|
|
359
|
+
lines.append(f"snapshot-of: {head}" if head else "snapshot-of: (unborn)")
|
|
360
|
+
lines.append(f"created-by: {SNAPSHOT_AUTHOR}")
|
|
361
|
+
return "\n".join(lines)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _snapshot_env() -> dict[str, str]:
|
|
365
|
+
"""Checkpoint commits are authored by *gitundo*, never the user, so they are
|
|
366
|
+
instantly recognisable in `git log` and can never be mistaken for (or
|
|
367
|
+
signed as) the user's own work. Timestamps stay real for useful history."""
|
|
368
|
+
return {
|
|
369
|
+
"GIT_AUTHOR_NAME": SNAPSHOT_AUTHOR,
|
|
370
|
+
"GIT_AUTHOR_EMAIL": SNAPSHOT_EMAIL,
|
|
371
|
+
"GIT_COMMITTER_NAME": SNAPSHOT_AUTHOR,
|
|
372
|
+
"GIT_COMMITTER_EMAIL": SNAPSHOT_EMAIL,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def snapshot(
|
|
377
|
+
path: Path | str = ".",
|
|
378
|
+
message: Optional[str] = None,
|
|
379
|
+
tag: Optional[str] = None,
|
|
380
|
+
*,
|
|
381
|
+
force: bool = False,
|
|
382
|
+
) -> dict:
|
|
383
|
+
"""Store the current working state as a checkpoint on the hidden ref.
|
|
384
|
+
|
|
385
|
+
Returns ``{"oid", "tag", "empty", "created", "staged", "unstaged",
|
|
386
|
+
"untracked", "total"}``. ``empty=True`` and ``created=False`` when there was
|
|
387
|
+
nothing new to capture (unless --force).
|
|
388
|
+
"""
|
|
389
|
+
repo = resolve_repo(path)
|
|
390
|
+
staged, unstaged, untracked = _state_lists(repo)
|
|
391
|
+
cps = read_checkpoints(repo)
|
|
392
|
+
last = cps[-1] if cps else None
|
|
393
|
+
|
|
394
|
+
counts = {
|
|
395
|
+
"staged": len(staged),
|
|
396
|
+
"unstaged": len(unstaged),
|
|
397
|
+
"untracked": len(untracked),
|
|
398
|
+
"total": len(set(staged) | set(unstaged) | set(untracked)),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
tree = _build_tree(repo, staged, unstaged, untracked)
|
|
402
|
+
if not force and last is not None:
|
|
403
|
+
last_tree = _out(["rev-parse", f"{last.oid}^{{tree}}"], repo, check=False).strip()
|
|
404
|
+
if tree == last_tree:
|
|
405
|
+
if tag and last is not None:
|
|
406
|
+
try:
|
|
407
|
+
_tag_ref_commit(repo, tag, last.oid)
|
|
408
|
+
except GitUndoError:
|
|
409
|
+
pass # duplicate tag on an empty snapshot: not an error
|
|
410
|
+
return {"oid": last.oid, "tag": tag or "", "empty": True, "created": False, **counts}
|
|
411
|
+
if not force and last is None and tree == empty_tree_oid(repo):
|
|
412
|
+
raise NothingToSnapshot("nothing to snapshot (empty repository)")
|
|
413
|
+
|
|
414
|
+
parent = last.oid if last else None
|
|
415
|
+
cmd = ["commit-tree", tree, "-m", _message(message, repo)]
|
|
416
|
+
if parent:
|
|
417
|
+
cmd += ["-p", parent]
|
|
418
|
+
oid = _out(cmd, repo, env_extra=_snapshot_env()).strip()
|
|
419
|
+
if not oid:
|
|
420
|
+
raise GitUndoError("git failed to create the checkpoint object")
|
|
421
|
+
_out(["update-ref", "-m", f"gitundo: snapshot {oid[:7]}", CHECKPOINT_REF, oid], repo)
|
|
422
|
+
if tag:
|
|
423
|
+
_tag_ref_commit(repo, tag, oid)
|
|
424
|
+
return {"oid": oid, "tag": tag or "", "empty": False, "created": True, **counts}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --------------------------------------------------------------------------- #
|
|
428
|
+
# listing
|
|
429
|
+
# --------------------------------------------------------------------------- #
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def list_checkpoints(
|
|
433
|
+
path: Path | str = ".", *, limit: Optional[int] = None, tags_only: bool = False
|
|
434
|
+
) -> list[Checkpoint]:
|
|
435
|
+
repo = resolve_repo(path)
|
|
436
|
+
cps = read_checkpoints(repo)
|
|
437
|
+
if tags_only:
|
|
438
|
+
cps = [c for c in cps if c.tag]
|
|
439
|
+
cps.reverse()
|
|
440
|
+
if limit and limit > 0:
|
|
441
|
+
cps = cps[:limit]
|
|
442
|
+
return cps
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def storage_stats(path: Path | str = ".") -> tuple[int, int, str]:
|
|
446
|
+
"""(checkpoint_count, object_bytes, human_bytes) for this repo."""
|
|
447
|
+
repo = resolve_repo(path)
|
|
448
|
+
cps = read_checkpoints(repo)
|
|
449
|
+
obj = 0
|
|
450
|
+
proc = _run(["count-objects", "-v"], repo, check=False)
|
|
451
|
+
if proc.returncode == 0:
|
|
452
|
+
m = re.search(r"size: (\d+)", proc.stdout)
|
|
453
|
+
if m:
|
|
454
|
+
obj = int(m.group(1)) * 1024
|
|
455
|
+
return len(cps), obj, _human(obj)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _human(n: float) -> str:
|
|
459
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
460
|
+
if n < 1024 or unit == "TiB":
|
|
461
|
+
return f"{n:.0f} {unit}"
|
|
462
|
+
n /= 1024
|
|
463
|
+
return f"{n:.0f} TiB"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# --------------------------------------------------------------------------- #
|
|
467
|
+
# tags
|
|
468
|
+
# --------------------------------------------------------------------------- #
|
|
469
|
+
# Tags are lightweight refs under ``refs/gitundo/tags/<name>`` pointing at a
|
|
470
|
+
# checkpoint commit. Unlike rewriting commit messages (which would corrupt the
|
|
471
|
+
# checkpoint chain), a ref can tag *any* checkpoint -- old or new -- with zero
|
|
472
|
+
# history surgery, and `git tag` output is never polluted.
|
|
473
|
+
|
|
474
|
+
TAG_REF_PREFIX = "refs/gitundo/tags/"
|
|
475
|
+
|
|
476
|
+
_TAG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _tag_ref_commit(repo: Path, name: str, oid: str) -> None:
|
|
480
|
+
"""Point (or move) a tag ref at a checkpoint, validating the name."""
|
|
481
|
+
if not name or not _TAG_RE.match(name) or name.endswith("."):
|
|
482
|
+
raise GitUndoError(
|
|
483
|
+
"tag must be 1-64 characters: letters, digits and '.', '_', '-' "
|
|
484
|
+
"(start with a letter/digit, must not end with '.')"
|
|
485
|
+
)
|
|
486
|
+
ref = TAG_REF_PREFIX + name
|
|
487
|
+
_out(["update-ref", "-m", f"gitundo: tag {name}", ref, oid], repo)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def add_tag(
|
|
491
|
+
path: Path | str = ".", name: Optional[str] = None, selector: Optional[str] = None
|
|
492
|
+
) -> str:
|
|
493
|
+
"""Give a checkpoint a human-readable name."""
|
|
494
|
+
repo = resolve_repo(path)
|
|
495
|
+
cps = read_checkpoints(repo)
|
|
496
|
+
if not cps:
|
|
497
|
+
raise NoCheckpoints("no checkpoints yet — run `gitundo snap` first")
|
|
498
|
+
if not name or not _TAG_RE.match(name) or name.endswith("."):
|
|
499
|
+
raise GitUndoError(
|
|
500
|
+
"tag must be 1-64 characters: letters, digits and '.', '_', '-' "
|
|
501
|
+
"(start with a letter/digit, must not end with '.')"
|
|
502
|
+
)
|
|
503
|
+
if (
|
|
504
|
+
_run(["show-ref", "--verify", "-q", TAG_REF_PREFIX + name], repo, check=False).returncode
|
|
505
|
+
== 0
|
|
506
|
+
):
|
|
507
|
+
raise GitUndoError(f"tag '{name}' already exists")
|
|
508
|
+
cp = _need(repo, selector, cps)
|
|
509
|
+
_tag_ref_commit(repo, name, cp.oid)
|
|
510
|
+
return name
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def remove_tag(path: Path | str = ".", name: Optional[str] = None) -> None:
|
|
514
|
+
"""Remove a tag. Accepts the tag name or a checkpoint selector."""
|
|
515
|
+
repo = resolve_repo(path)
|
|
516
|
+
if (
|
|
517
|
+
name
|
|
518
|
+
and not _run(
|
|
519
|
+
["show-ref", "--verify", "-q", TAG_REF_PREFIX + name], repo, check=False
|
|
520
|
+
).returncode
|
|
521
|
+
):
|
|
522
|
+
_out(["update-ref", "-d", "-m", f"gitundo: untag {name}", TAG_REF_PREFIX + name], repo)
|
|
523
|
+
return
|
|
524
|
+
# selector form: resolve checkpoint, remove any of its tags
|
|
525
|
+
cp = _need(repo, name or "latest", read_checkpoints(repo))
|
|
526
|
+
removed = False
|
|
527
|
+
for tag, oid in _tag_map(repo).items():
|
|
528
|
+
if oid == cp.oid:
|
|
529
|
+
_out(["update-ref", "-d", "-m", f"gitundo: untag {tag}", TAG_REF_PREFIX + tag], repo)
|
|
530
|
+
removed = True
|
|
531
|
+
if not removed:
|
|
532
|
+
raise GitUndoError(f"no tag found on checkpoint '{name or 'latest'}'")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _tag_map(repo: Path) -> dict[str, str]:
|
|
536
|
+
"""tag name -> checkpoint oid."""
|
|
537
|
+
raw = _out(
|
|
538
|
+
["for-each-ref", "--format=%(refname)%00%(objectname)", TAG_REF_PREFIX], repo, check=False
|
|
539
|
+
)
|
|
540
|
+
out: dict[str, str] = {}
|
|
541
|
+
for line in raw.splitlines():
|
|
542
|
+
if not line:
|
|
543
|
+
continue
|
|
544
|
+
ref, _, oid = line.partition("\x00")
|
|
545
|
+
tag = ref[len(TAG_REF_PREFIX) :] if ref.startswith(TAG_REF_PREFIX) else ref
|
|
546
|
+
if oid:
|
|
547
|
+
out[tag] = oid
|
|
548
|
+
return out
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _tags_by_oid(repo: Path) -> dict[str, str]:
|
|
552
|
+
"""checkpoint oid -> tag name (first tag wins for display)."""
|
|
553
|
+
by: dict[str, str] = {}
|
|
554
|
+
for tag, oid in _tag_map(repo).items():
|
|
555
|
+
by.setdefault(oid, tag)
|
|
556
|
+
return by
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# --------------------------------------------------------------------------- #
|
|
560
|
+
# diff
|
|
561
|
+
# --------------------------------------------------------------------------- #
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _base_for(repo: Path, cp: Checkpoint) -> str:
|
|
565
|
+
base = cp.snapshot_of
|
|
566
|
+
if not base or base == "(unborn)":
|
|
567
|
+
return empty_tree_oid(repo)
|
|
568
|
+
return base
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def diff_saved(path: Path | str = ".", selector: Optional[str] = None) -> str:
|
|
572
|
+
"""What a checkpoint captured: its tree vs the HEAD that was checked out at
|
|
573
|
+
capture time (empty when the snapshot was taken on a clean tree)."""
|
|
574
|
+
repo = resolve_repo(path)
|
|
575
|
+
cp = _need(repo, selector, read_checkpoints(repo))
|
|
576
|
+
base = _base_for(repo, cp)
|
|
577
|
+
stat = _out(["diff", "--stat", base, cp.oid], repo, check=False).rstrip()
|
|
578
|
+
patch = _out(["diff", base, cp.oid], repo, check=False).rstrip()
|
|
579
|
+
if stat and patch:
|
|
580
|
+
return stat + "\n\n" + patch + "\n"
|
|
581
|
+
return (stat + "\n") if stat else (patch + "\n" if patch else "")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def diff_between(path: Path | str = ".", a: Optional[str] = "1", b: Optional[str] = "0") -> str:
|
|
585
|
+
repo = resolve_repo(path)
|
|
586
|
+
cps = read_checkpoints(repo)
|
|
587
|
+
ca, cb = _need(repo, a, cps), _need(repo, b, cps)
|
|
588
|
+
return _out(["diff", ca.oid, cb.oid], repo, check=False)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def diff_workdir(path: Path | str = ".", selector: Optional[str] = None) -> str:
|
|
592
|
+
"""Checkpoint vs. the current working tree (what `restore` would change)."""
|
|
593
|
+
repo = resolve_repo(path)
|
|
594
|
+
cp = _need(repo, selector, read_checkpoints(repo))
|
|
595
|
+
head = head_oid(repo) or _base_for(repo, cp)
|
|
596
|
+
return _out(["diff", cp.oid, head], repo, check=False)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# --------------------------------------------------------------------------- #
|
|
600
|
+
# restore
|
|
601
|
+
# --------------------------------------------------------------------------- #
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def restore(
|
|
605
|
+
path: Path | str = ".",
|
|
606
|
+
selector: Optional[str] = None,
|
|
607
|
+
*,
|
|
608
|
+
index: bool = False,
|
|
609
|
+
hard: bool = False,
|
|
610
|
+
delete_extraneous: bool = False,
|
|
611
|
+
) -> dict[str, int]:
|
|
612
|
+
"""Restore the working tree to a checkpoint.
|
|
613
|
+
|
|
614
|
+
Safety guarantees (documented and enforced):
|
|
615
|
+
* committed history / branches / tags / HEAD are never modified;
|
|
616
|
+
* files with local edits are parked as ``<path>.gitundo-keep`` and reported
|
|
617
|
+
(unless ``--hard``, which overwrites them);
|
|
618
|
+
* files that exist only in commits newer than the checkpoint are never
|
|
619
|
+
deleted (they are safe in git already);
|
|
620
|
+
* untracked files are never deleted unless ``--delete-extraneous``.
|
|
621
|
+
|
|
622
|
+
Returns counts: ``{"restored", "kept", "removed"}``.
|
|
623
|
+
"""
|
|
624
|
+
repo = resolve_repo(path)
|
|
625
|
+
cp = _need(repo, selector, read_checkpoints(repo))
|
|
626
|
+
return _restore(repo, cp, index=index, hard=hard, delete_extraneous=delete_extraneous)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _tree_map(repo: Path, treeish: str) -> dict[str, tuple[str, str]]:
|
|
630
|
+
"""path -> (mode, oid) for every entry under *treeish*."""
|
|
631
|
+
raw = _out(["ls-tree", "-r", "-z", treeish], repo, check=False)
|
|
632
|
+
out: dict[str, tuple[str, str]] = {}
|
|
633
|
+
for rec in raw.split("\x00"):
|
|
634
|
+
if not rec:
|
|
635
|
+
continue
|
|
636
|
+
meta, _, path = rec.partition("\t")
|
|
637
|
+
if not path:
|
|
638
|
+
continue
|
|
639
|
+
parts = meta.split(" ")
|
|
640
|
+
if len(parts) >= 3:
|
|
641
|
+
out[path] = (parts[0], parts[2])
|
|
642
|
+
return out
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _disk_oid(repo: Path, rel: str) -> Optional[str]:
|
|
646
|
+
"""Object id of a path's current on-disk content (None if missing/dir)."""
|
|
647
|
+
p = repo / rel
|
|
648
|
+
try:
|
|
649
|
+
if p.is_symlink():
|
|
650
|
+
target = os.readlink(p).encode("utf-8")
|
|
651
|
+
return _hash_bytes(repo, target)
|
|
652
|
+
if p.is_file():
|
|
653
|
+
return _out(["hash-object", "--", rel], repo, check=False).strip() or None
|
|
654
|
+
except OSError:
|
|
655
|
+
return None
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _hash_bytes(repo: Path, data: bytes) -> Optional[str]:
|
|
660
|
+
proc = _run(
|
|
661
|
+
["hash-object", "-t", "blob", "--stdin"], repo, text=False, input_bytes=data, check=False
|
|
662
|
+
)
|
|
663
|
+
return proc.stdout.decode().strip() or None
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _restore(
|
|
667
|
+
repo: Path, cp: Checkpoint, *, index: bool, hard: bool, delete_extraneous: bool
|
|
668
|
+
) -> dict[str, int]:
|
|
669
|
+
stats = {"restored": 0, "kept": 0, "removed": 0}
|
|
670
|
+
cp_map = _tree_map(repo, cp.oid)
|
|
671
|
+
base = cp.snapshot_of
|
|
672
|
+
base_map: dict[str, tuple[str, str]] = {}
|
|
673
|
+
if base and base != "(unborn)":
|
|
674
|
+
try:
|
|
675
|
+
base_map = _tree_map(repo, base)
|
|
676
|
+
except GitUndoError:
|
|
677
|
+
base_map = {}
|
|
678
|
+
|
|
679
|
+
# 1. write/refresh every file the checkpoint contains -------------------
|
|
680
|
+
for rel, (mode, oid) in sorted(cp_map.items()):
|
|
681
|
+
if mode.startswith("16"): # submodule gitlink — requires a checkout
|
|
682
|
+
continue
|
|
683
|
+
dest = repo / rel
|
|
684
|
+
local = _disk_oid(repo, rel)
|
|
685
|
+
if local == oid:
|
|
686
|
+
continue # already exact
|
|
687
|
+
base_oid = base_map.get(rel, (None, None))[1]
|
|
688
|
+
unchanged_since_capture = base_oid is not None and local == base_oid
|
|
689
|
+
if local is not None and not unchanged_since_capture and not hard:
|
|
690
|
+
_keep_aside(repo, rel)
|
|
691
|
+
stats["kept"] += 1
|
|
692
|
+
try:
|
|
693
|
+
if dest.is_dir() and not dest.is_symlink():
|
|
694
|
+
shutil.rmtree(dest)
|
|
695
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
696
|
+
_materialize(repo, oid, mode, dest)
|
|
697
|
+
stats["restored"] += 1
|
|
698
|
+
except OSError as exc: # pragma: no cover (fs-dependent)
|
|
699
|
+
raise GitUndoError(f"could not restore '{rel}': {exc}") from exc
|
|
700
|
+
|
|
701
|
+
# 2. optional index reset -----------------------------------------------
|
|
702
|
+
if index:
|
|
703
|
+
_out(["read-tree", cp.oid], repo)
|
|
704
|
+
|
|
705
|
+
# 3. optional cleanup of untracked debris -------------------------------
|
|
706
|
+
if delete_extraneous:
|
|
707
|
+
untracked = set(
|
|
708
|
+
_zsplit(_out(["ls-files", "--others", "--exclude-standard", "-z"], repo, check=False))
|
|
709
|
+
)
|
|
710
|
+
for rel in sorted(untracked - set(cp_map)):
|
|
711
|
+
p = repo / rel
|
|
712
|
+
if p.is_file() or p.is_symlink():
|
|
713
|
+
try:
|
|
714
|
+
p.unlink()
|
|
715
|
+
stats["removed"] += 1
|
|
716
|
+
except OSError:
|
|
717
|
+
pass
|
|
718
|
+
return stats
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _materialize(repo: Path, oid: str, mode: str, dest: Path) -> None:
|
|
722
|
+
if mode.startswith("12"):
|
|
723
|
+
target = _bytes(["cat-file", "blob", oid], repo).decode("utf-8", "replace")
|
|
724
|
+
os.symlink(target, dest)
|
|
725
|
+
else:
|
|
726
|
+
dest.write_bytes(_bytes(["cat-file", "blob", oid], repo))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _keep_aside(repo: Path, rel: str) -> None:
|
|
730
|
+
src = repo / rel
|
|
731
|
+
if not src.exists():
|
|
732
|
+
return
|
|
733
|
+
n = 0
|
|
734
|
+
while True:
|
|
735
|
+
name = f"{rel}.gitundo-keep" if n == 0 else f"{rel}.gitundo-keep.{n}"
|
|
736
|
+
target = repo / name
|
|
737
|
+
if not target.exists():
|
|
738
|
+
src.replace(target)
|
|
739
|
+
return
|
|
740
|
+
n += 1
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# --------------------------------------------------------------------------- #
|
|
744
|
+
# prune
|
|
745
|
+
# --------------------------------------------------------------------------- #
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def prune(path: Path | str = ".", keep: int = 100, *, protected_tags: bool = True) -> int:
|
|
749
|
+
"""Drop old checkpoints, keeping the newest *keep* plus tagged ones. The
|
|
750
|
+
survivor chain is rewritten linearly; dropped commits become unreachable
|
|
751
|
+
(reclaimed by a later ``git gc``). Returns how many were removed."""
|
|
752
|
+
repo = resolve_repo(path)
|
|
753
|
+
cps = read_checkpoints(repo)
|
|
754
|
+
if not cps:
|
|
755
|
+
raise NoCheckpoints("no checkpoints to prune")
|
|
756
|
+
by_time = sorted(cps, key=lambda c: c.commit_time)
|
|
757
|
+
tagged_oids = {oid for tag, oid in _tag_map(repo).items()} if protected_tags else set()
|
|
758
|
+
protected: set[str] = set()
|
|
759
|
+
if protected_tags:
|
|
760
|
+
protected |= tagged_oids
|
|
761
|
+
if keep > 0:
|
|
762
|
+
protected |= {c.oid for c in by_time[-keep:]}
|
|
763
|
+
doomed = [c for c in by_time if c.oid not in protected]
|
|
764
|
+
if not doomed:
|
|
765
|
+
return 0
|
|
766
|
+
survivors = [c for c in by_time if c.oid in protected]
|
|
767
|
+
if not survivors:
|
|
768
|
+
raise GitUndoError("refusing to prune every checkpoint")
|
|
769
|
+
new_oid: Optional[str] = None
|
|
770
|
+
renamed: dict[str, str] = {}
|
|
771
|
+
for c in survivors:
|
|
772
|
+
tree = _out(["rev-parse", f"{c.oid}^{{tree}}"], repo).strip()
|
|
773
|
+
cmd = ["commit-tree", tree, "-m", c.body or c.subject]
|
|
774
|
+
if new_oid:
|
|
775
|
+
cmd += ["-p", new_oid]
|
|
776
|
+
renamed[c.oid] = _out(cmd, repo, env_extra=_snapshot_env()).strip()
|
|
777
|
+
new_oid = renamed[c.oid]
|
|
778
|
+
_out(
|
|
779
|
+
[
|
|
780
|
+
"update-ref",
|
|
781
|
+
"-m",
|
|
782
|
+
f"gitundo: prune {len(doomed)} checkpoint(s)",
|
|
783
|
+
CHECKPOINT_REF,
|
|
784
|
+
new_oid,
|
|
785
|
+
by_time[-1].oid,
|
|
786
|
+
],
|
|
787
|
+
repo,
|
|
788
|
+
)
|
|
789
|
+
# surviving checkpoints were rewritten (new oids): re-point any tags
|
|
790
|
+
for tag, oid in _tag_map(repo).items():
|
|
791
|
+
if oid in renamed:
|
|
792
|
+
_out(
|
|
793
|
+
[
|
|
794
|
+
"update-ref",
|
|
795
|
+
"-m",
|
|
796
|
+
f"gitundo: retag {tag} after prune",
|
|
797
|
+
TAG_REF_PREFIX + tag,
|
|
798
|
+
renamed[oid],
|
|
799
|
+
oid,
|
|
800
|
+
],
|
|
801
|
+
repo,
|
|
802
|
+
)
|
|
803
|
+
return len(doomed)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
# --------------------------------------------------------------------------- #
|
|
807
|
+
# config
|
|
808
|
+
# --------------------------------------------------------------------------- #
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def set_enabled(path: Path | str = ".", enabled: bool = True) -> None:
|
|
812
|
+
repo = resolve_repo(path)
|
|
813
|
+
if enabled:
|
|
814
|
+
_run(["config", "gitundo.enabled", "true"], repo)
|
|
815
|
+
else:
|
|
816
|
+
_run(["config", "--unset-all", "gitundo.enabled"], repo, check=False)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def is_enabled(path: Path | str = ".") -> bool:
|
|
820
|
+
try:
|
|
821
|
+
repo = resolve_repo(path)
|
|
822
|
+
except GitUndoError:
|
|
823
|
+
return False
|
|
824
|
+
val = _out(["config", "--get", "gitundo.enabled"], repo, check=False).strip().lower()
|
|
825
|
+
return val in ("true", "1", "yes", "on")
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# --------------------------------------------------------------------------- #
|
|
829
|
+
# destructive-command detection (used by the guard)
|
|
830
|
+
# --------------------------------------------------------------------------- #
|
|
831
|
+
|
|
832
|
+
DESTRUCTIVE_PATTERNS: tuple[tuple[str, str], ...] = (
|
|
833
|
+
# -- general shell file-destroyers (checked before git, so that `rm -rf`
|
|
834
|
+
# is never mislabelled as a git command) ------------------------------
|
|
835
|
+
(r"(?:^|&&|\||;)\s*\brm\s+(?:-[a-zA-Z]*[rf][a-zA-Z]*)", "rm -r/-f"),
|
|
836
|
+
(r"(?:^|&&|\||;)\s*\brmdir\s+", "rmdir"),
|
|
837
|
+
(r"(?:^|&&|\||;)\s*\bshred\s+", "shred"),
|
|
838
|
+
(r"(?:^|&&|\||;)\s*\b(?:mv|cp)\s+", "mv / cp"),
|
|
839
|
+
(r"(?:^|&&|\||;)\s*\b(?:truncate|dd)\s+", "truncate / dd"),
|
|
840
|
+
(r"(?:^|&&|\||;)\s*(?:sed|perl|python|python3|awk|ruby)\b[^|;&]*\s+-i\b", "in-place text edit"),
|
|
841
|
+
# -- git commands that destroy uncommitted work -------------------------
|
|
842
|
+
# (history-only operations such as merge/rebase/revert/cherry-pick are
|
|
843
|
+
# deliberately NOT guarded: git's reflog already protects them and they
|
|
844
|
+
# never delete uncommitted changes, so auto-snapshots would only add noise)
|
|
845
|
+
(r"(?:^|&&|\||;)\s*git\s+reset\s+(?:-[a-zA-Z]*\s+)*--hard\b", "git reset --hard"),
|
|
846
|
+
(r"(?:^|&&|\||;)\s*git\s+clean\s+(?:-[a-zA-Z]*[dfx][a-zA-Z]*)", "git clean -dfx"),
|
|
847
|
+
(r"(?:^|&&|\||;)\s*git\s+checkout\s+(?:-[a-zA-Z]*f|--force)\b", "git checkout --force"),
|
|
848
|
+
(r"(?:^|&&|\||;)\s*git\s+checkout\s+--(?=\s|$)", "git checkout -- (discard)"),
|
|
849
|
+
(r"(?:^|&&|\||;)\s*git\s+checkout\s+\.\s*$", "git checkout . (discard)"),
|
|
850
|
+
(r"(?:^|&&|\||;)\s*git\s+checkout\s+[^\s]+\s+--(?=\s|$)", "git checkout <ref> -- <path>"),
|
|
851
|
+
(r"(?:^|&&|\||;)\s*git\s+restore\b", "git restore"),
|
|
852
|
+
(r"(?:^|&&|\||;)\s*git\s+rm\b", "git rm"),
|
|
853
|
+
(r"(?:^|&&|\||;)\s*git\s+branch\s+(?:-[a-zA-Z]*D\b)", "git branch -D"),
|
|
854
|
+
(r"(?:^|&&|\||;)\s*git\s+update-ref\b", "git update-ref"),
|
|
855
|
+
(r"(?:^|&&|\||;)\s*git\s+replace\b", "git replace"),
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def describe_danger(command: str) -> Optional[str]:
|
|
860
|
+
"""Human label when *command* clearly destroys state, else None.
|
|
861
|
+
|
|
862
|
+
Conservative on purpose: only explicit destructive flags trip it, so normal
|
|
863
|
+
git usage is never interrupted.
|
|
864
|
+
"""
|
|
865
|
+
cmd = command.strip()
|
|
866
|
+
if not cmd or cmd.startswith("#") or len(cmd) > 4096:
|
|
867
|
+
return None
|
|
868
|
+
for pattern, label in DESTRUCTIVE_PATTERNS:
|
|
869
|
+
if re.search(pattern, cmd, re.IGNORECASE):
|
|
870
|
+
return label
|
|
871
|
+
return None
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# --------------------------------------------------------------------------- #
|
|
875
|
+
# shell auto-guard
|
|
876
|
+
# --------------------------------------------------------------------------- #
|
|
877
|
+
|
|
878
|
+
GUARD_MARKER = "# >>> gitundo auto-guard (managed by `gitundo autowrap`) <<<"
|
|
879
|
+
_RC_FILES = {"bash": ".bashrc", "zsh": ".zshrc"}
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def autowrap_block(shell: str = "bash") -> str:
|
|
883
|
+
"""Shell source block defining a real ``git()`` function.
|
|
884
|
+
|
|
885
|
+
A function (not an alias) works in scripts too. It runs a cheap string
|
|
886
|
+
check first: only commands that *look* destructive are handed to gitundo's
|
|
887
|
+
detector (which snapshots when appropriate); everything else runs through
|
|
888
|
+
to the real git immediately, so ordinary commands pay no measurable cost.
|
|
889
|
+
"""
|
|
890
|
+
del shell # identical block for bash and zsh today
|
|
891
|
+
return "".join(
|
|
892
|
+
[
|
|
893
|
+
GUARD_MARKER + "\n",
|
|
894
|
+
"# gitundo auto-guard: snapshot before destructive git commands.\n",
|
|
895
|
+
"# Disable any time with: export GITUNDO_DISABLE=1\n",
|
|
896
|
+
"git() {\n",
|
|
897
|
+
' if [ -n "${GITUNDO_DISABLE:-}" ]; then\n',
|
|
898
|
+
' command git "$@"; return $?\n',
|
|
899
|
+
" fi\n",
|
|
900
|
+
# cheap pre-filter: suspicious flags go to the real detector
|
|
901
|
+
' case " $*" in\n',
|
|
902
|
+
' *" --hard"*|*"clean"*|*"--force"*|*" -f"*|*" restore"*|*"branch"*|\\\n',
|
|
903
|
+
' *" rm"*|*"update-ref"*|*"replace"*|*"rebase"*|*"revert"*|*"cherry-pick"*) : ;;\n',
|
|
904
|
+
' *) command git "$@"; return $? ;;\n',
|
|
905
|
+
" esac\n",
|
|
906
|
+
' command gitundo _guard_git "$@"\n',
|
|
907
|
+
"}\n",
|
|
908
|
+
GUARD_MARKER + "\n",
|
|
909
|
+
]
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def is_autowrapped(shell: str = "bash") -> bool:
|
|
914
|
+
rc = Path.home() / _RC_FILES.get(shell, ".bashrc")
|
|
915
|
+
return rc.exists() and GUARD_MARKER in rc.read_text(errors="ignore")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def install_autowrap(shell: str = "bash") -> Path:
|
|
919
|
+
name = _RC_FILES.get(shell)
|
|
920
|
+
if not name:
|
|
921
|
+
raise GitUndoError(f"unsupported shell '{shell}' (supported: bash, zsh)")
|
|
922
|
+
rc = Path.home() / name
|
|
923
|
+
if is_autowrapped(shell):
|
|
924
|
+
raise GitUndoError(f"auto-guard is already installed in ~/{name} (uninstall first)")
|
|
925
|
+
rc.parent.mkdir(parents=True, exist_ok=True)
|
|
926
|
+
text = rc.read_text(errors="ignore") if rc.exists() else ""
|
|
927
|
+
with rc.open("a") as fh:
|
|
928
|
+
if text and not text.endswith("\n"):
|
|
929
|
+
fh.write("\n")
|
|
930
|
+
fh.write(autowrap_block(shell))
|
|
931
|
+
return rc
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def uninstall_autowrap(shell: str = "bash") -> Path:
|
|
935
|
+
name = _RC_FILES.get(shell)
|
|
936
|
+
if not name:
|
|
937
|
+
raise GitUndoError(f"unsupported shell '{shell}'")
|
|
938
|
+
rc = Path.home() / name
|
|
939
|
+
if not rc.exists() or not is_autowrapped(shell):
|
|
940
|
+
raise GitUndoError(f"auto-guard is not installed in ~/{name}")
|
|
941
|
+
text = rc.read_text(errors="ignore")
|
|
942
|
+
first = text.find(GUARD_MARKER)
|
|
943
|
+
second = text.find(GUARD_MARKER, first + len(GUARD_MARKER))
|
|
944
|
+
end = (second + len(GUARD_MARKER)) if second != -1 else len(text)
|
|
945
|
+
while end < len(text) and text[end] == "\n":
|
|
946
|
+
end += 1
|
|
947
|
+
new = text[:first].rstrip("\n") + "\n" + text[end:].lstrip("\n")
|
|
948
|
+
rc.write_text(new)
|
|
949
|
+
return rc
|