gitpro 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
gitpro/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")
|