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/agents.py ADDED
@@ -0,0 +1,1928 @@
1
+ """AI agent hooks: `gitpro agent-check` (Claude Code PreToolUse, experimental Cursor/Gemini/generic) and
2
+ `gitpro agent install|uninstall|status`.
3
+
4
+ agent-check reads the agent's hook JSON on stdin, finds the git/gh/glab commands in the shell command
5
+ the agent is about to run, and judges each repo the way the git guard does. On a mismatch it denies
6
+ the call with a reason that tells the agent to ask the user. It never grants anything: silence means
7
+ "no opinion" and the agent's own permission flow applies. Any failure fails open (exit 0, a warning
8
+ on stderr), so a broken gitpro never stops an agent.
9
+
10
+ Hot path: module level imports only the standard library; config and resolve load only once a
11
+ relevant command is found, so the ignore path stays fast.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import copy
17
+ import json
18
+ import os
19
+ import re
20
+ import shlex
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ from dataclasses import dataclass, field, replace
25
+ from pathlib import Path
26
+ from typing import Callable, Dict, List, Optional, Tuple
27
+
28
+ PROG = "gitpro"
29
+ MARKER = "gitpro agent-check" # how an installed hook entry is recognised
30
+ HOOK_TIMEOUT = 10 # seconds, Claude Code's per-hook timeout
31
+ MAX_DEPTH = 4 # sh -c / $(...) / eval nesting followed by the tokeniser
32
+ MAX_PARSE = 64 * 1024 # longer commands go straight to the regex scan
33
+ MAX_SCAN = 1024 * 1024 # the regex scan reads at most this much (the rest is not checked)
34
+ MAX_FOR = 20 # `for x in w1 .. wN` with more words: the body is judged once, x unknown
35
+ MAX_VALUE = 4096 # a longer expanded $VAR value counts as unknown
36
+ ALIAS_TIMEOUT = 3 # seconds for the `git config --get alias.X` lookup on the hook path
37
+ TRIGGERS = ("override", "pin_stale", "no_identity", "env_override")
38
+ SKIP_ENV = "GITPRO_SKIP"
39
+ BYPASS_HELP = (f"{SKIP_ENV}=1 in the environment the agent starts with turns the check off for that session; "
40
+ "[agents] hook = \"warn\" or \"off\" in the config changes it for good. The deny message never "
41
+ "mentions this: the agent is told to ask you.")
42
+
43
+ GIT_SUBS = {"commit", "push", "am", "cherry-pick", "rebase", "merge", "revert", "tag", "notes", "commit-tree"}
44
+ # git commands that are not aliases (an unknown subcommand may be a config alias, looked up at judge time)
45
+ GIT_BUILTINS = GIT_SUBS | {
46
+ "add", "annotate", "apply", "archive", "bisect", "blame", "branch", "bugreport", "bundle", "cat-file",
47
+ "check-attr", "check-ignore", "check-mailmap", "check-ref-format", "checkout", "checkout-index", "citool",
48
+ "clean", "clone", "column", "config", "count-objects", "credential", "describe", "diff", "diff-files",
49
+ "diff-index", "diff-tree", "difftool", "fast-export", "fast-import", "fetch", "fetch-pack", "filter-branch",
50
+ "for-each-ref", "for-each-repo", "format-patch", "fsck", "gc", "get-tar-commit-id", "grep", "gui", "hash-object",
51
+ "help", "hook", "http-backend", "index-pack", "init", "instaweb", "interpret-trailers", "lfs", "log",
52
+ "ls-files", "ls-remote", "ls-tree", "mailinfo", "mailsplit", "maintenance", "merge-base", "merge-file",
53
+ "merge-tree", "mergetool", "mktag", "mktree", "multi-pack-index", "mv", "name-rev", "pack-objects",
54
+ "pack-refs", "prune", "prune-packed", "pull", "range-diff", "read-tree", "reflog", "remote", "repack",
55
+ "replace", "request-pull", "rerere", "reset", "restore", "rev-list", "rev-parse", "rm", "send-email",
56
+ "send-pack", "shortlog", "show", "show-branch", "show-index", "show-ref", "sparse-checkout", "stash",
57
+ "status", "stripspace", "submodule", "switch", "symbolic-ref", "unpack-file", "unpack-objects",
58
+ "update-index", "update-ref", "update-server-info", "var", "verify-commit", "verify-pack", "verify-tag",
59
+ "version", "whatchanged", "worktree", "write-tree",
60
+ }
61
+ UNDO_FLAGS = {"--abort", "--quit", "--skip"} # rebase/merge/... that create no commit
62
+ UNDOABLE = {"rebase", "merge", "cherry-pick", "am", "revert"}
63
+ GH_SUBS = {("pr", "create"), ("pr", "merge"), ("pr", "close"), ("release", "create"), ("repo", "create"),
64
+ ("repo", "delete")}
65
+ GLAB_SUBS = {("mr", "create"), ("mr", "merge"), ("release", "create"), ("repo", "create")}
66
+ HUB_SUBS = {("pull-request",), ("create",), ("delete",), ("release", "create")}
67
+ HUB_COMMANDS = {"pull-request", "create", "delete", "fork", "pr", "issue", "release", "browse", "compare",
68
+ "ci-status", "sync", "api", "gist", "alias"}
69
+ API_TAKES = {"-X", "--method", "-f", "-F", "--field", "--raw-field", "-H", "--header", "--input", "-q", "--jq",
70
+ "-t", "--template", "--cache", "-p", "--preview", "--hostname", "-R", "--repo"}
71
+ WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
72
+ SHELLS = {"sh", "bash", "zsh", "dash", "ksh"}
73
+ # shell reserved words skipped in command position (`for`/`select`/`case` drop the whole header)
74
+ RESERVED = {"if", "then", "else", "elif", "fi", "do", "done", "while", "until", "{", "}", "!", "in", "esac"}
75
+ CMD_LEAD = {"if", "then", "else", "elif", "do", "while", "until", "{", "!", "time"} # a command follows
76
+ # command wrappers skipped before the real program, with the options that take a value
77
+ WRAPPERS: Dict[str, set] = {
78
+ "env": {"-u", "--unset", "-S", "--split-string"},
79
+ "sudo": {"-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-T", "-U", "--user", "--group", "--host",
80
+ "--prompt", "--chdir", "--role", "--type", "--command-timeout", "--other-user", "--close-from"},
81
+ "doas": {"-u", "-C"},
82
+ "command": set(),
83
+ "builtin": set(),
84
+ "nice": {"-n", "--adjustment"},
85
+ "ionice": {"-c", "-n", "-p", "-P", "-u", "--class", "--classdata", "--pid", "--pgid", "--uid"},
86
+ "time": {"-f", "--format", "-o", "--output"},
87
+ "xargs": {"-a", "-d", "-E", "-I", "-L", "-n", "-P", "-s", "--arg-file", "--delimiter", "--max-args",
88
+ "--max-procs", "--max-chars", "--max-lines"},
89
+ "exec": {"-a"},
90
+ "nohup": set(),
91
+ "setsid": set(),
92
+ "chronic": set(),
93
+ "unbuffer": set(),
94
+ "timeout": {"-s", "--signal", "-k", "--kill-after"},
95
+ "stdbuf": {"-i", "-o", "-e"},
96
+ "strace": {"-e", "-o", "-p", "-s", "-u", "-E", "-a", "-b", "-I", "-O", "-S", "-P", "-X", "--output",
97
+ "--attach", "--user", "--env", "--string-limit"},
98
+ "flock": {"-w", "--wait", "--timeout", "-E", "--conflict-exit-code"},
99
+ "watch": {"-n", "--interval", "-q", "--equexit"},
100
+ }
101
+ PUNCT = "();<>|&\n"
102
+ _ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
103
+ _SUB_RE = re.compile(r"__gitpro_sub(\d+)_(\d+)__") # depth, index
104
+ _NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
105
+ _VAR_RE = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
106
+ _HEREDOC_RE = re.compile(r"<<(?!<)-?[ \t]*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
107
+ _WRITE_API = r"api\b(?=[^\n;&|]{0,300}?(?:-X\s*|--method[=\s]+)(?:POST|PUT|PATCH|DELETE)\b)"
108
+ _FALLBACK = (
109
+ # bounded option run and no `git` taken as an option value: linear even on `git -C git -C ...`
110
+ ("git", re.compile(r"\bgit\b(?:\s+(?:-C\s+(?!git\b)(?P<dir>[^\s;&|]+)|-c\s+(?!git\b)\S+|--[\w-]+(?:=\S*)?"
111
+ r"|-(?![cC]\s)[\w-]+)){0,16}"
112
+ r"\s+(?P<sub>commit|push|am|cherry-pick|rebase|merge|revert|tag)\b")),
113
+ ("gh", re.compile(r"\bgh\s+(?P<sub>pr\s+(?:create|merge|close)|release\s+create|repo\s+(?:create|delete)|"
114
+ + _WRITE_API + ")")),
115
+ ("glab", re.compile(r"\bglab\s+(?P<sub>mr\s+(?:create|merge)|release\s+create|repo\s+create|"
116
+ + _WRITE_API + ")")),
117
+ )
118
+ AGENT_CHOICES = ("claude", "cursor", "gemini", "generic")
119
+ INSTALL_CHOICES = ("claude", "cursor", "gemini", "codex", "opencode")
120
+ NO_HOOK = ("codex", "opencode")
121
+ EXPERIMENTAL = ("cursor", "gemini")
122
+
123
+
124
+ # ---------------------------------------------------------------- tokeniser
125
+
126
+ @dataclass
127
+ class Call:
128
+ """One simple command of the agent's shell line."""
129
+ prog: str
130
+ words: List[str]
131
+ sub: Optional[str] = None # git: "commit"; gh/glab: "pr create"
132
+ dir: Optional[str] = None # from cd / git -C / env -C / GIT_DIR, relative to cwd; None = cwd
133
+ remote: Optional[str] = None # git push <remote>
134
+ author: Optional[str] = None # email from --author
135
+ config_email: Optional[str] = None # git -c user.email=...
136
+ env: Dict[str, str] = field(default_factory=dict) # VAR=val prefixes and earlier `export`s
137
+ unset: set = field(default_factory=set) # `unset VAR` / `env -u VAR`: not inherited
138
+ clear: bool = False # `env -i`: nothing inherited
139
+ relevant: bool = False
140
+ fallback: bool = False # found by the regex scan after the tokeniser failed
141
+ alias: bool = False # git: unknown subcommand, maybe a config alias
142
+ args: List[str] = field(default_factory=list) # git: the words after the subcommand
143
+
144
+ @property
145
+ def author_env(self) -> Optional[str]:
146
+ return self.env.get("GIT_AUTHOR_EMAIL") or None
147
+
148
+ @property
149
+ def committer_env(self) -> Optional[str]:
150
+ return self.env.get("GIT_COMMITTER_EMAIL") or None
151
+
152
+ def repo_dir(self, cwd: str) -> str:
153
+ """The directory to judge. A part the hook cannot know ($(...), an unset $VAR) cuts the path
154
+ there: the judgement falls back to what is known (in the end the cwd), never to 'not a repo'."""
155
+ if not self.dir:
156
+ return os.path.normpath(cwd)
157
+ parts = os.path.join(cwd, self.dir).split(os.sep)
158
+ for k, part in enumerate(parts):
159
+ if "$" in part or "`" in part or "__gitpro_sub" in part:
160
+ parts = parts[:k]
161
+ break
162
+ known = os.sep.join(parts)
163
+ return os.path.normpath(known) if known else os.path.normpath(cwd)
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class Ctx:
168
+ """What earlier commands of the line change for later ones: the dir (cd, cd -, pushd/popd), exported
169
+ variables (export/unset, env -i) and plain shell variables (NAME=value)."""
170
+ dir: Optional[str] = None # None: the cwd
171
+ env: Tuple[Tuple[str, Optional[str]], ...] = () # exported (name, value); None: unset
172
+ clear: bool = False
173
+ shvars: Tuple[Tuple[str, str], ...] = () # unexported shell variables set in the line
174
+ oldpwd: Optional[str] = "$OLDPWD" # for `cd -`: None is the cwd, "$OLDPWD" unknown
175
+ dirs: Tuple[Optional[str], ...] = () # the pushd stack
176
+
177
+ def derive(self, d: Optional[str] = None, env: Optional[Dict[str, str]] = None, unset=(),
178
+ clear: bool = False) -> "Ctx":
179
+ merged: Dict[str, Optional[str]] = {} if clear else dict(self.env)
180
+ for name in unset:
181
+ merged[name] = None
182
+ merged.update(env or {})
183
+ return replace(self, dir=_join(self.dir, d), env=tuple(merged.items()), clear=self.clear or clear)
184
+
185
+ def value(self, name: str) -> Optional[str]:
186
+ """$name as the shell expands it here, or None when the hook cannot know it."""
187
+ v = dict(self.shvars).get(name)
188
+ if v is not None:
189
+ return v
190
+ env = dict(self.env)
191
+ if name in env:
192
+ return "" if env[name] is None else env[name]
193
+ if self.clear or name in ("PWD", "OLDPWD"):
194
+ return None
195
+ return os.environ.get(name)
196
+
197
+ def exported(self, name: str) -> bool:
198
+ env = dict(self.env)
199
+ if name in env:
200
+ return env[name] is not None
201
+ return name not in dict(self.shvars) and not self.clear and name in os.environ
202
+
203
+ def expand(self, word: str) -> str:
204
+ """word with each $NAME / ${NAME} the hook knows replaced by its value; the others stay, so a
205
+ repo dir is cut there (Call.repo_dir)."""
206
+ if "$" not in word:
207
+ return word
208
+
209
+ def one(m: "re.Match[str]") -> str:
210
+ v = self.value(m.group(1) or m.group(2))
211
+ return m.group(0) if v is None else v
212
+ out = _VAR_RE.sub(one, word)
213
+ return out if len(out) <= MAX_VALUE else word # `R=$R$R` doubling: stays unknown
214
+
215
+ def assign(self, name: str, value: str) -> "Ctx":
216
+ """NAME=value: an exported variable stays exported."""
217
+ if self.exported(name):
218
+ return self.derive(env={name: value})
219
+ return replace(self, shvars=_set(self.shvars, name, value))
220
+
221
+ def export(self, name: str, value: Optional[str] = None) -> "Ctx":
222
+ if value is None:
223
+ value = dict(self.shvars).get(name)
224
+ if value is None:
225
+ return self # exported already, or not known
226
+ return replace(self.derive(env={name: value}), shvars=_drop(self.shvars, name))
227
+
228
+ def unexport(self, name: str) -> "Ctx":
229
+ v = self.value(name) if self.exported(name) else None
230
+ c = self.derive(unset=[name])
231
+ return replace(c, shvars=_set(c.shvars, name, v)) if v is not None else c
232
+
233
+ def unset_var(self, name: str) -> "Ctx":
234
+ return replace(self.derive(unset=[name]), shvars=_drop(self.shvars, name))
235
+
236
+ def unknown(self, name: str) -> "Ctx":
237
+ """name holds something the hook cannot know (a `for` it does not unroll)."""
238
+ return replace(self, shvars=_set(self.shvars, name, "$" + name))
239
+
240
+ def cd(self, target: str) -> "Ctx":
241
+ return replace(self, dir=_join(self.dir, target), oldpwd=self.dir)
242
+
243
+ def back(self) -> "Ctx":
244
+ """`cd -`."""
245
+ return replace(self, dir=self.oldpwd, oldpwd=self.dir)
246
+
247
+ def seed(self, shell_vars: bool) -> "Ctx":
248
+ """The context a nested parse starts from: a child shell sees the exported variables, an eval or
249
+ a $(...) also the plain ones. The dir is applied afterwards (_rebase)."""
250
+ return Ctx(env=self.env, clear=self.clear, shvars=self.shvars if shell_vars else ())
251
+
252
+
253
+ def _set(pairs: tuple, name: str, value) -> tuple:
254
+ return _drop(pairs, name) + ((name, value),)
255
+
256
+
257
+ def _drop(pairs: tuple, name: str) -> tuple:
258
+ return tuple((k, v) for k, v in pairs if k != name)
259
+
260
+
261
+ def _apply(c: Call, ctx: Ctx) -> Call:
262
+ """c as run after the context: its dir relative to ctx's, ctx's env under its own."""
263
+ c.dir = _join(ctx.dir, c.dir)
264
+ if c.clear:
265
+ return c
266
+ outer = {k: v for k, v in ctx.env if k not in c.env and k not in c.unset}
267
+ c.env = {**{k: v for k, v in outer.items() if v is not None}, **c.env}
268
+ c.unset = c.unset | {k for k, v in outer.items() if v is None}
269
+ c.clear = ctx.clear
270
+ return c
271
+
272
+
273
+ def _join(base: Optional[str], d: Optional[str]) -> Optional[str]:
274
+ """d (already variable-expanded by Ctx.expand) relative to base."""
275
+ if d is None:
276
+ return base
277
+ d = os.path.expanduser(d)
278
+ if base is None or os.path.isabs(d):
279
+ return d
280
+ return os.path.join(base, d)
281
+
282
+
283
+ def _here_word(body: str, quoted: bool) -> str:
284
+ """A here-document body as one shell word: single-quoted when the delimiter was quoted (nothing
285
+ expands), else double-quoted, so a $(...) or `...` in it is still found."""
286
+ if quoted:
287
+ return "'" + body.replace("'", "'\"'\"'") + "'"
288
+ return '"' + body.replace('"', '\\"') + '"'
289
+
290
+
291
+ def _drop_heredocs(cmd: str) -> str:
292
+ """Here-documents. Outside any parentheses `<<DELIM` and its body become a here-string `<<< 'body'`,
293
+ which a shell reading stdin (`sh <<'EOF'`) runs as its script. Inside ( or $(...) the body is
294
+ dropped and the `<<DELIM` stays (its delimiter is a redirection target). A `<<` inside quotes or a
295
+ comment is not a here-document."""
296
+ if "<<" not in cmd:
297
+ return cmd
298
+ out: List[str] = []
299
+ pending: List[Tuple[str, bool, bool, int]] = [] # (delimiter, <<-, quoted, its index in out or -1)
300
+ outer: List[str] = [] # the quote state around each open ( or $(: quotes restart inside
301
+ i, n, q = 0, len(cmd), ""
302
+ while i < n:
303
+ c = cmd[i]
304
+ if q == "'":
305
+ q = "" if c == "'" else q
306
+ elif c == "\\":
307
+ out.append(cmd[i:i + 2])
308
+ i += 2
309
+ continue
310
+ elif cmd.startswith("$(", i) or (c == "(" and not q):
311
+ outer.append(q)
312
+ q = ""
313
+ out.append(cmd[i:i + 2] if c == "$" else c)
314
+ i += 2 if c == "$" else 1
315
+ continue
316
+ elif c == ")" and not q and outer:
317
+ q = outer.pop()
318
+ elif q == '"':
319
+ q = "" if c == '"' else q
320
+ elif c in "'\"":
321
+ q = c
322
+ elif c == "#" and (i == 0 or cmd[i - 1] in " \t\n;&|()"):
323
+ j = cmd.find("\n", i)
324
+ j = n if j < 0 else j
325
+ out.append(cmd[i:j])
326
+ i = j
327
+ continue
328
+ elif c == "<" and (i == 0 or cmd[i - 1] != "<"):
329
+ m = _HEREDOC_RE.match(cmd, i)
330
+ if m:
331
+ inline = not outer
332
+ pending.append((m.group(2), m.group(0)[2:3] == "-", bool(m.group(1)), len(out) if inline else -1))
333
+ out.append("" if inline else m.group(0))
334
+ i = m.end()
335
+ continue
336
+ elif c == "\n" and pending:
337
+ out.append(c)
338
+ i += 1
339
+ for delim, dash, quoted, at in pending:
340
+ lines: List[str] = []
341
+ while i < n:
342
+ j = cmd.find("\n", i)
343
+ end = n if j < 0 else j
344
+ line, i = cmd[i:end], end + 1
345
+ line = line.lstrip("\t") if dash else line
346
+ if line.strip() == delim:
347
+ break
348
+ lines.append(line)
349
+ if at >= 0:
350
+ out[at] = "<<< " + _here_word("".join(x + "\n" for x in lines), quoted)
351
+ pending = []
352
+ continue
353
+ out.append(c)
354
+ i += 1
355
+ return "".join(out)
356
+
357
+
358
+ def _parens(cmd: str) -> Dict[int, int]:
359
+ """Position of each "(" -> its matching ")" (quotes ignored, as the $(...) scan always did)."""
360
+ match: Dict[int, int] = {}
361
+ stack: List[int] = []
362
+ for k, ch in enumerate(cmd):
363
+ if ch == "(":
364
+ stack.append(k)
365
+ elif ch == ")" and stack:
366
+ match[stack.pop()] = k
367
+ return match
368
+
369
+
370
+ def _prepare(cmd: str, depth: int = 0) -> Tuple[str, List[str]]:
371
+ """(text with $(...) and `...` replaced by placeholder words, comments, line continuations and the
372
+ fd number of a redirection (`2>`) removed, the inner command strings). Linear in len(cmd). The
373
+ placeholders carry the depth, so a nested parse never mistakes an outer one for its own."""
374
+ cmd = _drop_heredocs(cmd)
375
+ out: List[str] = []
376
+ inners: List[str] = []
377
+ parens: Optional[Dict[int, int]] = None
378
+ ticks = True # False once a ` has no partner: none further on has one either
379
+ i, n, in_s, in_d = 0, len(cmd), False, False
380
+ while i < n:
381
+ c = cmd[i]
382
+ if in_s:
383
+ out.append(c)
384
+ in_s = c != "'"
385
+ i += 1
386
+ elif c == "\\" and i + 1 < n:
387
+ if cmd[i + 1] != "\n":
388
+ out.append(cmd[i:i + 2])
389
+ i += 2
390
+ elif c == "'" and not in_d:
391
+ in_s = True
392
+ out.append(c)
393
+ i += 1
394
+ elif c == '"':
395
+ in_d = not in_d
396
+ out.append(c)
397
+ i += 1
398
+ elif c == "#" and not in_d and (i == 0 or cmd[i - 1] in " \t\n;&|()"):
399
+ j = cmd.find("\n", i)
400
+ i = n if j < 0 else j
401
+ elif cmd.startswith("$(", i):
402
+ if parens is None:
403
+ parens = _parens(cmd)
404
+ j = parens.get(i + 1, -1)
405
+ if j < 0:
406
+ out.append(c)
407
+ i += 1
408
+ continue
409
+ inners.append(cmd[i + 2:j])
410
+ out.append(f"__gitpro_sub{depth}_{len(inners) - 1}__")
411
+ i = j + 1
412
+ elif c == "`" and ticks:
413
+ j = i + 1
414
+ while j < n and cmd[j] != "`":
415
+ j += 2 if cmd[j] == "\\" else 1
416
+ if j >= n:
417
+ ticks = False
418
+ out.append(c)
419
+ i += 1
420
+ continue
421
+ inners.append(cmd[i + 1:j].replace("\\`", "`"))
422
+ out.append(f"__gitpro_sub{depth}_{len(inners) - 1}__")
423
+ i = j + 1
424
+ elif c.isdigit() and not in_d and (i == 0 or cmd[i - 1] in " \t\n;&|()"):
425
+ j = i
426
+ while j < n and cmd[j].isdigit():
427
+ j += 1
428
+ if not (j < n and cmd[j] in "<>"):
429
+ out.append(cmd[i:j]) # a word, not the fd of a redirection
430
+ i = j
431
+ else:
432
+ out.append(c)
433
+ i += 1
434
+ return "".join(out), inners
435
+
436
+
437
+ def _lex(text: str) -> List[str]:
438
+ lex = shlex.shlex(text, posix=True, punctuation_chars=PUNCT)
439
+ lex.whitespace = " \t\r"
440
+ lex.whitespace_split = True
441
+ lex.commenters = ""
442
+ return list(lex)
443
+
444
+
445
+ def _fallback(cmd: str) -> List[Call]:
446
+ calls = []
447
+ cmd = cmd[:MAX_SCAN]
448
+ for prog, rx in _FALLBACK:
449
+ for m in rx.finditer(cmd):
450
+ sub = " ".join(m.group("sub").split())
451
+ d = m.groupdict().get("dir")
452
+ calls.append(Call(prog, [prog, *sub.split()], sub, dir=d.strip("'\"") if d else None, relevant=True,
453
+ fallback=True))
454
+ return calls
455
+
456
+
457
+ def _rebase(calls: List[Call], ctx: Ctx) -> List[Call]:
458
+ return [_apply(c, ctx) for c in calls]
459
+
460
+
461
+ def _is_sep(tok: str) -> bool:
462
+ """A separator token (`;`, `&&`, `|`, `(`, a newline ...), not a redirection."""
463
+ return bool(tok) and all(ch in PUNCT for ch in tok) and "<" not in tok and ">" not in tok
464
+
465
+
466
+ def _command_follows(tok: str) -> bool:
467
+ """Whether the token after tok is in command position."""
468
+ return _is_sep(tok) or tok in CMD_LEAD
469
+
470
+
471
+ def _plain(word: str) -> bool:
472
+ """A `for` word that stands for exactly itself (no glob, brace expansion or $(...))."""
473
+ return not (re.search(r"[*?\[]", word) or re.search(r"(?<!\$)\{[^}]*(?:,|\.\.)", word)
474
+ or "__gitpro_sub" in word)
475
+
476
+
477
+ def _unroll(tokens: List[str], budget: int = 4096) -> List[str]:
478
+ """`for NAME in W1 .. Wn; do BODY; done` (plain words, n <= MAX_FOR) becomes `NAME=W1; BODY; ...
479
+ NAME=Wn; BODY;`, so the body is judged once per word with NAME known. Other loops stay as they are
480
+ (the body is judged once, NAME unknown). Linear: `do`/`done` are matched in one pass."""
481
+ if "for" not in tokens:
482
+ return tokens
483
+ n = len(tokens)
484
+ at_cmd = [True] * n # the token is in command position
485
+ for k in range(1, n):
486
+ at_cmd[k] = _command_follows(tokens[k - 1])
487
+ done_of: Dict[int, int] = {}
488
+ opened: List[int] = []
489
+ for k, t in enumerate(tokens):
490
+ if at_cmd[k] and t == "do":
491
+ opened.append(k)
492
+ elif at_cmd[k] and t == "done" and opened:
493
+ done_of[opened.pop()] = k
494
+
495
+ def header(i: int) -> Optional[Tuple[str, List[str], int]]:
496
+ """(NAME, words, index of `do`) of the plain `for` at tokens[i], or None."""
497
+ if i + 2 >= n or not _NAME_RE.match(tokens[i + 1]) or tokens[i + 2] != "in":
498
+ return None
499
+ j = i + 3
500
+ while j < n and not _is_sep(tokens[j]) and j - i <= MAX_FOR + 3:
501
+ j += 1
502
+ items = tokens[i + 3:j]
503
+ if j >= n or set(tokens[j]) - set(";\n") or len(items) > MAX_FOR or not all(_plain(w) for w in items):
504
+ return None
505
+ while j < n and not set(tokens[j]) - set(";\n"):
506
+ j += 1
507
+ return (tokens[i + 1], items, j) if j < n and tokens[j] == "do" else None
508
+
509
+ def emit(i: int, j: int, level: int) -> List[str]:
510
+ out: List[str] = []
511
+ while i < j:
512
+ if at_cmd[i] and tokens[i] == "for" and level < MAX_DEPTH:
513
+ h = header(i)
514
+ end = done_of.get(h[2], n) if h else n
515
+ if h and end < j:
516
+ name, items, do = h
517
+ body = emit(do + 1, end, level + 1)
518
+ if len(items) * (len(body) + 2) <= budget:
519
+ for w in items:
520
+ out += [f"{name}={w}", ";", *body, ";"]
521
+ else: # too big: the loop as written (its body's own loops unrolled)
522
+ out += [*tokens[i:do + 1], *body, tokens[end]]
523
+ i = end + 1 # each token is emitted once per nesting level: linear
524
+ continue
525
+ out.append(tokens[i])
526
+ i += 1
527
+ return out
528
+
529
+ out = emit(0, n, 0)
530
+ return out if len(out) <= max(n, 1) * 4 + budget else tokens
531
+
532
+
533
+ def _reserved(words: List[str]) -> Tuple[List[str], List[str], bool, int]:
534
+ """(the command after leading reserved words, the words dropped, whether a `case` opened, how many
535
+ `esac`s closed)."""
536
+ i, closes = 0, 0
537
+ while i < len(words):
538
+ w = words[i]
539
+ if w in ("for", "select", "case"):
540
+ return [], words, w == "case", closes
541
+ if w == "function":
542
+ i += 2
543
+ continue
544
+ if w not in RESERVED:
545
+ break
546
+ closes += w == "esac"
547
+ i += 1
548
+ return words[i:], words[:i], False, closes
549
+
550
+
551
+ def _pipe(sep: str) -> str:
552
+ """"|" when the separator pipes the command into the next one, "&" when it runs it in the
553
+ background, else ""."""
554
+ s = sep.lstrip("\n")
555
+ if s.startswith(("||", "&&")):
556
+ return ""
557
+ return s[:1] if s[:1] in ("|", "&") else ""
558
+
559
+
560
+ def parse_command(cmd: str, _depth: int = 0, _ctx: Optional[Ctx] = None) -> List[Call]:
561
+ """Every simple command in a shell line, in order, with `.relevant` set on the identity-relevant
562
+ ones. Never runs anything; never raises (an unbalanced quote or a huge command falls back to a regex
563
+ scan)."""
564
+ if _depth > MAX_DEPTH:
565
+ return []
566
+ if len(cmd) > MAX_PARSE:
567
+ return _fallback(cmd)
568
+ try:
569
+ text, inners = _prepare(cmd, _depth)
570
+ tokens = _unroll(_lex(text))
571
+ except ValueError:
572
+ return _fallback(cmd)
573
+ calls: List[Call] = []
574
+ words: List[str] = []
575
+ stack: List[Ctx] = []
576
+ cases: List[int] = [] # len(stack) at each open `case`: its `pat)` closes no subshell
577
+ cur = _ctx or Ctx()
578
+ skip: Optional[str] = None # the redirection whose target comes next
579
+ herestr: Optional[str] = None # `<<< text` (or a here-document) of the current command
580
+ piped: Optional[str] = None # what `echo/printf/cat TEXT |` feeds the next command
581
+ after_pipe = False # the current command is the last of a pipeline
582
+
583
+ def flush(sep: str) -> None:
584
+ nonlocal cur, herestr, piped, after_pipe
585
+ w = list(words)
586
+ words.clear()
587
+ stdin = herestr if herestr is not None else piped
588
+ herestr = None
589
+ pipe = _pipe(sep)
590
+ if not w:
591
+ after_pipe = after_pipe or pipe == "|"
592
+ return
593
+ sub = pipe or after_pipe # each part of a pipeline, and a `&` job, runs in a subshell
594
+ after_pipe = pipe == "|"
595
+ rest, dropped, opened, closed = _reserved(w)
596
+ for x in dropped:
597
+ calls.extend(_substitutions(x, cur, inners, _depth))
598
+ if dropped[:1] in (["for"], ["select"]) and len(dropped) > 1 and _NAME_RE.match(dropped[1]):
599
+ cur = cur.unknown(dropped[1]) # a loop _unroll left alone: its variable is not known
600
+ del cases[max(0, len(cases) - closed):]
601
+ if opened:
602
+ cases.append(len(stack))
603
+ piped = None
604
+ if rest:
605
+ found, nxt = _simple(rest, cur, inners, _depth, stdin)
606
+ calls.extend(found)
607
+ if not sub:
608
+ cur = nxt
609
+ if pipe == "|":
610
+ piped = _echo_text(rest, stdin)
611
+
612
+ for tok in tokens:
613
+ if skip is not None: # a redirection target is not a command word
614
+ if skip == "<<<":
615
+ herestr = tok
616
+ skip = None
617
+ calls.extend(_substitutions(tok, cur, inners, _depth))
618
+ continue
619
+ if tok and all(ch in PUNCT for ch in tok):
620
+ opens = tok.endswith("(") and len(tok) > 1 and tok[-2] in "<>" # <(...) process substitution
621
+ redirect = ("<" in tok or ">" in tok) and not opens
622
+ seps = ";\n()" if redirect else PUNCT
623
+ if any(ch in seps for ch in tok if ch not in "<>"):
624
+ if tok[0] == ")" and cases and cases[-1] == len(stack) and words[:1] != ["esac"]:
625
+ words.clear() # a case pattern `pat)`
626
+ else:
627
+ flush(tok)
628
+ for ch in tok:
629
+ if ch == "(":
630
+ stack.append(cur)
631
+ elif ch == ")" and not (cases and cases[-1] == len(stack)) and stack:
632
+ cur = stack.pop()
633
+ skip = tok if redirect else None
634
+ continue
635
+ words.append(tok)
636
+ flush("")
637
+ return calls
638
+
639
+
640
+ def _substitutions(word: str, cur: Ctx, inners: List[str], depth: int) -> List[Call]:
641
+ out: List[Call] = []
642
+ for m in _SUB_RE.finditer(word):
643
+ k = int(m.group(2))
644
+ if int(m.group(1)) == depth and k < len(inners): # else: an outer level's, handed on in a string
645
+ out += _rebase(parse_command(inners[k], depth + 1, cur.seed(True)), cur)
646
+ return out
647
+
648
+
649
+ @dataclass
650
+ class _Stripped:
651
+ words: List[str] # from the real program on
652
+ env: Dict[str, str] # VAR=val prefixes (also env's)
653
+ dir: Optional[str] = None # env -C
654
+ unset: set = field(default_factory=set)
655
+ clear: bool = False
656
+
657
+
658
+ def _strip(words: List[str], expand: Callable[[str], str] = lambda w: w) -> _Stripped:
659
+ """Skip VAR=val prefixes and wrappers like env/sudo/nice/flock/watch; `flock F -c S` and `watch CMD`
660
+ become `sh -c S`. expand: $VAR expansion for the prefix values and env -C."""
661
+ s = _Stripped([], {})
662
+ i = 0
663
+ while True:
664
+ while i < len(words) and _ASSIGN_RE.match(words[i]):
665
+ k, _, v = words[i].partition("=")
666
+ s.env[k] = expand(v)
667
+ s.unset.discard(k)
668
+ i += 1
669
+ if i >= len(words):
670
+ break
671
+ name = os.path.basename(words[i])
672
+ if name not in WRAPPERS:
673
+ break
674
+ i += 1
675
+ takes = WRAPPERS[name]
676
+ seen: List[str] = []
677
+ while i < len(words) and words[i].startswith("-"):
678
+ a = words[i]
679
+ i += 1
680
+ if a == "--":
681
+ break
682
+ seen.append(a)
683
+ if name == "command" and a in ("-v", "-V"):
684
+ return s # `command -v git` only looks git up
685
+ if name == "env":
686
+ if a in ("-", "-i", "--ignore-environment"):
687
+ s.env.clear()
688
+ s.unset.clear()
689
+ s.clear = True
690
+ continue
691
+ var = (words[i] if a in ("-u", "--unset") and i < len(words) else
692
+ a.split("=", 1)[1] if a.startswith("--unset=") else
693
+ a[2:] if a.startswith("-u") and len(a) > 2 else None)
694
+ if var is not None:
695
+ i += a in ("-u", "--unset")
696
+ s.env.pop(var, None)
697
+ s.unset.add(var)
698
+ continue
699
+ if a in ("-C", "--chdir") and i < len(words):
700
+ s.dir = _join(s.dir, expand(words[i]))
701
+ i += 1
702
+ continue
703
+ if a.startswith("--chdir="):
704
+ s.dir = _join(s.dir, expand(a.split("=", 1)[1]))
705
+ continue
706
+ if a in ("-S", "--split-string") and i < len(words):
707
+ try:
708
+ words = words[:i] + shlex.split(words[i]) + words[i + 1:]
709
+ except ValueError:
710
+ i += 1
711
+ continue
712
+ if a in takes:
713
+ i += 1
714
+ if name == "timeout" and i < len(words):
715
+ i += 1 # the duration
716
+ elif name == "flock":
717
+ i += 1 # the lock file (or fd)
718
+ while i < len(words) and words[i].startswith("-"):
719
+ a = words[i]
720
+ i += 1
721
+ if a in ("-c", "--command"):
722
+ s.words = ["sh", "-c", words[i]] if i < len(words) else []
723
+ return s
724
+ if a in takes:
725
+ i += 1
726
+ elif name == "watch" and not any(a in ("-x", "--exec") for a in seen):
727
+ s.words = ["sh", "-c", " ".join(words[i:])] if i < len(words) else []
728
+ return s
729
+ s.words = words[i:]
730
+ return s
731
+
732
+
733
+ def _shell_options(args: List[str]) -> Tuple[int, str]:
734
+ """(index of the first operand, the short option letters given) of a shell's arguments."""
735
+ i, letters = 0, ""
736
+ while i < len(args):
737
+ a = args[i]
738
+ if a == "--":
739
+ return i + 1, letters
740
+ if a in ("-o", "+o", "-O", "+O"):
741
+ i += 2
742
+ continue
743
+ if a.startswith("--"):
744
+ i += 1
745
+ continue
746
+ if len(a) > 1 and a[0] in "-+":
747
+ letters += a[1:] if a[0] == "-" else ""
748
+ i += 1
749
+ continue
750
+ break
751
+ return i, letters
752
+
753
+
754
+ def _shell_script(args: List[str], stdin: Optional[str] = None) -> Optional[str]:
755
+ """The script of `sh -c SCRIPT` (also -lc, -ec, ...); with stdin (a here-string, a here-document or
756
+ piped echo/cat), the script a shell without operands (or with -s) reads; else None."""
757
+ i, letters = _shell_options(args)
758
+ if "c" in letters:
759
+ return args[i] if i < len(args) else None
760
+ if stdin is not None and ("s" in letters or i >= len(args)):
761
+ return stdin
762
+ return None
763
+
764
+
765
+ def _echo_text(words: List[str], stdin: Optional[str] = None) -> Optional[str]:
766
+ """The text `echo ...` / `printf ...` writes (roughly: escapes \\n and \\t applied), what `cat` passes
767
+ on from its stdin, or None."""
768
+ rest = _strip(words).words
769
+ prog = os.path.basename(rest[0]) if rest else ""
770
+ args = rest[1:]
771
+ if prog == "cat":
772
+ return stdin if all(a == "-" for a in args) else None
773
+ if prog == "echo":
774
+ while args and re.match(r"^-[neE]+$", args[0]):
775
+ args = args[1:]
776
+ elif prog == "printf":
777
+ args = args[1:] if args[:1] == ["--"] else args
778
+ else:
779
+ return None
780
+ return " ".join(args).replace("\\n", "\n").replace("\\t", "\t") if args else None
781
+
782
+
783
+ def _declare(prog: str, args: List[str], cur: Ctx) -> Ctx:
784
+ """export / declare / typeset / local / readonly: -x exports, +x and `export -n` unexport."""
785
+ export, unexport, names = prog == "export", False, []
786
+ for w in args:
787
+ if w[:1] in ("-", "+") and len(w) > 1 and w != "--":
788
+ letters = w[1:]
789
+ if set(letters) & set("fFp"):
790
+ return cur # functions, or only printing
791
+ export = export or (w[0] == "-" and "x" in letters)
792
+ unexport = unexport or (w[0] == "+" and "x" in letters) or (prog == "export" and "n" in letters)
793
+ elif w != "--":
794
+ names.append(w)
795
+ for w in names:
796
+ name, eq, value = w.partition("=")
797
+ if not _NAME_RE.match(name):
798
+ continue
799
+ v = cur.expand(value) if eq else None
800
+ if unexport:
801
+ cur = cur.unexport(name)
802
+ cur = cur.assign(name, v) if v is not None else cur
803
+ elif export:
804
+ cur = cur.export(name, v)
805
+ elif v is not None:
806
+ cur = cur.assign(name, v)
807
+ return cur
808
+
809
+
810
+ def _simple(words: List[str], cur: Ctx, inners: List[str], depth: int, stdin: Optional[str] = None
811
+ ) -> Tuple[List[Call], Ctx]:
812
+ """The calls of one simple command, and the context for the commands after it."""
813
+ out: List[Call] = []
814
+ for w in words:
815
+ out += _substitutions(w, cur, inners, depth)
816
+ if all(_ASSIGN_RE.match(w) for w in words): # NAME=value ... alone: shell variables
817
+ for w in words:
818
+ k, _, v = w.partition("=")
819
+ cur = cur.assign(k, cur.expand(v))
820
+ return out, cur
821
+ s = _strip(words, cur.expand)
822
+ rest = s.words
823
+ if not rest:
824
+ return out, cur
825
+ prog = os.path.basename(rest[0])
826
+ if prog in ("cd", "pushd", "popd"):
827
+ out.append(Call(prog, rest, dir=cur.dir))
828
+ targets = [cur.expand(w) for w in rest[1:] if not (w[:1] in ("-", "+") and w != "-")]
829
+ turns = any(re.match(r"^[+-]\d+$", w) for w in rest[1:])
830
+ if prog == "popd" or (prog == "pushd" and not targets):
831
+ if turns or not cur.dirs:
832
+ return out, cur # `popd +N` drops an entry; an empty stack is an error
833
+ top = cur.dirs[-1]
834
+ dirs = cur.dirs[:-1] + ((cur.dir,) if prog == "pushd" else ())
835
+ return out, replace(cur, dir=top, dirs=dirs, oldpwd=cur.dir)
836
+ target = targets[0] if targets else "~"
837
+ nxt = cur.back() if target == "-" else cur.cd(target)
838
+ return out, (replace(nxt, dirs=cur.dirs + (cur.dir,)) if prog == "pushd" else nxt)
839
+ if prog == "unset":
840
+ if "-f" in rest[1:]:
841
+ return out, cur
842
+ for w in rest[1:]:
843
+ cur = cur if w.startswith("-") else cur.unset_var(w)
844
+ return out, cur
845
+ if prog in ("export", "declare", "typeset", "local", "readonly"):
846
+ return out, _declare(prog, rest[1:], cur)
847
+ ctx = cur.derive(s.dir, s.env, s.unset, s.clear)
848
+ script = None
849
+ if prog in SHELLS:
850
+ script = _shell_script(rest[1:], stdin)
851
+ seed = ctx.seed(False)
852
+ elif prog == "eval":
853
+ script = " ".join(rest[1:])
854
+ seed = ctx.seed(True)
855
+ if script is not None:
856
+ return out + _rebase(parse_command(script, depth + 1, seed), ctx), cur
857
+ call = _apply(Call(prog, rest), ctx)
858
+ if prog in ("git", "hub"):
859
+ script = (_git_call if prog == "git" else _hub_call)(call, cur.expand)
860
+ if script is not None: # `git -c alias.x='!...' x`: the alias's shell command, parsed only
861
+ return out + _rebase(parse_command(script, depth + 1, ctx.seed(False)), replace(ctx, dir=call.dir)), cur
862
+ elif prog in ("gh", "glab"):
863
+ _forge_call(call)
864
+ out.append(call)
865
+ return out, cur
866
+
867
+
868
+ def _email(value: str) -> Optional[str]:
869
+ m = re.search(r"<([^<>]*)>", value)
870
+ if m:
871
+ return m.group(1).strip() or None
872
+ return value.strip() if "@" in value else None
873
+
874
+
875
+ def _positionals(args: List[str], takes: set) -> List[str]:
876
+ out, i = [], 0
877
+ while i < len(args):
878
+ a = args[i]
879
+ i += 1
880
+ if a == "--":
881
+ out += args[i:]
882
+ break
883
+ if a.startswith("-") and a != "-":
884
+ if a in takes:
885
+ i += 1
886
+ continue
887
+ out.append(a)
888
+ return out
889
+
890
+
891
+ def _git_call(call: Call, expand: Callable[[str], str] = lambda w: w) -> Optional[str]:
892
+ """Read git's global options and the subcommand into call. Returns the shell command of a
893
+ `-c alias.X='!...'` alias when the subcommand is one (to be parsed, never run), else None."""
894
+ args = call.words[1:]
895
+ i, d = 0, call.dir
896
+ git_dir, work_tree = call.env.get("GIT_DIR"), None
897
+ aliases: Dict[str, str] = {}
898
+ while i < len(args) and args[i].startswith("-"):
899
+ a = args[i]
900
+ i += 1
901
+ if a == "-C" and i < len(args):
902
+ d = _join(d, expand(args[i]))
903
+ i += 1
904
+ elif a in ("--git-dir", "--work-tree") and i < len(args):
905
+ v = expand(args[i])
906
+ git_dir, work_tree = (v, work_tree) if a == "--git-dir" else (git_dir, v)
907
+ i += 1
908
+ elif a.startswith("--git-dir="):
909
+ git_dir = expand(a.split("=", 1)[1])
910
+ elif a.startswith("--work-tree="):
911
+ work_tree = expand(a.split("=", 1)[1])
912
+ elif a == "-c" and i < len(args):
913
+ key, _, value = args[i].partition("=")
914
+ if key.lower() == "user.email":
915
+ call.config_email = value
916
+ elif key.lower().startswith("alias."):
917
+ aliases[key[len("alias."):].lower()] = value
918
+ i += 1
919
+ elif a in ("--namespace", "--config-env", "--super-prefix"):
920
+ i += 1
921
+ call.dir = _join(d, git_dir) if git_dir else _join(d, work_tree)
922
+ if i >= len(args):
923
+ return None
924
+ sub, rest = args[i], args[i + 1:]
925
+ seen = set()
926
+ while sub not in GIT_BUILTINS and sub.lower() in aliases and sub.lower() not in seen:
927
+ seen.add(sub.lower()) # `git -c alias.ci=commit ci`
928
+ value = aliases[sub.lower()].lstrip()
929
+ if value.startswith("!"):
930
+ return value[1:] + "".join(" " + shlex.quote(a) for a in rest)
931
+ try:
932
+ parts = shlex.split(value)
933
+ except ValueError:
934
+ break
935
+ if not parts:
936
+ break
937
+ sub, rest = parts[0], parts[1:] + rest
938
+ _git_sub(call, sub, rest)
939
+ return None
940
+
941
+
942
+ # value-taking options of the identity-relevant git commands: (short letters taking a value, glued or
943
+ # the next word; short letters whose optional value is only ever glued on; long options taking the next
944
+ # word unless written --opt=value)
945
+ _GIT_OPTS: Dict[str, Tuple[str, str, set]] = {
946
+ "commit": ("mFCct", "uS", {"--author", "--date", "--message", "--file", "--template", "--reuse-message",
947
+ "--reedit-message", "--fixup", "--squash", "--cleanup", "--trailer",
948
+ "--pathspec-from-file"}),
949
+ "push": ("o", "", {"--push-option", "--receive-pack", "--exec", "--repo"}),
950
+ "merge": ("mFsX", "S", {"--message", "--file", "--strategy", "--strategy-option", "--into-name",
951
+ "--cleanup"}),
952
+ "rebase": ("sXx", "SC", {"--onto", "--strategy", "--strategy-option", "--exec", "--whitespace"}),
953
+ "cherry-pick": ("mX", "S", {"--mainline", "--strategy", "--strategy-option", "--cleanup"}),
954
+ "revert": ("mX", "S", {"--mainline", "--strategy", "--strategy-option", "--cleanup"}),
955
+ "am": ("", "pCS", {"--directory", "--exclude", "--include", "--patch-format", "--whitespace"}),
956
+ "tag": ("mFu", "n", {"--message", "--file", "--local-user", "--sort", "--format", "--contains",
957
+ "--no-contains", "--points-at", "--merged", "--no-merged", "--cleanup"}),
958
+ "notes": ("", "", {"--ref"}),
959
+ }
960
+
961
+
962
+ def _git_opts(sub: str, args: List[str]) -> Tuple[List[Tuple[str, Optional[str]]], List[str]]:
963
+ """(the options of `git SUB ARGS` as (name, value), the positionals). The value of an option that
964
+ takes one is never read as an option (`commit -m --dry-run` commits); `--` ends the options."""
965
+ takes, glued, longs = _GIT_OPTS.get(sub, ("", "", set()))
966
+ opts: List[Tuple[str, Optional[str]]] = []
967
+ pos: List[str] = []
968
+ i = 0
969
+ while i < len(args):
970
+ a = args[i]
971
+ i += 1
972
+ if a == "--":
973
+ pos += args[i:]
974
+ break
975
+ if a.startswith("--"):
976
+ name, eq, value = a.partition("=")
977
+ if eq:
978
+ opts.append((name, value))
979
+ elif name in longs:
980
+ opts.append((name, args[i] if i < len(args) else None))
981
+ i += 1
982
+ else:
983
+ opts.append((name, None))
984
+ elif a.startswith("-") and len(a) > 1:
985
+ for k in range(1, len(a)):
986
+ ch = a[k]
987
+ if ch in takes:
988
+ value: Optional[str] = a[k + 1:]
989
+ if not value:
990
+ value = args[i] if i < len(args) else None
991
+ i += 1
992
+ opts.append(("-" + ch, value))
993
+ break
994
+ if ch in glued:
995
+ opts.append(("-" + ch, a[k + 1:] or None))
996
+ break
997
+ opts.append(("-" + ch, None))
998
+ else:
999
+ pos.append(a)
1000
+ return opts, pos
1001
+
1002
+
1003
+ def _git_sub(call: Call, sub: str, rest: List[str]) -> None:
1004
+ """Set sub/relevant/author/remote for `git SUB REST...`."""
1005
+ call.sub, call.args, call.relevant = sub, rest, False
1006
+ call.alias = sub not in GIT_BUILTINS and bool(re.match(r"^[A-Za-z0-9][A-Za-z0-9-]*$", sub))
1007
+ if sub not in GIT_SUBS:
1008
+ return
1009
+ opts, pos = _git_opts(sub, rest)
1010
+ names = {n for n, _ in opts}
1011
+ if names & {"-h", "--help"}:
1012
+ return
1013
+ if sub in UNDOABLE and names & UNDO_FLAGS:
1014
+ return
1015
+ if sub == "commit" and "--dry-run" in names or sub == "push" and names & {"-n", "--dry-run"}:
1016
+ return
1017
+ if sub == "tag":
1018
+ call.relevant = not names & {"-l", "--list", "-d", "--delete", "-v", "--verify", "-n"} and bool(pos)
1019
+ return
1020
+ if sub == "notes":
1021
+ call.relevant = bool(pos) and pos[0] in ("add", "append", "copy", "edit", "merge")
1022
+ return
1023
+ call.relevant = True
1024
+ for name, value in opts:
1025
+ if name == "--author" and value is not None:
1026
+ call.author = _email(value)
1027
+ if sub == "push":
1028
+ call.remote = pos[0] if pos else None
1029
+
1030
+
1031
+ def _field_values(args: List[str]) -> List[str]:
1032
+ """The key=value of every -f/-F/--field/--raw-field of `gh api` / `glab api`."""
1033
+ out, i = [], 0
1034
+ while i < len(args):
1035
+ a = args[i]
1036
+ i += 1
1037
+ if a in ("-f", "-F", "--field", "--raw-field") and i < len(args):
1038
+ out.append(args[i])
1039
+ i += 1
1040
+ elif re.match(r"^-[fF].", a):
1041
+ out.append(a[2:])
1042
+ elif a.startswith(("--field=", "--raw-field=")):
1043
+ out.append(a.split("=", 1)[1])
1044
+ return out
1045
+
1046
+
1047
+ def _api_writes(args: List[str]) -> bool:
1048
+ pos = _positionals(args, API_TAKES)
1049
+ if len(pos) > 1 and pos[1] == "graphql":
1050
+ # always POST; a write only when the query is a mutation (unknown -> a write)
1051
+ queries = [v.split("=", 1)[1] for v in _field_values(args) if v.split("=", 1)[0] == "query"]
1052
+ if not queries or "--input" in args:
1053
+ return True
1054
+ return any(q.startswith("@") or q.lstrip().startswith("mutation") for q in queries)
1055
+ method, i = None, 0
1056
+ while i < len(args):
1057
+ a = args[i]
1058
+ i += 1
1059
+ if a in ("-X", "--method") and i < len(args):
1060
+ method = args[i].upper()
1061
+ i += 1
1062
+ elif a.startswith("--method="):
1063
+ method = a.split("=", 1)[1].upper()
1064
+ elif a.startswith("-X") and len(a) > 2:
1065
+ method = a[2:].upper()
1066
+ fields_ = bool(_field_values(args)) or any(a == "--input" or a.startswith("--input=") for a in args)
1067
+ return method in WRITE_METHODS or (method is None and fields_)
1068
+
1069
+
1070
+ def _forge_call(call: Call) -> None:
1071
+ args = call.words[1:]
1072
+ pos = _positionals(args, {"-R", "--repo"})
1073
+ if not pos:
1074
+ return
1075
+ if pos[0] == "api":
1076
+ call.sub, call.relevant = "api", _api_writes(args)
1077
+ return
1078
+ call.sub = " ".join(pos[:2])
1079
+ subs = GH_SUBS if call.prog == "gh" else GLAB_SUBS
1080
+ call.relevant = tuple(pos[:2]) in subs
1081
+
1082
+
1083
+ def _hub_call(call: Call, expand: Callable[[str], str] = lambda w: w) -> Optional[str]:
1084
+ """hub wraps git and adds gh-like commands."""
1085
+ script = _git_call(call, expand)
1086
+ if script is not None or call.relevant:
1087
+ call.prog = "git"
1088
+ return script
1089
+ pos = _positionals(call.words[1:], {"-C", "-c", "--git-dir", "--work-tree"})
1090
+ if not pos or pos[0] not in HUB_COMMANDS:
1091
+ return None
1092
+ call.alias = False
1093
+ if pos[0] == "api":
1094
+ call.sub, call.relevant = "api", _api_writes(call.words[1:])
1095
+ return None
1096
+ for n in (2, 1):
1097
+ if tuple(pos[:n]) in HUB_SUBS:
1098
+ call.sub, call.relevant = " ".join(pos[:n]), True
1099
+ return None
1100
+ call.sub = pos[0]
1101
+ return None
1102
+
1103
+
1104
+ # ---------------------------------------------------------------- adapters
1105
+
1106
+ class BadInput(ValueError):
1107
+ """The hook payload is not what the adapter expects."""
1108
+
1109
+
1110
+ @dataclass(frozen=True)
1111
+ class Decision:
1112
+ headline: str
1113
+ cause: str
1114
+ fix: str
1115
+ repo: str
1116
+
1117
+ @property
1118
+ def reason(self) -> str:
1119
+ # no bypass hint here (DECISIONS Q6): the agent asks the user, the user knows GITPRO_SKIP
1120
+ return (f"gitpro: {self.headline}. cause: {self.cause}. Ask the user before changing anything; "
1121
+ f"the fix is `{self.fix}`.")
1122
+
1123
+
1124
+ def _payload_dict(payload: object) -> dict:
1125
+ if not isinstance(payload, dict):
1126
+ raise BadInput("cannot parse hook input")
1127
+ return payload
1128
+
1129
+
1130
+ def _cwd_of(payload: dict) -> Optional[str]:
1131
+ cwd = payload.get("cwd")
1132
+ return cwd if isinstance(cwd, str) and cwd else None
1133
+
1134
+
1135
+ def _tool_command(payload: object, tool: str) -> Optional[Tuple[str, Optional[str]]]:
1136
+ p = _payload_dict(payload)
1137
+ if p.get("tool_name") != tool:
1138
+ return None
1139
+ ti = p.get("tool_input")
1140
+ if not isinstance(ti, dict) or not isinstance(ti.get("command"), str):
1141
+ raise BadInput("cannot parse hook input")
1142
+ return ti["command"], _cwd_of(p)
1143
+
1144
+
1145
+ def _flat_command(payload: object) -> Optional[Tuple[str, Optional[str]]]:
1146
+ p = _payload_dict(payload)
1147
+ if not isinstance(p.get("command"), str):
1148
+ raise BadInput("cannot parse hook input")
1149
+ return p["command"], _cwd_of(p)
1150
+
1151
+
1152
+ def _deny_claude(d: Decision) -> Tuple[str, int]:
1153
+ return json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
1154
+ "permissionDecisionReason": d.reason}}, ensure_ascii=False), 0
1155
+
1156
+
1157
+ def _deny_cursor(d: Decision) -> Tuple[str, int]:
1158
+ return json.dumps({"permission": "deny", "userMessage": f"gitpro: {d.headline}",
1159
+ "agentMessage": d.reason}, ensure_ascii=False), 0
1160
+
1161
+
1162
+ def _deny_gemini(d: Decision) -> Tuple[str, int]:
1163
+ return json.dumps({"decision": "deny", "reason": d.reason}, ensure_ascii=False), 0
1164
+
1165
+
1166
+ @dataclass(frozen=True)
1167
+ class Adapter:
1168
+ """One agent's hook envelope. verified=False: field names not yet checked against the tool's docs."""
1169
+ name: str
1170
+ title: str
1171
+ verified: bool
1172
+ extract: Callable[[object], Optional[Tuple[str, Optional[str]]]] # -> (command, cwd) or None: ignore
1173
+ deny: Callable[[Decision], Tuple[str, int]] # -> (stdout, exit code)
1174
+
1175
+
1176
+ ADAPTERS: Dict[str, Adapter] = {
1177
+ "claude": Adapter("claude", "Claude Code", True, lambda p: _tool_command(p, "Bash"), _deny_claude),
1178
+ "cursor": Adapter("cursor", "Cursor", False, _flat_command, _deny_cursor),
1179
+ "gemini": Adapter("gemini", "Gemini CLI", False, lambda p: _tool_command(p, "run_shell_command"),
1180
+ _deny_gemini),
1181
+ "generic": Adapter("generic", "a generic agent", False, _flat_command, lambda d: ("", 1)),
1182
+ }
1183
+
1184
+
1185
+ # ---------------------------------------------------------------- judging
1186
+
1187
+ def relevant_calls(payload: object, agent: str = "claude", cwd: Optional[str] = None
1188
+ ) -> Tuple[List[Call], str]:
1189
+ """(the identity-relevant calls of the payload's command, the cwd they run in). Raises BadInput."""
1190
+ found = ADAPTERS[agent].extract(payload)
1191
+ if found is None:
1192
+ return [], cwd or os.getcwd()
1193
+ command, payload_cwd = found
1194
+ calls = [c for c in parse_command(command) if c.relevant or c.alias]
1195
+ return calls, cwd or payload_cwd or os.getcwd()
1196
+
1197
+
1198
+ def decide(payload: object, cfg, agent: str = "claude", cwd: Optional[str] = None) -> Optional[Decision]:
1199
+ """The deny decision for a hook payload, or None (no opinion). cfg is a Config, None, or a callable
1200
+ returning one (called only when a relevant command is found). [agents] hook = "off" and GITPRO_SKIP
1201
+ give None; "warn" vs "block" is the caller's business. Raises BadInput for a malformed payload."""
1202
+ calls, cwd = relevant_calls(payload, agent, cwd)
1203
+ if not calls or os.environ.get(SKIP_ENV):
1204
+ return None
1205
+ cfg = cfg() if callable(cfg) else cfg
1206
+ if cfg is None or cfg.agents.hook == "off":
1207
+ return None
1208
+ return judge(calls, cwd, cfg)
1209
+
1210
+
1211
+ def judge(calls: List[Call], cwd: str, cfg) -> Optional[Decision]:
1212
+ """The first call whose repo is wrong for it, as a Decision; None when all are fine."""
1213
+ from . import resolve
1214
+ own = {e.lower() for e in resolve.own_emails(cfg)}
1215
+ for c in calls:
1216
+ top = resolve.is_repo(Path(c.repo_dir(cwd)))
1217
+ if top is None:
1218
+ continue # not a repo: git will say so itself
1219
+ if not c.relevant and not _expand_alias(c, top):
1220
+ continue
1221
+ env = {k: v for k, v in _environ(c).items() if not k.startswith("GIT_AUTHOR_")}
1222
+ env.update((k, v) for k, v in c.env.items() if k.startswith("GIT_COMMITTER_"))
1223
+ remote = _remote_arg(c, top)
1224
+ rep = resolve.compare(top, cfg, remote=remote, env=env)
1225
+ d = _judge_one(c, rep, cfg, own, pushed_to=remote)
1226
+ if d:
1227
+ return d
1228
+ return None
1229
+
1230
+
1231
+ def _environ(c: Call) -> Dict[str, str]:
1232
+ """The environment c inherits: the hook's, minus what the command line unset."""
1233
+ if c.clear:
1234
+ return {}
1235
+ return {k: v for k, v in os.environ.items() if k not in c.unset}
1236
+
1237
+
1238
+ def _expand_alias(c: Call, top: Path) -> bool:
1239
+ """Resolve an unknown git subcommand through the repo's alias.<name> (read-only `git config --get`;
1240
+ `!` shell aliases are not followed). True when the expanded command is relevant."""
1241
+ seen = set()
1242
+ while c.alias and not c.relevant and c.sub and c.sub.lower() not in seen and len(seen) < 5:
1243
+ seen.add(c.sub.lower())
1244
+ try:
1245
+ r = _git_out(top, "config", "--get", f"alias.{c.sub}", timeout=ALIAS_TIMEOUT)
1246
+ except (OSError, subprocess.SubprocessError):
1247
+ return False # slow or missing git: no opinion
1248
+ value = r.stdout.strip() if r.returncode == 0 else ""
1249
+ try:
1250
+ parts = shlex.split(value)
1251
+ except ValueError:
1252
+ return False
1253
+ if not parts or value.startswith("!"):
1254
+ return False
1255
+ _git_sub(c, parts[0], parts[1:] + c.args)
1256
+ return c.relevant
1257
+
1258
+
1259
+ def _remote_arg(c: Call, top: Path) -> Optional[str]:
1260
+ if c.prog != "git" or c.sub != "push" or not c.remote:
1261
+ return None
1262
+ if ":" in c.remote or "/" in c.remote:
1263
+ return c.remote # a url (or a path) pushed to directly
1264
+ from . import resolve
1265
+ return c.remote if c.remote in resolve.remotes(top) else None
1266
+
1267
+
1268
+ def _norm(email: Optional[str]) -> Optional[str]:
1269
+ return email.strip().lower() if email and email.strip() else None
1270
+
1271
+
1272
+ def _label(email: str, cfg) -> str:
1273
+ for i in cfg.identities:
1274
+ if _norm(i.email) == _norm(email):
1275
+ return i.id
1276
+ return "[default]"
1277
+
1278
+
1279
+ def _who_is_expected(rep) -> str:
1280
+ e = rep.expected
1281
+ if e.id is None:
1282
+ return f"no identity matches this repo, so [default] <{e.email}> applies"
1283
+ from .resolve import redact_url
1284
+ url = redact_url(e.match.url)
1285
+ where = f"{e.match.remote} ({url})" if e.match.remote else (url or "the remote")
1286
+ return f"{where} belongs to identity {e.id} <{e.email}>"
1287
+
1288
+
1289
+ def _what(c: Call) -> str:
1290
+ return c.sub if c.prog == "git" else f"{c.prog} {c.sub}"
1291
+
1292
+
1293
+ def _judge_one(c: Call, rep, cfg, own: set, pushed_to: Optional[str] = None) -> Optional[Decision]:
1294
+ from .resolve import identity_conflicts
1295
+ exp = rep.expected
1296
+ want = _norm(exp.email)
1297
+ base, base_src = (c.config_email, "config") if c.config_email else (rep.effective.email.value, "effective")
1298
+ environ = _environ(c)
1299
+ committer, c_src = next(((v, s) for v, s in ((c.committer_env, "prefix"),
1300
+ (environ.get("GIT_COMMITTER_EMAIL"), "environ")) if v),
1301
+ (base, base_src))
1302
+ author, a_src = next(((v, s) for v, s in ((c.author, "author"), (c.author_env, "prefix"),
1303
+ (environ.get("GIT_AUTHOR_EMAIL"), "environ")) if v),
1304
+ (base, base_src))
1305
+ c_bad, a_bad = identity_conflicts(want, own, _norm(author), _norm(committer) or "")
1306
+ if want is None:
1307
+ c_bad = _norm(committer) is not None
1308
+ a_bad = _norm(author) in own
1309
+ triggers = [p for p in rep.problems if p.code in TRIGGERS]
1310
+ if not (c_bad or a_bad or triggers):
1311
+ return None
1312
+ top = str(rep.repo)
1313
+ what = _what(c)
1314
+ commits = c.prog == "git" and c.sub != "push"
1315
+ if exp.email is None:
1316
+ from .resolve import redact_url
1317
+ url = redact_url(next(iter(rep.effective.remotes.values()), None)) or top
1318
+ headline = f"no identity matches this repo ({url}) and [default] has no email"
1319
+ elif c_bad or a_bad:
1320
+ email, verb = (committer, "committed") if c_bad else (author, "authored")
1321
+ email = email or "(no email)"
1322
+ lead = (f"this {what} would be {verb} as {email}" if commits else
1323
+ f"this push comes from a repo that commits as {email}" if c.prog == "git" else
1324
+ f"this {what} runs in a repo that commits as {email}")
1325
+ headline = f"{lead} but {_who_is_expected(rep)}"
1326
+ else:
1327
+ headline = f"this {what} would not use {exp.id or '[default]'} {exp.name or ''} <{exp.email}>"
1328
+ headline = headline.replace(" ", " ")
1329
+ if c_bad and c_src == "effective" and pushed_to and not triggers:
1330
+ # the repo's other problems were computed for this remote's identity; they would mislead here
1331
+ return Decision(headline, f"this repo's commits use {committer or '(no email)'} and {pushed_to} belongs "
1332
+ f"to {exp.id or '[default]'}", f"gitpro why {shlex.quote(top)}", top)
1333
+ cause, fix = _cause(c, c_bad, c_src, committer, a_bad, a_src, author, rep, cfg, environ)
1334
+ return Decision(headline, cause, fix, top)
1335
+
1336
+
1337
+ def _command_text(c: Call, limit: int = 200) -> str:
1338
+ text = shlex.join(c.words)
1339
+ return text if len(text) <= limit else shlex.join(c.words[:3]) + " ..."
1340
+
1341
+
1342
+ def _env_fix(c: Call, rep, environ: Dict[str, str], author: bool) -> str:
1343
+ """The command with the identity variables it inherits wrong set right as prefixes (never a bare
1344
+ `unset`: in a hook's shell that changes nothing for the user)."""
1345
+ exp = rep.expected
1346
+ want = {"GIT_COMMITTER_EMAIL": exp.email, "GIT_COMMITTER_NAME": exp.name,
1347
+ "GIT_AUTHOR_EMAIL": exp.email if author else None, "GIT_AUTHOR_NAME": None}
1348
+ assigns = dict(c.env)
1349
+ for var, value in want.items():
1350
+ have = assigns.get(var) or environ.get(var)
1351
+ if have and value and have != value:
1352
+ assigns[var] = value
1353
+ for var in ("GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT"):
1354
+ if environ.get(var) and var not in assigns:
1355
+ assigns[var] = "0" if var == "GIT_CONFIG_COUNT" else ""
1356
+ prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in assigns.items())
1357
+ return f"{prefix} {_command_text(c)}".strip()
1358
+
1359
+
1360
+ _SHELL_NOTE = "; it comes from the user's shell, where only the user can remove it"
1361
+
1362
+
1363
+ def _cause(c: Call, c_bad: bool, c_src: str, committer: Optional[str], a_bad: bool, a_src: str,
1364
+ author: Optional[str], rep, cfg, environ: Dict[str, str]) -> Tuple[str, str]:
1365
+ exp = rep.expected
1366
+ if c_bad and c_src == "prefix":
1367
+ return (f"the command sets GIT_COMMITTER_EMAIL={committer}",
1368
+ f"drop GIT_COMMITTER_EMAIL={committer} from the command")
1369
+ if c_bad and c_src == "environ":
1370
+ return (f"GIT_COMMITTER_EMAIL={committer} is set in the environment{_SHELL_NOTE}",
1371
+ _env_fix(c, rep, environ, a_bad and a_src == "environ"))
1372
+ if c_bad and c_src == "config":
1373
+ return f"the command sets -c user.email={committer}", f"drop -c user.email={committer} from the command"
1374
+ if not c_bad and a_bad and a_src != "effective":
1375
+ how = {"author": "--author", "prefix": "GIT_AUTHOR_EMAIL= on the command",
1376
+ "environ": "GIT_AUTHOR_EMAIL in the environment", "config": "-c user.email"}[a_src]
1377
+ fix = {"author": f"drop --author from the command, or use {exp.name} <{exp.email}>",
1378
+ "prefix": f"drop GIT_AUTHOR_EMAIL={author} from the command",
1379
+ "environ": _env_fix(c, rep, environ, True) if a_src == "environ" else "",
1380
+ "config": f"drop -c user.email={author} from the command"}[a_src]
1381
+ note = _SHELL_NOTE if a_src == "environ" else ""
1382
+ return f"the author {author} is your {_label(author or '', cfg)} email ({how}){note}", fix
1383
+ ordered = [p for p in rep.problems if p.code in TRIGGERS] + [p for p in rep.problems if p.fix]
1384
+ why = f"gitpro why {shlex.quote(str(rep.repo))}"
1385
+ if ordered:
1386
+ p = ordered[0]
1387
+ if p.code == "env_override":
1388
+ return p.message, _env_fix(c, rep, environ, False)
1389
+ return p.message, p.fix or why
1390
+ return f"git resolves user.email to {rep.effective.email.value or 'nothing'} here", why
1391
+
1392
+
1393
+ # ---------------------------------------------------------------- agent-check
1394
+
1395
+ class _ArgError(Exception):
1396
+ pass
1397
+
1398
+
1399
+ class _Parser(argparse.ArgumentParser):
1400
+ def error(self, message): # never exit 2 from the hook: that would block the agent
1401
+ raise _ArgError(message)
1402
+
1403
+
1404
+ def add_arguments(p: argparse.ArgumentParser) -> None:
1405
+ p.add_argument("--agent", choices=AGENT_CHOICES, default="claude",
1406
+ help="hook envelope (cursor, gemini, generic: experimental)")
1407
+ p.add_argument("--cwd", metavar="DIR", help="judge commands relative to DIR instead of the payload's cwd")
1408
+ p.set_defaults(func=_cmd_check)
1409
+
1410
+
1411
+ def _warn(msg: str) -> None:
1412
+ print(f"⚠ gitpro agent-check: {msg}", file=sys.stderr)
1413
+
1414
+
1415
+ def _load_config(explicit: Optional[Path], home: Path):
1416
+ from . import config
1417
+ try:
1418
+ path, how = config.find(explicit, Path(home).expanduser())
1419
+ except config.ConfigError:
1420
+ return None # no config: nothing to judge
1421
+ return config.load_all(path, how)
1422
+
1423
+
1424
+ def _check(args: argparse.Namespace, text: str) -> int:
1425
+ adapter = ADAPTERS[args.agent]
1426
+ try:
1427
+ payload = json.loads(text)
1428
+ calls, cwd = relevant_calls(payload, args.agent, args.cwd)
1429
+ except ValueError: # bad JSON, BadInput
1430
+ _warn("cannot parse hook input")
1431
+ return 0
1432
+ if not calls:
1433
+ return 0
1434
+ if os.environ.get(SKIP_ENV):
1435
+ print(f"gitpro agent-check: skipped ({SKIP_ENV})", file=sys.stderr)
1436
+ return 0
1437
+ cfg = _load_config(args.config, args.home)
1438
+ if cfg is None or cfg.agents.hook == "off":
1439
+ return 0
1440
+ d = judge(calls, cwd, cfg)
1441
+ if d is None:
1442
+ return 0
1443
+ if cfg.agents.hook == "warn":
1444
+ msg = f"⚠ gitpro: {d.headline}; fix: {d.fix}"
1445
+ if args.agent == "claude": # shown to the user; no permission decision: the normal flow goes on
1446
+ sys.stdout.write(json.dumps({"systemMessage": msg}, ensure_ascii=False) + "\n")
1447
+ sys.stdout.flush()
1448
+ print(msg, file=sys.stderr)
1449
+ return 0
1450
+ out, rc = adapter.deny(d)
1451
+ if out:
1452
+ sys.stdout.write(out + "\n")
1453
+ sys.stdout.flush()
1454
+ print(f"✗ {d.reason}", file=sys.stderr)
1455
+ return rc
1456
+
1457
+
1458
+ def _fail_open(func: Callable[[], int]) -> int:
1459
+ try:
1460
+ return func()
1461
+ except SystemExit as e: # --help
1462
+ return 0 if not e.code else _fail_open_msg("bad arguments")
1463
+ except _ArgError as e:
1464
+ return _fail_open_msg(str(e))
1465
+ except Exception as e: # fail open: never stop the agent
1466
+ return _fail_open_msg(str(e) or type(e).__name__)
1467
+
1468
+
1469
+ def _fail_open_msg(msg: str) -> int:
1470
+ try:
1471
+ _warn(msg)
1472
+ except Exception:
1473
+ pass
1474
+ return 0
1475
+
1476
+
1477
+ def _cmd_check(args: argparse.Namespace) -> int:
1478
+ return _fail_open(lambda: _check(args, sys.stdin.read()))
1479
+
1480
+
1481
+ def main(argv: List[str]) -> int:
1482
+ """Hot entry point: argv is ["agent-check", ...]. Always exits 0, except a generic-agent deny (1)."""
1483
+ def run() -> int:
1484
+ from . import args as gargs
1485
+ p = _Parser(prog=f"{PROG} agent-check", parents=[gargs.common()], epilog=BYPASS_HELP)
1486
+ add_arguments(p)
1487
+ return _check(p.parse_args(argv[1:]), sys.stdin.read())
1488
+ return _fail_open(run)
1489
+
1490
+
1491
+ # ---------------------------------------------------------------- the hook command
1492
+
1493
+ def hook_command(abs_path: Optional[str] = None, agent: str = "claude") -> str:
1494
+ """The shell line an agent runs. gitpro from PATH first, then abs_path; without either it exits 0
1495
+ silently. gitpro's own exit status never reaches the agent (exit 2 would block it)."""
1496
+ run = f"gitpro agent-check --agent {agent}"
1497
+ if not abs_path:
1498
+ inner = f"command -v gitpro >/dev/null 2>&1 || exit 0; {run}; exit 0"
1499
+ else:
1500
+ q = shlex.quote(abs_path)
1501
+ inner = (f"command -v gitpro >/dev/null 2>&1 && {{ {run}; exit 0; }}; "
1502
+ f"[ -x {q} ] && {{ {q} agent-check --agent {agent}; exit 0; }}; exit 0")
1503
+ return "sh -c " + shlex.quote(inner)
1504
+
1505
+
1506
+ def _command_v(login: bool) -> Optional[str]:
1507
+ """Where `sh [-l]c 'command -v gitpro'` finds gitpro, or None."""
1508
+ try:
1509
+ r = subprocess.run(["sh", "-lc" if login else "-c", "command -v gitpro"], capture_output=True,
1510
+ text=True, stdin=subprocess.DEVNULL, timeout=10)
1511
+ except (OSError, subprocess.SubprocessError):
1512
+ return None
1513
+ if r.returncode != 0:
1514
+ return None
1515
+ paths = [ln.strip() for ln in r.stdout.splitlines() if ln.strip().startswith("/")]
1516
+ return paths[-1] if paths else None
1517
+
1518
+
1519
+ def gitpro_path() -> Optional[str]:
1520
+ """The absolute path the installed hook falls back to."""
1521
+ found = _command_v(False) or _command_v(True) or shutil.which("gitpro")
1522
+ if found:
1523
+ return found
1524
+ sibling = Path(sys.executable).parent / "gitpro"
1525
+ return str(sibling) if os.access(sibling, os.X_OK) else None
1526
+
1527
+
1528
+ # ---------------------------------------------------------------- settings files
1529
+
1530
+ @dataclass(frozen=True)
1531
+ class Target:
1532
+ agent: str
1533
+ path: Path
1534
+ list_key: str # hooks.<list_key>
1535
+ matcher: Optional[str] # group matcher; None = a flat list of hook entries (Cursor)
1536
+ entry: dict
1537
+
1538
+
1539
+ def _is_ours(h: object) -> bool:
1540
+ return isinstance(h, dict) and isinstance(h.get("command"), str) and MARKER in h["command"]
1541
+
1542
+
1543
+ def target(agent: str, scope: str, home: Path, abs_path: Optional[str], repo: Optional[Path] = None) -> Target:
1544
+ cmd = hook_command(abs_path, agent)
1545
+ if agent == "claude":
1546
+ path = (repo / ".claude/settings.local.json") if scope == "project" and repo else \
1547
+ home / ".claude/settings.json"
1548
+ return Target(agent, path, "PreToolUse", "Bash", {"type": "command", "command": cmd,
1549
+ "timeout": HOOK_TIMEOUT})
1550
+ if agent == "cursor":
1551
+ return Target(agent, home / ".cursor/hooks.json", "beforeShellExecution", None, {"command": cmd})
1552
+ if agent == "gemini":
1553
+ return Target(agent, home / ".gemini/settings.json", "BeforeTool", "run_shell_command",
1554
+ {"type": "command", "command": cmd})
1555
+ raise ValueError(agent)
1556
+
1557
+
1558
+ def _hook_lists(obj: dict, t: Target) -> List[list]:
1559
+ """Every list of hook entries under hooks.<list_key> (one per group, or the flat list)."""
1560
+ items = obj.get("hooks", {}).get(t.list_key, []) if isinstance(obj.get("hooks"), dict) else []
1561
+ if not isinstance(items, list):
1562
+ return []
1563
+ if t.matcher is None:
1564
+ return [items]
1565
+ return [g["hooks"] for g in items if isinstance(g, dict) and isinstance(g.get("hooks"), list)]
1566
+
1567
+
1568
+ def installed(obj: object, t: Target) -> bool:
1569
+ return isinstance(obj, dict) and any(_is_ours(h) for lst in _hook_lists(obj, t) for h in lst)
1570
+
1571
+
1572
+ def add_hook(obj: dict, t: Target) -> dict:
1573
+ """obj with exactly one gitpro entry (an existing one is rewritten in place)."""
1574
+ if t.agent == "cursor":
1575
+ obj.setdefault("version", 1)
1576
+ items = obj.setdefault("hooks", {}).setdefault(t.list_key, [])
1577
+ done = False
1578
+
1579
+ def dedupe(lst: list) -> list:
1580
+ nonlocal done
1581
+ keep = []
1582
+ for h in lst:
1583
+ if not _is_ours(h):
1584
+ keep.append(h)
1585
+ elif not done: # the first one is rewritten in place, later ones dropped
1586
+ keep.append(copy.deepcopy(t.entry))
1587
+ done = True
1588
+ return keep
1589
+
1590
+ if t.matcher is None:
1591
+ items[:] = dedupe(items)
1592
+ else:
1593
+ groups = []
1594
+ for g in items:
1595
+ if isinstance(g, dict) and isinstance(g.get("hooks"), list) and any(_is_ours(h) for h in g["hooks"]):
1596
+ g["hooks"] = dedupe(g["hooks"])
1597
+ if not g["hooks"]:
1598
+ continue # emptied by removing a gitpro duplicate; the user's own stay
1599
+ groups.append(g)
1600
+ items[:] = groups
1601
+ if not done:
1602
+ items.append(copy.deepcopy(t.entry) if t.matcher is None else
1603
+ {"matcher": t.matcher, "hooks": [copy.deepcopy(t.entry)]})
1604
+ return obj
1605
+
1606
+
1607
+ def remove_hook(obj: dict, t: Target) -> dict:
1608
+ """obj without gitpro entries, and without the containers that leaves empty."""
1609
+ hooks = obj.get("hooks")
1610
+ if not isinstance(hooks, dict) or not isinstance(hooks.get(t.list_key), list):
1611
+ return obj
1612
+ items = hooks[t.list_key]
1613
+ if t.matcher is None:
1614
+ items[:] = [h for h in items if not _is_ours(h)]
1615
+ else:
1616
+ keep = []
1617
+ for g in items:
1618
+ if isinstance(g, dict) and isinstance(g.get("hooks"), list) and any(_is_ours(h) for h in g["hooks"]):
1619
+ g["hooks"] = [h for h in g["hooks"] if not _is_ours(h)]
1620
+ if not g["hooks"]:
1621
+ continue
1622
+ keep.append(g)
1623
+ items[:] = keep
1624
+ if not items:
1625
+ del hooks[t.list_key]
1626
+ if not hooks:
1627
+ del obj["hooks"]
1628
+ return obj
1629
+
1630
+
1631
+ def _precheck(path: Path, t: Target, snippet: str) -> Optional[object]:
1632
+ """The file's JSON (None when missing); ConfigError for anything gitpro must not edit."""
1633
+ from . import jsonfile, undo
1634
+ from .config import ConfigError
1635
+ shown = undo.tilde(path)
1636
+ try:
1637
+ text = path.read_text(encoding="utf-8")
1638
+ except FileNotFoundError:
1639
+ return None
1640
+ try:
1641
+ obj = json.loads(text)
1642
+ except ValueError:
1643
+ if jsonfile.strip_jsonc(text)[1]:
1644
+ raise ConfigError(f"{shown} has comments; gitpro cannot edit it without losing them"
1645
+ f" — add this by hand: {snippet}") from None
1646
+ raise ConfigError(f"{shown} is not valid JSON; fix it by hand (nothing written)") from None
1647
+ if not isinstance(obj, dict):
1648
+ raise ConfigError(f"{shown} is not a JSON object; fix it by hand (nothing written)")
1649
+ hooks = obj.get("hooks", {})
1650
+ if not isinstance(hooks, dict):
1651
+ raise ConfigError(f"{shown}: hooks is not an object; fix it by hand (nothing written)")
1652
+ if not isinstance(hooks.get(t.list_key, []), list):
1653
+ raise ConfigError(f"{shown}: hooks.{t.list_key} is not a list; fix it by hand (nothing written)")
1654
+ return obj
1655
+
1656
+
1657
+ def _snippet(t: Target) -> str:
1658
+ entry = t.entry if t.matcher is None else {"matcher": t.matcher, "hooks": [t.entry]}
1659
+ return json.dumps({"hooks": {t.list_key: [entry]}}, ensure_ascii=False)
1660
+
1661
+
1662
+ def _repo_top() -> Optional[Path]:
1663
+ from . import resolve
1664
+ return resolve.is_repo(Path.cwd())
1665
+
1666
+
1667
+ def _git_out(top: Path, *args: str, timeout: float = 30) -> subprocess.CompletedProcess:
1668
+ env = {k: v for k, v in os.environ.items() if k not in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")}
1669
+ return subprocess.run(["git", "-C", str(top), *args], capture_output=True, text=True, env=env,
1670
+ stdin=subprocess.DEVNULL, timeout=timeout)
1671
+
1672
+
1673
+ def _exclude_local(top: Path, script: Optional[Path]) -> bool:
1674
+ """Add .claude/settings.local.json to <git dir>/info/exclude unless git already ignores it; the undo
1675
+ line goes into `script` before the file changes. True when it was added."""
1676
+ from . import files, jsonfile
1677
+ rel = ".claude/settings.local.json"
1678
+ if _git_out(top, "check-ignore", "-q", rel).returncode != 1:
1679
+ return False # 0: ignored already; 128: git cannot tell, leave it alone
1680
+ r = _git_out(top, "rev-parse", "--path-format=absolute", "--git-path", "info/exclude")
1681
+ if r.returncode != 0 or not r.stdout.strip():
1682
+ return False
1683
+ exclude = Path(r.stdout.strip())
1684
+ try:
1685
+ old: Optional[str] = exclude.read_text(encoding="utf-8")
1686
+ except FileNotFoundError:
1687
+ old = None
1688
+ if old is not None:
1689
+ backup = jsonfile._backup(exclude, old)
1690
+ line = f"cp -p {shlex.quote(str(backup))} {shlex.quote(str(exclude))}"
1691
+ else:
1692
+ line = f"rm -f {shlex.quote(str(exclude))}"
1693
+ if script is not None:
1694
+ with open(script, "a", encoding="utf-8") as f:
1695
+ f.write(line + "\n")
1696
+ text = (old or "")
1697
+ text += ("" if not text or text.endswith("\n") else "\n") + rel + "\n"
1698
+ mode = exclude.stat().st_mode & 0o777 if old is not None else 0o644
1699
+ files.atomic_write(exclude, text, mode)
1700
+ return True
1701
+
1702
+
1703
+ def _confirm_with(args: argparse.Namespace, question: str) -> Callable[[str], bool]:
1704
+ def confirm(diff: str) -> bool:
1705
+ from .config import ConfigError
1706
+ if args.yes:
1707
+ return True
1708
+ if not sys.stdin.isatty():
1709
+ raise ConfigError("no terminal to confirm on; re-run with --yes")
1710
+ from . import view
1711
+ if diff:
1712
+ print(diff, end="" if diff.endswith("\n") else "\n")
1713
+ return view.confirm(question)
1714
+ return confirm
1715
+
1716
+
1717
+ def _edit(args: argparse.Namespace, removing: bool) -> int:
1718
+ from . import args as gargs
1719
+ from . import jsonfile, jsonout, undo
1720
+ command = "agent uninstall" if removing else "agent install"
1721
+ if args.json and not args.yes and not gargs.preview_only(args):
1722
+ return gargs.fail("--write --json needs -y (there is no confirmation in JSON mode)", args)
1723
+ agent = args.agent
1724
+ say = not args.json
1725
+ if agent not in INSTALL_CHOICES:
1726
+ return gargs.fail(f"unknown agent {agent!r}; choose from {', '.join(INSTALL_CHOICES)}", args)
1727
+ if agent in NO_HOOK:
1728
+ msg = (f"{agent} has no pre-tool hook; use the git guard (gitpro guard install) and the MCP server "
1729
+ f"(gitpro mcp) instead")
1730
+ if args.json: # nothing was installed: a script must not read that as success
1731
+ return gargs.fail(msg, args)
1732
+ from . import view
1733
+ view.warn(msg)
1734
+ return 0
1735
+ title = {"claude": "Claude Code", "cursor": "Cursor", "gemini": "Gemini CLI"}[agent]
1736
+ if agent in EXPERIMENTAL and not removing: # removing is always safe
1737
+ if say:
1738
+ from . import view
1739
+ view.warn(f"experimental: field names unverified; check the hook JSON against {title}'s docs")
1740
+ if not args.experimental:
1741
+ return gargs.fail(f"{agent} adapter is experimental; add --experimental", args)
1742
+ if args.scope == "project" and agent != "claude":
1743
+ return gargs.fail("--scope project is for Claude Code only", args)
1744
+ repo = None
1745
+ if args.scope == "project":
1746
+ repo = _repo_top()
1747
+ if repo is None or not (repo / ".git").exists():
1748
+ return gargs.fail("not a git repository (--scope project writes <repo>/.claude/settings.local.json)",
1749
+ args)
1750
+ home = Path(args.home).expanduser()
1751
+ t = target(agent, args.scope, home, None if removing else gitpro_path(), repo)
1752
+ obj = _precheck(t.path, t, _snippet(t))
1753
+ before = obj if obj is not None else {}
1754
+ change = (lambda o: remove_hook(o, t)) if removing else (lambda o: add_hook(o, t))
1755
+ after = change(copy.deepcopy(before))
1756
+ shown = undo.tilde(t.path)
1757
+ if say and agent == "claude" and not removing:
1758
+ from . import view
1759
+ view.step("plugin users already have this hook (gitpro agent status shows it)")
1760
+ if after == before and (obj is not None or removing):
1761
+ if args.json:
1762
+ jsonout.emit(command, {"file": str(t.path), "changed": False, "backup": None, "undo": None})
1763
+ else:
1764
+ from . import view
1765
+ view.ok("not installed, nothing to do" if removing else "already installed, nothing to do")
1766
+ return 0
1767
+ if say:
1768
+ verb = "update" if obj is not None else "create"
1769
+ sign = "-" if removing else "+"
1770
+ print(f" {verb:<7} {shown} ({sign} hooks.{t.list_key}: {MARKER} --agent {agent})")
1771
+ if gargs.preview_only(args):
1772
+ return gargs.preview_result(args, command, {"file": str(t.path), "changed": False,
1773
+ "action": "remove" if removing else "install",
1774
+ "hook": _snippet(t), "backup": None, "undo": None})
1775
+ question = f"{'remove' if removing else 'install'} the {title} hook?"
1776
+ res = jsonfile.edit_json(t.path, change, command=command, create={},
1777
+ confirm=_confirm_with(args, question), mkdir=True, snippet=_snippet(t))
1778
+ if not res.changed:
1779
+ from . import view
1780
+ view.aborted()
1781
+ return 1
1782
+ added = repo is not None and not removing and _exclude_local(repo, res.undo)
1783
+ if args.json:
1784
+ jsonout.emit(command, {"file": str(res.path), "changed": True,
1785
+ "backup": str(res.backup) if res.backup else None,
1786
+ "undo": str(res.undo) if res.undo else None})
1787
+ return 0
1788
+ from . import view
1789
+ if added:
1790
+ view.step("added .claude/settings.local.json to .git/info/exclude so it is not committed")
1791
+ view.ok(f"removed the gitpro hook from {shown}" if removing else
1792
+ f"installed; {title} picks it up on its next session")
1793
+ restores = f"restores from {undo.tilde(res.backup)}" if res.backup else f"removes {shown}"
1794
+ print(f"↩ undo: sh {undo.tilde(res.undo)} ({restores})")
1795
+ return 0
1796
+
1797
+
1798
+ def install(args: argparse.Namespace) -> int:
1799
+ """`gitpro agent install`."""
1800
+ return _edit(args, removing=False)
1801
+
1802
+
1803
+ def uninstall(args: argparse.Namespace) -> int:
1804
+ """`gitpro agent uninstall`."""
1805
+ return _edit(args, removing=True)
1806
+
1807
+
1808
+ # ---------------------------------------------------------------- status
1809
+
1810
+ def _read_lenient(path: Path) -> Optional[object]:
1811
+ from . import jsonfile
1812
+ try:
1813
+ return jsonfile.read(path, jsonc=True)[0]
1814
+ except Exception:
1815
+ return None
1816
+
1817
+
1818
+ def _plugin(obj: object) -> Optional[str]:
1819
+ plugins = obj.get("enabledPlugins") if isinstance(obj, dict) else None
1820
+ if isinstance(plugins, dict):
1821
+ for key, on in plugins.items():
1822
+ if isinstance(key, str) and key.startswith("gitpro@") and on:
1823
+ return key
1824
+ return None
1825
+
1826
+
1827
+ def guard_installed() -> Optional[bool]:
1828
+ """True/False from gitpro.guard.status()["installed"]; None when the guard is not in this build."""
1829
+ import importlib
1830
+ try:
1831
+ guard = importlib.import_module("gitpro.guard")
1832
+ inst = guard.status().get("installed")
1833
+ except Exception:
1834
+ return None
1835
+ return inst not in ("none", "", None, False)
1836
+
1837
+
1838
+ def status_data(home: Path, explicit_config: Optional[Path] = None) -> dict:
1839
+ """What `agent status --json` prints (without the envelope)."""
1840
+ from .config import ConfigError
1841
+ warnings: List[str] = []
1842
+ try:
1843
+ cfg = _load_config(explicit_config, home)
1844
+ mode = cfg.agents.hook if cfg is not None else "block"
1845
+ except ConfigError as e:
1846
+ mode = "block"
1847
+ warnings.append(str(e))
1848
+ repo = _repo_top()
1849
+ agents_: Dict[str, dict] = {}
1850
+ for agent in ("claude", "cursor", "gemini"):
1851
+ user = target(agent, "user", home, None)
1852
+ uobj = _read_lenient(user.path) if user.path.is_file() else None
1853
+ info = {"user": str(user.path) if installed(uobj, user) else None, "project": None, "plugin": None,
1854
+ "experimental": agent in EXPERIMENTAL}
1855
+ if agent == "claude":
1856
+ pobj = None
1857
+ if repo is not None:
1858
+ proj = target(agent, "project", home, None, repo)
1859
+ pobj = _read_lenient(proj.path) if proj.path.is_file() else None
1860
+ info["project"] = str(proj.path) if installed(pobj, proj) else None
1861
+ info["plugin"] = _plugin(uobj) or _plugin(pobj)
1862
+ agents_[agent] = info
1863
+ plain, login = _command_v(False), _command_v(True)
1864
+ if not plain and login:
1865
+ warnings.append(f"gitpro is not on PATH for sh (found by login shell at {login}); the hook exits "
1866
+ f"silently without it → gitpro agent install --agent claude rewrites the entry with "
1867
+ f"the absolute path")
1868
+ return {"mode": mode, "agents": agents_, "guard_installed": guard_installed(),
1869
+ "gitpro_path": {"sh": plain, "login": login}, "warnings": warnings}
1870
+
1871
+
1872
+ def status(args: argparse.Namespace) -> int:
1873
+ """`gitpro agent status` (also bare `gitpro agent`)."""
1874
+ from . import jsonout, undo
1875
+ home = Path(getattr(args, "home", None) or Path.home()).expanduser()
1876
+ data = status_data(home, getattr(args, "config", None))
1877
+ if getattr(args, "json", False):
1878
+ jsonout.emit("agent status", data)
1879
+ return 0
1880
+ from . import view
1881
+ for w in data["warnings"]:
1882
+ view.warn(w)
1883
+ print(f"agents: [agents] hook = {data['mode']}")
1884
+ c = data["agents"]["claude"]
1885
+ where = [f"user {undo.tilde(Path(c['user']))}"] if c["user"] else []
1886
+ where += [f"project {undo.tilde(Path(c['project']))}"] if c["project"] else []
1887
+ hook = f"hook: {', '.join(where)}" if where else "hook: not installed → gitpro agent install"
1888
+ plugin = f"plugin: enabled ({c['plugin']})" if c["plugin"] else "plugin: not enabled"
1889
+ print(f" {'claude':<8} {hook}; {plugin}")
1890
+ for agent in EXPERIMENTAL:
1891
+ a = data["agents"][agent]
1892
+ line = (f"hook: user {undo.tilde(Path(a['user']))} (experimental)" if a["user"] else
1893
+ f"not installed (experimental) → gitpro agent install --agent {agent} --experimental")
1894
+ print(f" {agent:<8} {line}")
1895
+ g = data["guard_installed"]
1896
+ guard = {True: "installed", False: "not installed → gitpro guard install", None: "not in this build"}[g]
1897
+ for agent in NO_HOOK:
1898
+ print(f" {agent:<8} no pre-tool hook; covered by the git guard ({guard}) and MCP")
1899
+ print(f" bypass: {SKIP_ENV}=1 in the shell that starts the agent; or [agents] hook = \"warn\"|\"off\"")
1900
+ if any(a["user"] or a["project"] for a in data["agents"].values()):
1901
+ view.step("gitpro uninstall leaves agent hooks in place; gitpro agent uninstall removes them")
1902
+ return 0
1903
+
1904
+
1905
+ # ---------------------------------------------------------------- cli
1906
+
1907
+ def register(sub, common, confirm) -> None:
1908
+ from .args import capability
1909
+ p = sub.add_parser("agent-check", parents=[common], epilog=BYPASS_HELP,
1910
+ help="agent hook: read a tool call on stdin, deny it when the repo's identity is wrong")
1911
+ add_arguments(p)
1912
+ a = sub.add_parser("agent", parents=[common], epilog=BYPASS_HELP,
1913
+ help="install the Claude Code hook; show agent hook status")
1914
+ a.set_defaults(func=status, command="agent status", json=False)
1915
+ asub = a.add_subparsers(dest="agent_command", metavar="ACTION")
1916
+ for name, func, what in (("install", install, "add"), ("uninstall", uninstall, "remove")):
1917
+ sp = asub.add_parser(name, parents=[common, confirm], help=f"{what} the agent's pre-tool hook")
1918
+ sp.add_argument("--agent", default="claude", metavar="{" + ",".join(INSTALL_CHOICES) + "}",
1919
+ help="default: claude")
1920
+ sp.add_argument("--scope", choices=("user", "project"), default="user",
1921
+ help="user: ~/.claude/settings.json; project: <repo>/.claude/settings.local.json")
1922
+ sp.add_argument("--experimental", action="store_true", help="allow the unverified cursor/gemini adapters")
1923
+ sp.add_argument("--json", action="store_true", help="machine-readable output (needs -y)")
1924
+ sp.set_defaults(func=func, command=f"agent {name}")
1925
+ sp = asub.add_parser("status", parents=[common], help="which agents have the hook")
1926
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
1927
+ sp.set_defaults(func=status, command="agent status")
1928
+ capability("agent-check", "agent.install", "agent.status")