gitpro 0.3.1__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.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
gitpro/guard.py
ADDED
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
"""Guard: global chained git hooks that refuse a commit or push with the wrong committer email.
|
|
2
|
+
|
|
3
|
+
`gitpro guard install` points core.hooksPath (through ~/.gitconfig-gitpro-guard, included from the
|
|
4
|
+
generated gitconfig) at ~/.config/gitpro/hooks. Its pre-commit / pre-push / commit-msg scripts run
|
|
5
|
+
`gitpro guard check <hook>`, then the repo's own hook, then [guard] chain_to; every other hook name is a
|
|
6
|
+
pass-through. Only exit 3 from `guard check` refuses; every failure of gitpro lets the commit through.
|
|
7
|
+
|
|
8
|
+
Hot path: `guard check`, `guard test` and `guard status` never import cli, view or rich.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, Dict, List, Mapping, Optional, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
from . import args as _args
|
|
26
|
+
from .config import HOOKS_DIR, ConfigError
|
|
27
|
+
|
|
28
|
+
PROG = "gitpro"
|
|
29
|
+
BLOCK = 3 # the only exit code that refuses; the hook script maps it to 1
|
|
30
|
+
GIT_TIMEOUT = 5 # seconds, for every git call made inside a hook
|
|
31
|
+
GUARD_GITCONFIG = ".gitconfig-gitpro-guard" # plan.GUARD_GITCONFIG, relative to home
|
|
32
|
+
REPO_HOOKS = ("pre-commit", "pre-push", "commit-msg") # render.GUARD_HOOKS
|
|
33
|
+
|
|
34
|
+
# hook name -> "module:function" entries, run in order with one Context (01 §5.6). A missing module or
|
|
35
|
+
# function is skipped. An entry returns 0 (pass) or 3 (block); anything else is a warning.
|
|
36
|
+
HOOKS: Dict[str, List[str]] = {
|
|
37
|
+
"pre-commit": ["gitpro.guard:identity_commit", "gitpro.policy:hook_commit"],
|
|
38
|
+
"pre-push": ["gitpro.guard:identity_push", "gitpro.policy:hook_push"],
|
|
39
|
+
"commit-msg": ["gitpro.trailers:hook_message", "gitpro.policy:hook_message"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Problems that explain a wrong committer email, in the order resolve reports them.
|
|
43
|
+
_CAUSES = ("url_form", "override", "pin_stale", "global_override", "not_applied", "env_override")
|
|
44
|
+
_SHEBANGS = ("#!/bin/sh", "#!/usr/bin/env sh", "#!/bin/bash", "#!/usr/bin/env bash", "#!/usr/bin/bash",
|
|
45
|
+
"#!/bin/zsh", "#!/usr/bin/env zsh")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Context:
|
|
50
|
+
hook: str # "pre-commit" | "pre-push" | "commit-msg"
|
|
51
|
+
repo: Path # the work-tree top level
|
|
52
|
+
args: Tuple[str, ...] # the hook's arguments from git
|
|
53
|
+
stdin: str # pre-push: the ref lines; "" otherwise
|
|
54
|
+
cfg: object # config.Config
|
|
55
|
+
report: object # resolve.Report for this repo (and remote, for pre-push)
|
|
56
|
+
committer_email: Optional[str]
|
|
57
|
+
author_email: Optional[str]
|
|
58
|
+
say: Callable[[str], None] # one line to stderr
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _say(line: str) -> None:
|
|
62
|
+
print(line, file=sys.stderr)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_ONCE_HOOKS = ("pre-commit", "commit-msg")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _warn(hook: str, line: str) -> None:
|
|
69
|
+
"""A fail-open warning, printed once per git command: pre-commit notes git's pid in GITPRO_WARNED_FILE
|
|
70
|
+
when it warns, and commit-msg of the same git process (GITPRO_GIT_PID, both set by the hook script)
|
|
71
|
+
stays quiet."""
|
|
72
|
+
f, pid = os.environ.get("GITPRO_WARNED_FILE"), os.environ.get("GITPRO_GIT_PID")
|
|
73
|
+
if f and pid and hook in _ONCE_HOOKS:
|
|
74
|
+
try:
|
|
75
|
+
if hook == "commit-msg":
|
|
76
|
+
if Path(f).read_text(encoding="utf-8", errors="replace").strip() == pid:
|
|
77
|
+
return
|
|
78
|
+
else:
|
|
79
|
+
Path(f).write_text(pid + "\n", encoding="utf-8")
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
_say(line)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _same(a: Optional[str], b: Optional[str]) -> bool:
|
|
86
|
+
return (a or "").casefold() == (b or "").casefold()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------- git inside a hook
|
|
90
|
+
|
|
91
|
+
def _hook_git(repo: Optional[Path], *argv: str, env: Optional[Mapping[str, str]] = None,
|
|
92
|
+
ok: Sequence[int] = (0,)) -> subprocess.CompletedProcess:
|
|
93
|
+
"""git with the hook timeout; ConfigError on timeout, on a missing git or an unexpected exit code.
|
|
94
|
+
repo=None runs git as the hook sees it (the hook's cwd and its GIT_DIR / GIT_WORK_TREE); with a repo,
|
|
95
|
+
git runs there without them. Output that is not UTF-8 is decoded with replacement characters."""
|
|
96
|
+
if env is None:
|
|
97
|
+
from .resolve import _child_env
|
|
98
|
+
env = os.environ if repo is None else _child_env()
|
|
99
|
+
try:
|
|
100
|
+
r = subprocess.run(["git", *(["-C", str(repo)] if repo is not None else []), *argv], capture_output=True,
|
|
101
|
+
text=True, encoding="utf-8", errors="replace", env=dict(env),
|
|
102
|
+
stdin=subprocess.DEVNULL, timeout=GIT_TIMEOUT)
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
raise ConfigError("git not found") from None
|
|
105
|
+
except subprocess.TimeoutExpired:
|
|
106
|
+
raise ConfigError(f"git {' '.join(argv[:2])} timed out after {GIT_TIMEOUT}s") from None
|
|
107
|
+
if r.returncode not in ok:
|
|
108
|
+
raise ConfigError(f"git {' '.join(argv[:2])} failed: {r.stderr.strip() or r.returncode}")
|
|
109
|
+
return r
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ident_email(repo: Optional[Path], which: str, env: Mapping[str, str]) -> Optional[str]:
|
|
113
|
+
"""The email in `git var GIT_<which>_IDENT`, or None when git cannot work one out."""
|
|
114
|
+
r = _hook_git(repo, "var", f"GIT_{which}_IDENT", env=env, ok=range(256))
|
|
115
|
+
line = r.stdout.strip()
|
|
116
|
+
if r.returncode or "<" not in line:
|
|
117
|
+
return None
|
|
118
|
+
return line[line.rfind("<") + 1:line.rfind(">")] or None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_cfg(home: Path):
|
|
122
|
+
from . import config
|
|
123
|
+
path, how = config.find(None, home)
|
|
124
|
+
return config.load_all(path, how)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _entry(spec: str) -> Optional[Callable]:
|
|
128
|
+
mod_name, _, attr = spec.partition(":")
|
|
129
|
+
try:
|
|
130
|
+
mod = importlib.import_module(mod_name)
|
|
131
|
+
except ModuleNotFoundError as e:
|
|
132
|
+
if e.name == mod_name:
|
|
133
|
+
return None
|
|
134
|
+
raise
|
|
135
|
+
fn = getattr(mod, attr, None)
|
|
136
|
+
return fn if callable(fn) else None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------- check
|
|
140
|
+
|
|
141
|
+
def check(name: str, argv: Sequence[str] = (), stdin: Optional[str] = None, *,
|
|
142
|
+
repo: Optional[Path] = None, hook: bool = True) -> int:
|
|
143
|
+
"""Run the entries of HOOKS[name]; 3 when one refuses and [guard] on_mismatch is "block", else 0.
|
|
144
|
+
Never raises: any failure of gitpro prints `⚠ gitpro guard: <msg>; not blocking` and returns 0."""
|
|
145
|
+
try:
|
|
146
|
+
return _check(name, list(argv), stdin, repo, hook)
|
|
147
|
+
except KeyboardInterrupt:
|
|
148
|
+
return 130
|
|
149
|
+
except BaseException as e: # noqa: BLE001 -- a broken gitpro must never block a commit
|
|
150
|
+
msg = str(e) if isinstance(e, ConfigError) else (f"{type(e).__name__}: {e}" if str(e) else type(e).__name__)
|
|
151
|
+
_warn(name, f"⚠ gitpro guard: {msg}; not blocking")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check(name: str, argv: List[str], stdin: Optional[str], repo: Optional[Path], hook: bool) -> int:
|
|
156
|
+
if os.environ.get("GITPRO_SKIP"):
|
|
157
|
+
return 0
|
|
158
|
+
if name not in HOOKS:
|
|
159
|
+
raise ConfigError(f"unknown hook {name!r}")
|
|
160
|
+
from . import resolve
|
|
161
|
+
resolve.TIMEOUT = GIT_TIMEOUT
|
|
162
|
+
resolve.require_git()
|
|
163
|
+
cfg = _load_cfg(Path.home())
|
|
164
|
+
ident_at = repo # None in a hook: ask git exactly as the hook sees it
|
|
165
|
+
repo = Path(repo) if repo is not None else (_hook_repo() if hook else Path(os.getcwd()))
|
|
166
|
+
# git exports GIT_AUTHOR_* in every pre-commit/commit-msg run; judging them would flag every --author.
|
|
167
|
+
judged = {k: v for k, v in os.environ.items()
|
|
168
|
+
if not (hook and k in ("GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE"))}
|
|
169
|
+
if name == "pre-push" and stdin is None:
|
|
170
|
+
stdin = sys.stdin.read()
|
|
171
|
+
stdin = stdin or ""
|
|
172
|
+
report = committer = author = None
|
|
173
|
+
if name in ("pre-commit", "pre-push"):
|
|
174
|
+
remote = argv[0] if name == "pre-push" and argv else None
|
|
175
|
+
destination = argv[1] if name == "pre-push" and len(argv) > 1 else None
|
|
176
|
+
report = resolve.compare(repo, cfg, remote=remote, remote_url=destination, env=judged)
|
|
177
|
+
repo = report.repo
|
|
178
|
+
if name == "pre-commit":
|
|
179
|
+
committer = _ident_email(ident_at, "COMMITTER", os.environ)
|
|
180
|
+
author = _ident_email(ident_at, "AUTHOR", os.environ)
|
|
181
|
+
ctx = Context(name, repo, tuple(argv), stdin, cfg, report, committer, author, _say)
|
|
182
|
+
refused = False
|
|
183
|
+
for spec in HOOKS[name]:
|
|
184
|
+
try:
|
|
185
|
+
fn = _entry(spec)
|
|
186
|
+
if fn is None:
|
|
187
|
+
continue
|
|
188
|
+
rc = fn(ctx)
|
|
189
|
+
except KeyboardInterrupt:
|
|
190
|
+
raise
|
|
191
|
+
except Exception as e: # noqa: BLE001
|
|
192
|
+
_warn(name, f"⚠ gitpro {spec}: {e}; not blocking")
|
|
193
|
+
continue
|
|
194
|
+
if rc == BLOCK:
|
|
195
|
+
refused = True
|
|
196
|
+
elif rc not in (0, None):
|
|
197
|
+
_warn(name, f"⚠ gitpro {spec}: returned {rc}; not blocking")
|
|
198
|
+
return BLOCK if refused and cfg.guard.on_mismatch == "block" else 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_REDIRECTS = ("GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _hook_repo() -> Path:
|
|
205
|
+
"""The repo git runs the hook for. `git --git-dir=D --work-tree=W` (a dotfiles repo, say) exports
|
|
206
|
+
GIT_DIR / GIT_WORK_TREE, and W may lie inside another repo or in no repo: judge D, which holds the
|
|
207
|
+
remotes and the config. A bare-style D is judged as itself."""
|
|
208
|
+
if not any(os.environ.get(k) for k in _REDIRECTS):
|
|
209
|
+
return Path(os.getcwd())
|
|
210
|
+
out = _hook_git(None, "rev-parse", "--absolute-git-dir").stdout.strip()
|
|
211
|
+
if not out:
|
|
212
|
+
raise ConfigError("git rev-parse --absolute-git-dir printed nothing")
|
|
213
|
+
return Path(out)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _belongs(exp) -> str:
|
|
217
|
+
who = f"identity {exp.id}" if exp.id else "[default]"
|
|
218
|
+
return f"{who} <{exp.email}>"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _where(exp) -> str:
|
|
222
|
+
from .resolve import redact_url
|
|
223
|
+
m = exp.match
|
|
224
|
+
url = redact_url(m.url)
|
|
225
|
+
if m.remote and url:
|
|
226
|
+
return f"{m.remote} ({url})"
|
|
227
|
+
return url or "this repo"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _mismatch(ctx: Context, head: str, lines: List[str], skip: str) -> int:
|
|
231
|
+
warn = ctx.cfg.guard.on_mismatch != "block"
|
|
232
|
+
ctx.say(("⚠" if warn else "✗") + f" gitpro: {head}")
|
|
233
|
+
for line in lines:
|
|
234
|
+
ctx.say(" " + line)
|
|
235
|
+
ctx.say(f" skip once: GITPRO_SKIP=1 git {skip} ...")
|
|
236
|
+
if warn:
|
|
237
|
+
ctx.say(' (not blocking: [guard] on_mismatch = "warn")')
|
|
238
|
+
return BLOCK
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _id_of(cfg, email: str) -> str:
|
|
242
|
+
for i in cfg.identities:
|
|
243
|
+
if _same(i.email, email):
|
|
244
|
+
return i.id
|
|
245
|
+
return "default"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def identity_commit(ctx: Context) -> int:
|
|
249
|
+
"""pre-commit: the committer email must be the expected identity's; an author email counts only when
|
|
250
|
+
it is one of the user's own (a colleague's --author, cherry-pick or git am never blocks)."""
|
|
251
|
+
from .resolve import _shown_fix, identity_conflicts, own_emails
|
|
252
|
+
rep = ctx.report
|
|
253
|
+
exp = rep.expected
|
|
254
|
+
for p in rep.problems:
|
|
255
|
+
if p.code in ("key_mismatch", "sign_mismatch"):
|
|
256
|
+
ctx.say(f"⚠ gitpro: {p.message}")
|
|
257
|
+
if exp.email is None:
|
|
258
|
+
return 0
|
|
259
|
+
bad_committer, bad_author = identity_conflicts(exp.email, own_emails(ctx.cfg),
|
|
260
|
+
ctx.author_email, ctx.committer_email)
|
|
261
|
+
if ctx.committer_email and bad_committer:
|
|
262
|
+
cause = next((p for p in rep.problems if p.code in _CAUSES), None)
|
|
263
|
+
if cause is not None:
|
|
264
|
+
lines = [f"cause: {cause.message}"] + ([f"fix: {_shown_fix(cause)}"] if _shown_fix(cause) else [])
|
|
265
|
+
elif os.environ.get("GIT_COMMITTER_EMAIL"):
|
|
266
|
+
lines = ["cause: GIT_COMMITTER_EMAIL is set in the environment",
|
|
267
|
+
"fix: unset GIT_AUTHOR_EMAIL GIT_COMMITTER_EMAIL"]
|
|
268
|
+
else:
|
|
269
|
+
lines = [f"cause: git resolves user.email to {ctx.committer_email} here",
|
|
270
|
+
f"fix: {PROG} why {shlex.quote(str(ctx.repo))}"]
|
|
271
|
+
return _mismatch(ctx, f"this commit would be committed as {ctx.committer_email} but {_where(exp)} "
|
|
272
|
+
f"belongs to {_belongs(exp)}", lines, "commit")
|
|
273
|
+
a = ctx.author_email
|
|
274
|
+
if a and bad_author:
|
|
275
|
+
lines = [f"cause: author is your {_id_of(ctx.cfg, a)} email",
|
|
276
|
+
f"fix: unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL, or commit with "
|
|
277
|
+
f"--author={shlex.quote(f'{exp.name} <{exp.email}>')}"]
|
|
278
|
+
return _mismatch(ctx, f"this commit would be authored as {a} but {_where(exp)} belongs to "
|
|
279
|
+
f"{_belongs(exp)}", lines, "commit")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _outgoing(repo: Path, stdin: str) -> List[Tuple[str, str, str, str]]:
|
|
284
|
+
"""(committer email, author email, short sha, subject) of every commit this push sends that no
|
|
285
|
+
remote-tracking ref has, oldest last as git log prints them."""
|
|
286
|
+
seen, out = set(), []
|
|
287
|
+
for line in stdin.splitlines():
|
|
288
|
+
parts = line.split()
|
|
289
|
+
if len(parts) != 4 or set(parts[1]) == {"0"}:
|
|
290
|
+
continue
|
|
291
|
+
local, remote = parts[1], parts[3]
|
|
292
|
+
argv = ["log", "--format=%ce%x00%ae%x00%h%x00%s", local]
|
|
293
|
+
if set(remote) != {"0"} and \
|
|
294
|
+
_hook_git(repo, "cat-file", "-e", f"{remote}^{{commit}}", ok=range(256)).returncode == 0:
|
|
295
|
+
argv.append(f"^{remote}")
|
|
296
|
+
for row in _hook_git(repo, *argv, "--not", "--remotes").stdout.splitlines():
|
|
297
|
+
f = row.split("\0")
|
|
298
|
+
if len(f) == 4 and f[2] not in seen:
|
|
299
|
+
seen.add(f[2])
|
|
300
|
+
out.append((f[0], f[1], f[2], f[3]))
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def identity_push(ctx: Context) -> int:
|
|
305
|
+
"""pre-push: every outgoing commit whose committer (or author) email is one of the user's own must be
|
|
306
|
+
the remote's identity's. Commits by anyone else are never judged (V-010)."""
|
|
307
|
+
from .resolve import _tilde, own_emails
|
|
308
|
+
exp = ctx.report.expected
|
|
309
|
+
if exp.email is None:
|
|
310
|
+
return 0
|
|
311
|
+
own = {e.casefold() for e in own_emails(ctx.cfg)}
|
|
312
|
+
|
|
313
|
+
def wrong(e: str) -> bool:
|
|
314
|
+
return e.casefold() in own and not _same(e, exp.email)
|
|
315
|
+
bad = [(c if wrong(c) else a, h, s) for c, a, h, s in _outgoing(ctx.repo, ctx.stdin) if wrong(c) or wrong(a)]
|
|
316
|
+
if not bad:
|
|
317
|
+
return 0
|
|
318
|
+
emails = ", ".join(dict.fromkeys(e for e, _, _ in bad))
|
|
319
|
+
n = len(bad)
|
|
320
|
+
lines = [f"{h} {e} {s}" for e, h, s in bad[:5]] + ([f"… and {n - 5} more"] if n > 5 else [])
|
|
321
|
+
if _audit_available():
|
|
322
|
+
lines.append(f"fix: {PROG} audit --rewrite {_tilde(str(ctx.repo))}")
|
|
323
|
+
else:
|
|
324
|
+
lines.append("fix: amend the listed commits with the right user.email (git rebase -i <base>, "
|
|
325
|
+
"then git commit --amend --no-edit on each)")
|
|
326
|
+
head = (f"{n} commit{'s' * (n != 1)} to {_where(exp)} {'are' if n != 1 else 'is'} committed as {emails}; "
|
|
327
|
+
f"this remote belongs to {exp.id or '[default]'} <{exp.email}>")
|
|
328
|
+
return _mismatch(ctx, head, lines, "push")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _audit_available() -> bool:
|
|
332
|
+
try:
|
|
333
|
+
importlib.import_module("gitpro.audit")
|
|
334
|
+
return True
|
|
335
|
+
except Exception: # noqa: BLE001
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------- status
|
|
340
|
+
|
|
341
|
+
def _home() -> Path:
|
|
342
|
+
return Path.home()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _config_entries(home: Path) -> List[Tuple[str, str, str, str]]:
|
|
346
|
+
"""(scope, origin, key, value) for core.hookspath and filter.lfs.process, from a non-repo dir."""
|
|
347
|
+
from .resolve import _child_env
|
|
348
|
+
try:
|
|
349
|
+
r = subprocess.run(["git", "config", "-z", "--show-scope", "--show-origin", "--get-regexp",
|
|
350
|
+
r"^(core\.hookspath|filter\.lfs\.process)$"], cwd=str(home), capture_output=True,
|
|
351
|
+
text=True, env=_child_env(), stdin=subprocess.DEVNULL, timeout=GIT_TIMEOUT)
|
|
352
|
+
except FileNotFoundError:
|
|
353
|
+
raise ConfigError("git not found") from None
|
|
354
|
+
except subprocess.TimeoutExpired:
|
|
355
|
+
raise ConfigError(f"git config timed out after {GIT_TIMEOUT}s") from None
|
|
356
|
+
fields = r.stdout.split("\0")
|
|
357
|
+
out = []
|
|
358
|
+
for n in range(0, len(fields) - 2, 3):
|
|
359
|
+
key, _, value = fields[n + 2].partition("\n")
|
|
360
|
+
# HOME may itself be a repo (dotfiles): its local config is that repo's, not the user's.
|
|
361
|
+
if fields[n] in ("system", "global"):
|
|
362
|
+
out.append((fields[n], fields[n + 1], key, value))
|
|
363
|
+
return out
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def chain_advice(value: str) -> str:
|
|
367
|
+
"""What to do about someone else's core.hooksPath = value: chain to it by its absolute path."""
|
|
368
|
+
v = os.path.expanduser(value)
|
|
369
|
+
if os.path.isabs(v):
|
|
370
|
+
return f'set [guard] chain_to = "{os.path.normpath(v)}" so gitpro runs those hooks too'
|
|
371
|
+
return (f"it is relative (each repo's own {value}), which chain_to cannot follow; set it per repo instead "
|
|
372
|
+
f"(git config --local core.hooksPath {shlex.quote(value)})")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _real(p: object) -> str:
|
|
376
|
+
return os.path.realpath(os.path.expanduser(str(p)))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _chain_of(script: Path) -> Optional[str]:
|
|
380
|
+
"""The chain_to directory baked into an installed hook script, or None."""
|
|
381
|
+
try:
|
|
382
|
+
for line in script.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
383
|
+
if line.startswith("chain="):
|
|
384
|
+
value = shlex.split(line[len("chain="):]) or [""]
|
|
385
|
+
return value[0] or None
|
|
386
|
+
except (OSError, ValueError):
|
|
387
|
+
pass
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _gitpro_on_path(home: Path) -> Optional[str]:
|
|
392
|
+
found = shutil.which(PROG)
|
|
393
|
+
if found:
|
|
394
|
+
return found
|
|
395
|
+
local = home / ".local/bin" / PROG
|
|
396
|
+
return str(local) if os.access(local, os.X_OK) else None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def status(home: Optional[Path] = None) -> dict:
|
|
400
|
+
"""Where the guard stands: installed ("global"|"none"), hooks_dir, on_mismatch, chain_to, gitpro_bin,
|
|
401
|
+
hooks {name: bool}, system_hooks_path, plus lfs / foreign_hooks_path. Never raises: on a failure
|
|
402
|
+
installed is "none" and "error" says why. One git call; no network."""
|
|
403
|
+
home = Path(home) if home is not None else _home()
|
|
404
|
+
hooks_dir = home / HOOKS_DIR
|
|
405
|
+
base = {"installed": "none", "hooks_dir": str(hooks_dir), "on_mismatch": "block", "chain_to": None,
|
|
406
|
+
"gitpro_bin": None, "hooks": {}, "system_hooks_path": None}
|
|
407
|
+
try:
|
|
408
|
+
return _status(home, hooks_dir, base)
|
|
409
|
+
except Exception as e: # noqa: BLE001
|
|
410
|
+
return {**base, "error": str(e) or type(e).__name__}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _status(home: Path, hooks_dir: Path, out: dict) -> dict:
|
|
414
|
+
from .render import HOOK_NAMES, is_generated
|
|
415
|
+
out = dict(out)
|
|
416
|
+
own = _real(hooks_dir)
|
|
417
|
+
guard_file = str(home / GUARD_GITCONFIG)
|
|
418
|
+
hook_paths = []
|
|
419
|
+
lfs = None
|
|
420
|
+
for scope, origin, key, value in _config_entries(home):
|
|
421
|
+
if key == "core.hookspath":
|
|
422
|
+
hook_paths.append({"scope": scope, "origin": origin, "value": value})
|
|
423
|
+
elif key == "filter.lfs.process" and value:
|
|
424
|
+
lfs = scope
|
|
425
|
+
ours = [h for h in hook_paths if _real(h["value"]) == own]
|
|
426
|
+
foreign = [h for h in hook_paths if _real(h["value"]) != own and h["origin"] != f"file:{guard_file}"]
|
|
427
|
+
effective = hook_paths[-1]["value"] if hook_paths else None
|
|
428
|
+
system = [h["value"] for h in hook_paths if h["scope"] == "system"]
|
|
429
|
+
|
|
430
|
+
def present(name: str) -> bool:
|
|
431
|
+
p = hooks_dir / name
|
|
432
|
+
try:
|
|
433
|
+
return os.access(p, os.X_OK) and p.is_file() and is_generated(p.read_text(errors="replace"))
|
|
434
|
+
except OSError:
|
|
435
|
+
return False
|
|
436
|
+
out["hooks"] = {name: present(name) for name in HOOK_NAMES}
|
|
437
|
+
out["installed"] = "global" if ours and effective is not None and _real(effective) == own else "none"
|
|
438
|
+
out["system_hooks_path"] = system[-1] if system else None
|
|
439
|
+
out["foreign_hooks_path"] = foreign
|
|
440
|
+
out["effective_hooks_path"] = effective
|
|
441
|
+
out["lfs"] = lfs
|
|
442
|
+
out["chain_to"] = _chain_of(hooks_dir / "pre-commit")
|
|
443
|
+
out["gitpro_bin"] = _gitpro_on_path(home)
|
|
444
|
+
try:
|
|
445
|
+
out["on_mismatch"] = _load_cfg(home).guard.on_mismatch
|
|
446
|
+
except Exception: # noqa: BLE001 -- status works without a config
|
|
447
|
+
pass
|
|
448
|
+
return out
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _bypassed(home: Path) -> List[dict]:
|
|
452
|
+
"""Repos under the scan roots with a local core.hooksPath (the global guard does not run there)."""
|
|
453
|
+
from . import scan
|
|
454
|
+
from .resolve import _git
|
|
455
|
+
out = []
|
|
456
|
+
for repo in scan.find([home]):
|
|
457
|
+
value = _git(repo, "config", "--local", "--get", "core.hooksPath").stdout.strip()
|
|
458
|
+
if not value:
|
|
459
|
+
continue
|
|
460
|
+
hooks = (repo / value) if not os.path.isabs(value) else Path(value)
|
|
461
|
+
mode = any(_has_block(hooks / n) for n in REPO_HOOKS)
|
|
462
|
+
out.append({"repo": str(repo), "hooks_path": value, "repo_mode": mode})
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _has_block(path: Path) -> bool:
|
|
467
|
+
from .render import has_block
|
|
468
|
+
try:
|
|
469
|
+
return has_block(path.read_text(errors="replace"))
|
|
470
|
+
except OSError:
|
|
471
|
+
return False
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def cmd_status(args) -> int:
|
|
475
|
+
from . import jsonout
|
|
476
|
+
from .render import GUARD_HOOKS, HOOK_NAMES, NOT_FORWARDED
|
|
477
|
+
from .undo import tilde
|
|
478
|
+
st = status()
|
|
479
|
+
if getattr(args, "repos", False):
|
|
480
|
+
st["bypassed_repos"] = _bypassed(_home())
|
|
481
|
+
if getattr(args, "json", False):
|
|
482
|
+
jsonout.emit("guard status", st)
|
|
483
|
+
return 0
|
|
484
|
+
hooks = st["hooks"]
|
|
485
|
+
if st["installed"] == "global":
|
|
486
|
+
print(f"guard: installed (global, {tilde(Path(st['hooks_dir']))}) on_mismatch = {st['on_mismatch']}")
|
|
487
|
+
mark = {True: "✓", False: "✗"}
|
|
488
|
+
extra = (f" (trailers: {'installed' if _entry('gitpro.trailers:hook_message') else 'not installed → no-op'}"
|
|
489
|
+
f"; policy: {'installed' if _entry('gitpro.policy:hook_message') else 'not installed'})")
|
|
490
|
+
print(" " + " ".join(f"{n} {mark[hooks.get(n, False)]}" for n in GUARD_HOOKS) + extra)
|
|
491
|
+
stubs = [n for n in HOOK_NAMES if n not in GUARD_HOOKS]
|
|
492
|
+
missing = [n for n in stubs if not hooks.get(n)]
|
|
493
|
+
print(f" {len(stubs)} pass-through hooks {'✓' if not missing else '✗ missing: ' + ', '.join(missing)}"
|
|
494
|
+
f" (not forwarded: {', '.join(NOT_FORWARDED)})")
|
|
495
|
+
print(f" chain_to: {st['chain_to'] or '(none)'}")
|
|
496
|
+
print(f" gitpro on PATH: {tilde(Path(st['gitpro_bin'])) if st['gitpro_bin'] else '(not found; the hooks do nothing)'}")
|
|
497
|
+
if st.get("lfs"):
|
|
498
|
+
print(f" git lfs: filter.lfs.process set {'globally' if st['lfs'] == 'global' else 'in ' + st['lfs'] + ' scope'}; "
|
|
499
|
+
"stubs call 'git lfs <hook>' unless the repo hook already does")
|
|
500
|
+
elif st.get("effective_hooks_path"):
|
|
501
|
+
other = st["effective_hooks_path"]
|
|
502
|
+
print(f"guard: core.hooksPath = {other} (not gitpro) → {chain_advice(other)}, then run {PROG} guard install")
|
|
503
|
+
else:
|
|
504
|
+
print(f"guard: not installed → {PROG} guard install")
|
|
505
|
+
if st.get("error"):
|
|
506
|
+
print(f"⚠ guard status: {st['error']}", file=sys.stderr)
|
|
507
|
+
if st["system_hooks_path"]:
|
|
508
|
+
print(f"⚠ system gitconfig sets core.hooksPath = {st['system_hooks_path']}; the global guard overrides it; "
|
|
509
|
+
"set chain_to to keep it", file=sys.stderr)
|
|
510
|
+
else:
|
|
511
|
+
print("system core.hooksPath: (none)")
|
|
512
|
+
if "bypassed_repos" in st:
|
|
513
|
+
if st["bypassed_repos"]:
|
|
514
|
+
print("repos with their own core.hooksPath (global guard bypassed):")
|
|
515
|
+
for r in st["bypassed_repos"]:
|
|
516
|
+
p = tilde(Path(r["repo"]))
|
|
517
|
+
tail = "repo mode ✓" if r["repo_mode"] else f"→ {PROG} guard install --repo {shlex.quote(p)}"
|
|
518
|
+
print(f" {p} {r['hooks_path']} {tail}")
|
|
519
|
+
else:
|
|
520
|
+
print("repos with their own core.hooksPath: none")
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def doctor_section(ctx) -> dict:
|
|
525
|
+
"""`gitpro doctor`: the guard's state from status(); never raises, no network."""
|
|
526
|
+
st = status(getattr(ctx, "home", None))
|
|
527
|
+
fix_install = f"{PROG} guard install"
|
|
528
|
+
if st.get("error"):
|
|
529
|
+
return {"status": "warning", "summary": f"could not read the guard state: {st['error']}",
|
|
530
|
+
"problems": [{"message": st["error"], "fix": f"{PROG} guard status", "level": "warning"}],
|
|
531
|
+
"details": []}
|
|
532
|
+
cfg = None
|
|
533
|
+
try:
|
|
534
|
+
cfg = getattr(ctx, "cfg", None)
|
|
535
|
+
except Exception: # noqa: BLE001
|
|
536
|
+
pass
|
|
537
|
+
if st["installed"] != "global":
|
|
538
|
+
other = st.get("effective_hooks_path")
|
|
539
|
+
details = [f"core.hooksPath = {other} (not gitpro) → {chain_advice(other)}"] if other else []
|
|
540
|
+
if cfg is not None and getattr(getattr(cfg, "guard", None), "hooks", False):
|
|
541
|
+
return {"status": "warning", "summary": "not installed", "details": details,
|
|
542
|
+
"problems": [{"message": "[guard] hooks = true but the hooks are not installed",
|
|
543
|
+
"fix": fix_install, "level": "warning"}]}
|
|
544
|
+
return {"status": "skipped", "summary": f"not installed → {fix_install}", "problems": [],
|
|
545
|
+
"details": details}
|
|
546
|
+
problems = []
|
|
547
|
+
try:
|
|
548
|
+
from . import plan
|
|
549
|
+
missing = chain_missing(cfg, plan.Env(Path(getattr(ctx, "home", None) or _home()))) if cfg else None
|
|
550
|
+
except Exception: # noqa: BLE001
|
|
551
|
+
missing = None
|
|
552
|
+
if missing:
|
|
553
|
+
msg, _, fix = missing.partition(" → ")
|
|
554
|
+
problems.append({"message": msg, "fix": fix, "level": "warning"})
|
|
555
|
+
missing = [n for n, ok in st["hooks"].items() if not ok]
|
|
556
|
+
if missing:
|
|
557
|
+
problems.append({"message": f"hook scripts missing or not gitpro's: {', '.join(missing)}",
|
|
558
|
+
"fix": fix_install, "level": "error"})
|
|
559
|
+
if not st["gitpro_bin"]:
|
|
560
|
+
problems.append({"message": "gitpro is not on PATH or in ~/.local/bin; the hooks check nothing",
|
|
561
|
+
"fix": "put gitpro on PATH", "level": "warning"})
|
|
562
|
+
if st["system_hooks_path"]:
|
|
563
|
+
problems.append({"message": f"system gitconfig sets core.hooksPath = {st['system_hooks_path']}; the "
|
|
564
|
+
"global guard overrides it",
|
|
565
|
+
"fix": chain_advice(st["system_hooks_path"]), "level": "warning"})
|
|
566
|
+
level = "error" if any(p["level"] == "error" for p in problems) else ("warning" if problems else "ok")
|
|
567
|
+
return {"status": level, "summary": f"installed (global), on_mismatch = {st['on_mismatch']}",
|
|
568
|
+
"problems": problems,
|
|
569
|
+
"details": [f"hooks: {st['hooks_dir']}", f"chain_to: {st['chain_to'] or '(none)'}"]}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# ---------------------------------------------------------------- test
|
|
573
|
+
|
|
574
|
+
def cmd_test(args) -> int:
|
|
575
|
+
from . import resolve
|
|
576
|
+
from .undo import tilde
|
|
577
|
+
d = Path(args.dir)
|
|
578
|
+
if resolve.is_repo(d) is None:
|
|
579
|
+
raise ConfigError(f"{d}: not a git repository")
|
|
580
|
+
rc = _check_strict(d)
|
|
581
|
+
if rc == 0:
|
|
582
|
+
rep_cfg = _load_cfg(Path.home())
|
|
583
|
+
exp = resolve.expected(resolve.is_repo(d), rep_cfg)
|
|
584
|
+
if exp.email is None:
|
|
585
|
+
print(f"✓ guard: no identity for {tilde(resolve.is_repo(d))}; commits are not checked")
|
|
586
|
+
else:
|
|
587
|
+
print(f"✓ guard: would allow commits as {exp.email} ({exp.id or 'default'})")
|
|
588
|
+
return rc
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _check_strict(repo: Path) -> int:
|
|
592
|
+
"""The pre-commit check as `guard test` runs it: errors are errors (exit 2), not warnings."""
|
|
593
|
+
from . import resolve
|
|
594
|
+
resolve.require_git()
|
|
595
|
+
cfg = _load_cfg(Path.home())
|
|
596
|
+
report = resolve.compare(repo, cfg)
|
|
597
|
+
top = report.repo
|
|
598
|
+
ctx = Context("pre-commit", top, (), "", cfg, report, _ident_email(top, "COMMITTER", os.environ),
|
|
599
|
+
_ident_email(top, "AUTHOR", os.environ), _say)
|
|
600
|
+
refused = False
|
|
601
|
+
for spec in HOOKS["pre-commit"]:
|
|
602
|
+
fn = _entry(spec)
|
|
603
|
+
if fn is not None and fn(ctx) == BLOCK:
|
|
604
|
+
refused = True
|
|
605
|
+
return BLOCK if refused and cfg.guard.on_mismatch == "block" else 0
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# ---------------------------------------------------------------- install / uninstall (global)
|
|
609
|
+
|
|
610
|
+
def _env(args):
|
|
611
|
+
from . import plan
|
|
612
|
+
return plan.Env(Path(getattr(args, "home", None) or Path.home()).expanduser().resolve())
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _confirm(question: str, yes: bool) -> bool:
|
|
616
|
+
if yes:
|
|
617
|
+
return True
|
|
618
|
+
if not sys.stdin.isatty():
|
|
619
|
+
raise ConfigError("no terminal to confirm on; re-run with --yes")
|
|
620
|
+
from . import view
|
|
621
|
+
return view.confirm(question)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _need_yes_for_json(args) -> None:
|
|
625
|
+
if getattr(args, "json", False) and getattr(args, "write", False) and not args.yes:
|
|
626
|
+
raise ConfigError("--write --json needs -y (there is no confirmation in JSON mode)")
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
_WARNED_MISSING: set = set()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def chain_missing(cfg, env) -> Optional[str]:
|
|
633
|
+
"""The warning for a [guard] chain_to dir that does not exist (deleted after install), else None."""
|
|
634
|
+
if not cfg.guard.chain_to:
|
|
635
|
+
return None
|
|
636
|
+
p = env.expand(cfg.guard.chain_to)
|
|
637
|
+
if os.path.lexists(p):
|
|
638
|
+
return None
|
|
639
|
+
return (f"[guard] chain_to {cfg.guard.chain_to} does not exist; the hooks run without it → create it, "
|
|
640
|
+
f"or remove chain_to from the config and run {PROG} apply")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def check_chain_to(cfg, env, missing_ok: bool = False) -> Optional[Path]:
|
|
644
|
+
"""The [guard] chain_to dir, refused unless it is a directory you own that only you can write. With
|
|
645
|
+
missing_ok (plan/apply), a missing dir is a ⚠ line (once per process) and None: the hooks are rendered
|
|
646
|
+
without the chain, so a deleted chain_to never blocks unrelated changes. `guard install` refuses it."""
|
|
647
|
+
if not cfg.guard.chain_to:
|
|
648
|
+
return None
|
|
649
|
+
p = env.expand(cfg.guard.chain_to)
|
|
650
|
+
shown = cfg.guard.chain_to
|
|
651
|
+
missing = chain_missing(cfg, env)
|
|
652
|
+
if missing and not missing_ok:
|
|
653
|
+
raise ConfigError(f"chain_to {shown} is not a directory (it does not exist); create it, "
|
|
654
|
+
f"or remove chain_to from the config")
|
|
655
|
+
if missing:
|
|
656
|
+
if str(p) not in _WARNED_MISSING:
|
|
657
|
+
_WARNED_MISSING.add(str(p))
|
|
658
|
+
_say(f"⚠ {missing}")
|
|
659
|
+
return None
|
|
660
|
+
if not p.is_absolute() or not p.is_dir():
|
|
661
|
+
raise ConfigError(f"chain_to {shown} is not a directory; point [guard] chain_to at a hooks directory")
|
|
662
|
+
if _real(p) == _real(env.home / HOOKS_DIR):
|
|
663
|
+
raise ConfigError(f"chain_to {shown} is gitpro's own hooks dir; that would run the hooks in a loop")
|
|
664
|
+
st = p.resolve().stat()
|
|
665
|
+
if st.st_uid != os.getuid():
|
|
666
|
+
raise ConfigError(f"chain_to {shown} is not owned by you; refusing to run its hooks on every commit "
|
|
667
|
+
f"(fix: chown it, or point [guard] chain_to at a directory you own)")
|
|
668
|
+
if st.st_mode & 0o022:
|
|
669
|
+
raise ConfigError(f"chain_to {shown} is group/world-writable; fix: chmod go-w {shlex.quote(str(p))}")
|
|
670
|
+
return p
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def foreign_hooks_path(home: Path) -> List[dict]:
|
|
674
|
+
"""core.hooksPath values ({scope, origin, value}) set by anything but gitpro's own guard include."""
|
|
675
|
+
own = _real(home / HOOKS_DIR)
|
|
676
|
+
mine = f"file:{home / GUARD_GITCONFIG}"
|
|
677
|
+
return [{"scope": s, "origin": o, "value": v} for s, o, k, v in _config_entries(home)
|
|
678
|
+
if k == "core.hookspath" and _real(v) != own and o != mine]
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def check_foreign(cfg, home: Path) -> None:
|
|
682
|
+
"""Refuse to take over someone else's core.hooksPath unless [guard] chain_to keeps running it."""
|
|
683
|
+
if cfg.guard.chain_to:
|
|
684
|
+
return
|
|
685
|
+
for h in foreign_hooks_path(home):
|
|
686
|
+
raise ConfigError(f"core.hooksPath is already {h['value']} ({h['scope']}, {h['origin']}); "
|
|
687
|
+
f"{chain_advice(h['value'])}, then re-run")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _check_applied(env) -> None:
|
|
691
|
+
from . import plan, render
|
|
692
|
+
managed = env.home / plan.MANAGED_GITCONFIG
|
|
693
|
+
gitconfig = env.home / ".gitconfig"
|
|
694
|
+
ok = managed.is_file() and gitconfig.is_file() and render.has_block(gitconfig.read_text())
|
|
695
|
+
if not ok:
|
|
696
|
+
raise ConfigError(f"the gitpro config is not applied yet; run {PROG} apply first")
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _backup_lines(changes, state: Path) -> List[str]:
|
|
700
|
+
"""Undo lines that put each changed file back: copies of the old contents go into state/<dir>."""
|
|
701
|
+
lines = []
|
|
702
|
+
store = None
|
|
703
|
+
for n, c in enumerate(changes):
|
|
704
|
+
q = shlex.quote(str(c.path))
|
|
705
|
+
if c.old is None:
|
|
706
|
+
lines.append(f"rm -f {q}")
|
|
707
|
+
continue
|
|
708
|
+
if store is None:
|
|
709
|
+
store = state / f"guard-backup-{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
|
|
710
|
+
store.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
711
|
+
keep = store / f"{n:02d}-{c.path.name}"
|
|
712
|
+
keep.write_text(c.old)
|
|
713
|
+
mode = c.path.stat().st_mode & 0o777 if c.path.exists() else 0o600
|
|
714
|
+
os.chmod(keep, mode)
|
|
715
|
+
lines.append(f"mkdir -p {shlex.quote(str(c.path.parent))} && cp -p {shlex.quote(str(keep))} {q}")
|
|
716
|
+
return lines
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _show_changes(changes, home: Path) -> None:
|
|
720
|
+
from .render import GUARD_HOOKS, HOOK_NAMES
|
|
721
|
+
from .undo import tilde
|
|
722
|
+
stubs = [c for c in changes if c.path.parent == home / HOOKS_DIR and c.path.name in HOOK_NAMES
|
|
723
|
+
and c.path.name not in GUARD_HOOKS]
|
|
724
|
+
shown_stub = False
|
|
725
|
+
for c in changes:
|
|
726
|
+
if c in stubs:
|
|
727
|
+
if not shown_stub:
|
|
728
|
+
print(f" {c.action:7} {tilde(c.path)} … ({len(stubs)} pass-through hooks)")
|
|
729
|
+
shown_stub = True
|
|
730
|
+
continue
|
|
731
|
+
note = ""
|
|
732
|
+
if c.path.name == ".gitconfig-identities":
|
|
733
|
+
note = f" (+ include ~/{GUARD_GITCONFIG})" if c.action != "remove" and \
|
|
734
|
+
GUARD_GITCONFIG in (c.new or "") else f" (- include ~/{GUARD_GITCONFIG})"
|
|
735
|
+
print(f" {c.action:7} {tilde(c.path)}{note}")
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def cmd_install(args) -> int:
|
|
739
|
+
_need_yes_for_json(args)
|
|
740
|
+
if getattr(args, "repo", None):
|
|
741
|
+
return _install_repo(args)
|
|
742
|
+
from . import config, files, jsonout, plan, render, resolve, undo
|
|
743
|
+
from .args import preview_only, preview_result
|
|
744
|
+
resolve.require_git()
|
|
745
|
+
env = _env(args)
|
|
746
|
+
path, how = config.find(args.config, env.home)
|
|
747
|
+
cfg = config.load_all(path, how)
|
|
748
|
+
check_chain_to(cfg, env)
|
|
749
|
+
check_foreign(cfg, env.home)
|
|
750
|
+
_check_applied(env)
|
|
751
|
+
managed = env.home / plan.MANAGED_GITCONFIG
|
|
752
|
+
for t in plan.guard_targets(cfg, env):
|
|
753
|
+
if t.path.exists() and not render.is_generated(t.path.read_text(errors="replace")):
|
|
754
|
+
raise ConfigError(f"{t.path} exists and was not written by gitpro; move it away and re-run")
|
|
755
|
+
changes = files.diff(plan.guard_targets(cfg, env) + [plan.Target(managed, render.with_guard_include(
|
|
756
|
+
managed.read_text(), plan.guard_include(env), plan.guard_manifest(env)))], [])
|
|
757
|
+
as_json = getattr(args, "json", False)
|
|
758
|
+
if preview_only(args):
|
|
759
|
+
if not as_json:
|
|
760
|
+
_show_changes(changes, env.home)
|
|
761
|
+
return preview_result(args, "guard install", {
|
|
762
|
+
"changes": [{"action": c.action, "path": str(c.path)} for c in changes], "undo": None})
|
|
763
|
+
if not changes:
|
|
764
|
+
if as_json:
|
|
765
|
+
jsonout.emit("guard install", {"changes": [], "undo": None})
|
|
766
|
+
else:
|
|
767
|
+
print("✓ guard already installed; nothing to change")
|
|
768
|
+
return 0
|
|
769
|
+
if not as_json:
|
|
770
|
+
_show_changes(changes, env.home)
|
|
771
|
+
n_files = sum(1 for c in changes if c.path != managed)
|
|
772
|
+
n_upd = len(changes) - n_files
|
|
773
|
+
if not _confirm(f"install guard hooks ({n_files} files, {n_upd} change{'s' * (n_upd != 1)})?", args.yes):
|
|
774
|
+
from . import view
|
|
775
|
+
view.aborted()
|
|
776
|
+
return 1
|
|
777
|
+
lines = _backup_lines(changes, undo.state_dir())
|
|
778
|
+
lines.append(f"rmdir {shlex.quote(str(env.home / HOOKS_DIR))} 2>/dev/null || true # only when empty")
|
|
779
|
+
script = undo.write("guard install", lines, what="guard install")
|
|
780
|
+
files.apply(changes)
|
|
781
|
+
if as_json:
|
|
782
|
+
jsonout.emit("guard install", {"changes": [{"action": c.action, "path": str(c.path)} for c in changes],
|
|
783
|
+
"undo": str(script)})
|
|
784
|
+
return 0
|
|
785
|
+
from . import view
|
|
786
|
+
view.ok(f"guard installed; test it: {PROG} guard test")
|
|
787
|
+
print(f"↩ undo: {PROG} guard uninstall (or: sh {undo.tilde(script)})")
|
|
788
|
+
view.step(f"'{PROG} apply' keeps these hooks; {PROG} guard uninstall removes them "
|
|
789
|
+
"(or set [guard] hooks = true to make the TOML the source of truth)")
|
|
790
|
+
return 0
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def cmd_uninstall(args) -> int:
|
|
794
|
+
_need_yes_for_json(args)
|
|
795
|
+
if getattr(args, "repo", None):
|
|
796
|
+
return _uninstall_repo(args)
|
|
797
|
+
from . import files, jsonout, plan, render, undo
|
|
798
|
+
from .args import preview_only, preview_result
|
|
799
|
+
env = _env(args)
|
|
800
|
+
managed = env.home / plan.MANAGED_GITCONFIG
|
|
801
|
+
targets = []
|
|
802
|
+
if managed.is_file():
|
|
803
|
+
text = managed.read_text()
|
|
804
|
+
new = render.without_guard_include(text, plan.guard_include(env), plan.guard_manifest(env))
|
|
805
|
+
targets.append(plan.Target(managed, new))
|
|
806
|
+
changes = files.diff(targets, plan.guard_paths(env))
|
|
807
|
+
as_json = getattr(args, "json", False)
|
|
808
|
+
if preview_only(args):
|
|
809
|
+
if not as_json:
|
|
810
|
+
_show_changes(changes, env.home)
|
|
811
|
+
return preview_result(args, "guard uninstall", {
|
|
812
|
+
"changes": [{"action": c.action, "path": str(c.path)} for c in changes], "undo": None})
|
|
813
|
+
if not changes:
|
|
814
|
+
if as_json:
|
|
815
|
+
jsonout.emit("guard uninstall", {"changes": [], "undo": None})
|
|
816
|
+
else:
|
|
817
|
+
print("✓ guard not installed; nothing to remove")
|
|
818
|
+
return 0
|
|
819
|
+
if not as_json:
|
|
820
|
+
_show_changes(changes, env.home)
|
|
821
|
+
if not _confirm(f"remove guard hooks ({len(changes)} change{'s' * (len(changes) != 1)})?", args.yes):
|
|
822
|
+
from . import view
|
|
823
|
+
view.aborted()
|
|
824
|
+
return 1
|
|
825
|
+
script = undo.write("guard uninstall", _backup_lines(changes, undo.state_dir()), what="guard uninstall")
|
|
826
|
+
files.apply(changes)
|
|
827
|
+
plan.prune_hooks_dir(env)
|
|
828
|
+
hooks_on = False
|
|
829
|
+
try:
|
|
830
|
+
hooks_on = _load_cfg(env.home).guard.hooks
|
|
831
|
+
except Exception: # noqa: BLE001
|
|
832
|
+
pass
|
|
833
|
+
if as_json:
|
|
834
|
+
jsonout.emit("guard uninstall", {"changes": [{"action": c.action, "path": str(c.path)} for c in changes],
|
|
835
|
+
"undo": str(script)})
|
|
836
|
+
return 0
|
|
837
|
+
from . import view
|
|
838
|
+
view.ok("guard removed; git runs each repo's own hooks again")
|
|
839
|
+
print(f"↩ undo: sh {undo.tilde(script)}")
|
|
840
|
+
if hooks_on:
|
|
841
|
+
view.warn(f"[guard] hooks = true in your config: the next '{PROG} apply' installs them again; set it to false")
|
|
842
|
+
return 0
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ---------------------------------------------------------------- repo mode
|
|
846
|
+
|
|
847
|
+
def _repo_layout(path: Path) -> Tuple[Path, Path, Path]:
|
|
848
|
+
"""(top, common dir, hooks dir) for a repo; hooks dir honours the repo's local core.hooksPath."""
|
|
849
|
+
from . import resolve
|
|
850
|
+
top = resolve.is_repo(path)
|
|
851
|
+
if top is None:
|
|
852
|
+
raise ConfigError(f"{path}: not a git repository")
|
|
853
|
+
out = resolve._git(top, "rev-parse", "--path-format=absolute", "--git-common-dir", "--git-path", "hooks")
|
|
854
|
+
lines = out.stdout.splitlines()
|
|
855
|
+
if out.returncode or len(lines) < 2:
|
|
856
|
+
raise ConfigError(f"{path}: git rev-parse failed")
|
|
857
|
+
common, hooks = Path(lines[0]), Path(lines[1])
|
|
858
|
+
if _real(hooks) == _real(_home() / HOOKS_DIR):
|
|
859
|
+
hooks = common / "hooks"
|
|
860
|
+
elif _real(hooks) != _real(common / "hooks"):
|
|
861
|
+
_refuse_shared_hooks_dir(top, hooks)
|
|
862
|
+
return top, common, hooks
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _refuse_shared_hooks_dir(top: Path, hooks: Path) -> None:
|
|
866
|
+
"""Repo mode edits only this repo's hooks: a core.hooksPath from global, system or included config
|
|
867
|
+
names a dir other repos share."""
|
|
868
|
+
from . import resolve
|
|
869
|
+
from .undo import tilde
|
|
870
|
+
r = resolve._git(top, "config", "--show-scope", "--show-origin", "--get", "core.hooksPath")
|
|
871
|
+
scope, origin, _ = (r.stdout.rstrip("\n").split("\t", 2) + ["", "", ""])[:3]
|
|
872
|
+
if scope in ("local", "worktree"):
|
|
873
|
+
return
|
|
874
|
+
where = origin[5:] if origin.startswith("file:") else origin
|
|
875
|
+
raise ConfigError(f"{tilde(hooks)} is core.hooksPath from {scope or 'unknown'} config"
|
|
876
|
+
f"{' (' + tilde(Path(where)) + ')' if where else ''}; other repos share it, so gitpro "
|
|
877
|
+
f"does not edit it. Use {PROG} guard install with [guard] chain_to = \"{hooks}\", or add "
|
|
878
|
+
f"this block to {tilde(hooks / 'pre-commit')} yourself:\n{_block_help('pre-commit')}")
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _block_help(name: str) -> str:
|
|
882
|
+
from .render import guard_block
|
|
883
|
+
return guard_block(name).rstrip("\n")
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _refuse_tool_dirs(top: Path, common: Path, hooks: Path) -> None:
|
|
887
|
+
from . import resolve
|
|
888
|
+
from .undo import tilde
|
|
889
|
+
try:
|
|
890
|
+
rel = hooks.resolve().relative_to(top.resolve())
|
|
891
|
+
except ValueError:
|
|
892
|
+
rel = None
|
|
893
|
+
if rel is not None and "_" in rel.parts:
|
|
894
|
+
user_dir = top / Path(*rel.parts[:rel.parts.index("_")])
|
|
895
|
+
files_ = ", ".join(tilde(user_dir / n) for n in ("pre-commit", "pre-push"))
|
|
896
|
+
raise ConfigError(f"{tilde(hooks)} is managed by husky (regenerated on install); add this block to the "
|
|
897
|
+
f"hook that tool runs ({files_}):\n{_block_help('pre-commit')}")
|
|
898
|
+
if _real(hooks) != _real(common / "hooks") and rel is not None and \
|
|
899
|
+
resolve._git(top, "check-ignore", "-q", str(rel)).returncode == 0:
|
|
900
|
+
raise ConfigError(f"{tilde(hooks)} is managed by a tool (the dir is ignored by git, so it is regenerated); "
|
|
901
|
+
f"add this block to the hook that tool runs:\n{_block_help('pre-commit')}")
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _repo_plan(top: Path, common: Path, hooks: Path) -> Tuple[List[Tuple[str, Path, Optional[str], str]], List[Path]]:
|
|
905
|
+
"""([(hook, file, old text or None, new text)] for the hooks that need the block, [hook files skipped
|
|
906
|
+
because they are not executable: the user turned them off, and git does not run them])."""
|
|
907
|
+
from . import resolve
|
|
908
|
+
from .render import has_block
|
|
909
|
+
from .undo import tilde
|
|
910
|
+
out, skipped = [], []
|
|
911
|
+
for name in REPO_HOOKS:
|
|
912
|
+
f = hooks / name
|
|
913
|
+
try:
|
|
914
|
+
rel = f.resolve().relative_to(top.resolve())
|
|
915
|
+
except ValueError:
|
|
916
|
+
rel = None
|
|
917
|
+
if rel is not None and resolve._git(top, "ls-files", "--error-unmatch", str(rel)).returncode == 0:
|
|
918
|
+
raise ConfigError(f"{tilde(f)} is tracked by git; gitpro does not edit team files. Add this block "
|
|
919
|
+
f"yourself:\n{_block_help(name)}")
|
|
920
|
+
block = _block_help(name) + "\n"
|
|
921
|
+
if not f.exists():
|
|
922
|
+
out.append((name, f, None, "#!/bin/sh\n" + block))
|
|
923
|
+
continue
|
|
924
|
+
old = f.read_text()
|
|
925
|
+
if has_block(old):
|
|
926
|
+
continue
|
|
927
|
+
if not os.access(f, os.X_OK):
|
|
928
|
+
skipped.append(f)
|
|
929
|
+
continue
|
|
930
|
+
first, nl, rest = old.partition("\n")
|
|
931
|
+
if first.strip() not in _SHEBANGS:
|
|
932
|
+
raise ConfigError(f"{tilde(f)} is not a shell script; add this block yourself:\n{_block_help(name)}")
|
|
933
|
+
out.append((name, f, old, first + "\n" + block + rest))
|
|
934
|
+
return out, skipped
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _install_repo(args) -> int:
|
|
938
|
+
_need_yes_for_json(args)
|
|
939
|
+
from . import files, jsonout, undo
|
|
940
|
+
from .args import preview_only, preview_result
|
|
941
|
+
top, common, hooks = _repo_layout(Path(args.repo))
|
|
942
|
+
_refuse_tool_dirs(top, common, hooks)
|
|
943
|
+
todo, skipped = _repo_plan(top, common, hooks)
|
|
944
|
+
as_json = getattr(args, "json", False)
|
|
945
|
+
if preview_only(args):
|
|
946
|
+
planned = [{"action": "create" if old is None else "update", "path": str(path)}
|
|
947
|
+
for _, path, old, _ in todo]
|
|
948
|
+
if not as_json:
|
|
949
|
+
for item in planned:
|
|
950
|
+
print(f" {item['action']} {item['path']}")
|
|
951
|
+
return preview_result(args, "guard install", {"changes": planned, "undo": None,
|
|
952
|
+
"skipped": [str(p) for p in skipped]})
|
|
953
|
+
if not as_json:
|
|
954
|
+
for f in skipped:
|
|
955
|
+
print(f"· skip {undo.tilde(f)}: not executable, so git does not run it; left as it is "
|
|
956
|
+
"(chmod +x it and re-run to add the block)")
|
|
957
|
+
if not todo:
|
|
958
|
+
if as_json:
|
|
959
|
+
jsonout.emit("guard install", {"changes": [], "undo": None, "skipped": [str(f) for f in skipped]})
|
|
960
|
+
else:
|
|
961
|
+
print(f"✓ guard block already in every hook in {undo.tilde(hooks)}; nothing to change")
|
|
962
|
+
return 0
|
|
963
|
+
if not as_json:
|
|
964
|
+
for _, f, old, _ in todo:
|
|
965
|
+
print(f" {'create' if old is None else 'update':7} {undo.tilde(f)} (+ gitpro block)")
|
|
966
|
+
if not _confirm(f"add the guard block to {len(todo)} hook{'s' * (len(todo) != 1)}?", args.yes):
|
|
967
|
+
from . import view
|
|
968
|
+
view.aborted()
|
|
969
|
+
return 1
|
|
970
|
+
state = undo.state_dir()
|
|
971
|
+
backup = state / f"guard-backup-{time.strftime('%Y%m%d-%H%M%S')}" / \
|
|
972
|
+
hashlib.sha1(str(top).encode()).hexdigest()[:12]
|
|
973
|
+
lines = []
|
|
974
|
+
for name, f, old, _ in todo:
|
|
975
|
+
if old is None:
|
|
976
|
+
lines.append(f"rm -f {shlex.quote(str(f))}")
|
|
977
|
+
else:
|
|
978
|
+
lines.append(f"cp -p {shlex.quote(str(backup / name))} {shlex.quote(str(f))}")
|
|
979
|
+
script = undo.write("guard install", lines, what=f"guard block in {top}")
|
|
980
|
+
for name, f, old, new in todo:
|
|
981
|
+
mode = 0o700
|
|
982
|
+
if old is not None:
|
|
983
|
+
mode = f.stat().st_mode & 0o777
|
|
984
|
+
backup.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
985
|
+
shutil.copy2(f, backup / name)
|
|
986
|
+
files.atomic_write(f, new, mode)
|
|
987
|
+
if as_json:
|
|
988
|
+
jsonout.emit("guard install", {"changes": [{"action": "create" if o is None else "update", "path": str(f)}
|
|
989
|
+
for _, f, o, _ in todo], "undo": str(script),
|
|
990
|
+
"skipped": [str(f) for f in skipped]})
|
|
991
|
+
return 0
|
|
992
|
+
from . import view
|
|
993
|
+
view.ok(f"guard block added to {len(todo)} hook{'s' * (len(todo) != 1)} in {undo.tilde(hooks)}")
|
|
994
|
+
print(f"↩ undo: sh {undo.tilde(script)}")
|
|
995
|
+
return 0
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _uninstall_repo(args) -> int:
|
|
999
|
+
_need_yes_for_json(args)
|
|
1000
|
+
from . import files, jsonout, undo
|
|
1001
|
+
from .render import has_block, without_block
|
|
1002
|
+
from .args import preview_only, preview_result
|
|
1003
|
+
top, common, hooks = _repo_layout(Path(args.repo))
|
|
1004
|
+
todo = []
|
|
1005
|
+
for name in REPO_HOOKS:
|
|
1006
|
+
f = hooks / name
|
|
1007
|
+
if not f.is_file():
|
|
1008
|
+
continue
|
|
1009
|
+
old = f.read_text()
|
|
1010
|
+
if not has_block(old):
|
|
1011
|
+
continue
|
|
1012
|
+
new = without_block(old)
|
|
1013
|
+
todo.append((name, f, old, None if new.strip() in ("", "#!/bin/sh") else new))
|
|
1014
|
+
as_json = getattr(args, "json", False)
|
|
1015
|
+
if preview_only(args):
|
|
1016
|
+
planned = [{"action": "remove" if new is None else "update", "path": str(path)}
|
|
1017
|
+
for _, path, _, new in todo]
|
|
1018
|
+
if not as_json:
|
|
1019
|
+
for item in planned:
|
|
1020
|
+
print(f" {item['action']} {item['path']}")
|
|
1021
|
+
return preview_result(args, "guard uninstall", {"changes": planned, "undo": None})
|
|
1022
|
+
if not todo:
|
|
1023
|
+
if as_json:
|
|
1024
|
+
jsonout.emit("guard uninstall", {"changes": [], "undo": None})
|
|
1025
|
+
else:
|
|
1026
|
+
print(f"✓ no guard block in {undo.tilde(hooks)}; nothing to remove")
|
|
1027
|
+
return 0
|
|
1028
|
+
if not as_json:
|
|
1029
|
+
for _, f, _, new in todo:
|
|
1030
|
+
print(f" {'remove' if new is None else 'update':7} {undo.tilde(f)} (- gitpro block)")
|
|
1031
|
+
if not _confirm(f"remove the guard block from {len(todo)} hook{'s' * (len(todo) != 1)}?", args.yes):
|
|
1032
|
+
from . import view
|
|
1033
|
+
view.aborted()
|
|
1034
|
+
return 1
|
|
1035
|
+
state = undo.state_dir()
|
|
1036
|
+
backup = state / f"guard-backup-{time.strftime('%Y%m%d-%H%M%S')}" / \
|
|
1037
|
+
hashlib.sha1(str(top).encode()).hexdigest()[:12]
|
|
1038
|
+
backup.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
1039
|
+
lines = []
|
|
1040
|
+
for name, f, _, _ in todo:
|
|
1041
|
+
shutil.copy2(f, backup / name)
|
|
1042
|
+
lines.append(f"cp -p {shlex.quote(str(backup / name))} {shlex.quote(str(f))}")
|
|
1043
|
+
script = undo.write("guard uninstall", lines, what=f"guard block in {top}")
|
|
1044
|
+
for name, f, _, new in todo:
|
|
1045
|
+
if new is None:
|
|
1046
|
+
f.unlink()
|
|
1047
|
+
else:
|
|
1048
|
+
files.atomic_write(f, new, f.stat().st_mode & 0o777)
|
|
1049
|
+
if as_json:
|
|
1050
|
+
jsonout.emit("guard uninstall", {"changes": [{"action": "remove" if n is None else "update", "path": str(f)}
|
|
1051
|
+
for _, f, _, n in todo], "undo": str(script)})
|
|
1052
|
+
return 0
|
|
1053
|
+
from . import view
|
|
1054
|
+
view.ok(f"guard block removed from {len(todo)} hook{'s' * (len(todo) != 1)} in {undo.tilde(hooks)}")
|
|
1055
|
+
print(f"↩ undo: sh {undo.tilde(script)}")
|
|
1056
|
+
return 0
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
# ---------------------------------------------------------------- argument parsing
|
|
1060
|
+
|
|
1061
|
+
def _cmd_check(args) -> int:
|
|
1062
|
+
return check(args.hook, args.hook_args)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _leaf(gs, name: str, help_: str, **kw) -> argparse.ArgumentParser:
|
|
1066
|
+
sp = gs.add_parser(name, help=help_, **kw)
|
|
1067
|
+
# Also accepted after the subcommand; SUPPRESS keeps the values given before it.
|
|
1068
|
+
sp.add_argument("-c", "--config", type=Path, metavar="PATH", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
1069
|
+
sp.add_argument("--home", type=Path, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
1070
|
+
sp.add_argument("-y", "--yes", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
1071
|
+
sp.add_argument("--write", action="store_true", default=argparse.SUPPRESS,
|
|
1072
|
+
help="execute after confirmation (default: preview only)")
|
|
1073
|
+
return sp
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def add_arguments(p: argparse.ArgumentParser) -> None:
|
|
1077
|
+
gs = p.add_subparsers(dest="guard_command", metavar="SUBCOMMAND")
|
|
1078
|
+
for name, help_, func in (("install", "install the guard hooks (global, or --repo for one repo)", cmd_install),
|
|
1079
|
+
("uninstall", "remove the guard hooks", cmd_uninstall)):
|
|
1080
|
+
sp = _leaf(gs, name, help_)
|
|
1081
|
+
sp.add_argument("--repo", type=Path, metavar="PATH",
|
|
1082
|
+
help="only this repo: add the guard block to its own hook files")
|
|
1083
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output (needs -y)")
|
|
1084
|
+
sp.set_defaults(func=func)
|
|
1085
|
+
sp = _leaf(gs, "status", "show whether the guard is installed and what it runs")
|
|
1086
|
+
sp.add_argument("--repos", action="store_true", help="also list repos with their own core.hooksPath")
|
|
1087
|
+
sp.add_argument("--json", action="store_true", help="machine-readable output")
|
|
1088
|
+
sp.set_defaults(func=cmd_status)
|
|
1089
|
+
sp = _leaf(gs, "check", "called by the hook scripts; not for humans")
|
|
1090
|
+
sp.add_argument("hook", help="pre-commit | pre-push | commit-msg")
|
|
1091
|
+
sp.add_argument("hook_args", nargs=argparse.REMAINDER)
|
|
1092
|
+
sp.set_defaults(func=_cmd_check)
|
|
1093
|
+
sp = _leaf(gs, "test", "run the pre-commit check on a repo; exits as the hook would")
|
|
1094
|
+
sp.add_argument("dir", nargs="?", default=".", help="repo directory (default: .)")
|
|
1095
|
+
sp.set_defaults(func=cmd_test)
|
|
1096
|
+
p.set_defaults(func=cmd_status)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def register(sub, common, confirm) -> None:
|
|
1100
|
+
add_arguments(sub.add_parser("guard", parents=[common, confirm],
|
|
1101
|
+
help="git hooks that refuse commits with the wrong identity"))
|
|
1102
|
+
_args.capability("guard")
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def main(argv: Sequence[str]) -> int:
|
|
1106
|
+
"""`gitpro guard ...` without cli.py. argv includes "guard". `guard check` never fails a hook: no
|
|
1107
|
+
argparse, every error is a ⚠ line and exit 0."""
|
|
1108
|
+
rest = list(argv)[1:] if list(argv)[:1] == ["guard"] else list(argv)
|
|
1109
|
+
if rest[:1] == ["check"]:
|
|
1110
|
+
try:
|
|
1111
|
+
if len(rest) < 2:
|
|
1112
|
+
raise ConfigError("guard check needs a hook name")
|
|
1113
|
+
return check(rest[1], rest[2:])
|
|
1114
|
+
except KeyboardInterrupt:
|
|
1115
|
+
return 130
|
|
1116
|
+
except BaseException as e: # noqa: BLE001
|
|
1117
|
+
_say(f"⚠ gitpro guard: {e}; not blocking")
|
|
1118
|
+
return 0
|
|
1119
|
+
p = argparse.ArgumentParser(prog=f"{PROG} guard", parents=[_args.common(), _args.confirm()])
|
|
1120
|
+
add_arguments(p)
|
|
1121
|
+
return _args.guarded(lambda a: a.func(a), p.parse_args(rest))
|