phi-gate 0.2.0__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.
- phi_gate/__init__.py +3 -0
- phi_gate/__main__.py +5 -0
- phi_gate/audit.py +330 -0
- phi_gate/cli.py +410 -0
- phi_gate/config.py +600 -0
- phi_gate/detectors.py +1086 -0
- phi_gate/globs.py +227 -0
- phi_gate/hooks.py +644 -0
- phi_gate/policy.py +97 -0
- phi_gate/py.typed +0 -0
- phi_gate/redact.py +236 -0
- phi_gate/scanner.py +776 -0
- phi_gate/shell.py +1582 -0
- phi_gate-0.2.0.dist-info/METADATA +344 -0
- phi_gate-0.2.0.dist-info/RECORD +19 -0
- phi_gate-0.2.0.dist-info/WHEEL +5 -0
- phi_gate-0.2.0.dist-info/entry_points.txt +2 -0
- phi_gate-0.2.0.dist-info/licenses/LICENSE +21 -0
- phi_gate-0.2.0.dist-info/top_level.txt +1 -0
phi_gate/shell.py
ADDED
|
@@ -0,0 +1,1582 @@
|
|
|
1
|
+
"""Static analysis of Bash tool commands.
|
|
2
|
+
|
|
3
|
+
Before a command runs, the PreToolUse hook asks this module:
|
|
4
|
+
|
|
5
|
+
- which existing files and directories the command could read, print, copy,
|
|
6
|
+
link or send, so they can be scanned first;
|
|
7
|
+
- whether it sends data off the machine (curl, wget, scp, sftp, rsync, nc,
|
|
8
|
+
ncat, socat, ssh and similar tools, /dev/tcp redirections, network calls
|
|
9
|
+
in inline scripts);
|
|
10
|
+
- whether it reads a path that cannot be known without running it (command
|
|
11
|
+
substitution, unknown variables, paths computed inside inline scripts);
|
|
12
|
+
- which git commands print repository history (show, log -p, diff, stash
|
|
13
|
+
show -p, grep, archive, cat-file, blame and others) and with which
|
|
14
|
+
arguments;
|
|
15
|
+
- whether it creates commits, through `git commit`, an alias, or plumbing;
|
|
16
|
+
- whether it changes core.hooksPath;
|
|
17
|
+
- which files it writes.
|
|
18
|
+
|
|
19
|
+
What it follows: cd, pushd, popd, $PWD, $OLDPWD, ~+ and ~-; assignments,
|
|
20
|
+
including export, declare, readonly, local and typeset; environment
|
|
21
|
+
variables visible to the hook; for-loops over literal words; ANSI-C quoting;
|
|
22
|
+
brace expansion; globs; redirections; heredocs and here-strings; bash -c and
|
|
23
|
+
eval; command and process substitution (each analysed as a command of its
|
|
24
|
+
own); interpreter one-liners and heredoc scripts; git aliases given with -c
|
|
25
|
+
or configured in the repository.
|
|
26
|
+
|
|
27
|
+
What it does not cover: shell is a programming language, so this analysis
|
|
28
|
+
cannot be complete. A program or script file that opens files itself
|
|
29
|
+
(`python tool.py`, `make`, a test runner) is not analysed. Its standard
|
|
30
|
+
output is scanned by the PostToolUse hook, but only after the data has
|
|
31
|
+
reached the model's context, and that scan never sees data a command sends
|
|
32
|
+
to the network, to another process, or to a file. Paths that cannot be
|
|
33
|
+
resolved are reported in `Analysis.unresolved` so the caller can decide
|
|
34
|
+
conservatively.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import glob
|
|
40
|
+
import itertools
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import shlex
|
|
44
|
+
import shutil
|
|
45
|
+
import subprocess
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
SEPARATORS = {";", "&&", "||", "|", "&", "\n", "(", ")", "|&", ";;", "`"}
|
|
50
|
+
SHELLS = {"bash", "sh", "zsh", "dash", "ksh"}
|
|
51
|
+
WRAPPERS = {"sudo", "env", "nice", "nohup", "time", "command", "exec", "xargs", "timeout", "stdbuf", "builtin"}
|
|
52
|
+
KEYWORDS = {"if", "then", "else", "elif", "do", "while", "until", "!", "{", "time"}
|
|
53
|
+
|
|
54
|
+
# Commands that change or describe files without printing their content. A
|
|
55
|
+
# segment run by one of these does not count as reading the files it names
|
|
56
|
+
# (so `rm` or `git rm --cached` on a PHI file, the remediation, is allowed).
|
|
57
|
+
# cp, mv, ln, install and rsync are handled separately: their sources count
|
|
58
|
+
# as reads.
|
|
59
|
+
NON_READING = {
|
|
60
|
+
"rm", "ls", "stat", "touch", "chmod", "chown", "shred", "unlink", "mkdir", "rmdir", "cd", "du", "wc",
|
|
61
|
+
"basename", "dirname", "realpath", "test", "[", "echo", "printf", "true", "false", "sleep", "export", "set",
|
|
62
|
+
"unset", "which", "type", "pwd", "date", "whoami", "kill", "pkill", "declare", "readonly", "local", "typeset",
|
|
63
|
+
"pushd", "popd", "alias", "hash", "shift", "wait", "jobs", "umask", "trap", "exit", "return", "break",
|
|
64
|
+
"continue", "nproc", "uname", "hostname", "id",
|
|
65
|
+
}
|
|
66
|
+
GIT_NON_READING = {
|
|
67
|
+
"rm", "mv", "add", "restore", "reset", "status", "ls-files", "stash", "checkout", "switch", "commit",
|
|
68
|
+
"commit-tree", "update-ref", "write-tree", "merge", "rebase", "cherry-pick", "revert", "branch", "tag",
|
|
69
|
+
"init", "fetch", "pull", "push", "remote", "config", "rev-parse", "worktree", "gc", "log", "show", "diff",
|
|
70
|
+
"whatchanged", "reflog", "grep", "archive", "cat-file", "fast-export", "format-patch", "range-diff",
|
|
71
|
+
"diff-tree", "diff-index", "diff-files", "shortlog", "ls-tree", "rev-list", "describe", "merge-base",
|
|
72
|
+
"show-ref", "symbolic-ref", "for-each-ref", "count-objects", "version", "help", "clean",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Commands that walk directories given as arguments.
|
|
76
|
+
RECURSIVE_READERS = {"grep", "egrep", "fgrep", "rg", "ag", "ack", "ack-grep", "find", "tar", "zip", "7z", "zgrep"}
|
|
77
|
+
# Recursive by default; searching the working directory when given no path.
|
|
78
|
+
ALWAYS_RECURSIVE = {"rg", "ag", "ack", "ack-grep"}
|
|
79
|
+
GREP_FAMILY = {"grep", "egrep", "fgrep", "zgrep"}
|
|
80
|
+
|
|
81
|
+
# Commands that transmit data to another machine.
|
|
82
|
+
NETWORK = {
|
|
83
|
+
"curl", "wget", "scp", "sftp", "rsync", "nc", "ncat", "netcat", "socat", "ssh", "telnet", "ftp", "tftp",
|
|
84
|
+
"lftp", "rclone", "aws", "gsutil", "gcloud", "az", "azcopy", "s3cmd", "mail", "mailx", "sendmail", "mutt",
|
|
85
|
+
"http", "https", "xh", "openssl", "smbclient",
|
|
86
|
+
}
|
|
87
|
+
COPIERS = {"cp", "mv", "ln", "install", "rsync", "scp"}
|
|
88
|
+
# Options of copying commands that take a separate value.
|
|
89
|
+
COPY_VALUE_OPTS = {
|
|
90
|
+
"install": {"-m", "--mode", "-o", "--owner", "-g", "--group", "-S", "--suffix"},
|
|
91
|
+
"rsync": {"-e", "--rsh", "--exclude", "--include", "--filter", "-f"},
|
|
92
|
+
"scp": {"-P", "-i", "-F", "-l", "-o", "-c", "-J", "-S"},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
WRITE_WORDS = {
|
|
96
|
+
"tee", "sed", "rm", "mv", "cp", "truncate", "chmod", "chown", "ln", "dd", "install", "shred", "unlink",
|
|
97
|
+
"python", "python3", "perl", "ruby", "node", "deno", "bun", "awk", "gawk", "echo", "printf", "touch",
|
|
98
|
+
"vi", "vim", "nano", "ed", "rsync", "git",
|
|
99
|
+
}
|
|
100
|
+
# Commands whose operands are the files they modify.
|
|
101
|
+
MODIFIERS = {"touch", "truncate", "chmod", "chown", "tee", "shred"}
|
|
102
|
+
|
|
103
|
+
MAX_EMBEDDED_SCAN = 20_000
|
|
104
|
+
MAX_BRACE_EXPANSIONS = 64
|
|
105
|
+
MAX_VALUES = 64
|
|
106
|
+
MAX_DEPTH = 3
|
|
107
|
+
GIT_TIMEOUT = 5
|
|
108
|
+
|
|
109
|
+
# Markers substituted into the command text before tokenising. They are in a
|
|
110
|
+
# Unicode private-use range, so they cannot collide with ordinary text.
|
|
111
|
+
LIT_DOLLAR = "" # a `$` that the shell does not expand (quoted or escaped)
|
|
112
|
+
LIT_TICK = "" # a literal backtick
|
|
113
|
+
MARK = "" # brackets a placeholder: S<n> command substitution, P<n> process substitution, D<n> heredoc
|
|
114
|
+
_PLACEHOLDER = re.compile(MARK + r"([SPD])(\d+)" + MARK)
|
|
115
|
+
|
|
116
|
+
# The plugin's own CLI. Only this file is trusted to run read-only phi-gate
|
|
117
|
+
# commands without its arguments being scanned.
|
|
118
|
+
PLUGIN_ENTRY = Path(__file__).resolve().parents[2] / "bin" / "phi-gate"
|
|
119
|
+
ENTRY_NAME = "phi-gate"
|
|
120
|
+
# Ignored or untracked paths `git clean -x` could delete that phi-gate
|
|
121
|
+
# depends on: its state, its configuration, and the agent's settings.
|
|
122
|
+
CLEAN_PROTECTED = (".phi-gate", ".phi-gate.toml", ".claude", ".mcp.json", ".pre-commit-config.yaml")
|
|
123
|
+
|
|
124
|
+
_EMBEDDED_PATH = re.compile(
|
|
125
|
+
r"(?<![\w~./-])(?:[\w~./-]{0,200}[\w-]\.[A-Za-z0-9]{1,8}\b|[\w~.-]{0,200}/[\w~./-]{1,300})"
|
|
126
|
+
)
|
|
127
|
+
_BRACE = re.compile(r"^(.*?)\{([^{}]*,[^{}]*)\}(.*)$")
|
|
128
|
+
_ASSIGN = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(\+?)=(.*)$", re.S)
|
|
129
|
+
_UNEXPANDED = re.compile(r"\$(?:[A-Za-z_{(@*#?!$0-9-])")
|
|
130
|
+
_VAR_REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-=+?])([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)")
|
|
131
|
+
_HEREDOC = re.compile(r"<<(-?)[ \t]*(?:'([^'\n]*)'|\"([^\"\n]*)\"|\\?([^\s;&|<>()'\"]+))")
|
|
132
|
+
# Substitutions whose output is known not to be a file path, or is known.
|
|
133
|
+
_SAFE_SUBST = re.compile(
|
|
134
|
+
r"(?:date|whoami|id(?: -[ugn]+)?|nproc|uname(?: -\w+)?|hostname|arch|getconf \w+|tput \w+|"
|
|
135
|
+
r"git rev-parse (?:--short )?HEAD|git branch --show-current|git describe(?: [\w-]+)*)(?:\s+[+\w%:-]*)*"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def tokenize(command: str) -> list[str]:
|
|
140
|
+
try:
|
|
141
|
+
lex = shlex.shlex(command.replace("`", " ` "), posix=True, punctuation_chars="();<>|&\n")
|
|
142
|
+
lex.whitespace = " \t\r"
|
|
143
|
+
lex.whitespace_split = True
|
|
144
|
+
return list(lex)
|
|
145
|
+
except ValueError: # unbalanced quotes: fall back to a crude split
|
|
146
|
+
return re.findall(r"[^\s;&|<>()]+|[;&|<>()\n]+", command)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def segments(tokens: list[str]) -> list[list[str]]:
|
|
150
|
+
return [seg for _, seg in _segments(tokens)]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _segments(tokens: list[str]) -> list[tuple[str, list[str]]]:
|
|
154
|
+
"""Simple commands with the separator that precedes each."""
|
|
155
|
+
out: list[tuple[str, list[str]]] = [("", [])]
|
|
156
|
+
for tok in tokens:
|
|
157
|
+
if tok in SEPARATORS:
|
|
158
|
+
out.append((tok, []))
|
|
159
|
+
else:
|
|
160
|
+
out[-1][1].append(tok)
|
|
161
|
+
return [(sep, s) for sep, s in out if s]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _lit(word: str) -> str:
|
|
165
|
+
"""The word as the program receives it, with literal-dollar markers restored."""
|
|
166
|
+
return word.replace(LIT_DOLLAR, "$").replace(LIT_TICK, "`")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _expand_vars(word: str, cwd: Path, oldpwd: Path | None = None) -> str:
|
|
170
|
+
home = str(Path.home())
|
|
171
|
+
word = re.sub(r"\$\{PWD\}|\$PWD\b", lambda _m: str(cwd), word)
|
|
172
|
+
word = re.sub(r"\$\{HOME\}|\$HOME\b", lambda _m: home, word)
|
|
173
|
+
if oldpwd is not None:
|
|
174
|
+
word = re.sub(r"\$\{OLDPWD\}|\$OLDPWD\b", lambda _m: str(oldpwd), word)
|
|
175
|
+
if word == "~+" or word.startswith("~+/"):
|
|
176
|
+
word = str(cwd) + word[2:]
|
|
177
|
+
elif oldpwd is not None and (word == "~-" or word.startswith("~-/")):
|
|
178
|
+
word = str(oldpwd) + word[2:]
|
|
179
|
+
elif word == "~" or word.startswith("~/"):
|
|
180
|
+
word = home + word[1:]
|
|
181
|
+
return word
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _expand_braces(word: str) -> list[str]:
|
|
185
|
+
results = [word]
|
|
186
|
+
for _ in range(3):
|
|
187
|
+
nxt: list[str] = []
|
|
188
|
+
changed = False
|
|
189
|
+
for w in results:
|
|
190
|
+
m = _BRACE.match(w)
|
|
191
|
+
if m:
|
|
192
|
+
changed = True
|
|
193
|
+
nxt.extend(m.group(1) + alt + m.group(3) for alt in m.group(2).split(","))
|
|
194
|
+
else:
|
|
195
|
+
nxt.append(w)
|
|
196
|
+
results = nxt[:MAX_BRACE_EXPANSIONS]
|
|
197
|
+
if not changed:
|
|
198
|
+
break
|
|
199
|
+
return results
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _command_word(seg: list[str]) -> tuple[str, int]:
|
|
203
|
+
"""Basename of the command a segment runs, skipping VAR=x, keywords and wrappers."""
|
|
204
|
+
i = 0
|
|
205
|
+
while i < len(seg):
|
|
206
|
+
w = seg[i]
|
|
207
|
+
if _ASSIGN.match(w) or w in KEYWORDS:
|
|
208
|
+
i += 1
|
|
209
|
+
continue
|
|
210
|
+
name = os.path.basename(_lit(w))
|
|
211
|
+
if name in WRAPPERS:
|
|
212
|
+
i += 1
|
|
213
|
+
while i < len(seg) and seg[i].startswith("-"):
|
|
214
|
+
i += 1
|
|
215
|
+
if name == "timeout" and i < len(seg):
|
|
216
|
+
i += 1 # the duration
|
|
217
|
+
continue
|
|
218
|
+
return name, i
|
|
219
|
+
return "", len(seg)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class Commit:
|
|
224
|
+
args: list[str]
|
|
225
|
+
bypass: bool = False
|
|
226
|
+
include_all: bool = False
|
|
227
|
+
kind: str = "commit"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class Move:
|
|
232
|
+
sources: list[Path]
|
|
233
|
+
dest: Path
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass
|
|
237
|
+
class GitRead:
|
|
238
|
+
"""A git command that prints file content from the index or history."""
|
|
239
|
+
|
|
240
|
+
sub: str
|
|
241
|
+
args: list[str]
|
|
242
|
+
cwd: Path
|
|
243
|
+
unresolved: bool = False
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class Analysis:
|
|
248
|
+
read_files: list[Path] = field(default_factory=list)
|
|
249
|
+
read_dirs: list[Path] = field(default_factory=list)
|
|
250
|
+
commits: list[Commit] = field(default_factory=list)
|
|
251
|
+
stages_changes: bool = False # `git add` somewhere in the command
|
|
252
|
+
disables_hooks: bool = False
|
|
253
|
+
shows_staged: bool = False # `git diff --cached` prints staged content
|
|
254
|
+
writes: list[Path] = field(default_factory=list) # paths a writing command names
|
|
255
|
+
created: list[Path] = field(default_factory=list) # paths the command certainly creates or modifies
|
|
256
|
+
egress: list[str] = field(default_factory=list) # how the command sends data off the machine
|
|
257
|
+
unresolved: list[str] = field(default_factory=list) # reads whose path is known only at run time
|
|
258
|
+
git_reads: list[GitRead] = field(default_factory=list)
|
|
259
|
+
moves: list[Move] = field(default_factory=list)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def commit_flags(args: list[str]) -> tuple[bool, bool]:
|
|
263
|
+
"""(bypasses_hooks, includes_unstaged) for `git commit` arguments."""
|
|
264
|
+
bypass = include_all = False
|
|
265
|
+
skip_next = False
|
|
266
|
+
takes_value = {"-m", "--message", "-F", "--file", "-C", "-c", "--author", "--date", "-t", "--template",
|
|
267
|
+
"--reuse-message", "--reedit-message", "--fixup", "--squash", "--cleanup", "--trailer"}
|
|
268
|
+
for tok in args:
|
|
269
|
+
if skip_next:
|
|
270
|
+
skip_next = False
|
|
271
|
+
continue
|
|
272
|
+
if tok in takes_value:
|
|
273
|
+
skip_next = True
|
|
274
|
+
continue
|
|
275
|
+
if tok.startswith("--"):
|
|
276
|
+
name = tok[2:].split("=", 1)[0]
|
|
277
|
+
if len(name) >= 4 and "no-verify".startswith(name): # git accepts unambiguous prefixes
|
|
278
|
+
bypass = True
|
|
279
|
+
elif len(name) >= 2 and "all".startswith(name):
|
|
280
|
+
include_all = True
|
|
281
|
+
continue
|
|
282
|
+
if tok.startswith("-") and len(tok) > 1:
|
|
283
|
+
for k, ch in enumerate(tok[1:]):
|
|
284
|
+
if ch in "mFCct": # option with a value: rest of cluster, or the next token
|
|
285
|
+
if k == len(tok) - 2:
|
|
286
|
+
skip_next = True
|
|
287
|
+
break
|
|
288
|
+
if ch == "n":
|
|
289
|
+
bypass = True
|
|
290
|
+
if ch == "a":
|
|
291
|
+
include_all = True
|
|
292
|
+
return bypass, include_all
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _long_no_verify(args: list[str]) -> bool:
|
|
296
|
+
for tok in args:
|
|
297
|
+
if tok == "--":
|
|
298
|
+
break
|
|
299
|
+
name = tok[2:].split("=", 1)[0] if tok.startswith("--") else ""
|
|
300
|
+
if len(name) >= 6 and "no-verify".startswith(name):
|
|
301
|
+
return True
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# --------------------------------------------------------------------------
|
|
306
|
+
# Text preparation: quoting, substitutions, heredocs
|
|
307
|
+
# --------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass
|
|
311
|
+
class _Prepared:
|
|
312
|
+
text: str
|
|
313
|
+
subs: list[str] = field(default_factory=list)
|
|
314
|
+
procs: list[str] = field(default_factory=list)
|
|
315
|
+
docs: list[str] = field(default_factory=list)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
_C_ESCAPES = {"a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", "f": "\f", "n": "\n", "r": "\r", "t": "\t",
|
|
319
|
+
"v": "\v", "\\": "\\", "'": "'", '"': '"', "?": "?"}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _ansi_c(text: str, start: int) -> tuple[int, str]:
|
|
323
|
+
"""Decode the body of $'...' starting at `start`; returns (index after the quote, value)."""
|
|
324
|
+
out: list[str] = []
|
|
325
|
+
i, n = start, len(text)
|
|
326
|
+
while i < n:
|
|
327
|
+
c = text[i]
|
|
328
|
+
if c == "'":
|
|
329
|
+
return i + 1, "".join(out)
|
|
330
|
+
if c != "\\" or i + 1 >= n:
|
|
331
|
+
out.append(c)
|
|
332
|
+
i += 1
|
|
333
|
+
continue
|
|
334
|
+
e = text[i + 1]
|
|
335
|
+
if e in _C_ESCAPES:
|
|
336
|
+
out.append(_C_ESCAPES[e])
|
|
337
|
+
i += 2
|
|
338
|
+
elif e in "xuU":
|
|
339
|
+
width = {"x": 2, "u": 4, "U": 8}[e]
|
|
340
|
+
m = re.match(r"[0-9A-Fa-f]+", text[i + 2:i + 2 + width])
|
|
341
|
+
if m:
|
|
342
|
+
try:
|
|
343
|
+
out.append(chr(int(m.group(0), 16)))
|
|
344
|
+
except (ValueError, OverflowError):
|
|
345
|
+
pass
|
|
346
|
+
i += 2 + len(m.group(0))
|
|
347
|
+
else:
|
|
348
|
+
out.append("\\" + e)
|
|
349
|
+
i += 2
|
|
350
|
+
elif e in "01234567":
|
|
351
|
+
m = re.match(r"[0-7]{1,3}", text[i + 1:i + 4])
|
|
352
|
+
assert m is not None
|
|
353
|
+
out.append(chr(int(m.group(0), 8) & 0xFF))
|
|
354
|
+
i += 1 + len(m.group(0))
|
|
355
|
+
elif e == "c" and i + 2 < n:
|
|
356
|
+
out.append(chr(ord(text[i + 2]) & 0x1F))
|
|
357
|
+
i += 3
|
|
358
|
+
else:
|
|
359
|
+
out.append("\\" + e)
|
|
360
|
+
i += 2
|
|
361
|
+
return n, "".join(out)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _squote(value: str) -> str:
|
|
365
|
+
value = value.replace("$", LIT_DOLLAR).replace("`", LIT_TICK)
|
|
366
|
+
return "'" + value.replace("'", "'\\''") + "'"
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _matching_paren(text: str, open_idx: int) -> int:
|
|
370
|
+
depth = 0
|
|
371
|
+
quote: str | None = None
|
|
372
|
+
k = open_idx
|
|
373
|
+
n = len(text)
|
|
374
|
+
while k < n:
|
|
375
|
+
ch = text[k]
|
|
376
|
+
if quote:
|
|
377
|
+
if ch == "\\" and quote == '"':
|
|
378
|
+
k += 2
|
|
379
|
+
continue
|
|
380
|
+
if ch == quote:
|
|
381
|
+
quote = None
|
|
382
|
+
elif ch == "\\":
|
|
383
|
+
k += 2
|
|
384
|
+
continue
|
|
385
|
+
elif ch in "'\"":
|
|
386
|
+
quote = ch
|
|
387
|
+
elif ch == "(":
|
|
388
|
+
depth += 1
|
|
389
|
+
elif ch == ")":
|
|
390
|
+
depth -= 1
|
|
391
|
+
if depth == 0:
|
|
392
|
+
return k
|
|
393
|
+
k += 1
|
|
394
|
+
return n
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _prepare(command: str, top: Path | None) -> _Prepared:
|
|
398
|
+
"""Rewrite the command so the tokenizer sees what the shell would: literal
|
|
399
|
+
dollars marked, ANSI-C strings decoded, substitutions and heredoc bodies
|
|
400
|
+
replaced by placeholders that refer to their text."""
|
|
401
|
+
prep = _Prepared("")
|
|
402
|
+
out: list[str] = []
|
|
403
|
+
pending: list[tuple[str, bool, int]] = []
|
|
404
|
+
quote: str | None = None
|
|
405
|
+
i, n = 0, len(command)
|
|
406
|
+
|
|
407
|
+
def substitution(inner: str) -> str:
|
|
408
|
+
s = inner.strip()
|
|
409
|
+
if s in ("pwd", "pwd -P", "pwd -L"):
|
|
410
|
+
return "$PWD"
|
|
411
|
+
if s == "git rev-parse --show-toplevel" and top is not None:
|
|
412
|
+
return str(top)
|
|
413
|
+
if _SAFE_SUBST.fullmatch(s):
|
|
414
|
+
return "0"
|
|
415
|
+
prep.subs.append(inner)
|
|
416
|
+
return f"{MARK}S{len(prep.subs) - 1}{MARK}"
|
|
417
|
+
|
|
418
|
+
while i < n:
|
|
419
|
+
c = command[i]
|
|
420
|
+
if quote == "'":
|
|
421
|
+
if c == "'":
|
|
422
|
+
quote = None
|
|
423
|
+
out.append({"$": LIT_DOLLAR, "`": LIT_TICK}.get(c, c))
|
|
424
|
+
i += 1
|
|
425
|
+
continue
|
|
426
|
+
if c == "\\" and i + 1 < n:
|
|
427
|
+
nxt = command[i + 1]
|
|
428
|
+
if nxt == "\n":
|
|
429
|
+
i += 2
|
|
430
|
+
continue
|
|
431
|
+
if nxt == "$":
|
|
432
|
+
out.append(LIT_DOLLAR)
|
|
433
|
+
elif nxt == "`":
|
|
434
|
+
out.append(LIT_TICK)
|
|
435
|
+
else:
|
|
436
|
+
out.append(command[i:i + 2])
|
|
437
|
+
i += 2
|
|
438
|
+
continue
|
|
439
|
+
if c == "'" and quote is None:
|
|
440
|
+
quote = "'"
|
|
441
|
+
out.append(c)
|
|
442
|
+
i += 1
|
|
443
|
+
continue
|
|
444
|
+
if c == '"':
|
|
445
|
+
quote = None if quote == '"' else '"'
|
|
446
|
+
out.append(c)
|
|
447
|
+
i += 1
|
|
448
|
+
continue
|
|
449
|
+
if c == "$" and i + 1 < n:
|
|
450
|
+
nxt = command[i + 1]
|
|
451
|
+
if nxt == "'" and quote is None:
|
|
452
|
+
i, value = _ansi_c(command, i + 2)
|
|
453
|
+
out.append(_squote(value))
|
|
454
|
+
continue
|
|
455
|
+
if nxt == '"' and quote is None: # $"..." is a translated string
|
|
456
|
+
i += 1
|
|
457
|
+
continue
|
|
458
|
+
if nxt == "(":
|
|
459
|
+
j = _matching_paren(command, i + 1)
|
|
460
|
+
if command.startswith("$((", i):
|
|
461
|
+
out.append("0") # arithmetic expansion
|
|
462
|
+
else:
|
|
463
|
+
out.append(substitution(command[i + 2:j]))
|
|
464
|
+
i = j + 1
|
|
465
|
+
continue
|
|
466
|
+
if c == "`":
|
|
467
|
+
j = i + 1
|
|
468
|
+
while j < n and command[j] != "`":
|
|
469
|
+
j += 2 if command[j] == "\\" else 1
|
|
470
|
+
out.append(substitution(command[i + 1:j].replace("\\`", "`")))
|
|
471
|
+
i = j + 1
|
|
472
|
+
continue
|
|
473
|
+
if quote is None and c in "<>" and i + 1 < n and command[i + 1] == "(":
|
|
474
|
+
j = _matching_paren(command, i + 1)
|
|
475
|
+
prep.procs.append(command[i + 2:j])
|
|
476
|
+
out.append(f" {MARK}P{len(prep.procs) - 1}{MARK} ")
|
|
477
|
+
i = j + 1
|
|
478
|
+
continue
|
|
479
|
+
if quote is None and command.startswith("<<<", i):
|
|
480
|
+
out.append("<<<")
|
|
481
|
+
i += 3
|
|
482
|
+
continue
|
|
483
|
+
if quote is None and command.startswith("<<", i):
|
|
484
|
+
m = _HEREDOC.match(command, i)
|
|
485
|
+
if m:
|
|
486
|
+
delim = next(g for g in (m.group(2), m.group(3), m.group(4)) if g is not None)
|
|
487
|
+
prep.docs.append("")
|
|
488
|
+
pending.append((delim, m.group(1) == "-", len(prep.docs) - 1))
|
|
489
|
+
out.append(f" < {MARK}D{len(prep.docs) - 1}{MARK} ")
|
|
490
|
+
i = m.end()
|
|
491
|
+
continue
|
|
492
|
+
if c == "\n" and quote is None and pending:
|
|
493
|
+
out.append("\n")
|
|
494
|
+
i += 1
|
|
495
|
+
for delim, strip_tabs, idx in pending:
|
|
496
|
+
body: list[str] = []
|
|
497
|
+
while i < n:
|
|
498
|
+
e = command.find("\n", i)
|
|
499
|
+
e = n if e < 0 else e
|
|
500
|
+
line = command[i:e]
|
|
501
|
+
i = e + 1
|
|
502
|
+
if (line.lstrip("\t") if strip_tabs else line) == delim:
|
|
503
|
+
break
|
|
504
|
+
body.append(line)
|
|
505
|
+
prep.docs[idx] = "\n".join(body)
|
|
506
|
+
pending = []
|
|
507
|
+
continue
|
|
508
|
+
if c == "#" and quote is None and (i == 0 or command[i - 1] in " \t\n;&|("):
|
|
509
|
+
e = command.find("\n", i)
|
|
510
|
+
i = n if e < 0 else e
|
|
511
|
+
continue
|
|
512
|
+
out.append(c)
|
|
513
|
+
i += 1
|
|
514
|
+
prep.text = "".join(out)
|
|
515
|
+
return prep
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# --------------------------------------------------------------------------
|
|
519
|
+
# Inline scripts
|
|
520
|
+
# --------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
_INTERPRETER = re.compile(
|
|
523
|
+
r"^(?:(python|pypy)[\d.]*|(node|nodejs|bun|deno)|(perl)[\d.]*|(ruby)[\d.]*|(php)[\d.]*|(Rscript|lua[\d.]*|"
|
|
524
|
+
r"osascript|tclsh)|(awk|gawk|mawk|nawk))$"
|
|
525
|
+
)
|
|
526
|
+
_LANGS = ("python", "node", "perl", "ruby", "php", "other", "awk")
|
|
527
|
+
|
|
528
|
+
# Calls whose first argument names a file to open, copy or connect to.
|
|
529
|
+
_PATH_CALL = re.compile(
|
|
530
|
+
r"(?<![\w$])(?:open|io\.open|codecs\.open|os\.open|Path|PurePath|PosixPath|pathlib\.Path|readFileSync|"
|
|
531
|
+
r"readFile|createReadStream|openSync|fopen|file_get_contents|readfile|read_csv|read_excel|read_json|"
|
|
532
|
+
r"read_table|read_parquet|read_fwf|read_feather|read_pickle|read_sas|read_stata|loadtxt|genfromtxt|np\.load|numpy\.load|torch\.load|"
|
|
533
|
+
r"connect|copy|copyfile|copy2|copytree|move|File\.read|File\.open|File\.readlines|File\.foreach|IO\.read|"
|
|
534
|
+
r"IO\.readlines|CSV\.read|CSV\.foreach|CSV\.open|Pathname\.new|Bun\.file|Deno\.readTextFile|"
|
|
535
|
+
r"Deno\.readFile)\s*\("
|
|
536
|
+
)
|
|
537
|
+
# Constructs that reach files whose names are not written in the script.
|
|
538
|
+
_COMPUTED = re.compile(
|
|
539
|
+
r"(?<![\w$])(?:os\.walk|os\.listdir|os\.scandir|scandir|listdir|glob|iglob|iterdir|rglob|readdirSync|readdir|"
|
|
540
|
+
r"opendir|Dir\.glob|Dir\.entries|Dir\[|Find\.find|File::Find|os\.path\.join|path\.join|path\.resolve|"
|
|
541
|
+
r"Path\.cwd|Path\.home|subprocess|os\.system|os\.popen|os\.exec\w*|popen|check_output|child_process|"
|
|
542
|
+
r"execSync|spawnSync|exec|eval|system|shell_exec|passthru|proc_open|IO\.popen|Open3|__import__|importlib|"
|
|
543
|
+
r"getline|qx|readlink)\b|<\s*\$|<\*|%x[\(\[{]|`"
|
|
544
|
+
)
|
|
545
|
+
_NETWORK_API = re.compile(
|
|
546
|
+
r"(?<![\w$])(?:urllib|requests|httpx|http\.client|aiohttp|socket|smtplib|ftplib|paramiko|fetch|XMLHttpRequest|"
|
|
547
|
+
r"axios|https?\.request|https?\.get|net\.connect|net\.Socket|Net::HTTP|LWP|IO::Socket|HTTP::Tiny|curl_exec|"
|
|
548
|
+
r"fsockopen|Deno\.connect|WebSocket)\b"
|
|
549
|
+
)
|
|
550
|
+
_STRING = re.compile(r"""(?<![\w$])[rRbBuU]{0,2}(?:'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*")""")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _first_argument(script: str, start: int) -> str:
|
|
554
|
+
depth = 0
|
|
555
|
+
out: list[str] = []
|
|
556
|
+
for ch in script[start:start + 2000]:
|
|
557
|
+
if ch in "([{":
|
|
558
|
+
depth += 1
|
|
559
|
+
elif ch in ")]}":
|
|
560
|
+
if depth == 0:
|
|
561
|
+
break
|
|
562
|
+
depth -= 1
|
|
563
|
+
elif ch == "," and depth == 0:
|
|
564
|
+
break
|
|
565
|
+
out.append(ch)
|
|
566
|
+
return "".join(out)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def script_access(script: str, lang: str) -> tuple[bool, bool]:
|
|
570
|
+
"""(reaches a computed path, uses the network) for an inline script.
|
|
571
|
+
|
|
572
|
+
A script reaches only literal paths when every file-opening call takes a
|
|
573
|
+
single string literal (those paths are scanned like any other) and it
|
|
574
|
+
uses nothing that lists directories, joins paths, or runs other programs.
|
|
575
|
+
"""
|
|
576
|
+
text = script[:MAX_EMBEDDED_SCAN]
|
|
577
|
+
network = bool(_NETWORK_API.search(text))
|
|
578
|
+
if lang == "awk":
|
|
579
|
+
computed = bool(re.search(r"getline\s*(?:\w+\s*)?<\s*(?![\"'][^\"']*[\"']\s*[);}\n]|[\"'][^\"']*[\"']\s*$)", text)
|
|
580
|
+
or re.search(r"\bsystem\s*\(|\|\s*getline", text))
|
|
581
|
+
return computed, network
|
|
582
|
+
if _COMPUTED.search(text):
|
|
583
|
+
return True, network
|
|
584
|
+
flat = _STRING.sub("S", text)
|
|
585
|
+
for m in _PATH_CALL.finditer(flat):
|
|
586
|
+
arg = _first_argument(flat, m.end()).strip()
|
|
587
|
+
if lang == "perl" and re.match(r"^(?:my\s+)?[$\w]+$", arg):
|
|
588
|
+
# open(FH, MODE, PATH): every argument after the handle must be literal
|
|
589
|
+
rest = flat[m.end():m.end() + 2000].split(")", 1)[0].split(",")[1:]
|
|
590
|
+
if all(a.strip() in ("S", "") for a in rest):
|
|
591
|
+
continue
|
|
592
|
+
return True, network
|
|
593
|
+
if arg in ("S", "") or arg.isdigit():
|
|
594
|
+
continue
|
|
595
|
+
return True, network
|
|
596
|
+
return False, network
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _inline_scripts(lang: str, args: list[str]) -> tuple[list[str], bool]:
|
|
600
|
+
"""(scripts given on the command line, whether stdin is the script)."""
|
|
601
|
+
scripts: list[str] = []
|
|
602
|
+
i = 0
|
|
603
|
+
positional: list[str] = []
|
|
604
|
+
while i < len(args):
|
|
605
|
+
a = args[i]
|
|
606
|
+
nxt = args[i + 1] if i + 1 < len(args) else None
|
|
607
|
+
if lang == "python" and re.fullmatch(r"-[A-Za-z]*c", a) and nxt is not None:
|
|
608
|
+
scripts.append(nxt)
|
|
609
|
+
i += 2
|
|
610
|
+
continue
|
|
611
|
+
if lang == "python" and re.fullmatch(r"-[A-Za-z]*m", a):
|
|
612
|
+
return scripts, False # runs a module: not analysed
|
|
613
|
+
if lang == "node" and (a in ("-e", "-p", "--eval", "--print", "-pe") or re.fullmatch(r"-[A-Za-z]*[ep]", a)) \
|
|
614
|
+
and nxt is not None:
|
|
615
|
+
scripts.append(nxt)
|
|
616
|
+
i += 2
|
|
617
|
+
continue
|
|
618
|
+
if lang == "node" and a == "eval" and nxt is not None:
|
|
619
|
+
scripts.append(nxt)
|
|
620
|
+
i += 2
|
|
621
|
+
continue
|
|
622
|
+
if lang in ("perl", "ruby") and re.fullmatch(r"-[A-Za-z]*[eE]", a) and nxt is not None:
|
|
623
|
+
scripts.append(nxt)
|
|
624
|
+
i += 2
|
|
625
|
+
continue
|
|
626
|
+
if lang == "php" and a == "-r" and nxt is not None:
|
|
627
|
+
scripts.append(nxt)
|
|
628
|
+
i += 2
|
|
629
|
+
continue
|
|
630
|
+
if lang == "other" and a == "-e" and nxt is not None:
|
|
631
|
+
scripts.append(nxt)
|
|
632
|
+
i += 2
|
|
633
|
+
continue
|
|
634
|
+
if lang == "awk":
|
|
635
|
+
if a == "-f":
|
|
636
|
+
return scripts, False
|
|
637
|
+
if a in ("-F", "-v") and nxt is not None:
|
|
638
|
+
i += 2
|
|
639
|
+
continue
|
|
640
|
+
if not a.startswith("-") or a == "-":
|
|
641
|
+
positional.append(a)
|
|
642
|
+
i += 1
|
|
643
|
+
if lang == "awk" and positional:
|
|
644
|
+
return [positional[0]], False
|
|
645
|
+
stdin_script = not scripts and (not positional or positional[0] == "-")
|
|
646
|
+
return scripts, stdin_script
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# --------------------------------------------------------------------------
|
|
650
|
+
# git
|
|
651
|
+
# --------------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
GIT_GLOBAL_VALUE = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env",
|
|
654
|
+
"--super-prefix", "--attr-source", "--list-cmds"}
|
|
655
|
+
COMMIT_LIKE = {"commit", "commit-tree", "update-ref", "merge", "cherry-pick", "revert", "am", "rebase"}
|
|
656
|
+
GIT_BUILTINS = GIT_NON_READING | COMMIT_LIKE | {
|
|
657
|
+
"am", "annotate", "apply", "bisect", "blame", "bundle", "check-attr", "check-ignore", "cherry", "clone",
|
|
658
|
+
"difftool", "fast-import", "fsck", "hash-object", "ls-remote", "mergetool", "notes", "read-tree", "repack",
|
|
659
|
+
"replace", "send-email", "show-branch", "sparse-checkout", "submodule", "update-index", "var",
|
|
660
|
+
"verify-commit", "maintenance", "request-pull", "filter-branch", "unpack-file", "checkout-index",
|
|
661
|
+
}
|
|
662
|
+
_PATCH_FLAG = re.compile(
|
|
663
|
+
r"^(?:-p|-u|--patch|--patch-with-stat|--patch-with-raw|-U\d*|--unified(?:=\d+)?|--cc|-c|--word-diff.*|"
|
|
664
|
+
r"--color-words.*|-W|--function-context|--binary|--textconv|--ext-diff|-L.*|--full-diff|-m)$"
|
|
665
|
+
)
|
|
666
|
+
_NO_PATCH_FLAG = re.compile(
|
|
667
|
+
r"^(?:-s|--no-patch|--stat.*|--shortstat|--numstat|--name-only|--name-status|--raw|--summary|--dirstat.*|"
|
|
668
|
+
r"-X.*|--compact-summary|--quiet)$"
|
|
669
|
+
)
|
|
670
|
+
HISTORY_SUBS = {"bundle", "show", "log", "whatchanged", "reflog", "diff", "diff-tree", "diff-index", "diff-files", "stash",
|
|
671
|
+
"grep", "archive", "cat-file", "blame", "annotate", "fast-export", "format-patch", "range-diff"}
|
|
672
|
+
|
|
673
|
+
_alias_cache: dict[str, dict[str, str]] = {}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _parse_git(args: list[str]) -> tuple[list[tuple[str, str]], list[str], str, list[str]]:
|
|
677
|
+
"""(-c config pairs, -C directories, subcommand, subcommand arguments)."""
|
|
678
|
+
configs: list[tuple[str, str]] = []
|
|
679
|
+
dirs: list[str] = []
|
|
680
|
+
j = 0
|
|
681
|
+
while j < len(args) and args[j].startswith("-"):
|
|
682
|
+
a = args[j]
|
|
683
|
+
if a in ("-c", "--config-env") and j + 1 < len(args):
|
|
684
|
+
key, _, value = args[j + 1].partition("=")
|
|
685
|
+
configs.append((key.lower(), value))
|
|
686
|
+
j += 2
|
|
687
|
+
continue
|
|
688
|
+
if a == "-C" and j + 1 < len(args):
|
|
689
|
+
dirs.append(args[j + 1])
|
|
690
|
+
j += 2
|
|
691
|
+
continue
|
|
692
|
+
if a in GIT_GLOBAL_VALUE:
|
|
693
|
+
j += 2
|
|
694
|
+
continue
|
|
695
|
+
if a.startswith("--config-env="):
|
|
696
|
+
key, _, value = a[len("--config-env="):].partition("=")
|
|
697
|
+
configs.append((key.lower(), value))
|
|
698
|
+
j += 1
|
|
699
|
+
if j < len(args):
|
|
700
|
+
return configs, dirs, args[j], args[j + 1:]
|
|
701
|
+
return configs, dirs, "", []
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _configured_aliases(cwd: Path) -> dict[str, str]:
|
|
705
|
+
key = str(cwd)
|
|
706
|
+
if key not in _alias_cache:
|
|
707
|
+
aliases: dict[str, str] = {}
|
|
708
|
+
try:
|
|
709
|
+
res = subprocess.run(["git", "config", "--get-regexp", r"^alias\."], cwd=cwd, capture_output=True,
|
|
710
|
+
text=True, timeout=GIT_TIMEOUT)
|
|
711
|
+
for line in res.stdout.splitlines():
|
|
712
|
+
name, _, value = line.partition(" ")
|
|
713
|
+
aliases[name[len("alias."):].lower()] = value
|
|
714
|
+
except (OSError, subprocess.SubprocessError):
|
|
715
|
+
pass
|
|
716
|
+
_alias_cache[key] = aliases
|
|
717
|
+
return _alias_cache[key]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _git_top(cwd: Path) -> Path | None:
|
|
721
|
+
try:
|
|
722
|
+
res = subprocess.run(["git", "rev-parse", "--show-toplevel"], cwd=cwd, capture_output=True, text=True,
|
|
723
|
+
timeout=GIT_TIMEOUT)
|
|
724
|
+
except (OSError, subprocess.SubprocessError):
|
|
725
|
+
return None
|
|
726
|
+
return Path(res.stdout.strip()) if res.returncode == 0 and res.stdout.strip() else None
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _shows_content(sub: str, args: list[str]) -> bool:
|
|
730
|
+
opts = [a for a in itertools.takewhile(lambda x: x != "--", args) if a.startswith("-")]
|
|
731
|
+
patch = any(_PATCH_FLAG.match(a) for a in opts)
|
|
732
|
+
quiet = any(_NO_PATCH_FLAG.match(a) for a in opts)
|
|
733
|
+
if sub in ("log", "whatchanged", "reflog", "diff-tree", "diff-index", "diff-files", "fast-export"):
|
|
734
|
+
return patch or sub == "fast-export"
|
|
735
|
+
if sub in ("show", "diff"):
|
|
736
|
+
return patch or not quiet
|
|
737
|
+
if sub == "stash":
|
|
738
|
+
action = args[0] if args else ""
|
|
739
|
+
return action in ("show", "list") and patch
|
|
740
|
+
if sub == "cat-file":
|
|
741
|
+
return not any(a in ("-t", "-s", "-e") or a.startswith("--batch-check") for a in opts)
|
|
742
|
+
return True
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# --------------------------------------------------------------------------
|
|
746
|
+
# Analysis
|
|
747
|
+
# --------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _unresolved(word: str) -> bool:
|
|
751
|
+
return f"{MARK}S" in word or bool(_UNEXPANDED.search(word))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _is_plugin_entry(word: str, base: Path, path_overridden: bool) -> bool:
|
|
755
|
+
raw = _lit(word)
|
|
756
|
+
try:
|
|
757
|
+
if "/" in raw:
|
|
758
|
+
return _abs(_expand_vars(raw, base), base).resolve() == PLUGIN_ENTRY.resolve()
|
|
759
|
+
if path_overridden:
|
|
760
|
+
return False
|
|
761
|
+
found = shutil.which(raw)
|
|
762
|
+
if found is None:
|
|
763
|
+
# Claude Code puts the plugin's bin/ on the Bash tool's PATH, which
|
|
764
|
+
# the hook's own PATH may lack.
|
|
765
|
+
return PLUGIN_ENTRY.is_file()
|
|
766
|
+
return Path(found).resolve() == PLUGIN_ENTRY.resolve()
|
|
767
|
+
except (OSError, ValueError):
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _merge(result: Analysis, sub: Analysis) -> None:
|
|
772
|
+
result.read_files += sub.read_files
|
|
773
|
+
result.read_dirs += sub.read_dirs
|
|
774
|
+
result.commits += sub.commits
|
|
775
|
+
result.writes += sub.writes
|
|
776
|
+
result.created += sub.created
|
|
777
|
+
result.egress += sub.egress
|
|
778
|
+
result.unresolved += sub.unresolved
|
|
779
|
+
result.git_reads += sub.git_reads
|
|
780
|
+
result.moves += sub.moves
|
|
781
|
+
result.stages_changes |= sub.stages_changes
|
|
782
|
+
result.disables_hooks |= sub.disables_hooks
|
|
783
|
+
result.shows_staged |= sub.shows_staged
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _operands(args: list[str], value_opts: set[str]) -> tuple[list[str], str | None]:
|
|
787
|
+
"""(non-option words, -t/--target-directory value) for cp-like commands."""
|
|
788
|
+
out: list[str] = []
|
|
789
|
+
target: str | None = None
|
|
790
|
+
i = 0
|
|
791
|
+
end = False
|
|
792
|
+
while i < len(args):
|
|
793
|
+
a = args[i]
|
|
794
|
+
if end or not a.startswith("-") or a == "-":
|
|
795
|
+
out.append(a)
|
|
796
|
+
elif a == "--":
|
|
797
|
+
end = True
|
|
798
|
+
elif a in ("-t", "--target-directory") and i + 1 < len(args):
|
|
799
|
+
target = args[i + 1]
|
|
800
|
+
i += 1
|
|
801
|
+
elif a.startswith("--target-directory="):
|
|
802
|
+
target = a.split("=", 1)[1]
|
|
803
|
+
elif a in value_opts:
|
|
804
|
+
i += 1
|
|
805
|
+
i += 1
|
|
806
|
+
return out, target
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
_GREP_VALUE_OPTS = {"-e", "-f", "-A", "-B", "-C", "-m", "-d", "-D", "--regexp", "--file", "--include", "--exclude",
|
|
810
|
+
"--exclude-dir", "--context", "--max-count", "--label", "-g", "--glob", "-t", "--type", "-T",
|
|
811
|
+
"--type-not", "-j", "--threads", "-M", "--max-columns", "-E", "--encoding", "--iglob", "--pre",
|
|
812
|
+
"--sort", "--sortr", "--max-depth", "--max-filesize", "--type-add", "--ignore-dir", "-r",
|
|
813
|
+
"--replace", "--colors", "--path-separator"}
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _search_operands(name: str, args: list[str]) -> list[str]:
|
|
817
|
+
"""Path operands of grep, rg, ag or ack: positional words after the pattern."""
|
|
818
|
+
positional: list[str] = []
|
|
819
|
+
has_pattern_opt = False
|
|
820
|
+
skip = False
|
|
821
|
+
end = False
|
|
822
|
+
for a in args:
|
|
823
|
+
if skip:
|
|
824
|
+
skip = False
|
|
825
|
+
continue
|
|
826
|
+
if end or not a.startswith("-") or a == "-":
|
|
827
|
+
positional.append(a)
|
|
828
|
+
continue
|
|
829
|
+
if a == "--":
|
|
830
|
+
end = True
|
|
831
|
+
continue
|
|
832
|
+
if a in ("-e", "-f", "--regexp", "--file") or a.startswith(("--regexp=", "--file=")) \
|
|
833
|
+
or re.fullmatch(r"-[A-Za-z]*[ef]\S*", a) and not a.startswith("--"):
|
|
834
|
+
has_pattern_opt = True
|
|
835
|
+
value_opts = _GREP_VALUE_OPTS - ({"-r"} if name in GREP_FAMILY else set())
|
|
836
|
+
if a in value_opts and "=" not in a:
|
|
837
|
+
skip = True
|
|
838
|
+
return positional if has_pattern_opt else positional[1:]
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def analyze(command: str, cwd: Path, depth: int = 0, feeds: bool = False, literal_only: bool = False) -> Analysis:
|
|
842
|
+
"""Analyse a command run in `cwd`.
|
|
843
|
+
|
|
844
|
+
`feeds` marks a command whose output becomes another command's arguments
|
|
845
|
+
(inside $(...) or piped to xargs), so a listing counts as a read.
|
|
846
|
+
`literal_only` analyses text that is data rather than a command (a heredoc
|
|
847
|
+
fed to cat, for example): literal paths in it count, unknown ones do not.
|
|
848
|
+
"""
|
|
849
|
+
result = Analysis()
|
|
850
|
+
if "core.hookspath" in command.lower():
|
|
851
|
+
result.disables_hooks = True
|
|
852
|
+
feeds = feeds or "xargs" in command
|
|
853
|
+
top = _git_top(cwd) if "rev-parse --show-toplevel" in command else None
|
|
854
|
+
prep = _prepare(command, top)
|
|
855
|
+
bases = [cwd]
|
|
856
|
+
oldpwd = cwd
|
|
857
|
+
stack: list[Path] = []
|
|
858
|
+
base_unknown = False
|
|
859
|
+
variables: dict[str, list[str]] = {}
|
|
860
|
+
path_overridden = False
|
|
861
|
+
entry_shadowed = bool(re.search(r"(?:^|[\s;&|(])(?:function\s+)?phi-gate\s*\(\s*\)|alias\s+phi-gate=", command))
|
|
862
|
+
reading_text: list[str] = []
|
|
863
|
+
done_placeholders: set[str] = set()
|
|
864
|
+
prev_seg: list[str] = []
|
|
865
|
+
prev_name = ""
|
|
866
|
+
|
|
867
|
+
def recurse(text: str, base: Path, *, as_feed: bool = False, literal: bool = False) -> None:
|
|
868
|
+
if depth >= MAX_DEPTH:
|
|
869
|
+
if not literal and not literal_only:
|
|
870
|
+
result.unresolved.append("nesting too deep to follow")
|
|
871
|
+
return
|
|
872
|
+
_merge(result, analyze(text, base, depth + 1, feeds=as_feed or feeds, literal_only=literal or literal_only))
|
|
873
|
+
|
|
874
|
+
def expand(word: str) -> list[str]:
|
|
875
|
+
"""The words `word` can become. An unknown variable stays in place,
|
|
876
|
+
so the word reads as unresolved, and also expands to nothing, as
|
|
877
|
+
an unset variable does."""
|
|
878
|
+
word = _expand_vars(word, bases[-1], oldpwd)
|
|
879
|
+
choices: list[list[str]] = []
|
|
880
|
+
pos = 0
|
|
881
|
+
for m in _VAR_REF.finditer(word):
|
|
882
|
+
choices.append([word[pos:m.start()]])
|
|
883
|
+
var = m.group(1) or m.group(4)
|
|
884
|
+
values = variables.get(var)
|
|
885
|
+
if values is None and var in os.environ and var not in ("PWD", "OLDPWD"):
|
|
886
|
+
values = [os.environ[var]]
|
|
887
|
+
op, default = m.group(2), m.group(3) or ""
|
|
888
|
+
if values is None and op in (":-", "-", ":=", "="):
|
|
889
|
+
values = [default]
|
|
890
|
+
elif values is not None and op in (":+", "+"):
|
|
891
|
+
values = [default]
|
|
892
|
+
choices.append(values if values is not None else [m.group(0), ""])
|
|
893
|
+
pos = m.end()
|
|
894
|
+
if not choices:
|
|
895
|
+
return [word]
|
|
896
|
+
choices.append([word[pos:]])
|
|
897
|
+
return ["".join(parts) for parts in itertools.islice(itertools.product(*choices), MAX_VALUES)]
|
|
898
|
+
|
|
899
|
+
def place_holders(words: list[str], base: Path) -> None:
|
|
900
|
+
for w in words:
|
|
901
|
+
for m in _PLACEHOLDER.finditer(w):
|
|
902
|
+
key = m.group(0)
|
|
903
|
+
if key in done_placeholders:
|
|
904
|
+
continue
|
|
905
|
+
done_placeholders.add(key)
|
|
906
|
+
kind, k = m.group(1), int(m.group(2))
|
|
907
|
+
if kind == "S" and k >= len(prep.subs) or kind == "P" and k >= len(prep.procs):
|
|
908
|
+
continue # a placeholder from an enclosing command, recorded there
|
|
909
|
+
if kind == "S":
|
|
910
|
+
recurse(prep.subs[k], base, as_feed=True)
|
|
911
|
+
elif kind == "P":
|
|
912
|
+
recurse(prep.procs[k], base)
|
|
913
|
+
|
|
914
|
+
for sep, seg in _segments(tokenize(prep.text)):
|
|
915
|
+
piped = sep in ("|", "|&")
|
|
916
|
+
base = bases[-1]
|
|
917
|
+
place_holders(seg, base)
|
|
918
|
+
|
|
919
|
+
if seg[0] in ("for", "select") and len(seg) >= 3 and seg[2] == "in":
|
|
920
|
+
values: list[str] = []
|
|
921
|
+
for w in seg[3:]:
|
|
922
|
+
values += expand(w)
|
|
923
|
+
variables[seg[1]] = [_lit(v) for v in values][:MAX_VALUES]
|
|
924
|
+
prev_seg, prev_name = seg, seg[0]
|
|
925
|
+
continue
|
|
926
|
+
|
|
927
|
+
name, idx = _command_word(seg)
|
|
928
|
+
via_xargs = any(os.path.basename(_lit(w)) == "xargs" for w in seg[:idx])
|
|
929
|
+
args = seg[idx + 1:]
|
|
930
|
+
for w in seg[:idx]: # VAR=value prefixes and bare assignments
|
|
931
|
+
m = _ASSIGN.match(w)
|
|
932
|
+
if m:
|
|
933
|
+
variables[m.group(1)] = [_lit(v) for v in expand(m.group(3))][:MAX_VALUES]
|
|
934
|
+
path_overridden |= m.group(1) == "PATH"
|
|
935
|
+
if name in ("export", "declare", "readonly", "local", "typeset"):
|
|
936
|
+
for w in args:
|
|
937
|
+
m = _ASSIGN.match(w)
|
|
938
|
+
if m:
|
|
939
|
+
variables[m.group(1)] = [_lit(v) for v in expand(m.group(3))][:MAX_VALUES]
|
|
940
|
+
path_overridden |= m.group(1) == "PATH"
|
|
941
|
+
|
|
942
|
+
# split redirections from words
|
|
943
|
+
words: list[str] = []
|
|
944
|
+
inputs: list[str] = []
|
|
945
|
+
outputs: list[str] = []
|
|
946
|
+
stdin_texts: list[str] = []
|
|
947
|
+
i = idx + 1
|
|
948
|
+
while i < len(seg):
|
|
949
|
+
tok = seg[i]
|
|
950
|
+
if tok == "<<<":
|
|
951
|
+
if i + 1 < len(seg):
|
|
952
|
+
stdin_texts.append(_lit(seg[i + 1]))
|
|
953
|
+
i += 2
|
|
954
|
+
continue
|
|
955
|
+
if re.fullmatch(r"\d*[<>]+&?|&>>?|>\|", tok):
|
|
956
|
+
if i + 1 < len(seg):
|
|
957
|
+
target = seg[i + 1]
|
|
958
|
+
doc = _PLACEHOLDER.fullmatch(target)
|
|
959
|
+
if doc and doc.group(1) == "D":
|
|
960
|
+
stdin_texts.append(prep.docs[int(doc.group(2))])
|
|
961
|
+
elif "<" in tok:
|
|
962
|
+
inputs += expand(target)
|
|
963
|
+
elif not re.fullmatch(r"\d+|-", target) or "&" not in tok:
|
|
964
|
+
outputs += expand(target)
|
|
965
|
+
i += 2
|
|
966
|
+
continue
|
|
967
|
+
if tok.isdigit() and i + 1 < len(seg) and re.fullmatch(r"[<>]+&?", seg[i + 1]):
|
|
968
|
+
i += 1 # "2" in "2>/dev/null"
|
|
969
|
+
continue
|
|
970
|
+
words += expand(tok)
|
|
971
|
+
i += 1
|
|
972
|
+
|
|
973
|
+
# nested shells: bash -c "...", eval "...", bash <<EOF
|
|
974
|
+
if name in SHELLS or name == "eval":
|
|
975
|
+
inner = [a for a in args if " " in a or "\n" in a] or args[-1:]
|
|
976
|
+
for script in inner:
|
|
977
|
+
if f"{MARK}S" in script and not literal_only:
|
|
978
|
+
result.unresolved.append(f"{name} runs a script built when the command runs")
|
|
979
|
+
recurse(_PLACEHOLDER.sub(" ", _lit(script)), base)
|
|
980
|
+
for text in stdin_texts:
|
|
981
|
+
recurse(text, base)
|
|
982
|
+
if piped and prev_name in ("echo", "printf"):
|
|
983
|
+
recurse(" ".join(_lit(w) for w in prev_seg[1:]), base)
|
|
984
|
+
else:
|
|
985
|
+
for text in stdin_texts: # data for the command; literal paths in it still count
|
|
986
|
+
recurse(text, base, literal=True)
|
|
987
|
+
|
|
988
|
+
if name in ("cd", "pushd", "popd"):
|
|
989
|
+
if name == "popd":
|
|
990
|
+
if stack:
|
|
991
|
+
oldpwd = bases[-1]
|
|
992
|
+
bases.append(stack.pop())
|
|
993
|
+
continue
|
|
994
|
+
targets = [w for w in words if not w.startswith("-") or w == "-"]
|
|
995
|
+
if not targets:
|
|
996
|
+
target_word = str(Path.home()) if name == "cd" else ""
|
|
997
|
+
else:
|
|
998
|
+
target_word = targets[0]
|
|
999
|
+
if not target_word or target_word.startswith("+"):
|
|
1000
|
+
base_unknown = True
|
|
1001
|
+
continue
|
|
1002
|
+
if target_word == "-":
|
|
1003
|
+
new = oldpwd
|
|
1004
|
+
elif _unresolved(target_word):
|
|
1005
|
+
base_unknown = True
|
|
1006
|
+
continue
|
|
1007
|
+
else:
|
|
1008
|
+
new = _abs(_lit(target_word), base)
|
|
1009
|
+
if name == "pushd":
|
|
1010
|
+
stack.append(base)
|
|
1011
|
+
oldpwd = base
|
|
1012
|
+
bases.append(new)
|
|
1013
|
+
continue
|
|
1014
|
+
|
|
1015
|
+
reading = bool(name) and (name not in NON_READING or feeds)
|
|
1016
|
+
if name in KEYWORDS or name in ("done", "fi", "esac", "}"):
|
|
1017
|
+
reading = bool(inputs)
|
|
1018
|
+
egress = name in NETWORK
|
|
1019
|
+
if name == ENTRY_NAME:
|
|
1020
|
+
# The plugin's own read-only commands print locations or redacted
|
|
1021
|
+
# text, so the files they name are not exposed. --map writes the
|
|
1022
|
+
# original values out, so it counts as a read.
|
|
1023
|
+
action = _lit(args[0]) if args else ""
|
|
1024
|
+
trusted = not entry_shadowed and _is_plugin_entry(seg[idx], base, path_overridden)
|
|
1025
|
+
reading = not trusted or action not in PHI_GATE_SAFE or any(_lit(a).startswith("--map") for a in args)
|
|
1026
|
+
extra_bases: list[Path] = []
|
|
1027
|
+
read_words: list[str] | None = None # when set, only these words are reads
|
|
1028
|
+
recursive_flag = any(
|
|
1029
|
+
a in ("--recursive", "--archive") or re.fullmatch(r"-[A-Za-z]*[rR][A-Za-z]*", a) for a in words
|
|
1030
|
+
)
|
|
1031
|
+
recursive = name in RECURSIVE_READERS or recursive_flag
|
|
1032
|
+
|
|
1033
|
+
if name == "git":
|
|
1034
|
+
reading, shell_alias = _analyze_git(words, base, result, literal_only)
|
|
1035
|
+
if shell_alias:
|
|
1036
|
+
recurse(shell_alias, base)
|
|
1037
|
+
top_dir = _git_top(base)
|
|
1038
|
+
if top_dir is not None:
|
|
1039
|
+
extra_bases.append(top_dir)
|
|
1040
|
+
reading = reading or feeds
|
|
1041
|
+
if name == "find":
|
|
1042
|
+
reading = feeds or any(a in FIND_EXEC for a in words)
|
|
1043
|
+
starts = list(itertools.takewhile(lambda w: not w.startswith(("-", "(", "!")), words))
|
|
1044
|
+
if reading and not starts:
|
|
1045
|
+
result.read_dirs.append(base)
|
|
1046
|
+
if "-delete" in words:
|
|
1047
|
+
result.writes += _resolve(starts, bases, must_exist=False)
|
|
1048
|
+
|
|
1049
|
+
if name in COPIERS:
|
|
1050
|
+
operands, target_dir = _operands(words, COPY_VALUE_OPTS.get(name, {"-S", "--suffix"}))
|
|
1051
|
+
if target_dir is not None:
|
|
1052
|
+
sources, dest = operands, target_dir
|
|
1053
|
+
elif len(operands) >= 2:
|
|
1054
|
+
sources, dest = operands[:-1], operands[-1]
|
|
1055
|
+
else:
|
|
1056
|
+
sources, dest = operands, None
|
|
1057
|
+
if dest is not None:
|
|
1058
|
+
result.created += _resolve([dest], bases, must_exist=False)
|
|
1059
|
+
if re.match(r"^[^/]*[^/\\]:", dest) or dest.startswith(("rsync://", "sftp://")):
|
|
1060
|
+
egress = True
|
|
1061
|
+
recursive = recursive or name == "ln" or any(re.fullmatch(r"-[A-Za-z]*a[A-Za-z]*", w) for w in words)
|
|
1062
|
+
if name == "mv":
|
|
1063
|
+
if dest is not None:
|
|
1064
|
+
result.moves.append(Move(_resolve(sources, bases), _abs(_lit(dest), base)))
|
|
1065
|
+
read_words = []
|
|
1066
|
+
reading = False
|
|
1067
|
+
else:
|
|
1068
|
+
read_words = sources
|
|
1069
|
+
reading = True
|
|
1070
|
+
if name in MODIFIERS or (name == "sed" and any(w.startswith("-i") or w == "--in-place" for w in words)):
|
|
1071
|
+
result.created += _resolve([w for w in words if not w.startswith("-")], bases, must_exist=False)
|
|
1072
|
+
if name == "dd":
|
|
1073
|
+
result.created += _resolve([w[3:] for w in words if w.startswith("of=")], bases, must_exist=False)
|
|
1074
|
+
result.created += _resolve(outputs, bases, must_exist=False)
|
|
1075
|
+
|
|
1076
|
+
if any(re.match(r"^/dev/(?:tcp|udp)/", _lit(o)) for o in outputs + inputs):
|
|
1077
|
+
egress = True
|
|
1078
|
+
if any(_DEV_STREAM.match(_lit(w)) for w in words + outputs):
|
|
1079
|
+
reading = True # cp file /dev/stdout prints it
|
|
1080
|
+
|
|
1081
|
+
# interpreters: inline scripts and scripts on stdin
|
|
1082
|
+
lang = _language(name)
|
|
1083
|
+
scripts: list[str] = []
|
|
1084
|
+
if lang is not None:
|
|
1085
|
+
inline, from_stdin = _inline_scripts(lang, [_lit(w) for w in words])
|
|
1086
|
+
scripts += inline
|
|
1087
|
+
if from_stdin:
|
|
1088
|
+
scripts += stdin_texts
|
|
1089
|
+
if piped and prev_name in ("echo", "printf"):
|
|
1090
|
+
scripts.append(" ".join(_lit(w) for w in prev_seg[1:]))
|
|
1091
|
+
for script in scripts:
|
|
1092
|
+
computed, network = script_access(script, lang or "other")
|
|
1093
|
+
if computed and not literal_only:
|
|
1094
|
+
result.unresolved.append(f"{name} script builds a file path at run time")
|
|
1095
|
+
if network:
|
|
1096
|
+
egress = True
|
|
1097
|
+
reading_text.append(script)
|
|
1098
|
+
if egress:
|
|
1099
|
+
result.egress.append(name or "redirection")
|
|
1100
|
+
|
|
1101
|
+
candidates: list[str] = []
|
|
1102
|
+
source_words = words if read_words is None else read_words
|
|
1103
|
+
for w in source_words:
|
|
1104
|
+
if w.startswith("-") and w != "-":
|
|
1105
|
+
if "=" in w:
|
|
1106
|
+
candidates.append(w.split("=", 1)[1])
|
|
1107
|
+
continue
|
|
1108
|
+
candidates.append(w.split("=", 1)[1] if _ASSIGN.match(w) else w)
|
|
1109
|
+
if name in ("curl", "wget", "http", "https", "xh") and "@" in w:
|
|
1110
|
+
candidates.append(w.split("@", 1)[1])
|
|
1111
|
+
if " " in w or "(" in w or "'" in w or '"' in w: # inline script: look for paths inside
|
|
1112
|
+
candidates += [m.group(0) for m in _EMBEDDED_PATH.finditer(_lit(w)[:MAX_EMBEDDED_SCAN])]
|
|
1113
|
+
if name in ("curl", "wget"):
|
|
1114
|
+
candidates += [w.split("@", 1)[1] for w in words if w.startswith("-") and "@" in w]
|
|
1115
|
+
|
|
1116
|
+
search = bases + extra_bases
|
|
1117
|
+
literal_candidates = [_lit(c) for c in candidates if not _unresolved(c)]
|
|
1118
|
+
literal_inputs = [_lit(c) for c in inputs if not _unresolved(c)]
|
|
1119
|
+
resolved_reads = _resolve(literal_candidates + literal_inputs, search)
|
|
1120
|
+
if name in WRITE_WORDS:
|
|
1121
|
+
result.writes += _resolve([_lit(c) for c in candidates], bases, must_exist=False)
|
|
1122
|
+
result.writes += _resolve([_lit(o) for o in outputs], bases, must_exist=False)
|
|
1123
|
+
|
|
1124
|
+
if via_xargs and (reading or egress) and not literal_only:
|
|
1125
|
+
result.unresolved.append(f"xargs {name}: arguments read from standard input")
|
|
1126
|
+
if (reading or egress) and not literal_only and name != "git":
|
|
1127
|
+
unknown = [c for c in candidates + inputs if _unresolved(c)]
|
|
1128
|
+
if unknown:
|
|
1129
|
+
result.unresolved.append(f"{name}: {_lit(unknown[0])[:80]}")
|
|
1130
|
+
elif base_unknown and any(not os.path.isabs(_lit(c)) for c in candidates + inputs if c):
|
|
1131
|
+
result.unresolved.append(f"{name} after a change to an unknown directory")
|
|
1132
|
+
elif inputs and not literal_only and any(_unresolved(c) for c in inputs):
|
|
1133
|
+
result.unresolved.append(f"{name}: input {_lit(inputs[0])[:80]}")
|
|
1134
|
+
|
|
1135
|
+
if reading and (name in GREP_FAMILY and recursive_flag or name in ALWAYS_RECURSIVE):
|
|
1136
|
+
# rg and similar search standard input when it is a pipe or a file; --files lists names only
|
|
1137
|
+
no_tree = name in ALWAYS_RECURSIVE and bool(piped or inputs or stdin_texts or "--files" in words)
|
|
1138
|
+
if not _search_operands(name, [_lit(w) for w in words]) and not no_tree:
|
|
1139
|
+
result.read_dirs.append(base)
|
|
1140
|
+
|
|
1141
|
+
input_paths = {os.path.normpath(str(_abs(_lit(x), base))) for x in inputs}
|
|
1142
|
+
if reading:
|
|
1143
|
+
reading_text.append(" ".join(_lit(w) for w in (seg if read_words is None else read_words)))
|
|
1144
|
+
for p in resolved_reads:
|
|
1145
|
+
try:
|
|
1146
|
+
if p.is_file():
|
|
1147
|
+
if reading or os.path.normpath(str(p)) in input_paths:
|
|
1148
|
+
result.read_files.append(p)
|
|
1149
|
+
elif p.is_dir() and reading and recursive:
|
|
1150
|
+
result.read_dirs.append(p)
|
|
1151
|
+
except OSError:
|
|
1152
|
+
continue
|
|
1153
|
+
prev_seg, prev_name = seg, name
|
|
1154
|
+
|
|
1155
|
+
# placeholders that no segment reached (inside heredoc bodies, for example)
|
|
1156
|
+
leftovers = [f"{MARK}S{k}{MARK}" for k in range(len(prep.subs))] + \
|
|
1157
|
+
[f"{MARK}P{k}{MARK}" for k in range(len(prep.procs))]
|
|
1158
|
+
place_holders(leftovers, bases[-1])
|
|
1159
|
+
|
|
1160
|
+
# Paths anywhere in the text of reading segments: heredoc bodies, inline
|
|
1161
|
+
# scripts, arguments the per-word analysis could not attribute.
|
|
1162
|
+
if reading_text:
|
|
1163
|
+
joined = "\n".join(reading_text)[:MAX_EMBEDDED_SCAN]
|
|
1164
|
+
embedded = [m.group(0) for m in _EMBEDDED_PATH.finditer(joined)]
|
|
1165
|
+
for p in _resolve(embedded, bases):
|
|
1166
|
+
try:
|
|
1167
|
+
if p.is_file():
|
|
1168
|
+
result.read_files.append(p)
|
|
1169
|
+
except OSError:
|
|
1170
|
+
continue
|
|
1171
|
+
|
|
1172
|
+
result.read_files = _dedupe(result.read_files)
|
|
1173
|
+
result.read_dirs = _dedupe(result.read_dirs)
|
|
1174
|
+
result.writes += result.created
|
|
1175
|
+
return result
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _language(name: str) -> str | None:
|
|
1179
|
+
m = _INTERPRETER.match(name)
|
|
1180
|
+
if not m:
|
|
1181
|
+
return None
|
|
1182
|
+
return next(lang for lang, g in zip(_LANGS, m.groups(), strict=True) if g is not None)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _analyze_git(words: list[str], base: Path, result: Analysis, literal_only: bool) -> tuple[bool, str]:
|
|
1186
|
+
"""Record commits, history reads and hook changes for one git invocation.
|
|
1187
|
+
Returns (whether the generic file-read analysis applies, a shell alias
|
|
1188
|
+
command to analyse)."""
|
|
1189
|
+
lits = [_lit(w) for w in words]
|
|
1190
|
+
for w in lits:
|
|
1191
|
+
if "core.hookspath" in w.lower():
|
|
1192
|
+
result.disables_hooks = True
|
|
1193
|
+
configs, dirs, sub, sub_args = _parse_git(lits)
|
|
1194
|
+
git_base = base
|
|
1195
|
+
for d in dirs:
|
|
1196
|
+
git_base = _abs(d, git_base)
|
|
1197
|
+
inline = {k[len("alias."):]: v for k, v in configs if k.startswith("alias.")}
|
|
1198
|
+
shell_alias = ""
|
|
1199
|
+
for _ in range(5):
|
|
1200
|
+
if not sub or sub in GIT_BUILTINS:
|
|
1201
|
+
break
|
|
1202
|
+
value = inline.get(sub.lower())
|
|
1203
|
+
if value is None:
|
|
1204
|
+
value = _configured_aliases(git_base).get(sub.lower())
|
|
1205
|
+
if value is None:
|
|
1206
|
+
break
|
|
1207
|
+
if "core.hookspath" in value.lower():
|
|
1208
|
+
result.disables_hooks = True
|
|
1209
|
+
if value.startswith("!"):
|
|
1210
|
+
shell_alias = value[1:] + " " + " ".join(shlex.quote(a) for a in sub_args)
|
|
1211
|
+
return False, shell_alias
|
|
1212
|
+
try:
|
|
1213
|
+
parts = shlex.split(value)
|
|
1214
|
+
except ValueError:
|
|
1215
|
+
parts = value.split()
|
|
1216
|
+
if not parts:
|
|
1217
|
+
break
|
|
1218
|
+
if parts[0].startswith("-"):
|
|
1219
|
+
more, more_dirs, sub, rest = _parse_git(parts)
|
|
1220
|
+
configs += more
|
|
1221
|
+
for d in more_dirs:
|
|
1222
|
+
git_base = _abs(d, git_base)
|
|
1223
|
+
sub_args = rest + sub_args
|
|
1224
|
+
else:
|
|
1225
|
+
sub, sub_args = parts[0], parts[1:] + sub_args
|
|
1226
|
+
|
|
1227
|
+
if sub in COMMIT_LIKE:
|
|
1228
|
+
if sub == "commit":
|
|
1229
|
+
bypass, include_all = commit_flags(sub_args)
|
|
1230
|
+
else:
|
|
1231
|
+
bypass, include_all = _long_no_verify(sub_args), False
|
|
1232
|
+
result.commits.append(Commit(sub_args, bypass, include_all, sub))
|
|
1233
|
+
if sub == "add":
|
|
1234
|
+
result.stages_changes = True
|
|
1235
|
+
if sub == "diff" and ("--cached" in sub_args or "--staged" in sub_args):
|
|
1236
|
+
result.shows_staged = True
|
|
1237
|
+
if sub == "clean" and any(a.startswith("-") and not a.startswith("--") and ("x" in a or "X" in a)
|
|
1238
|
+
for a in sub_args):
|
|
1239
|
+
result.writes += [git_base / name for name in CLEAN_PROTECTED]
|
|
1240
|
+
if sub in HISTORY_SUBS and not literal_only and _shows_content(sub, sub_args):
|
|
1241
|
+
unresolved = any(_unresolved(w) for w in words)
|
|
1242
|
+
result.git_reads.append(GitRead(sub, list(sub_args), git_base, unresolved))
|
|
1243
|
+
return sub not in GIT_NON_READING, shell_alias
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
_DEV_STREAM = re.compile(r"^/(?:dev/(?:std(?:out|err)|fd/|tty)|proc/self/fd/)")
|
|
1247
|
+
FIND_EXEC = {"-exec", "-execdir", "-ok", "-okdir"}
|
|
1248
|
+
PHI_GATE_SAFE = {"scan", "redact", "status", "explain", "--version", "--help", "-h"}
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _abs(word: str, base: Path) -> Path:
|
|
1252
|
+
p = Path(word)
|
|
1253
|
+
return p if p.is_absolute() else base / p
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _resolve(words: list[str], bases: list[Path], must_exist: bool = True) -> list[Path]:
|
|
1257
|
+
out: list[Path] = []
|
|
1258
|
+
for raw in words:
|
|
1259
|
+
if not raw or MARK in raw:
|
|
1260
|
+
continue
|
|
1261
|
+
for base in bases:
|
|
1262
|
+
word = _expand_vars(raw, base)
|
|
1263
|
+
for w in _expand_braces(word):
|
|
1264
|
+
if any(ch in w for ch in "*?["):
|
|
1265
|
+
pattern = w if os.path.isabs(w) else str(base / w)
|
|
1266
|
+
try:
|
|
1267
|
+
out += [Path(m) for m in glob.glob(pattern)[:500]]
|
|
1268
|
+
except (OSError, ValueError):
|
|
1269
|
+
pass
|
|
1270
|
+
if not must_exist:
|
|
1271
|
+
out.append(_abs(w, base))
|
|
1272
|
+
else:
|
|
1273
|
+
try:
|
|
1274
|
+
p = _abs(w, base)
|
|
1275
|
+
if not must_exist or p.exists():
|
|
1276
|
+
out.append(p)
|
|
1277
|
+
except (OSError, ValueError):
|
|
1278
|
+
continue
|
|
1279
|
+
return out
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _dedupe(paths: list[Path]) -> list[Path]:
|
|
1283
|
+
seen: set[str] = set()
|
|
1284
|
+
out = []
|
|
1285
|
+
for p in paths:
|
|
1286
|
+
key = os.path.normpath(str(p))
|
|
1287
|
+
if key not in seen:
|
|
1288
|
+
seen.add(key)
|
|
1289
|
+
out.append(Path(key))
|
|
1290
|
+
return out
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
# --------------------------------------------------------------------------
|
|
1294
|
+
# What a git history read prints
|
|
1295
|
+
# --------------------------------------------------------------------------
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
@dataclass
|
|
1299
|
+
class Blob:
|
|
1300
|
+
"""A file a git command would print: an object in the repository (`sha`)
|
|
1301
|
+
or the working-tree file (`sha` is None). `path` is relative to the top
|
|
1302
|
+
of the working tree."""
|
|
1303
|
+
|
|
1304
|
+
path: str
|
|
1305
|
+
sha: str | None
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
class Undetermined(Exception):
|
|
1309
|
+
"""The set of files a git command would print could not be determined."""
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
ZERO_SHA = re.compile(r"^0+$")
|
|
1313
|
+
_KEEP_LOG_OPTS = re.compile(
|
|
1314
|
+
r"^(?:-\d+|--max-count=\d+|--skip=\d+|--(?:since|after|until|before|author|committer|grep|glob|"
|
|
1315
|
+
r"branches|tags|remotes)(?:=.*)?|--all|-g|--walk-reflogs|--first-parent|--no-merges|--merges|--follow|"
|
|
1316
|
+
r"-[SG].+|--cached|--staged|--merge-base|-R|--root)$"
|
|
1317
|
+
)
|
|
1318
|
+
_LOG_VALUE_OPTS = {"-n", "--max-count", "--skip", "--since", "--after", "--until", "--before", "--author",
|
|
1319
|
+
"--committer", "--grep", "-S", "-G", "-L", "--format", "--pretty", "--date", "-O", "--stat-width",
|
|
1320
|
+
"--diff-filter", "--relative", "-U", "--unified", "--encoding"}
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _git(args: list[str], cwd: Path, input_text: str | None = None) -> str:
|
|
1324
|
+
try:
|
|
1325
|
+
res = subprocess.run(["git", "-c", "core.quotepath=off", *args], cwd=cwd, capture_output=True, text=True,
|
|
1326
|
+
input=input_text, timeout=GIT_TIMEOUT, errors="surrogateescape")
|
|
1327
|
+
except subprocess.TimeoutExpired as exc:
|
|
1328
|
+
raise Undetermined("git took too long to list the files") from exc
|
|
1329
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1330
|
+
raise Undetermined(f"git could not run ({exc})") from exc
|
|
1331
|
+
if res.returncode != 0:
|
|
1332
|
+
raise Undetermined(f"git could not resolve the arguments ({res.stderr.strip()[:120]})")
|
|
1333
|
+
return res.stdout
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _split_paths(args: list[str]) -> tuple[list[str], list[str]]:
|
|
1337
|
+
if "--" in args:
|
|
1338
|
+
k = args.index("--")
|
|
1339
|
+
return args[:k], args[k + 1:]
|
|
1340
|
+
return args, []
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _log_args(args: list[str]) -> tuple[list[str], list[str], list[str]]:
|
|
1344
|
+
"""(options kept, revision words, paths) from log-like arguments."""
|
|
1345
|
+
before, paths = _split_paths(args)
|
|
1346
|
+
opts: list[str] = []
|
|
1347
|
+
words: list[str] = []
|
|
1348
|
+
i = 0
|
|
1349
|
+
while i < len(before):
|
|
1350
|
+
a = before[i]
|
|
1351
|
+
if a.startswith("-") and a != "-":
|
|
1352
|
+
if a == "-n" and i + 1 < len(before):
|
|
1353
|
+
opts += [a, before[i + 1]]
|
|
1354
|
+
i += 2
|
|
1355
|
+
continue
|
|
1356
|
+
if a == "-L" and i + 1 < len(before) or a.startswith("-L") and len(a) > 2:
|
|
1357
|
+
spec = before[i + 1] if a == "-L" else a[2:]
|
|
1358
|
+
paths.append(spec.rsplit(":", 1)[-1])
|
|
1359
|
+
i += 2 if a == "-L" else 1
|
|
1360
|
+
continue
|
|
1361
|
+
if a in _LOG_VALUE_OPTS and i + 1 < len(before):
|
|
1362
|
+
if _KEEP_LOG_OPTS.match(f"{a}={before[i + 1]}"):
|
|
1363
|
+
opts.append(f"{a}={before[i + 1]}")
|
|
1364
|
+
i += 2
|
|
1365
|
+
continue
|
|
1366
|
+
if _KEEP_LOG_OPTS.match(a):
|
|
1367
|
+
opts.append(a)
|
|
1368
|
+
i += 1
|
|
1369
|
+
continue
|
|
1370
|
+
words.append(a)
|
|
1371
|
+
i += 1
|
|
1372
|
+
return opts, words, paths
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _raw_blobs(text: str) -> list[Blob]:
|
|
1376
|
+
out: list[Blob] = []
|
|
1377
|
+
for line in text.splitlines():
|
|
1378
|
+
if not line.startswith(":") or "\t" not in line:
|
|
1379
|
+
continue
|
|
1380
|
+
meta, _, names = line.partition("\t")
|
|
1381
|
+
fields = meta.lstrip(":").split()
|
|
1382
|
+
if len(fields) < 5:
|
|
1383
|
+
continue
|
|
1384
|
+
old_mode, new_mode, old_sha, new_sha = fields[:4]
|
|
1385
|
+
parts = names.split("\t")
|
|
1386
|
+
old_path, new_path = parts[0], parts[-1]
|
|
1387
|
+
if old_mode != "160000" and not ZERO_SHA.match(old_sha):
|
|
1388
|
+
out.append(Blob(old_path, old_sha))
|
|
1389
|
+
if new_mode == "160000":
|
|
1390
|
+
continue
|
|
1391
|
+
if ZERO_SHA.match(new_sha):
|
|
1392
|
+
if new_mode != "000000":
|
|
1393
|
+
out.append(Blob(new_path, None)) # working-tree side of a diff
|
|
1394
|
+
else:
|
|
1395
|
+
out.append(Blob(new_path, new_sha))
|
|
1396
|
+
return out
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def _tree_blobs(tree: str, paths: list[str], cwd: Path) -> list[Blob]:
|
|
1400
|
+
out: list[Blob] = []
|
|
1401
|
+
for line in _git(["ls-tree", "-r", "--full-name", tree, "--", *paths], cwd).splitlines():
|
|
1402
|
+
meta, _, path = line.partition("\t")
|
|
1403
|
+
fields = meta.split()
|
|
1404
|
+
if len(fields) >= 3 and fields[1] == "blob":
|
|
1405
|
+
out.append(Blob(path, fields[2]))
|
|
1406
|
+
return out
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _object_type(obj: str, cwd: Path) -> str:
|
|
1410
|
+
return _git(["cat-file", "-t", obj], cwd).strip()
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _log_blobs(opts: list[str], words: list[str], paths: list[str], cwd: Path, extra: list[str] | None = None) -> list[Blob]:
|
|
1414
|
+
args = ["log", "--format=", "--raw", "--no-abbrev", "-r", "-m", *(extra or []), *opts, *words]
|
|
1415
|
+
if paths:
|
|
1416
|
+
args += ["--", *paths]
|
|
1417
|
+
return _raw_blobs(_git(args, cwd))
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _object_blobs(obj: str, paths: list[str], cwd: Path) -> list[Blob]:
|
|
1421
|
+
if ".." in obj or obj.startswith("^"):
|
|
1422
|
+
return _log_blobs([], [obj], paths, cwd)
|
|
1423
|
+
if ":" in obj and not obj.startswith(":/"):
|
|
1424
|
+
sha = _git(["rev-parse", "--verify", "--quiet", obj], cwd).strip()
|
|
1425
|
+
kind = _object_type(sha, cwd)
|
|
1426
|
+
if kind == "tree":
|
|
1427
|
+
return _tree_blobs(sha, [], cwd)
|
|
1428
|
+
return [Blob(obj.split(":", 1)[1].removeprefix("./") or obj, sha)] if kind == "blob" else []
|
|
1429
|
+
kind = _object_type(obj + "^{}", cwd)
|
|
1430
|
+
if kind == "commit":
|
|
1431
|
+
return _log_blobs([], ["-1", obj], paths, cwd)
|
|
1432
|
+
if kind == "tree":
|
|
1433
|
+
return _tree_blobs(obj + "^{}", paths, cwd)
|
|
1434
|
+
if kind == "blob":
|
|
1435
|
+
return [Blob(obj, _git(["rev-parse", obj], cwd).strip())]
|
|
1436
|
+
return []
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _stash_blobs(rev: str, cwd: Path) -> list[Blob]:
|
|
1440
|
+
blobs = _raw_blobs(_git(["diff", "--raw", "--no-abbrev", f"{rev}^1", rev], cwd))
|
|
1441
|
+
try:
|
|
1442
|
+
blobs += _tree_blobs(f"{rev}^3", [], cwd)
|
|
1443
|
+
except Undetermined:
|
|
1444
|
+
pass # the stash has no untracked files
|
|
1445
|
+
return blobs
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _positional(args: list[str], value_opts: set[str]) -> list[str]:
|
|
1449
|
+
out: list[str] = []
|
|
1450
|
+
skip = False
|
|
1451
|
+
for a in args:
|
|
1452
|
+
if skip:
|
|
1453
|
+
skip = False
|
|
1454
|
+
continue
|
|
1455
|
+
if a.startswith("-") and a != "-":
|
|
1456
|
+
if a in value_opts:
|
|
1457
|
+
skip = True
|
|
1458
|
+
continue
|
|
1459
|
+
out.append(a)
|
|
1460
|
+
return out
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def history_blobs(read: GitRead) -> list[Blob]:
|
|
1464
|
+
"""The files `read` would print. Raises Undetermined when that cannot be
|
|
1465
|
+
worked out without running the command."""
|
|
1466
|
+
if read.unresolved:
|
|
1467
|
+
raise Undetermined("its arguments are computed when the command runs")
|
|
1468
|
+
sub, args, cwd = read.sub, read.args, read.cwd
|
|
1469
|
+
if sub == "bundle":
|
|
1470
|
+
if args[:1] != ["create"]:
|
|
1471
|
+
return []
|
|
1472
|
+
opts, words, paths = _log_args(args[2:])
|
|
1473
|
+
return _log_blobs(opts, words, paths, cwd)
|
|
1474
|
+
if sub in ("log", "whatchanged", "fast-export", "range-diff"):
|
|
1475
|
+
opts, words, paths = _log_args(args)
|
|
1476
|
+
return _log_blobs(opts, words, paths, cwd)
|
|
1477
|
+
if sub == "reflog":
|
|
1478
|
+
rest = args[1:] if args[:1] == ["show"] else args
|
|
1479
|
+
opts, words, paths = _log_args(rest)
|
|
1480
|
+
return _log_blobs(opts, words, paths, cwd, extra=["-g"])
|
|
1481
|
+
if sub == "format-patch":
|
|
1482
|
+
opts, words, paths = _log_args(args)
|
|
1483
|
+
words = [w if ".." in w else f"{w}..HEAD" for w in words]
|
|
1484
|
+
if not words and any(re.fullmatch(r"-\d+", o) or o == "-n" for o in opts):
|
|
1485
|
+
words = ["HEAD"]
|
|
1486
|
+
return _log_blobs(opts, words, paths, cwd)
|
|
1487
|
+
if sub == "show":
|
|
1488
|
+
before, paths = _split_paths(args)
|
|
1489
|
+
objs = _positional(before, {"--format", "--pretty", "-O"}) or ["HEAD"]
|
|
1490
|
+
out: list[Blob] = []
|
|
1491
|
+
for obj in objs:
|
|
1492
|
+
out += _object_blobs(obj, paths, cwd)
|
|
1493
|
+
return out
|
|
1494
|
+
if sub in ("diff", "diff-tree", "diff-index", "diff-files"):
|
|
1495
|
+
opts, words, paths = _log_args(args)
|
|
1496
|
+
if "--no-index" in args:
|
|
1497
|
+
return []
|
|
1498
|
+
keep = [o for o in opts if o in ("--cached", "--staged", "--merge-base", "-R")]
|
|
1499
|
+
cmd = [sub, "--raw", "--no-abbrev", *(["-r"] if sub == "diff-tree" else []), *keep, *words]
|
|
1500
|
+
if paths:
|
|
1501
|
+
cmd += ["--", *paths]
|
|
1502
|
+
return _raw_blobs(_git(cmd, cwd))
|
|
1503
|
+
if sub == "stash":
|
|
1504
|
+
action, rest = (args[0], args[1:]) if args else ("show", [])
|
|
1505
|
+
if action == "list":
|
|
1506
|
+
refs = _git(["stash", "list", "--format=%gd"], cwd).split()
|
|
1507
|
+
out = []
|
|
1508
|
+
for ref in refs[:50]:
|
|
1509
|
+
out += _stash_blobs(ref, cwd)
|
|
1510
|
+
return out
|
|
1511
|
+
revs = _positional(rest, set())
|
|
1512
|
+
rev = revs[0] if revs else "stash@{0}"
|
|
1513
|
+
if rev.isdigit():
|
|
1514
|
+
rev = f"stash@{{{rev}}}"
|
|
1515
|
+
return _stash_blobs(rev, cwd)
|
|
1516
|
+
if sub == "grep":
|
|
1517
|
+
return _grep_blobs(args, cwd)
|
|
1518
|
+
if sub == "archive":
|
|
1519
|
+
before, paths = _split_paths(args)
|
|
1520
|
+
if any(a == "--remote" or a.startswith("--remote=") for a in before):
|
|
1521
|
+
return []
|
|
1522
|
+
pos = _positional(before, {"--format", "-o", "--output", "--prefix", "--exec", "--add-file", "--mtime"})
|
|
1523
|
+
if not pos:
|
|
1524
|
+
raise Undetermined("no tree was named")
|
|
1525
|
+
out = _tree_blobs(pos[0], pos[1:] + paths, cwd)
|
|
1526
|
+
out += [Blob(a.split("=", 1)[1], None) for a in before if a.startswith("--add-file=")]
|
|
1527
|
+
return out
|
|
1528
|
+
if sub == "cat-file":
|
|
1529
|
+
if any(a.startswith("--batch") for a in args):
|
|
1530
|
+
raise Undetermined("it reads object names from standard input")
|
|
1531
|
+
pos = _positional(args, {"--path", "--filters"})
|
|
1532
|
+
if not pos:
|
|
1533
|
+
return []
|
|
1534
|
+
obj = pos[-1]
|
|
1535
|
+
sha = _git(["rev-parse", "--verify", "--quiet", obj], cwd).strip()
|
|
1536
|
+
if _object_type(sha, cwd) != "blob":
|
|
1537
|
+
return []
|
|
1538
|
+
return [Blob(obj.split(":", 1)[1] if ":" in obj else obj, sha)]
|
|
1539
|
+
if sub in ("blame", "annotate"):
|
|
1540
|
+
before, paths = _split_paths(args)
|
|
1541
|
+
pos = _positional(before, {"-L", "-S", "--contents", "--since", "--ignore-rev", "--ignore-revs-file"})
|
|
1542
|
+
if not paths and pos:
|
|
1543
|
+
paths = [pos.pop()]
|
|
1544
|
+
if not pos or not paths:
|
|
1545
|
+
return [] # blame of the working-tree file, which the file analysis scans
|
|
1546
|
+
rev = pos[0]
|
|
1547
|
+
sha = _git(["rev-parse", "--verify", "--quiet", f"{rev}:./{paths[0]}"], cwd).strip()
|
|
1548
|
+
return [Blob(paths[0], sha)]
|
|
1549
|
+
return []
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def _grep_blobs(args: list[str], cwd: Path) -> list[Blob]:
|
|
1553
|
+
before, paths = _split_paths(args)
|
|
1554
|
+
value_opts = {"-e", "-f", "-A", "-B", "-C", "-m", "--max-count", "--max-depth", "--threads", "-O",
|
|
1555
|
+
"--open-files-in-pager", "--context", "--before-context", "--after-context"}
|
|
1556
|
+
pos = _positional(before, value_opts)
|
|
1557
|
+
if not any(a in ("-e", "-f") or re.fullmatch(r"-[A-Za-z]*[ef]", a) for a in before):
|
|
1558
|
+
pos = pos[1:]
|
|
1559
|
+
revs: list[str] = []
|
|
1560
|
+
for w in pos:
|
|
1561
|
+
try:
|
|
1562
|
+
_git(["rev-parse", "--verify", "--quiet", f"{w}^{{tree}}"], cwd)
|
|
1563
|
+
revs.append(w)
|
|
1564
|
+
except Undetermined:
|
|
1565
|
+
paths.append(w)
|
|
1566
|
+
if revs:
|
|
1567
|
+
out: list[Blob] = []
|
|
1568
|
+
for rev in revs:
|
|
1569
|
+
out += _tree_blobs(rev, paths, cwd)
|
|
1570
|
+
return out
|
|
1571
|
+
if "--cached" in before:
|
|
1572
|
+
out = []
|
|
1573
|
+
for line in _git(["ls-files", "-s", "--full-name", "--", *paths], cwd).splitlines():
|
|
1574
|
+
meta, _, path = line.partition("\t")
|
|
1575
|
+
fields = meta.split()
|
|
1576
|
+
if len(fields) >= 2:
|
|
1577
|
+
out.append(Blob(path, fields[1]))
|
|
1578
|
+
return out
|
|
1579
|
+
listing = ["ls-files", "--full-name"]
|
|
1580
|
+
if "--untracked" in before or "--no-index" in before:
|
|
1581
|
+
listing += ["--cached", "--others", "--exclude-standard"]
|
|
1582
|
+
return [Blob(p, None) for p in _git([*listing, "--", *paths], cwd).splitlines()]
|