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/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gitundo - the undo button git never had.
|
|
3
|
+
|
|
4
|
+
A non-invasive safety net for git repositories. Every snapshot is stored as an
|
|
5
|
+
ordinary git commit on the hidden ref ``refs/gitundo/checkpoints``, so it uses
|
|
6
|
+
git's own content-addressed object store: zero dependencies, no extra state to
|
|
7
|
+
corrupt, and every checkpoint is as durable as git itself.
|
|
8
|
+
|
|
9
|
+
Public API:
|
|
10
|
+
gitundo.core.GitUndo - programmatic access to all operations
|
|
11
|
+
gitundo.guard - destructive-command detection used by the shell guard
|
|
12
|
+
gitundo.autowrap - install/remove the auto-guard shell wrapper
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__title__ = "gitundo"
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__license__ = "MIT"
|
|
18
|
+
__author__ = "gitundo contributors"
|
|
19
|
+
__description__ = "The undo button git never had. Automatic safety-net snapshots for any git repo."
|
|
20
|
+
|
|
21
|
+
# The hidden ref (namespace) under which all checkpoints live. Nothing else in
|
|
22
|
+
# the repository is ever touched: the working tree, the index and branch refs
|
|
23
|
+
# are all left exactly as they were.
|
|
24
|
+
CHECKPOINT_REF = "refs/gitundo/checkpoints"
|
|
25
|
+
GUARD_ENV_DISABLE = "GITUNDO_DISABLE"
|
|
26
|
+
|
|
27
|
+
# Identity stamped on every snapshot commit so users can spot them instantly in
|
|
28
|
+
# ``git log`` / tooling and so snapshots never get signed or attributed to the
|
|
29
|
+
# user's real identity.
|
|
30
|
+
SNAPSHOT_AUTHOR = "gitundo"
|
|
31
|
+
SNAPSHOT_EMAIL = "gitundo@localhost"
|
gitundo/__main__.py
ADDED
gitundo/cli.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""gitundo command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__, core
|
|
12
|
+
from .core import (
|
|
13
|
+
GitUndoError,
|
|
14
|
+
NoCheckpoints,
|
|
15
|
+
NotARepository,
|
|
16
|
+
NothingToSnapshot,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
PROG = "gitundo"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# --------------------------------------------------------------------------- #
|
|
23
|
+
# colour helpers (auto-disable when piped or NO_COLOR is set)
|
|
24
|
+
# --------------------------------------------------------------------------- #
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _colour() -> bool:
|
|
28
|
+
if os.environ.get("NO_COLOR"):
|
|
29
|
+
return False
|
|
30
|
+
return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_COL = _colour()
|
|
34
|
+
_C = lambda code: f"\033[{code}m" if _COL else "" # noqa: E731
|
|
35
|
+
RESET = _C("0")
|
|
36
|
+
BOLD = _C("1")
|
|
37
|
+
DIM = _C("2")
|
|
38
|
+
RED = _C("31")
|
|
39
|
+
GREEN = _C("32")
|
|
40
|
+
YELLOW = _C("33")
|
|
41
|
+
BLUE = _C("34")
|
|
42
|
+
MAGENTA = _C("35")
|
|
43
|
+
CYAN = _C("36")
|
|
44
|
+
GREY = _C("90")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ok(msg: str) -> None:
|
|
48
|
+
print(f"{GREEN}✔{RESET} {msg}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def info(msg: str) -> None:
|
|
52
|
+
print(msg)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def warn(msg: str) -> None:
|
|
56
|
+
print(f"{YELLOW}⚠{RESET} {msg}", file=sys.stderr)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def err(msg: str) -> None:
|
|
60
|
+
print(f"{RED}✖{RESET} {msg}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def die(msg: str, code: int = 1) -> None:
|
|
64
|
+
err(msg)
|
|
65
|
+
sys.exit(code)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------------------- #
|
|
69
|
+
# argument parser
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
74
|
+
p = argparse.ArgumentParser(
|
|
75
|
+
prog=PROG,
|
|
76
|
+
description="The undo button git never had. "
|
|
77
|
+
"Automatic safety-net snapshots for any git repository.",
|
|
78
|
+
epilog="selectors: latest (default) | N (snapshots ago, 0 = latest) "
|
|
79
|
+
"| a tag name | an object-id prefix. "
|
|
80
|
+
"Run '%(prog)s help <command>' for details.",
|
|
81
|
+
)
|
|
82
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
83
|
+
p.add_argument(
|
|
84
|
+
"-C",
|
|
85
|
+
dest="chdir",
|
|
86
|
+
metavar="DIR",
|
|
87
|
+
default=None,
|
|
88
|
+
help="run as if gitundo was started in DIR (like git -C)",
|
|
89
|
+
)
|
|
90
|
+
sub = p.add_subparsers(dest="command", metavar="<command>")
|
|
91
|
+
|
|
92
|
+
def add(name: str, help_: str, *, aliases: list[str] | None = None) -> argparse.ArgumentParser:
|
|
93
|
+
return sub.add_parser(
|
|
94
|
+
name, help=help_, aliases=aliases or [], description=help_, add_help=True
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# snap ----------------------------------------------------------------- #
|
|
98
|
+
sp = add(
|
|
99
|
+
"snap",
|
|
100
|
+
"Snapshot the current working state (staged + unstaged + untracked).",
|
|
101
|
+
aliases=["checkpoint", "save", "s"],
|
|
102
|
+
)
|
|
103
|
+
sp.add_argument("message", nargs="?", default=None, help="label for this snapshot")
|
|
104
|
+
sp.add_argument("-m", dest="message_opt", default=None, help="alias for MESSAGE")
|
|
105
|
+
sp.add_argument("-t", "--tag", default=None, help="tag the snapshot (e.g. -t before-refactor)")
|
|
106
|
+
sp.add_argument(
|
|
107
|
+
"-f", "--force", action="store_true", help="create a snapshot even when nothing changed"
|
|
108
|
+
)
|
|
109
|
+
sp.add_argument(
|
|
110
|
+
"--json", action="store_true", help="machine-readable JSON result (ok/created/oid/…)"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# list ---------------------------------------------------------------- #
|
|
114
|
+
lp = add("list", "List checkpoints, newest first.", aliases=["ls", "log", "history"])
|
|
115
|
+
lp.add_argument("-n", "--limit", type=int, default=None, help="show at most N checkpoints")
|
|
116
|
+
lp.add_argument("--tags", action="store_true", help="show only tagged checkpoints")
|
|
117
|
+
lp.add_argument(
|
|
118
|
+
"--json",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="machine-readable JSON output (one array of checkpoint objects, newest first)",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# restore ------------------------------------------------------------- #
|
|
124
|
+
rp = add("restore", "Restore your working tree to a checkpoint.", aliases=["rollback", "back"])
|
|
125
|
+
rp.add_argument(
|
|
126
|
+
"selector",
|
|
127
|
+
nargs="?",
|
|
128
|
+
default="latest",
|
|
129
|
+
help="which checkpoint to restore (default: latest)",
|
|
130
|
+
)
|
|
131
|
+
rp.add_argument(
|
|
132
|
+
"--index", action="store_true", help="also reset the index to match the checkpoint"
|
|
133
|
+
)
|
|
134
|
+
rp.add_argument(
|
|
135
|
+
"--hard",
|
|
136
|
+
action="store_true",
|
|
137
|
+
help="overwrite local edits instead of keeping them aside as *.gitundo-keep",
|
|
138
|
+
)
|
|
139
|
+
rp.add_argument(
|
|
140
|
+
"--delete-extraneous",
|
|
141
|
+
action="store_true",
|
|
142
|
+
help="also delete untracked files that did not exist in the checkpoint",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# diff ---------------------------------------------------------------- #
|
|
146
|
+
dp = add(
|
|
147
|
+
"diff", "Show what a checkpoint saved, or differences between checkpoints.", aliases=["d"]
|
|
148
|
+
)
|
|
149
|
+
dp.add_argument(
|
|
150
|
+
"selector", nargs="?", default="latest", help="checkpoint to diff (default: latest)"
|
|
151
|
+
)
|
|
152
|
+
dp.add_argument(
|
|
153
|
+
"--from",
|
|
154
|
+
dest="from_sel",
|
|
155
|
+
default=None,
|
|
156
|
+
metavar="SEL",
|
|
157
|
+
help="diff this checkpoint against another checkpoint SEL",
|
|
158
|
+
)
|
|
159
|
+
dp.add_argument(
|
|
160
|
+
"--workdir",
|
|
161
|
+
action="store_true",
|
|
162
|
+
help="diff the checkpoint against the current working tree "
|
|
163
|
+
"instead of the commit it was taken from",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# tag ----------------------------------------------------------------- #
|
|
167
|
+
tp = add("tag", "Give a checkpoint a human-readable name.", aliases=["label", "name"])
|
|
168
|
+
tp.add_argument("name", nargs="?", help="tag name (letters, digits, . _ -)")
|
|
169
|
+
tp.add_argument(
|
|
170
|
+
"selector", nargs="?", default="latest", help="checkpoint to tag (default: latest)"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
ut = add("untag", "Remove a tag from a checkpoint.", aliases=["unlabel"])
|
|
174
|
+
ut.add_argument(
|
|
175
|
+
"name",
|
|
176
|
+
nargs="?",
|
|
177
|
+
default="latest",
|
|
178
|
+
help="tag name, or a selector of the checkpoint to untag",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# prune --------------------------------------------------------------- #
|
|
182
|
+
pp = add("prune", "Delete old checkpoints, keeping the newest N and tagged ones.")
|
|
183
|
+
pp.add_argument(
|
|
184
|
+
"-k",
|
|
185
|
+
"--keep",
|
|
186
|
+
type=int,
|
|
187
|
+
default=100,
|
|
188
|
+
metavar="N",
|
|
189
|
+
help="keep the N most recent checkpoints (default: 100)",
|
|
190
|
+
)
|
|
191
|
+
pp.add_argument(
|
|
192
|
+
"--no-tags", action="store_true", help="do not protect tagged checkpoints from pruning"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# status -------------------------------------------------------------- #
|
|
196
|
+
add("status", "Show current protection state and latest checkpoints.", aliases=["st"]).add_argument(
|
|
197
|
+
"--json", action="store_true", help="machine-readable JSON status"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# on / off ------------------------------------------------------------ #
|
|
201
|
+
add("on", "Enable the guard for this repository (git config gitundo.enabled true)")
|
|
202
|
+
add("off", "Disable the guard for this repository")
|
|
203
|
+
|
|
204
|
+
# autowrap ------------------------------------------------------------ #
|
|
205
|
+
aw = add(
|
|
206
|
+
"autowrap",
|
|
207
|
+
"Install/uninstall the automatic git wrapper for your shell.",
|
|
208
|
+
aliases=["guard-install", "install"],
|
|
209
|
+
)
|
|
210
|
+
aw.add_argument("--uninstall", action="store_true", help="remove the wrapper")
|
|
211
|
+
aw.add_argument(
|
|
212
|
+
"--shell",
|
|
213
|
+
default="bash",
|
|
214
|
+
choices=["bash", "zsh"],
|
|
215
|
+
help="which rc file to edit (default: bash)",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# internal ------------------------------------------------------------ #
|
|
219
|
+
ig = add("_guard_git", "Internal: called by the auto-guard wrapper.", aliases=["guard-run"])
|
|
220
|
+
ig.add_argument("git_args", nargs=argparse.REMAINDER, help="the original `git ...` arguments")
|
|
221
|
+
|
|
222
|
+
return p
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --------------------------------------------------------------------------- #
|
|
226
|
+
# rendering
|
|
227
|
+
# --------------------------------------------------------------------------- #
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _row(cp: core.Checkpoint) -> str:
|
|
231
|
+
tag = cp.tag or ""
|
|
232
|
+
return (
|
|
233
|
+
f"{CYAN}{cp.short()}{RESET} {DIM}{cp.when}{RESET} {MAGENTA}{tag:<16}{RESET} {cp.subject}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _files_badge(msg: str) -> str:
|
|
238
|
+
short = msg.replace("\n", " ").strip()
|
|
239
|
+
return short[:72] + ("…" if len(short) > 72 else "")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# --------------------------------------------------------------------------- #
|
|
243
|
+
# commands
|
|
244
|
+
# --------------------------------------------------------------------------- #
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_snap(a: argparse.Namespace) -> int:
|
|
248
|
+
message = a.message_opt or a.message
|
|
249
|
+
res: dict = {}
|
|
250
|
+
try:
|
|
251
|
+
res = core.snapshot(message=message, tag=a.tag, force=a.force)
|
|
252
|
+
except NothingToSnapshot as exc:
|
|
253
|
+
res = {"oid": "", "tag": a.tag or "", "empty": True, "created": False,
|
|
254
|
+
"reason": str(exc), "staged": 0, "unstaged": 0, "untracked": 0}
|
|
255
|
+
except GitUndoError as exc:
|
|
256
|
+
if a.json:
|
|
257
|
+
print(json.dumps({"ok": False, "error": str(exc)}))
|
|
258
|
+
return 1
|
|
259
|
+
die(str(exc))
|
|
260
|
+
if a.json:
|
|
261
|
+
print(json.dumps({
|
|
262
|
+
"ok": True,
|
|
263
|
+
"created": res.get("created", False),
|
|
264
|
+
"oid": res.get("oid", ""),
|
|
265
|
+
"short": (res.get("oid", "") or "")[:7],
|
|
266
|
+
"tag": res.get("tag", ""),
|
|
267
|
+
"staged": res.get("staged", 0),
|
|
268
|
+
"unstaged": res.get("unstaged", 0),
|
|
269
|
+
"untracked": res.get("untracked", 0),
|
|
270
|
+
"reason": res.get("reason", ""),
|
|
271
|
+
}))
|
|
272
|
+
return 0
|
|
273
|
+
if not res["created"]:
|
|
274
|
+
info(f"{DIM}∅{RESET} nothing changed since the last snapshot ({res['oid'][:7]})")
|
|
275
|
+
return 0
|
|
276
|
+
tag = f" tagged {MAGENTA}{res['tag']}{RESET}" if res.get("tag") else ""
|
|
277
|
+
bits = []
|
|
278
|
+
if res.get("staged"):
|
|
279
|
+
bits.append(f"{res['staged']} staged")
|
|
280
|
+
if res.get("unstaged"):
|
|
281
|
+
bits.append(f"{res['unstaged']} unstaged")
|
|
282
|
+
if res.get("untracked"):
|
|
283
|
+
bits.append(f"{res['untracked']} untracked")
|
|
284
|
+
ok(f"checkpoint {BOLD}{res['oid'][:7]}{RESET}{tag} — " + (", ".join(bits) or "clean tree"))
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_list(a: argparse.Namespace) -> int:
|
|
289
|
+
cps = core.list_checkpoints(limit=a.limit, tags_only=a.tags)
|
|
290
|
+
if a.json:
|
|
291
|
+
print(json.dumps([_checkpoint_json(cp) for cp in cps], indent=2, default=str))
|
|
292
|
+
return 0
|
|
293
|
+
if not cps:
|
|
294
|
+
info(
|
|
295
|
+
f"{DIM}no checkpoints yet — run `gitundo snap` (or `gitundo help`) to create one.{RESET}"
|
|
296
|
+
)
|
|
297
|
+
return 0
|
|
298
|
+
count, _, human = core.storage_stats()
|
|
299
|
+
for cp in cps:
|
|
300
|
+
print(_row(cp))
|
|
301
|
+
print(f"{DIM}\n{len(cps)} shown · {count} total · ~{human} in .git objects{RESET}")
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _checkpoint_json(cp: core.Checkpoint) -> dict:
|
|
306
|
+
"""Stable, documented JSON shape consumed by editors, CI and gitundo-vscode."""
|
|
307
|
+
return {
|
|
308
|
+
"id": cp.oid,
|
|
309
|
+
"short": cp.short(),
|
|
310
|
+
"tag": cp.tag or "",
|
|
311
|
+
"message": cp.subject,
|
|
312
|
+
"when": cp.when,
|
|
313
|
+
"timestamp": cp.commit_time,
|
|
314
|
+
"snapshot_of": cp.snapshot_of,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def cmd_restore(a: argparse.Namespace) -> int:
|
|
319
|
+
try:
|
|
320
|
+
stats = core.restore(
|
|
321
|
+
selector=a.selector, index=a.index, hard=a.hard, delete_extraneous=a.delete_extraneous
|
|
322
|
+
)
|
|
323
|
+
except GitUndoError as exc:
|
|
324
|
+
die(str(exc))
|
|
325
|
+
kept = stats.get("kept", 0)
|
|
326
|
+
if kept:
|
|
327
|
+
warn(
|
|
328
|
+
f"{kept} file(s) with local edits were kept aside as "
|
|
329
|
+
f"{DIM}*.gitundo-keep{RESET} (use {BOLD}--hard{RESET} to overwrite them)"
|
|
330
|
+
)
|
|
331
|
+
ok(
|
|
332
|
+
f"restored working tree to {BOLD}{a.selector}{RESET} "
|
|
333
|
+
f"({stats.get('restored', 0)} file(s) written, "
|
|
334
|
+
f"{stats.get('removed', 0)} removed)"
|
|
335
|
+
)
|
|
336
|
+
print(
|
|
337
|
+
f" {DIM}committed history untouched; the current branch is exactly as you left it.{RESET}"
|
|
338
|
+
)
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def cmd_diff(a: argparse.Namespace) -> int:
|
|
343
|
+
try:
|
|
344
|
+
if a.from_sel:
|
|
345
|
+
out = core.diff_between(a=a.from_sel, b=a.selector)
|
|
346
|
+
elif a.workdir:
|
|
347
|
+
out = core.diff_workdir(selector=a.selector)
|
|
348
|
+
else:
|
|
349
|
+
out = core.diff_saved(selector=a.selector)
|
|
350
|
+
except GitUndoError as exc:
|
|
351
|
+
die(str(exc))
|
|
352
|
+
if not out.strip():
|
|
353
|
+
info(f"{DIM}(empty diff — that snapshot captured a clean tree){RESET}")
|
|
354
|
+
return 0
|
|
355
|
+
sys.stdout.write(out if out.endswith("\n") else out + "\n")
|
|
356
|
+
return 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def cmd_tag(a: argparse.Namespace) -> int:
|
|
360
|
+
try:
|
|
361
|
+
name = core.add_tag(name=a.name, selector=a.selector)
|
|
362
|
+
except GitUndoError as exc:
|
|
363
|
+
die(str(exc))
|
|
364
|
+
ok(f"tagged checkpoint as {MAGENTA}{name}{RESET}")
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_untag(a: argparse.Namespace) -> int:
|
|
369
|
+
try:
|
|
370
|
+
core.remove_tag(name=a.name)
|
|
371
|
+
except GitUndoError as exc:
|
|
372
|
+
die(str(exc))
|
|
373
|
+
ok(f"removed tag from checkpoint {a.name}")
|
|
374
|
+
return 0
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def cmd_prune(a: argparse.Namespace) -> int:
|
|
378
|
+
try:
|
|
379
|
+
removed = core.prune(keep=a.keep, protected_tags=not a.no_tags)
|
|
380
|
+
except GitUndoError as exc:
|
|
381
|
+
die(str(exc))
|
|
382
|
+
ok(f"pruned {removed} old checkpoint(s)")
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def cmd_status(a: argparse.Namespace) -> int:
|
|
387
|
+
repo = core.resolve_repo(".")
|
|
388
|
+
try:
|
|
389
|
+
cps = core.list_checkpoints(limit=5)
|
|
390
|
+
except GitUndoError:
|
|
391
|
+
cps = []
|
|
392
|
+
if getattr(a, "json", False):
|
|
393
|
+
print(
|
|
394
|
+
json.dumps(
|
|
395
|
+
{
|
|
396
|
+
"version": __version__,
|
|
397
|
+
"enabled": core.is_enabled(repo),
|
|
398
|
+
"autowrapped": core.is_autowrapped(),
|
|
399
|
+
"head": core.head_oid(repo) or "",
|
|
400
|
+
"count": len(core.read_checkpoints(repo)),
|
|
401
|
+
"recent": [_checkpoint_json(c) for c in cps],
|
|
402
|
+
},
|
|
403
|
+
indent=2,
|
|
404
|
+
default=str,
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
return 0
|
|
408
|
+
state = []
|
|
409
|
+
if core.is_enabled(repo):
|
|
410
|
+
state.append(f"{GREEN}guard: on{RESET}")
|
|
411
|
+
else:
|
|
412
|
+
state.append(f"{GREY}guard: off{RESET} (run `gitundo on`)")
|
|
413
|
+
if core.is_autowrapped():
|
|
414
|
+
state.append(f"{GREEN}auto-wrap: on{RESET}")
|
|
415
|
+
else:
|
|
416
|
+
state.append(f"{GREY}auto-wrap: off{RESET} (run `gitundo autowrap`)")
|
|
417
|
+
print(" · ".join(state))
|
|
418
|
+
head = core.head_oid(repo)
|
|
419
|
+
print(f"branch head: {head[:7] if head else DIM + '(unborn)' + RESET}")
|
|
420
|
+
cps = core.list_checkpoints(limit=5)
|
|
421
|
+
if cps:
|
|
422
|
+
print(f"\n{BOLD}latest checkpoints{RESET}")
|
|
423
|
+
for cp in cps:
|
|
424
|
+
print(" " + _row(cp))
|
|
425
|
+
else:
|
|
426
|
+
print(f"\n{DIM}no checkpoints yet.{RESET}")
|
|
427
|
+
count, obj, human = core.storage_stats()
|
|
428
|
+
print(f"{DIM}{count} checkpoint(s) · ~{human} stored in .git{RESET}")
|
|
429
|
+
return 0
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def cmd_on(a: argparse.Namespace) -> int:
|
|
433
|
+
core.set_enabled(True)
|
|
434
|
+
ok("guard enabled for this repository (git config gitundo.enabled true)")
|
|
435
|
+
return 0
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def cmd_off(a: argparse.Namespace) -> int:
|
|
439
|
+
core.set_enabled(False)
|
|
440
|
+
ok("guard disabled for this repository")
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def cmd_autowrap(a: argparse.Namespace) -> int:
|
|
445
|
+
try:
|
|
446
|
+
if a.uninstall:
|
|
447
|
+
rc = core.uninstall_autowrap(a.shell)
|
|
448
|
+
ok(f"removed the auto-guard from ~/{rc.name}")
|
|
449
|
+
else:
|
|
450
|
+
rc = core.install_autowrap(a.shell)
|
|
451
|
+
ok(f"auto-guard installed in ~/{rc.name} — open a new shell to activate")
|
|
452
|
+
except GitUndoError as exc:
|
|
453
|
+
die(str(exc))
|
|
454
|
+
print(
|
|
455
|
+
f"\n{DIM}Destructive git commands (reset --hard, clean -dfx, rebase …) will now\n"
|
|
456
|
+
f"snapshot your state automatically first. Disable any time with:\n"
|
|
457
|
+
f" export GITUNDO_DISABLE=1{RESET}"
|
|
458
|
+
)
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def cmd_guard_git(a: argparse.Namespace) -> int:
|
|
463
|
+
"""Invoked by the auto-wrap ``git()`` function. Snapshots before clearly
|
|
464
|
+
destructive git invocations, then runs the real git command."""
|
|
465
|
+
args = a.git_args or []
|
|
466
|
+
if os.environ.get("GITUNDO_DISABLE"):
|
|
467
|
+
return _exec_git(args)
|
|
468
|
+
line = "git " + " ".join(args)
|
|
469
|
+
danger = core.describe_danger(line)
|
|
470
|
+
if danger and core.is_repository("."):
|
|
471
|
+
try:
|
|
472
|
+
res = core.snapshot()
|
|
473
|
+
if res.get("created"):
|
|
474
|
+
print(
|
|
475
|
+
f"{GREEN}gitundo{RESET} snapshot before {danger} → {res['oid'][:7]}",
|
|
476
|
+
file=sys.stderr,
|
|
477
|
+
)
|
|
478
|
+
except GitUndoError:
|
|
479
|
+
pass # never block the user's git command
|
|
480
|
+
return _exec_git(args)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _exec_git(args: list[str]) -> int:
|
|
484
|
+
"""Run the real git binary, inheriting stdio and exit status."""
|
|
485
|
+
try:
|
|
486
|
+
code = subprocess.call(["git", *args])
|
|
487
|
+
except FileNotFoundError:
|
|
488
|
+
die("could not find the real `git` binary", 127)
|
|
489
|
+
except KeyboardInterrupt:
|
|
490
|
+
return 130
|
|
491
|
+
return code if code is not None else 1
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# --------------------------------------------------------------------------- #
|
|
495
|
+
# entry point
|
|
496
|
+
# --------------------------------------------------------------------------- #
|
|
497
|
+
|
|
498
|
+
_COMMANDS = {
|
|
499
|
+
"snap": cmd_snap,
|
|
500
|
+
"checkpoint": cmd_snap,
|
|
501
|
+
"save": cmd_snap,
|
|
502
|
+
"s": cmd_snap,
|
|
503
|
+
"list": cmd_list,
|
|
504
|
+
"ls": cmd_list,
|
|
505
|
+
"log": cmd_list,
|
|
506
|
+
"history": cmd_list,
|
|
507
|
+
"restore": cmd_restore,
|
|
508
|
+
"rollback": cmd_restore,
|
|
509
|
+
"back": cmd_restore,
|
|
510
|
+
"diff": cmd_diff,
|
|
511
|
+
"d": cmd_diff,
|
|
512
|
+
"tag": cmd_tag,
|
|
513
|
+
"label": cmd_tag,
|
|
514
|
+
"name": cmd_tag,
|
|
515
|
+
"untag": cmd_untag,
|
|
516
|
+
"unlabel": cmd_untag,
|
|
517
|
+
"prune": cmd_prune,
|
|
518
|
+
"status": cmd_status,
|
|
519
|
+
"st": cmd_status,
|
|
520
|
+
"on": cmd_on,
|
|
521
|
+
"off": cmd_off,
|
|
522
|
+
"autowrap": cmd_autowrap,
|
|
523
|
+
"guard-install": cmd_autowrap,
|
|
524
|
+
"install": cmd_autowrap,
|
|
525
|
+
"_guard_git": cmd_guard_git,
|
|
526
|
+
"guard-run": cmd_guard_git,
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def main(argv: list[str] | None = None) -> int:
|
|
531
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
532
|
+
parser = build_parser()
|
|
533
|
+
|
|
534
|
+
if argv and argv[0] in ("help", "--help", "-h"):
|
|
535
|
+
if len(argv) == 1:
|
|
536
|
+
parser.print_help()
|
|
537
|
+
return 0
|
|
538
|
+
# print help of the subcommand
|
|
539
|
+
subargv = [argv[1], "--help"]
|
|
540
|
+
subargv[0] = argv[1]
|
|
541
|
+
try:
|
|
542
|
+
ns = parser.parse_args(subargv)
|
|
543
|
+
if ns.command and ns.command in _COMMANDS:
|
|
544
|
+
return 0
|
|
545
|
+
except SystemExit:
|
|
546
|
+
return 0
|
|
547
|
+
return 0
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
a = parser.parse_args(argv)
|
|
551
|
+
except SystemExit as exc:
|
|
552
|
+
return int(exc.code or 0)
|
|
553
|
+
|
|
554
|
+
if not a.command:
|
|
555
|
+
parser.print_help()
|
|
556
|
+
return 0
|
|
557
|
+
|
|
558
|
+
# honor -C by chdir-ing before anything else
|
|
559
|
+
if getattr(a, "chdir", None):
|
|
560
|
+
try:
|
|
561
|
+
os.chdir(a.chdir)
|
|
562
|
+
except OSError as exc:
|
|
563
|
+
die(f"cannot enter '{a.chdir}': {exc}", 2)
|
|
564
|
+
|
|
565
|
+
handler = _COMMANDS.get(a.command)
|
|
566
|
+
if handler is None:
|
|
567
|
+
parser.print_help()
|
|
568
|
+
return 0
|
|
569
|
+
try:
|
|
570
|
+
return handler(a) or 0
|
|
571
|
+
except NotARepository as exc:
|
|
572
|
+
die(str(exc))
|
|
573
|
+
except NoCheckpoints as exc:
|
|
574
|
+
die(str(exc))
|
|
575
|
+
except GitUndoError as exc:
|
|
576
|
+
die(str(exc))
|
|
577
|
+
except KeyboardInterrupt:
|
|
578
|
+
return 130
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == "__main__": # pragma: no cover
|
|
583
|
+
sys.exit(main())
|