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/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