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/render.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Pure text rendering: config objects in, file contents out. No I/O."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .config import Default, Identity, check, signs
|
|
10
|
+
|
|
11
|
+
TOOL = "gitpro"
|
|
12
|
+
# Also recognise what earlier versions wrote (git-identities, git_identities.py), so existing setups migrate.
|
|
13
|
+
_TOOLS = r"(?:gitpro|git-identities|git_identities\.py)"
|
|
14
|
+
|
|
15
|
+
MARKER = f"# Generated by {TOOL}"
|
|
16
|
+
HEADER = f"{MARKER} -- edit the TOML config and re-run; do not edit this file."
|
|
17
|
+
MANIFEST = "# managed: "
|
|
18
|
+
BLOCK_BEGIN = f"# BEGIN {TOOL}"
|
|
19
|
+
BLOCK_END = f"# END {TOOL}"
|
|
20
|
+
INDENT = " " # gitconfig keys; git itself writes a tab
|
|
21
|
+
|
|
22
|
+
SECTION_RE = re.compile(r"^[A-Za-z0-9.-]+$")
|
|
23
|
+
KEY_RE = re.compile(r"^[A-Za-z][A-Za-z0-9-]*$")
|
|
24
|
+
_GENERATED_RE = re.compile(rf"# Generated by {_TOOLS}(?:\s|$)")
|
|
25
|
+
_BLOCK_RE = re.compile(rf"^# BEGIN {_TOOLS}[^\n]*\n.*?^# END {_TOOLS}[^\n]*(?:\n|\Z)", re.M | re.S)
|
|
26
|
+
_MARK_RE = re.compile(rf"^# (BEGIN|END) {_TOOLS}", re.M)
|
|
27
|
+
|
|
28
|
+
Expand = Callable[[str], Path]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------- gitconfig syntax
|
|
32
|
+
|
|
33
|
+
def gval(v: object) -> str:
|
|
34
|
+
if isinstance(v, bool):
|
|
35
|
+
return "true" if v else "false"
|
|
36
|
+
return '"' + str(v).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def section(name: str, sub: str | None = None) -> str:
|
|
40
|
+
return f"[{name}]" if sub is None else f"[{name} {gval(sub)}]"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _kv_lines(body: dict, where: str) -> list[str]:
|
|
44
|
+
out = []
|
|
45
|
+
for k, v in body.items():
|
|
46
|
+
check(KEY_RE.match(k), f"{where}: bad git key {k!r}")
|
|
47
|
+
for item in v if isinstance(v, list) else [v]:
|
|
48
|
+
check(not isinstance(item, (dict, list)), f"{where}.{k}: nested too deep")
|
|
49
|
+
out.append(f"{INDENT}{k} = {gval(item)}")
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def git_lines(sections: dict, where: str) -> list[str]:
|
|
54
|
+
"""{section: {key: val, subsection: {key: val}}} -> gitconfig lines."""
|
|
55
|
+
out = []
|
|
56
|
+
for sec, body in sections.items():
|
|
57
|
+
check(isinstance(body, dict) and SECTION_RE.match(sec), f"{where}: bad git section {sec!r}")
|
|
58
|
+
flat = {k: v for k, v in body.items() if not isinstance(v, dict)}
|
|
59
|
+
if flat:
|
|
60
|
+
out += [section(sec), *_kv_lines(flat, f"{where}.{sec}")]
|
|
61
|
+
for sub, sbody in body.items():
|
|
62
|
+
if isinstance(sbody, dict):
|
|
63
|
+
out += [section(sec, sub), *_kv_lines(sbody, f"{where}.{sec}.{sub}")]
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def merge(a: dict, b: dict) -> dict:
|
|
68
|
+
out = dict(a)
|
|
69
|
+
for k, v in b.items():
|
|
70
|
+
out[k] = merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else v
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------- helpers
|
|
75
|
+
|
|
76
|
+
def pub_path(key: Path) -> Path:
|
|
77
|
+
return key.with_name(key.name + ".pub")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _shell_path(p: str) -> str:
|
|
81
|
+
return "~/" + shlex.quote(p[2:]) if p.startswith("~/") else shlex.quote(p)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _owner_prefixes(i: Identity) -> list[str]:
|
|
85
|
+
return [o.strip("/") + "/" for o in i.owners] or [""]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _scp_patterns(user: str, host: str, owner: str) -> list[str]:
|
|
89
|
+
# In git's wildmatch "**" crosses "/" only as a whole path component, so "host:**" would stop at the
|
|
90
|
+
# first "/": an owner-less identity needs "host:*" (one segment) and "host:*/**" (nested).
|
|
91
|
+
return [f"{user}@{host}:{owner}**"] if owner else [f"{user}@{host}:*", f"{user}@{host}:*/**"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def url_patterns(i: Identity) -> list[str]:
|
|
95
|
+
"""The includeIf hasconfig:remote.*.url patterns for i (resolve.match_url uses the same list)."""
|
|
96
|
+
pats = []
|
|
97
|
+
for o in _owner_prefixes(i):
|
|
98
|
+
for h in [i.host] + ([i.ssh_alias] if i.ssh_alias else []):
|
|
99
|
+
pats += [*_scp_patterns(i.ssh_user, h, o), f"ssh://{i.ssh_user}@{h}/{o}**"]
|
|
100
|
+
if i.ssh_port:
|
|
101
|
+
pats.append(f"ssh://{i.ssh_user}@{i.host}:{i.ssh_port}/{o}**")
|
|
102
|
+
pats.append(f"https://{i.host}/{o}**")
|
|
103
|
+
return list(dict.fromkeys(pats))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _sign_cfg(key: Path) -> dict:
|
|
107
|
+
return {"gpg": {"format": "ssh"}, "user": {"signingkey": str(pub_path(key))},
|
|
108
|
+
"commit": {"gpgsign": True}, "tag": {"gpgsign": True}}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------- files
|
|
112
|
+
|
|
113
|
+
def managed_gitconfig(d: Default, ids: list[Identity], expand: Expand,
|
|
114
|
+
signers_file: str | None, manifest: list[str]) -> str:
|
|
115
|
+
lines = [HEADER, *(MANIFEST + m for m in manifest), "# Later includeIf blocks win when several match.", ""]
|
|
116
|
+
lines += git_lines(default_settings(d, expand, signers_file), "default.git")
|
|
117
|
+
for i in ids:
|
|
118
|
+
lines += ["", f"# {i.id}: {i.host} {' '.join(i.owners) or '(all owners)'}"]
|
|
119
|
+
for p in url_patterns(i):
|
|
120
|
+
lines += [section("includeIf", f"hasconfig:remote.*.url:{p}"), f"{INDENT}path = {gval(i.file)}"]
|
|
121
|
+
if i.ssh_alias and i.rewrite:
|
|
122
|
+
# Send this identity's URLs through the alias, whose ssh Host offers only its key.
|
|
123
|
+
for o in _owner_prefixes(i):
|
|
124
|
+
if "*" in o:
|
|
125
|
+
continue
|
|
126
|
+
srcs = [f"{i.ssh_user}@{i.host}:{o}", f"ssh://{i.ssh_user}@{i.host}/{o}"]
|
|
127
|
+
if i.rewrite_https:
|
|
128
|
+
srcs.append(f"https://{i.host}/{o}")
|
|
129
|
+
lines.append(section("url", f"{i.ssh_user}@{i.ssh_alias}:{o}"))
|
|
130
|
+
lines += [f"{INDENT}insteadOf = {gval(s)}" for s in srcs]
|
|
131
|
+
return "\n".join(lines) + "\n"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def default_settings(d: Default, expand: Expand, signers_file: str | None = None) -> dict:
|
|
135
|
+
"""The git settings the managed file gives every repo: gitpro's base with [default.git] over it.
|
|
136
|
+
resolve reads the same dict, so `why` expects exactly what apply writes."""
|
|
137
|
+
# Never fall back to a guessed user@hostname identity.
|
|
138
|
+
base: dict = {"user": {"useConfigOnly": True}}
|
|
139
|
+
if d.name:
|
|
140
|
+
base["user"].update(name=d.name, email=d.email)
|
|
141
|
+
if d.sign:
|
|
142
|
+
base = merge(base, _sign_cfg(expand(d.ssh_key)))
|
|
143
|
+
if signers_file:
|
|
144
|
+
base = merge(base, {"gpg": {"ssh": {"allowedSignersFile": signers_file}}})
|
|
145
|
+
return merge(base, d.git)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def identity_settings(i: Identity, d: Default, expand: Expand) -> dict:
|
|
149
|
+
"""The git settings of i's generated file: gitpro's base with [identity.git] over it."""
|
|
150
|
+
base: dict = {"user": {"name": i.name, "email": i.email}}
|
|
151
|
+
if i.ssh_key:
|
|
152
|
+
base["core"] = {"sshCommand": f"ssh -i {_shell_path(i.ssh_key)} -o IdentitiesOnly=yes"}
|
|
153
|
+
if signs(i, d):
|
|
154
|
+
base = merge(base, _sign_cfg(expand(i.ssh_key or d.ssh_key)))
|
|
155
|
+
elif i.sign is False:
|
|
156
|
+
base = merge(base, {"commit": {"gpgsign": False}, "tag": {"gpgsign": False}})
|
|
157
|
+
return merge(base, i.git)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def identity_gitconfig(i: Identity, d: Default, expand: Expand) -> str:
|
|
161
|
+
return "\n".join([HEADER, *git_lines(identity_settings(i, d, expand), f"identity {i.id!r}.git")]) + "\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _ssh_block(name: str, key: str, host: str | None = None, user: str | None = None,
|
|
165
|
+
port: int | None = None) -> list[str]:
|
|
166
|
+
lines = [f"Host {name}"]
|
|
167
|
+
if host:
|
|
168
|
+
lines.append(f" HostName {host}")
|
|
169
|
+
if user:
|
|
170
|
+
lines.append(f" User {user}")
|
|
171
|
+
if port:
|
|
172
|
+
lines.append(f" Port {port}")
|
|
173
|
+
return lines + [f" IdentityFile {key}", " IdentitiesOnly yes", ""]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def ssh_config(d: Default, ids: list[Identity]) -> str:
|
|
177
|
+
lines = [HEADER, ""]
|
|
178
|
+
for h in d.ssh_hosts:
|
|
179
|
+
lines += _ssh_block(h, d.ssh_key)
|
|
180
|
+
for i in ids:
|
|
181
|
+
if i.ssh_alias:
|
|
182
|
+
lines += _ssh_block(i.ssh_alias, i.ssh_key, i.host, i.ssh_user, i.ssh_port)
|
|
183
|
+
return "\n".join(lines)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def allowed_signers(entries: list[tuple[str, str]]) -> str:
|
|
187
|
+
return "\n".join([HEADER, *(f'{email} namespaces="git" {pub}' for email, pub in entries)]) + "\n"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------- generated files, marked blocks in user files
|
|
191
|
+
|
|
192
|
+
def is_generated(text: str) -> bool:
|
|
193
|
+
"""The marker on line 1, or on line 2 after a #! line (hook scripts)."""
|
|
194
|
+
if _GENERATED_RE.match(text):
|
|
195
|
+
return True
|
|
196
|
+
first, nl, rest = text.partition("\n")
|
|
197
|
+
return bool(nl) and first.startswith("#!") and bool(_GENERATED_RE.match(rest))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def has_block(text: str) -> bool:
|
|
201
|
+
return bool(_MARK_RE.search(text))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def without_block(text: str) -> str:
|
|
205
|
+
return _BLOCK_RE.sub("", text)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _check_markers(text: str, where: str) -> None:
|
|
209
|
+
# An orphaned marker would make without_block swallow the hand-written text between two blocks.
|
|
210
|
+
marks = _MARK_RE.findall(text)
|
|
211
|
+
check(marks == ["BEGIN", "END"] * (len(marks) // 2),
|
|
212
|
+
f"{where}: unpaired '{BLOCK_BEGIN}' / '{BLOCK_END}' marker; fix it by hand")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def with_block(text: str, body: str, first: bool, where: str) -> str:
|
|
216
|
+
"""Exactly one marked block at the start or end of text; re-running is a no-op."""
|
|
217
|
+
_check_markers(text, where)
|
|
218
|
+
block = f"{BLOCK_BEGIN} -- keep {'first' if first else 'last'}\n{body}\n{BLOCK_END}\n"
|
|
219
|
+
rest = without_block(text)
|
|
220
|
+
if first:
|
|
221
|
+
rest = rest.lstrip("\n")
|
|
222
|
+
return block + ("\n" + rest if rest else "")
|
|
223
|
+
rest = rest.rstrip("\n")
|
|
224
|
+
return (rest + "\n\n" if rest else "") + block
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def strip_block(text: str, where: str) -> str:
|
|
228
|
+
"""text without the marked block and the blank lines with_block added around it."""
|
|
229
|
+
_check_markers(text, where)
|
|
230
|
+
rest = without_block(text).strip("\n")
|
|
231
|
+
return rest + "\n" if rest else ""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def manifest_of(text: str) -> list[str]:
|
|
235
|
+
return [line[len(MANIFEST):] for line in text.splitlines() if line.startswith(MANIFEST)]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------- guard hooks (06)
|
|
239
|
+
|
|
240
|
+
GUARD_HOOKS = ("pre-commit", "pre-push", "commit-msg") # run `gitpro guard check <name>`, then chain
|
|
241
|
+
# githooks(5) names a repo or a tool plausibly installs; with a global core.hooksPath git runs only the
|
|
242
|
+
# hooks in that dir, so each one forwards to the repo's own hook and to chain_to.
|
|
243
|
+
STUB_HOOKS = ("applypatch-msg", "pre-applypatch", "post-applypatch", "prepare-commit-msg", "post-commit",
|
|
244
|
+
"pre-merge-commit", "pre-rebase", "post-checkout", "post-merge", "pre-receive", "update",
|
|
245
|
+
"proc-receive", "post-receive", "post-update", "pre-auto-gc", "post-rewrite",
|
|
246
|
+
"sendemail-validate")
|
|
247
|
+
HOOK_NAMES = GUARD_HOOKS + STUB_HOOKS
|
|
248
|
+
# push-to-checkout: git skips its built-in receive.denyCurrentBranch=updateInstead deploy whenever the hook
|
|
249
|
+
# exists, so a stub would stop pushes from updating a checked-out tree.
|
|
250
|
+
NOT_FORWARDED = ("push-to-checkout", "reference-transaction", "post-index-change", "fsmonitor-watchman", "p4-*")
|
|
251
|
+
STDIN_HOOKS = ("pre-push", "pre-receive", "proc-receive", "post-receive", "post-rewrite")
|
|
252
|
+
LFS_HOOKS = ("pre-push", "post-checkout", "post-commit", "post-merge") # what `git lfs install` writes
|
|
253
|
+
MAX_HOOK_DEPTH = 8 # nested hook runs before a script assumes a loop and stops forwarding
|
|
254
|
+
GUARD_INCLUDE_COMMENT = "# guard hooks: gitpro guard install, or [guard] hooks = true"
|
|
255
|
+
|
|
256
|
+
_HOOK_HEAD = """\
|
|
257
|
+
#!/bin/sh
|
|
258
|
+
{header}
|
|
259
|
+
# gitpro {what}: {name}. Skip gitpro once: GITPRO_SKIP=1 git ... (git --no-verify skips every hook)
|
|
260
|
+
# {after}
|
|
261
|
+
|
|
262
|
+
depth=${{GITPRO_HOOK_DEPTH:-0}}
|
|
263
|
+
case $depth in ''|*[!0-9]*) depth=0 ;; esac
|
|
264
|
+
if [ "$depth" -ge {max_depth} ]; then
|
|
265
|
+
echo "⚠ gitpro guard: hooks nested $depth deep (a hook loop?); not running {name} hooks" >&2
|
|
266
|
+
exit 0
|
|
267
|
+
fi
|
|
268
|
+
GITPRO_HOOK_DEPTH=$((depth + 1)); export GITPRO_HOOK_DEPTH
|
|
269
|
+
# An exported CDPATH makes cd search it (and print the dir) for a relative path like .git.
|
|
270
|
+
unset CDPATH
|
|
271
|
+
|
|
272
|
+
own_dir=$(cd -P -- "$(dirname -- "$0")" >/dev/null 2>&1 && pwd)
|
|
273
|
+
# --git-common-dir may be relative to the cwd (.git); cd -P makes it absolute on every git (no
|
|
274
|
+
# --path-format, which git < 2.31 lacks). ./ keeps cd from searching CDPATH even if it were set.
|
|
275
|
+
common=$(git rev-parse --git-common-dir 2>/dev/null) || common=""
|
|
276
|
+
case $common in ''|/*) ;; *) common="./$common" ;; esac
|
|
277
|
+
if [ -n "$common" ]; then common=$(cd -P -- "$common" >/dev/null 2>&1 && pwd) || common=""; fi
|
|
278
|
+
repo_hook="$common/hooks/{name}"
|
|
279
|
+
run_repo=""
|
|
280
|
+
# Never run a hook from gitpro's own dir, nor one that is (a link to) this script: that would loop.
|
|
281
|
+
# test -ef is not POSIX but dash, bash, zsh, ksh and busybox sh all have it.
|
|
282
|
+
if [ -n "$common" ] && [ -x "$repo_hook" ] && [ "$(cd -P -- "$(dirname -- "$repo_hook")" >/dev/null 2>&1 && pwd)" != "$own_dir" ] \
|
|
283
|
+
&& ! [ "$repo_hook" -ef "$own_dir/{name}" ]; then
|
|
284
|
+
run_repo=1
|
|
285
|
+
fi
|
|
286
|
+
chain={chain}
|
|
287
|
+
run_chain=""
|
|
288
|
+
if [ -n "$chain" ] && [ -x "$chain/{name}" ] && [ "$(cd -P -- "$chain" >/dev/null 2>&1 && pwd)" != "$own_dir" ] \
|
|
289
|
+
&& ! [ "$chain/{name}" -ef "$own_dir/{name}" ]; then
|
|
290
|
+
run_chain=1
|
|
291
|
+
fi
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
_STDIN_ALWAYS = """
|
|
295
|
+
tmp=$(mktemp "${TMPDIR:-/tmp}/gitpro-hook.XXXXXX" 2>/dev/null) || tmp=""
|
|
296
|
+
if [ -n "$tmp" ]; then trap 'rm -f "$tmp"' EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM
|
|
297
|
+
cat >"$tmp"; fi
|
|
298
|
+
in_tmp() { if [ -n "$tmp" ]; then "$@" <"$tmp"; else "$@"; fi; }
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
# A stub keeps stdin only when two hooks need it; one hook reads git's stdin directly (proc-receive talks
|
|
302
|
+
# to git over stdin/stdout, so it must never be buffered).
|
|
303
|
+
_STDIN_SHARED = """
|
|
304
|
+
tmp=""
|
|
305
|
+
if [ -n "$run_repo" ] && [ -n "$run_chain" ]; then
|
|
306
|
+
tmp=$(mktemp "${TMPDIR:-/tmp}/gitpro-hook.XXXXXX" 2>/dev/null) || tmp=""
|
|
307
|
+
if [ -n "$tmp" ]; then trap 'rm -f "$tmp"' EXIT; trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM
|
|
308
|
+
cat >"$tmp"; fi
|
|
309
|
+
fi
|
|
310
|
+
in_tmp() { if [ -n "$tmp" ]; then "$@" <"$tmp"; else "$@"; fi; }
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
_NO_STDIN = """
|
|
314
|
+
in_tmp() { "$@"; }
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
# The fail-open warning prints once per git command: pre-commit clears $once on every run and notes git's
|
|
318
|
+
# pid ($PPID) in it when it warns; commit-msg of the same git process then stays quiet and removes the
|
|
319
|
+
# file. GITPRO_GIT_PID / GITPRO_WARNED_FILE let `guard check` do the same for its own warnings. A file
|
|
320
|
+
# left by an aborted commit is cleared by the next pre-commit; gitpro guard uninstall does not visit repos.
|
|
321
|
+
_CHECK = """
|
|
322
|
+
if [ -z "${{GITPRO_SKIP:-}}" ]; then
|
|
323
|
+
gp=""
|
|
324
|
+
if command -v gitpro >/dev/null 2>&1; then gp=gitpro
|
|
325
|
+
elif [ -x "$HOME/.local/bin/gitpro" ]; then gp="$HOME/.local/bin/gitpro"
|
|
326
|
+
elif [ -x {gitpro_bin} ]; then gp={gitpro_bin}
|
|
327
|
+
fi
|
|
328
|
+
if [ -n "$gp" ]; then
|
|
329
|
+
once=""; if [ -n "$common" ]; then once="$common/gitpro-guard-warned"; fi
|
|
330
|
+
GITPRO_GIT_PID=$PPID; GITPRO_WARNED_FILE=$once; export GITPRO_GIT_PID GITPRO_WARNED_FILE
|
|
331
|
+
{clear} rc=0
|
|
332
|
+
{run}
|
|
333
|
+
if [ "$rc" -eq 3 ]; then exit 1; fi
|
|
334
|
+
if [ "$rc" -ne 0 ]; then
|
|
335
|
+
{warn}
|
|
336
|
+
fi
|
|
337
|
+
{consume} fi
|
|
338
|
+
fi
|
|
339
|
+
"""
|
|
340
|
+
_RUN_TIMEOUT = """\
|
|
341
|
+
if command -v timeout >/dev/null 2>&1; then in_tmp {timeout} "$gp" guard check {name} "$@" || rc=$?
|
|
342
|
+
else in_tmp "$gp" guard check {name} "$@" || rc=$?
|
|
343
|
+
fi"""
|
|
344
|
+
_RUN_PLAIN = """\
|
|
345
|
+
in_tmp "$gp" guard check {name} "$@" || rc=$?"""
|
|
346
|
+
_CLEAR_ONCE = """\
|
|
347
|
+
if [ -n "$once" ] && [ -e "$once" ]; then rm -f "$once" 2>/dev/null; fi
|
|
348
|
+
"""
|
|
349
|
+
_WARN = """\
|
|
350
|
+
echo "⚠ gitpro guard: exited $rc; not blocking" >&2"""
|
|
351
|
+
_WARN_FIRST = _WARN + """
|
|
352
|
+
if [ -n "$once" ]; then { echo "$PPID" >"$once"; } 2>/dev/null; fi"""
|
|
353
|
+
_WARN_AGAIN = """\
|
|
354
|
+
if [ -z "$once" ] || [ "$(cat "$once" 2>/dev/null)" != "$PPID" ]; then
|
|
355
|
+
echo "⚠ gitpro guard: exited $rc; not blocking" >&2
|
|
356
|
+
fi"""
|
|
357
|
+
|
|
358
|
+
# `git lfs install` writes .git/hooks/<name> calling `git lfs <name>`; under a global core.hooksPath those
|
|
359
|
+
# files would not run, so the hook calls it unless the repo's own hook already does.
|
|
360
|
+
_LFS = """
|
|
361
|
+
if [ -n "$(git config filter.lfs.process 2>/dev/null)" ] && command -v git-lfs >/dev/null 2>&1; then
|
|
362
|
+
if [ ! -f "$repo_hook" ] || ! grep -q -e 'git lfs' -e 'git-lfs' "$repo_hook" 2>/dev/null; then
|
|
363
|
+
in_tmp git lfs {name} "$@" || exit $?
|
|
364
|
+
fi
|
|
365
|
+
fi
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
_TAIL = """
|
|
369
|
+
if [ -n "$run_repo" ]; then in_tmp "$repo_hook" "$@" || exit $?; fi
|
|
370
|
+
if [ -n "$run_chain" ]; then in_tmp "$chain/{name}" "$@" || exit $?; fi
|
|
371
|
+
exit 0
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _hook(name: str, chain_to: str | None, gitpro_bin: str | None, check_first: bool, timeout: str = "") -> str:
|
|
376
|
+
chain = shlex.quote(str(chain_to)) if chain_to else "''"
|
|
377
|
+
after = ("After the gitpro check, the repo's own hook runs (<common dir>/hooks/{n}), then the chain_to hook."
|
|
378
|
+
if check_first else "Runs the repo's own hook (<common dir>/hooks/{n}), then the chain_to hook.")
|
|
379
|
+
text = _HOOK_HEAD.format(header=HEADER, what="guard hook" if check_first else "pass-through hook",
|
|
380
|
+
name=name, after=after.format(n=name), max_depth=MAX_HOOK_DEPTH, chain=chain)
|
|
381
|
+
if check_first and name == "pre-push":
|
|
382
|
+
text += _STDIN_ALWAYS
|
|
383
|
+
elif name in STDIN_HOOKS:
|
|
384
|
+
text += _STDIN_SHARED
|
|
385
|
+
else:
|
|
386
|
+
text += _NO_STDIN
|
|
387
|
+
if check_first:
|
|
388
|
+
run = (_RUN_TIMEOUT.format(timeout=timeout, name=name) if timeout else _RUN_PLAIN.format(name=name))
|
|
389
|
+
warn = {"pre-commit": _WARN_FIRST, "commit-msg": _WARN_AGAIN}.get(name, _WARN)
|
|
390
|
+
text += _CHECK.format(gitpro_bin=shlex.quote(str(gitpro_bin)) if gitpro_bin else "''", run=run,
|
|
391
|
+
warn=warn, clear=_CLEAR_ONCE if name == "pre-commit" else "",
|
|
392
|
+
consume=_CLEAR_ONCE if name == "commit-msg" else "")
|
|
393
|
+
if name in LFS_HOOKS:
|
|
394
|
+
text += _LFS.format(name=name)
|
|
395
|
+
return text + _TAIL.format(name=name)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def hook_script(name: str, chain_to: str | None, gitpro_bin: str | None, timeout: str = "") -> str:
|
|
399
|
+
"""The global chain script for pre-commit / pre-push / commit-msg: `gitpro guard check <name>` (only
|
|
400
|
+
exit 3 blocks; any other failure warns and continues), then the repo's own hook, then chain_to's.
|
|
401
|
+
timeout: a command prefix that bounds the check (`timeout --foreground 30`), "" for none."""
|
|
402
|
+
check(name in GUARD_HOOKS, f"not a guard hook: {name}")
|
|
403
|
+
return _hook(name, chain_to, gitpro_bin, True, timeout)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def hook_stub(name: str, chain_to: str | None) -> str:
|
|
407
|
+
"""The pass-through script for every other hook name: the repo's own hook, then chain_to's."""
|
|
408
|
+
check(name in STUB_HOOKS, f"not a pass-through hook: {name}")
|
|
409
|
+
return _hook(name, chain_to, None, False)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def hooks_readme() -> str:
|
|
413
|
+
return (f"{HEADER}\n\n"
|
|
414
|
+
"These are gitpro's guard hooks, used by git for every repo through core.hooksPath. pre-commit and "
|
|
415
|
+
"pre-push refuse a commit or push whose committer email is not the identity the remote belongs "
|
|
416
|
+
"to; every hook then runs the repo's own hook (.git/hooks/<name>) and the [guard] chain_to hook. "
|
|
417
|
+
"If gitpro fails, the commit goes through. Skip gitpro's check once with GITPRO_SKIP=1 git ...; "
|
|
418
|
+
"git --no-verify skips every hook. Remove them with: gitpro guard uninstall. To warn once per "
|
|
419
|
+
"commit, pre-commit may leave <git dir>/gitpro-guard-warned behind (cleared on the next commit; "
|
|
420
|
+
"uninstall leaves it, it is safe to delete). Not forwarded "
|
|
421
|
+
f"(repos that need them keep their own core.hooksPath): {', '.join(NOT_FORWARDED)}.\n")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def hooks_gitconfig(hooks_dir: Path) -> str:
|
|
425
|
+
"""~/.gitconfig-gitpro-guard: the absolute core.hooksPath."""
|
|
426
|
+
return f"{HEADER}\n[core]\n{INDENT}hooksPath = {gval(str(hooks_dir))}\n"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _guard_section(include: str) -> list[str]:
|
|
430
|
+
return [GUARD_INCLUDE_COMMENT, "[include]", f"{INDENT}path = {gval(include)}"]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def without_guard_include(text: str, include: str, paths: list[str]) -> str:
|
|
434
|
+
"""The managed gitconfig without the guard include section and without `paths` in its manifest."""
|
|
435
|
+
lines = text.splitlines()
|
|
436
|
+
sec = _guard_section(include)
|
|
437
|
+
for n in range(len(lines) - len(sec), -1, -1):
|
|
438
|
+
if lines[n:n + len(sec)] == sec:
|
|
439
|
+
del lines[n:n + len(sec)]
|
|
440
|
+
if n and n <= len(lines) and not lines[n - 1].strip():
|
|
441
|
+
del lines[n - 1]
|
|
442
|
+
drop = {MANIFEST + p for p in paths}
|
|
443
|
+
lines = [ln for ln in lines if ln not in drop]
|
|
444
|
+
return "\n".join(lines) + "\n" if lines else ""
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def with_guard_include(text: str, include: str, paths: list[str]) -> str:
|
|
448
|
+
"""The managed gitconfig with `paths` merged into its sorted manifest and the include of the guard
|
|
449
|
+
gitconfig as its last section. Applying it twice changes nothing."""
|
|
450
|
+
lines = without_guard_include(text, include, paths).rstrip("\n").splitlines()
|
|
451
|
+
idx = [n for n, ln in enumerate(lines) if ln.startswith(MANIFEST)]
|
|
452
|
+
entries = sorted(set(manifest_of("\n".join(lines))) | set(paths))
|
|
453
|
+
at = idx[0] if idx else 1
|
|
454
|
+
lines = [ln for ln in lines if not ln.startswith(MANIFEST)]
|
|
455
|
+
lines[at:at] = [MANIFEST + p for p in entries]
|
|
456
|
+
return "\n".join(lines + [""] + _guard_section(include)) + "\n"
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def guard_block(name: str) -> str:
|
|
460
|
+
"""The repo-mode managed block for a hand-written hook (fails open; only exit 3 refuses)."""
|
|
461
|
+
if name == "pre-push":
|
|
462
|
+
body = ("if [ -z \"${GITPRO_SKIP:-}\" ] && command -v gitpro >/dev/null 2>&1; then\n"
|
|
463
|
+
" rc=0; tmp=$(mktemp \"${TMPDIR:-/tmp}/gitpro-prepush.XXXXXX\" 2>/dev/null) || tmp=\"\"\n"
|
|
464
|
+
" if [ -n \"$tmp\" ]; then cat >\"$tmp\"; gitpro guard check pre-push \"$@\" <\"$tmp\" || rc=$?; "
|
|
465
|
+
"exec <\"$tmp\"; rm -f \"$tmp\"\n"
|
|
466
|
+
" else gitpro guard check pre-push \"$@\" || rc=$?; fi\n"
|
|
467
|
+
" if [ \"$rc\" -eq 3 ]; then exit 1; fi\n"
|
|
468
|
+
"fi\n")
|
|
469
|
+
else:
|
|
470
|
+
body = ("if [ -z \"${GITPRO_SKIP:-}\" ] && command -v gitpro >/dev/null 2>&1; then\n"
|
|
471
|
+
f" rc=0; gitpro guard check {name} \"$@\" || rc=$?\n"
|
|
472
|
+
" if [ \"$rc\" -eq 3 ]; then exit 1; fi\n"
|
|
473
|
+
"fi\n")
|
|
474
|
+
return f"{BLOCK_BEGIN} -- keep first\n{body}{BLOCK_END}\n"
|