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