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