@veda-hq/kavach 0.0.0-stage → 0.5.6
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.
- package/LICENSE +21 -0
- package/README.md +134 -2
- package/corpus/corpus.json +1544 -0
- package/dist/rule-checks/args.js +26 -0
- package/dist/rule-checks/cli-entry.js +42 -0
- package/dist/rule-checks/cli.js +146 -0
- package/dist/rule-checks/edit-text.js +23 -0
- package/dist/rule-checks/fire-log.js +81 -0
- package/dist/rule-checks/latency.js +192 -0
- package/dist/rule-checks/live-runs.js +282 -0
- package/dist/rule-checks/registry.js +163 -0
- package/dist/rule-checks/runtime.js +353 -0
- package/dist/rule-checks/shell-comments.js +103 -0
- package/dist/rule-checks/shell-words.js +567 -0
- package/dist/rule-checks/spawn.js +27 -0
- package/package.json +51 -3
- package/python/kavach/__init__.py +9 -0
- package/python/kavach/bash_guard.py +1672 -0
- package/python/kavach/boundary.py +4030 -0
- package/python/kavach/command_guard.py +170 -0
- package/python/kavach/config.py +156 -0
- package/python/kavach/git_safety.py +1980 -0
- package/python/kavach/refusal_log.py +157 -0
- package/python/kavach/worktree_guard.py +675 -0
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
# The git-command check that command_guard.py runs first; its limits and
|
|
2
|
+
# rules are in that file's docstring. Importing this module runs the check: it
|
|
3
|
+
# exits 0 to allow the command or 2 to block it. It is a module, not code fed
|
|
4
|
+
# on stdin, so Python keeps its compiled cache instead of recompiling it on
|
|
5
|
+
# every call. Any error it raises also blocks: the entry turns it into exit 2.
|
|
6
|
+
|
|
7
|
+
import json, os, re, sys
|
|
8
|
+
|
|
9
|
+
# The consumer's settings (config.py). command_guard.py has already loaded
|
|
10
|
+
# them once and refused on a ConfigError; should this second reading fail
|
|
11
|
+
# (the file changed in between), the error blocks like any other.
|
|
12
|
+
import config
|
|
13
|
+
|
|
14
|
+
CONFIG = config.load()
|
|
15
|
+
# Paired changes (config `pairedChanges`), judged at the end of this module.
|
|
16
|
+
# Empty: the rule is off, and nothing reads a commit's repository.
|
|
17
|
+
PAIRED = CONFIG.paired_changes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def paired_refusal(pair):
|
|
21
|
+
"""The refusal for a commit that may record `pair.changes` without
|
|
22
|
+
certainly recording `pair.requires`."""
|
|
23
|
+
return (
|
|
24
|
+
f"BLOCKED: '{pair.changes}' is staged without a path containing "
|
|
25
|
+
f"'{pair.requires}': {pair.reason.rstrip('.')}. Stage the required change "
|
|
26
|
+
"with it — beforehand, or with `git add ... && git commit` (an add after "
|
|
27
|
+
"`;`, `||` or a pipe may not have run). If this change truly needs none, "
|
|
28
|
+
"ask the user before committing it alone."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Claude Code always sends a JSON object whose tool_input carries the command
|
|
32
|
+
# as a string; anything else leaves the command unknown, so it is refused
|
|
33
|
+
# rather than waved through.
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(os.environ.get("HOOK_INPUT", ""))
|
|
36
|
+
except ValueError:
|
|
37
|
+
data = None
|
|
38
|
+
tool_input = data.get("tool_input") if isinstance(data, dict) else None
|
|
39
|
+
raw = tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
40
|
+
if not isinstance(raw, str):
|
|
41
|
+
print(
|
|
42
|
+
"BLOCKED: the git-command check could not read a command in its hook "
|
|
43
|
+
"input, so it cannot judge this command.",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
sys.exit(2)
|
|
47
|
+
|
|
48
|
+
# Strip heredoc bodies
|
|
49
|
+
lines = raw.split("\n")
|
|
50
|
+
out, marker = [], None
|
|
51
|
+
for line in lines:
|
|
52
|
+
if marker is not None:
|
|
53
|
+
if line.strip() == marker:
|
|
54
|
+
marker = None
|
|
55
|
+
continue
|
|
56
|
+
m = re.search(r"<<-?\s*['\"]?([A-Za-z_][A-Za-z_0-9]*)['\"]?", line)
|
|
57
|
+
if m:
|
|
58
|
+
marker = m.group(1)
|
|
59
|
+
out.append(line)
|
|
60
|
+
cmd = "\n".join(out)
|
|
61
|
+
|
|
62
|
+
# Protected variables (config `protectedVariables`): environment variables an
|
|
63
|
+
# agent must leave as its session set them, such as the ones that tell a
|
|
64
|
+
# hosted session from a laptop. A command that sets one is refused, so an
|
|
65
|
+
# agent cannot make its laptop look like the hosted session. Read with
|
|
66
|
+
# quoting undone as bash undoes it before `export` or `env` sees the word:
|
|
67
|
+
# line continuations joined, $'...' escapes decoded, then quotes and
|
|
68
|
+
# backslashes removed (`export HOSTED_""REMOTE=1`, `env $'HOSTED_\x52EMOTE=1'`).
|
|
69
|
+
# Reading the value (`$HOSTED_REMOTE`) passes. Limits, accepted: what this
|
|
70
|
+
# line cannot see (eval, a script file, a computed name); the consumer keeps
|
|
71
|
+
# its own backstop (a check that needs more evidence than the variable, and
|
|
72
|
+
# review of every pull request). No protected variables: the rule is off.
|
|
73
|
+
PROTECTED_VAR = (
|
|
74
|
+
r"(?:" + "|".join(re.escape(name) for name in CONFIG.protected_variables) + r")\b"
|
|
75
|
+
if CONFIG.protected_variables
|
|
76
|
+
else None
|
|
77
|
+
)
|
|
78
|
+
ANSI_C = {"a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", "f": "\f", "n": "\n",
|
|
79
|
+
"r": "\r", "t": "\t", "v": "\v", "\\": "\\", "'": "'", '"': '"', "?": "?"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ansi_c(body):
|
|
83
|
+
def one(m):
|
|
84
|
+
s = m.group(0)[1:]
|
|
85
|
+
if s[0] in "xuU":
|
|
86
|
+
return chr(int(s[1:], 16))
|
|
87
|
+
if s[0] == "c":
|
|
88
|
+
return chr(ord(s[1]) & 0x1F)
|
|
89
|
+
if s[0].isdigit():
|
|
90
|
+
return chr(int(s, 8) & 0xFF)
|
|
91
|
+
return ANSI_C.get(s, "\\" + s)
|
|
92
|
+
|
|
93
|
+
return re.sub(
|
|
94
|
+
r"\\(?:x[0-9A-Fa-f]{1,2}|u[0-9A-Fa-f]{1,4}|U[0-9A-Fa-f]{1,8}|c.|[0-7]{1,3}|.)",
|
|
95
|
+
one,
|
|
96
|
+
body,
|
|
97
|
+
flags=re.S,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if PROTECTED_VAR is not None:
|
|
102
|
+
bare = re.sub(r"\\\n", "", cmd)
|
|
103
|
+
bare = re.sub(r"\$'((?:[^'\\]|\\.)*)'", lambda m: ansi_c(m.group(1)), bare, flags=re.S)
|
|
104
|
+
bare = re.sub(r"\$(?=\")", "", bare)
|
|
105
|
+
bare = re.sub(r"[\\'\"]", "", bare)
|
|
106
|
+
if (
|
|
107
|
+
re.search(r"(?<![\w$])" + PROTECTED_VAR + r"\s*\+?=", bare)
|
|
108
|
+
or re.search(r"\$\{" + PROTECTED_VAR + r":?=", bare)
|
|
109
|
+
or re.search(
|
|
110
|
+
r"(?<![\w-])(?:export|declare|typeset|readonly|local|read|printf\s[^\n;&|]*?-v)\s[^\n;&|]*?(?<![\w$])"
|
|
111
|
+
+ PROTECTED_VAR,
|
|
112
|
+
bare,
|
|
113
|
+
)
|
|
114
|
+
):
|
|
115
|
+
print(
|
|
116
|
+
"BLOCKED: this command sets " + " or ".join(CONFIG.protected_variables)
|
|
117
|
+
+ ", which this repository protects: " + CONFIG.protected_variables_reason.rstrip(".")
|
|
118
|
+
+ ". To search for the name, leave out the `=`.",
|
|
119
|
+
file=sys.stderr,
|
|
120
|
+
)
|
|
121
|
+
sys.exit(2)
|
|
122
|
+
|
|
123
|
+
# Fast exit only for commands that cannot run git: no "git" anywhere (so
|
|
124
|
+
# `(git commit)` and `/usr/bin/git commit` are read) and no quoting, escape or
|
|
125
|
+
# expansion that could spell it (`g\it`, `g''it`, `$'\x67it'`).
|
|
126
|
+
if "git" not in raw and not re.search(r"[\\'\"$]", raw):
|
|
127
|
+
sys.exit(0)
|
|
128
|
+
# Heredoc-stripped but quoting INTACT — the push analysis tokenises this, because
|
|
129
|
+
# stripping quotes first would let `git push '--force'` slip through as a bare push.
|
|
130
|
+
cmd_raw = cmd
|
|
131
|
+
# Strip quoted strings (used by the text-matching checks below, so that a flag
|
|
132
|
+
# mentioned inside a commit message does not trip them).
|
|
133
|
+
cmd = re.sub(r"'[^']*'", "", cmd)
|
|
134
|
+
cmd = re.sub(r'"[^"]*"', "", cmd)
|
|
135
|
+
|
|
136
|
+
def block(msg):
|
|
137
|
+
print(msg, file=sys.stderr)
|
|
138
|
+
sys.exit(2)
|
|
139
|
+
|
|
140
|
+
NO_VERIFY = "BLOCKED: --no-verify is forbidden on all git commands (hooks auto-validate; fix the failure, never bypass it)."
|
|
141
|
+
NO_VERIFY_ALIAS = "BLOCKED: 'git commit -n' (--no-verify alias) is forbidden. Fix the hook failure instead of bypassing it."
|
|
142
|
+
|
|
143
|
+
if "--no-verify" in cmd:
|
|
144
|
+
block(NO_VERIFY)
|
|
145
|
+
|
|
146
|
+
# Git accepts an unambiguous abbreviation, and `--no-veri` is one (verified:
|
|
147
|
+
# `--no-ver` is ambiguous with --no-verbose, `--no-veri` is not). Shorter stubs
|
|
148
|
+
# are ambiguous, so git rejects them anyway; blocking them costs nothing.
|
|
149
|
+
for word in re.findall(r"(?<!\S)--no-ve[a-z]*", cmd):
|
|
150
|
+
if "--no-verify".startswith(word):
|
|
151
|
+
block(NO_VERIFY)
|
|
152
|
+
|
|
153
|
+
# `git commit -n` is an alias for --no-verify (also catch bundled flags like -an)
|
|
154
|
+
if re.search(r"git\s+commit[^;&|\n]*\s-[a-zA-Z]*n([a-zA-Z]*)?(\s|$)", cmd):
|
|
155
|
+
flags = re.findall(r"\s(-[a-zA-Z]+)(?=\s|$)", re.search(r"git\s+commit[^;&|\n]*", cmd).group(0))
|
|
156
|
+
if any("n" in f[1:] for f in flags):
|
|
157
|
+
block(NO_VERIFY_ALIAS)
|
|
158
|
+
|
|
159
|
+
# Force pushes: EVERY form is blocked, including --force-with-lease.
|
|
160
|
+
# Rewriting a pushed branch is never the sanctioned way to refresh it — merge
|
|
161
|
+
# origin/main in instead, which keeps the remote history append-only so review
|
|
162
|
+
# threads, in-flight CI and anyone else's checkout stay valid.
|
|
163
|
+
#
|
|
164
|
+
# This analysis runs on SHELL TOKENS, not on the quote-stripped text: quoting is
|
|
165
|
+
# a bypass otherwise (`git push '--force'` survives quote-stripping as a bare
|
|
166
|
+
# `git push`). Tokenising also keeps a commit message that merely mentions a
|
|
167
|
+
# force flag safe, because the whole message collapses into a single token.
|
|
168
|
+
FORCE_REMEDY = (
|
|
169
|
+
"Never rewrite a pushed branch. To refresh it against main, merge instead:\n"
|
|
170
|
+
" git fetch origin main && git merge origin/main\n"
|
|
171
|
+
"then resolve any conflicts, commit, and `git push` normally.\n"
|
|
172
|
+
"If a history rewrite is genuinely unavoidable, stop and ask the user to run it."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
import shlex
|
|
176
|
+
|
|
177
|
+
# `;;`, `;&` and `;;&` end a case arm, and end its command like `;` does.
|
|
178
|
+
CASE_ENDS = {";;", ";&", ";;&"}
|
|
179
|
+
TERMINATORS = {";", "\n"} | CASE_ENDS
|
|
180
|
+
SEPARATORS = {"&&", "||", "|", "|&", "&", "(", ")"} | TERMINATORS
|
|
181
|
+
PUNCTUATION = "();<>|&\n"
|
|
182
|
+
# shlex groups a run of operator characters into one token (`)&&`, `;\n`);
|
|
183
|
+
# split it into the operators bash reads, longest first.
|
|
184
|
+
OPERATOR = re.compile(r";;&|;;|;&|&&|\|\||\|&|&>>|&>|>>|>&|>\||<<<|<<-|<<|<&|<>|[();<>|&\n]")
|
|
185
|
+
# Options whose value is a SEPARATE following token — that value must never be
|
|
186
|
+
# mistaken for a positional refspec (e.g. `git push -o +ci.skip origin main`).
|
|
187
|
+
VALUE_OPTS = {"-o", "--push-option", "--repo", "--receive-pack", "--exec"}
|
|
188
|
+
# `git` global options that take a separate value before the subcommand.
|
|
189
|
+
GIT_GLOBAL_VALUE_OPTS = {
|
|
190
|
+
"-C", "-c", "--config-env", "--git-dir", "--work-tree", "--namespace",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Operator characters inside quotes travel as private-use placeholders through
|
|
195
|
+
# shlex and are restored afterwards, so only UNQUOTED ones become operators.
|
|
196
|
+
HIDE_OPS = str.maketrans({c: chr(0xE000 + n) for n, c in enumerate(";&|<>()")})
|
|
197
|
+
SHOW_OPS = str.maketrans({chr(0xE000 + n): c for n, c in enumerate(";&|<>()")})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def strip_comments(text):
|
|
201
|
+
"""Drop `# ...` shell comments but KEEP the newline that ends each one.
|
|
202
|
+
|
|
203
|
+
shlex's own comment handling swallows that newline, so `pwd # x` NEWLINE
|
|
204
|
+
`cd y` would hide the cd's command boundary. A '#' only starts a comment
|
|
205
|
+
outside quotes and at the start of a word, as in bash.
|
|
206
|
+
|
|
207
|
+
Also drops a redirection's file-descriptor number (`2>&1` → `>&1`): shlex
|
|
208
|
+
would split it into a lone `2` word, indistinguishable from an argument.
|
|
209
|
+
|
|
210
|
+
And hides operator characters that are QUOTED or backslash-escaped behind
|
|
211
|
+
placeholders: shlex unquotes `'>'` into the same `>` token as a real
|
|
212
|
+
redirection, so `-m '>' schema.sql` would lose both words.
|
|
213
|
+
"""
|
|
214
|
+
out, quote, i = [], None, 0
|
|
215
|
+
while i < len(text):
|
|
216
|
+
ch = text[i]
|
|
217
|
+
# Backslash-newline is a line continuation: bash deletes BOTH characters
|
|
218
|
+
# (no space), outside quotes and inside double quotes alike, so
|
|
219
|
+
# `schema.s\` NEWLINE `ql` is one word. Single quotes keep it literal.
|
|
220
|
+
if ch == "\\" and text[i + 1:i + 2] == "\n" and quote != "'":
|
|
221
|
+
i += 2
|
|
222
|
+
continue
|
|
223
|
+
if quote:
|
|
224
|
+
if ch == "\\" and quote == '"' and i + 1 < len(text):
|
|
225
|
+
out.append("\\" + text[i + 1].translate(HIDE_OPS))
|
|
226
|
+
i += 2
|
|
227
|
+
continue
|
|
228
|
+
if ch == quote:
|
|
229
|
+
quote = None
|
|
230
|
+
else:
|
|
231
|
+
ch = ch.translate(HIDE_OPS)
|
|
232
|
+
elif ch == "\\" and i + 1 < len(text):
|
|
233
|
+
out.append("\\" + text[i + 1].translate(HIDE_OPS))
|
|
234
|
+
i += 2
|
|
235
|
+
continue
|
|
236
|
+
elif ch in "'\"":
|
|
237
|
+
quote = ch
|
|
238
|
+
elif ch == "#" and (i == 0 or text[i - 1] in " \t\n;&|()"):
|
|
239
|
+
while i < len(text) and text[i] != "\n":
|
|
240
|
+
i += 1
|
|
241
|
+
continue
|
|
242
|
+
elif ch.isdigit() and (i == 0 or text[i - 1] in " \t\n;&|()"):
|
|
243
|
+
j = i
|
|
244
|
+
while j < len(text) and text[j].isdigit():
|
|
245
|
+
j += 1
|
|
246
|
+
if j < len(text) and text[j] in "<>":
|
|
247
|
+
i = j
|
|
248
|
+
continue
|
|
249
|
+
out.append(ch)
|
|
250
|
+
i += 1
|
|
251
|
+
return "".join(out)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def tokenize(text):
|
|
255
|
+
"""Shell-tokenise, keeping operators separate. None if unparseable.
|
|
256
|
+
|
|
257
|
+
A newline ends a command just like ';', so it is kept as a token: without
|
|
258
|
+
it, `pwd` NEWLINE `cd x` hides the cd from the command-start check.
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
# Backslash-newline is a line continuation, not a command boundary.
|
|
262
|
+
text = strip_comments(text)
|
|
263
|
+
lex = shlex.shlex(text, posix=True, punctuation_chars=PUNCTUATION)
|
|
264
|
+
lex.commenters = ""
|
|
265
|
+
lex.whitespace = " \t\r"
|
|
266
|
+
lex.whitespace_split = True
|
|
267
|
+
tokens = []
|
|
268
|
+
for tok in lex:
|
|
269
|
+
if set(tok) <= set(PUNCTUATION):
|
|
270
|
+
tokens += OPERATOR.findall(tok)
|
|
271
|
+
elif tok.translate(SHOW_OPS) == tok or not set(tok.translate(SHOW_OPS)) <= set(PUNCTUATION):
|
|
272
|
+
tokens.append(tok.translate(SHOW_OPS))
|
|
273
|
+
else:
|
|
274
|
+
# A word made only of quoted operator characters (`'>'`, `';'`)
|
|
275
|
+
# keeps its placeholders, so it can never equal an operator.
|
|
276
|
+
tokens.append(tok)
|
|
277
|
+
return tokens
|
|
278
|
+
except ValueError:
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Long options that rewrite or delete remote history. Git accepts any
|
|
283
|
+
# UNAMBIGUOUS ABBREVIATION of a long option, so `--force-with` and `--mir` are
|
|
284
|
+
# real spellings (verified against git) — match by prefix, not by equality.
|
|
285
|
+
# Over-matching an ambiguous stub like `--f` is harmless: git rejects it anyway.
|
|
286
|
+
DANGEROUS_LONG = ("--force", "--force-with-lease", "--mirror")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def is_dangerous_long(arg):
|
|
290
|
+
base = arg.split("=", 1)[0]
|
|
291
|
+
if not base.startswith("--") or len(base) < 3:
|
|
292
|
+
return None
|
|
293
|
+
for full in DANGEROUS_LONG:
|
|
294
|
+
if full.startswith(base):
|
|
295
|
+
return full
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# A path the shell would expand cannot be resolved by static analysis. Reading
|
|
300
|
+
# the WRONG repo would silently allow a forcing refspec or a commit that lacks
|
|
301
|
+
# its paired change, so the guard refuses instead of guessing — the one safe
|
|
302
|
+
# answer for an undecidable input.
|
|
303
|
+
UNRESOLVABLE = re.compile(r"[$`*?\[]|^~")
|
|
304
|
+
|
|
305
|
+
AMBIGUOUS_REMEDY = (
|
|
306
|
+
"This guard must inspect the repository a push or commit targets (push "
|
|
307
|
+
"config, staged files), and a shell-expanded path cannot be resolved "
|
|
308
|
+
"statically.\nRe-run it with a literal path (or from inside the target repo) "
|
|
309
|
+
"so the check can see the right repository."
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def shell_cwd():
|
|
314
|
+
"""The directory the Bash tool's shell runs this command in.
|
|
315
|
+
|
|
316
|
+
The hook input's `cwd` is the shell's directory — for a subagent working in
|
|
317
|
+
its own worktree, that worktree — and this hook process may have been
|
|
318
|
+
started somewhere else. Fall back to the process cwd only when it is absent.
|
|
319
|
+
"""
|
|
320
|
+
cwd = data.get("cwd")
|
|
321
|
+
return cwd if isinstance(cwd, str) and os.path.isabs(cwd) else os.getcwd()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
SHELL_CWD = shell_cwd()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class Unresolved:
|
|
328
|
+
"""A directory only the running shell can compute (`cd $X`, `cd -`, `popd`).
|
|
329
|
+
|
|
330
|
+
Kept rather than blocked on sight: it only matters if a push or commit
|
|
331
|
+
later runs there, so `cd $X && git status` stays allowed.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(self, target):
|
|
335
|
+
self.target = target
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def step_dir(target, cwd):
|
|
339
|
+
"""Directory after `cd target` from `cwd` (None = the shell's cwd)."""
|
|
340
|
+
if target == "-" or UNRESOLVABLE.search(target):
|
|
341
|
+
return Unresolved(target)
|
|
342
|
+
if os.path.isabs(target):
|
|
343
|
+
return target
|
|
344
|
+
if isinstance(cwd, Unresolved):
|
|
345
|
+
return cwd
|
|
346
|
+
return os.path.join(cwd or SHELL_CWD, target)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def settle(cwd):
|
|
350
|
+
"""`cwd` as a usable path; blocks if the shell alone could compute it."""
|
|
351
|
+
if isinstance(cwd, Unresolved):
|
|
352
|
+
block(
|
|
353
|
+
f"BLOCKED: cannot statically resolve the directory '{cwd.target}'.\n"
|
|
354
|
+
+ AMBIGUOUS_REMEDY
|
|
355
|
+
)
|
|
356
|
+
return cwd
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def resolve_dir(target, base):
|
|
360
|
+
"""Absolute path for `target` relative to `base`; blocks if unresolvable."""
|
|
361
|
+
return settle(step_dir(target, base))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# For a config VALUE, only a shell expansion makes it unreadable. A glob like
|
|
365
|
+
# `refs/heads/*` is a legitimate literal refspec, and globbing cannot plausibly
|
|
366
|
+
# change whether the value starts with '+', which is all this check needs.
|
|
367
|
+
VALUE_UNRESOLVABLE = re.compile(r"[$`]")
|
|
368
|
+
|
|
369
|
+
REMOTE_PUSH_KEY = re.compile(r"(?i)^remote\..+\.push$")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def check_config_overrides(overrides):
|
|
373
|
+
"""Reject `-c remote.<r>.push=+...`, which forces this invocation alone.
|
|
374
|
+
|
|
375
|
+
Verified against git: a plain push rejected as non-fast-forward succeeds
|
|
376
|
+
with `(forced update)` under this override. It never touches repo config,
|
|
377
|
+
so it can only be caught here. Only evaluated once the command is known to
|
|
378
|
+
be a push, so `git -c ... config --list` is untouched.
|
|
379
|
+
"""
|
|
380
|
+
for opt, val in overrides:
|
|
381
|
+
key, _, cval = val.partition("=")
|
|
382
|
+
if not REMOTE_PUSH_KEY.match(key.strip()):
|
|
383
|
+
continue
|
|
384
|
+
if opt == "--config-env" or VALUE_UNRESOLVABLE.search(cval):
|
|
385
|
+
block(
|
|
386
|
+
f"BLOCKED: '{opt} {key}=...' sets a push refspec from a value this "
|
|
387
|
+
"guard cannot read, so it cannot be proven non-forcing.\n"
|
|
388
|
+
+ FORCE_REMEDY
|
|
389
|
+
)
|
|
390
|
+
if cval.strip().startswith("+"):
|
|
391
|
+
block(
|
|
392
|
+
f"BLOCKED: '{opt} {val}' sets a forcing '+' push refspec for this "
|
|
393
|
+
"command.\n" + FORCE_REMEDY
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
from collections import namedtuple
|
|
398
|
+
|
|
399
|
+
# One `git <sub>` the guard inspects, with the repository it really targets.
|
|
400
|
+
# cwd None means the shell's cwd; env holds GIT_* variables set for it.
|
|
401
|
+
GitCall = namedtuple("GitCall", "sub args cwd git_dir work_tree env overrides pos end")
|
|
402
|
+
|
|
403
|
+
# Variables that move git to another repository or index without any flag.
|
|
404
|
+
GIT_REPO_ENV = {"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"}
|
|
405
|
+
# Tracked through exports too: CDPATH decides where a relative `cd` lands.
|
|
406
|
+
TRACKED_ENV = GIT_REPO_ENV | {"CDPATH"}
|
|
407
|
+
ENV_ASSIGN = re.compile(r"^([A-Za-z_][A-Za-z_0-9]*)=(.*)$", re.S)
|
|
408
|
+
# Words after which the next token is again a command name. `builtin cd` and
|
|
409
|
+
# `command cd` are still the shell's own cd.
|
|
410
|
+
COMMAND_LEADERS = SEPARATORS | {
|
|
411
|
+
"if", "then", "else", "elif", "do", "while", "until", "{", "!", "time",
|
|
412
|
+
"builtin", "command",
|
|
413
|
+
}
|
|
414
|
+
CD_OPTS = {"-L", "-P", "-e", "-@"}
|
|
415
|
+
CONTROL_OPENERS = {"if", "while", "until", "for", "select", "case"}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
class Branches:
|
|
419
|
+
"""One if/while/until/for/select/case being walked: the state before it,
|
|
420
|
+
the state its branches start from, and what any branch changed."""
|
|
421
|
+
|
|
422
|
+
def __init__(self, kind, cwd, env, dir_stack):
|
|
423
|
+
self.kind, self.start = kind, None
|
|
424
|
+
self.entry = (cwd, dict(env), list(dir_stack))
|
|
425
|
+
self.cwd_changed, self.env_changed = False, set()
|
|
426
|
+
|
|
427
|
+
def note(self, cwd, env, dir_stack):
|
|
428
|
+
"""Record what the branch just walked changed relative to the entry."""
|
|
429
|
+
entry_cwd, entry_env, entry_stack = self.entry
|
|
430
|
+
self.cwd_changed |= (
|
|
431
|
+
cwd != entry_cwd or isinstance(cwd, Unresolved) or dir_stack != entry_stack
|
|
432
|
+
)
|
|
433
|
+
self.env_changed |= {
|
|
434
|
+
n for n in set(env) | set(entry_env) if env.get(n) != entry_env.get(n)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
def restart(self):
|
|
438
|
+
"""State the next branch starts from."""
|
|
439
|
+
cwd, env, stack = self.start or self.entry
|
|
440
|
+
return cwd, dict(env), list(stack)
|
|
441
|
+
|
|
442
|
+
def blend(self, cwd, env, dir_stack):
|
|
443
|
+
"""Either this state or the branch start: whatever differs is unknown."""
|
|
444
|
+
start_cwd, start_env, start_stack = self.start or self.entry
|
|
445
|
+
env = dict(env)
|
|
446
|
+
if cwd != start_cwd or isinstance(cwd, Unresolved) or dir_stack != start_stack:
|
|
447
|
+
cwd = Unresolved("a cd inside an if/while/for/case body")
|
|
448
|
+
for name in set(env) | set(start_env):
|
|
449
|
+
if env.get(name) != start_env.get(name):
|
|
450
|
+
env[name] = "$(set only if an if/while/for/case body ran)"
|
|
451
|
+
return cwd, env, list(dir_stack)
|
|
452
|
+
|
|
453
|
+
def after(self):
|
|
454
|
+
"""State after the compound: anything a branch changed is unknown."""
|
|
455
|
+
cwd, env, stack = self.entry
|
|
456
|
+
env = dict(env)
|
|
457
|
+
if self.cwd_changed:
|
|
458
|
+
cwd = Unresolved("a cd inside an if/while/for/case body")
|
|
459
|
+
for name in self.env_changed:
|
|
460
|
+
env[name] = "$(set only if an if/while/for/case body ran)"
|
|
461
|
+
return cwd, env, list(stack)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def command_head(tokens, i):
|
|
465
|
+
"""Index just before the command at `i`, skipping `CDPATH= cd`-style
|
|
466
|
+
assignments and `builtin`/`command` wrappers."""
|
|
467
|
+
k = i - 1
|
|
468
|
+
while k >= 0 and (tokens[k] in ("builtin", "command") or ENV_ASSIGN.match(tokens[k])):
|
|
469
|
+
k -= 1
|
|
470
|
+
return k
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def at_command_start(tokens, i):
|
|
474
|
+
k = command_head(tokens, i)
|
|
475
|
+
return k < 0 or tokens[k] in COMMAND_LEADERS
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def assignment_only(tokens, i):
|
|
479
|
+
"""True for `NAME=value` with no command after it: a shell assignment."""
|
|
480
|
+
j = i
|
|
481
|
+
while j < len(tokens) and ENV_ASSIGN.match(tokens[j]):
|
|
482
|
+
j += 1
|
|
483
|
+
return j > i and (j == len(tokens) or tokens[j] in SEPARATORS)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# Commands that print or search their arguments: a `git` among them is a word,
|
|
487
|
+
# not a command (`echo git commit`). Anything else that takes `git` as an
|
|
488
|
+
# argument (env, nohup, xargs, sudo…) may run it, so it is still checked.
|
|
489
|
+
INERT_COMMANDS = {"echo", "printf", "grep", "egrep", "fgrep", "rg", "man", "type", "which"}
|
|
490
|
+
WORD_STARTS = (COMMAND_LEADERS | {"if", "while", "until"}) - {"builtin", "command", "time", "!"}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def is_argument(tokens, i):
|
|
494
|
+
"""True if tokens[i] is an argument of an INERT_COMMANDS command."""
|
|
495
|
+
k = i - 1
|
|
496
|
+
while k >= 0 and tokens[k] not in WORD_STARTS:
|
|
497
|
+
k -= 1
|
|
498
|
+
k += 1
|
|
499
|
+
while k < i and ENV_ASSIGN.match(tokens[k]):
|
|
500
|
+
k += 1
|
|
501
|
+
return k < i and tokens[k] in INERT_COMMANDS
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
PIPES = {"|", "|&"}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def in_pipeline(tokens, i, j):
|
|
508
|
+
"""True if the command spanning tokens[i:j…] is a pipeline component, which
|
|
509
|
+
bash forks: its cd/export changes nothing, even later in the same list.
|
|
510
|
+
"""
|
|
511
|
+
k = command_head(tokens, i)
|
|
512
|
+
if k >= 0 and tokens[k] in PIPES:
|
|
513
|
+
return True
|
|
514
|
+
while j < len(tokens) and tokens[j] not in SEPARATORS:
|
|
515
|
+
j += 1
|
|
516
|
+
return j < len(tokens) and tokens[j] in PIPES
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
LIST_ENDS = TERMINATORS | {"&", "(", ")"}
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def runs_conditionally(tokens, i):
|
|
523
|
+
"""True if the command at tokens[i] follows `&&` or `||` in its list, so
|
|
524
|
+
the shell may skip it (`false && cd x`)."""
|
|
525
|
+
k = command_head(tokens, i)
|
|
526
|
+
while k >= 0 and tokens[k] not in LIST_ENDS:
|
|
527
|
+
if tokens[k] in ("&&", "||"):
|
|
528
|
+
return True
|
|
529
|
+
k -= 1
|
|
530
|
+
return False
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def list_end(tokens, j):
|
|
534
|
+
"""Index of the `;`/newline/`)` ending the list containing tokens[j], or
|
|
535
|
+
None when nothing follows it."""
|
|
536
|
+
depth = 0
|
|
537
|
+
while j < len(tokens):
|
|
538
|
+
if tokens[j] == "(":
|
|
539
|
+
depth += 1
|
|
540
|
+
elif tokens[j] == ")":
|
|
541
|
+
if depth == 0:
|
|
542
|
+
return j
|
|
543
|
+
depth -= 1
|
|
544
|
+
elif depth == 0 and tokens[j] in TERMINATORS:
|
|
545
|
+
return j
|
|
546
|
+
j += 1
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def first_or(tokens, j, end):
|
|
551
|
+
"""Index of the first top-level `||` in tokens[j:end], or None."""
|
|
552
|
+
depth, end = 0, len(tokens) if end is None else end
|
|
553
|
+
while j < end:
|
|
554
|
+
if tokens[j] == "(":
|
|
555
|
+
depth += 1
|
|
556
|
+
elif tokens[j] == ")":
|
|
557
|
+
depth -= 1
|
|
558
|
+
elif depth == 0 and tokens[j] == "||":
|
|
559
|
+
return j
|
|
560
|
+
j += 1
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def background_end(tokens, j):
|
|
565
|
+
"""Index of the `&` that backgrounds the AND/OR list containing tokens[j],
|
|
566
|
+
or None. Bash forks the WHOLE list (`cd x && git commit &`), so a cd in it
|
|
567
|
+
holds until that `&` and is gone after it.
|
|
568
|
+
"""
|
|
569
|
+
depth = 0
|
|
570
|
+
while j < len(tokens):
|
|
571
|
+
tok = tokens[j]
|
|
572
|
+
if tok == "(":
|
|
573
|
+
depth += 1
|
|
574
|
+
elif tok == ")":
|
|
575
|
+
if depth == 0:
|
|
576
|
+
return None
|
|
577
|
+
depth -= 1
|
|
578
|
+
elif depth == 0 and tok in TERMINATORS:
|
|
579
|
+
return None
|
|
580
|
+
elif depth == 0 and tok == "&":
|
|
581
|
+
return j
|
|
582
|
+
j += 1
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
ENV_CLEAR_OPTS = {"-i", "-", "--ignore-environment"}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def command_env(tokens, i, exported):
|
|
590
|
+
"""GIT_* variables the `git` at tokens[i] really runs with.
|
|
591
|
+
|
|
592
|
+
Reads the words between the command's start and `git`, left to right as
|
|
593
|
+
the shell and env(1) do: `GIT_DIR=x git` scopes x to this call and beats
|
|
594
|
+
an export (the last assignment wins); `env -u GIT_DIR` and `env -i` drop
|
|
595
|
+
exported values, so the guard must not inspect the repo those name.
|
|
596
|
+
"""
|
|
597
|
+
s = i
|
|
598
|
+
# `GIT_DIR=x command git` still passes x: wrappers do not end the scan.
|
|
599
|
+
while s > 0 and tokens[s - 1] not in COMMAND_LEADERS - {"builtin", "command", "time"}:
|
|
600
|
+
s -= 1
|
|
601
|
+
env, in_env, k = dict(exported), False, s
|
|
602
|
+
while k < i:
|
|
603
|
+
tok = tokens[k]
|
|
604
|
+
m = ENV_ASSIGN.match(tok)
|
|
605
|
+
if m:
|
|
606
|
+
if m.group(1) in GIT_REPO_ENV:
|
|
607
|
+
env[m.group(1)] = m.group(2)
|
|
608
|
+
elif tok == "env":
|
|
609
|
+
in_env = True
|
|
610
|
+
elif in_env and tok in ENV_CLEAR_OPTS:
|
|
611
|
+
env = {}
|
|
612
|
+
elif in_env and tok in ("-u", "--unset") and k + 1 < i:
|
|
613
|
+
env.pop(tokens[k + 1], None)
|
|
614
|
+
k += 1
|
|
615
|
+
elif in_env and tok.startswith(("--unset=", "-u")):
|
|
616
|
+
env.pop(tok.split("=", 1)[1] if tok.startswith("--") else tok[2:], None)
|
|
617
|
+
k += 1
|
|
618
|
+
return {name: val for name, val in env.items() if name in GIT_REPO_ENV}
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def cd_path(tokens, i, env):
|
|
622
|
+
"""CDPATH for the `cd` at tokens[i]: its own prefix (`CDPATH=x cd y`), else
|
|
623
|
+
the exported or inherited value."""
|
|
624
|
+
k = i - 1
|
|
625
|
+
while k >= 0 and (tokens[k] in ("builtin", "command") or ENV_ASSIGN.match(tokens[k])):
|
|
626
|
+
m = ENV_ASSIGN.match(tokens[k])
|
|
627
|
+
if m and m.group(1) == "CDPATH":
|
|
628
|
+
return m.group(2)
|
|
629
|
+
k -= 1
|
|
630
|
+
return env.get("CDPATH", "")
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def cd_dir(target, cwd, cdpath):
|
|
634
|
+
"""Where `cd target` lands, searching CDPATH as bash does.
|
|
635
|
+
|
|
636
|
+
Bash tries each CDPATH entry (empty = '.') for a relative target that is
|
|
637
|
+
not '.'/'..'-led or absolute, and takes the first existing directory; only if
|
|
638
|
+
none exists does it fall back to the current directory.
|
|
639
|
+
"""
|
|
640
|
+
dotted = target in (".", "..") or target.startswith(("./", "../", "/"))
|
|
641
|
+
if not cdpath or dotted or UNRESOLVABLE.search(target):
|
|
642
|
+
return step_dir(target, cwd)
|
|
643
|
+
if isinstance(cwd, Unresolved) or VALUE_UNRESOLVABLE.search(cdpath):
|
|
644
|
+
return Unresolved(f"CDPATH={cdpath} cd {target}")
|
|
645
|
+
for entry in cdpath.split(":"):
|
|
646
|
+
base = step_dir(entry or ".", cwd)
|
|
647
|
+
if isinstance(base, Unresolved):
|
|
648
|
+
return Unresolved(f"CDPATH={cdpath} cd {target}")
|
|
649
|
+
if os.path.isdir(os.path.normpath(os.path.join(base, target))):
|
|
650
|
+
return os.path.join(base, target)
|
|
651
|
+
return step_dir(target, cwd)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def is_redirect(tok):
|
|
655
|
+
"""A redirection operator token (`>`, `>>`, `>&`, `&>`, `<`, `>|`…)."""
|
|
656
|
+
return bool(tok) and set(tok) <= set("<>&|") and ("<" in tok or ">" in tok)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def is_git(tok):
|
|
660
|
+
return tok == "git" or tok.endswith("/git")
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def iter_git_calls(tokens, wanted, soft=frozenset()):
|
|
664
|
+
"""Yield a GitCall for every `git <sub>` whose subcommand is in `wanted`.
|
|
665
|
+
|
|
666
|
+
A sub in `soft` whose repository cannot be resolved is yielded with an
|
|
667
|
+
Unresolved cwd rather than blocked: it only informs a later check.
|
|
668
|
+
|
|
669
|
+
Tracks the directory each one runs in, because `cd other/repo && git push`,
|
|
670
|
+
`pushd`, `git -C other/repo push`, `--git-dir`/`--work-tree` and GIT_DIR all
|
|
671
|
+
target a different repository — and that repo's config and index are what
|
|
672
|
+
the checks must read. Subshell parentheses scope the directory and exports,
|
|
673
|
+
matching real shell behaviour.
|
|
674
|
+
"""
|
|
675
|
+
# Start from the variables this shell inherited, like git and cd would.
|
|
676
|
+
i, cwd = 0, None
|
|
677
|
+
env = {k: os.environ[k] for k in TRACKED_ENV if k in os.environ}
|
|
678
|
+
scope_stack, dir_stack, restore_at, uncertain_at, settle_at = [], [], {}, {}, {}
|
|
679
|
+
control_stack, case_pattern = [], False
|
|
680
|
+
while i < len(tokens):
|
|
681
|
+
tok = tokens[i]
|
|
682
|
+
# The `&` closing a backgrounded list: its cds and exports end here.
|
|
683
|
+
if i in restore_at:
|
|
684
|
+
cwd, env, dir_stack = restore_at.pop(i)
|
|
685
|
+
# The end of a list whose cd/export may have been skipped: whatever
|
|
686
|
+
# differs from before it is now unknown, and fails closed if used.
|
|
687
|
+
if i in uncertain_at:
|
|
688
|
+
before_cwd, before_env, _ = uncertain_at.pop(i)
|
|
689
|
+
if before_cwd != cwd or isinstance(cwd, Unresolved):
|
|
690
|
+
cwd = Unresolved("a cd that may have been skipped (after && or ||)")
|
|
691
|
+
for name in set(env) | set(before_env):
|
|
692
|
+
if env.get(name) != before_env.get(name):
|
|
693
|
+
env[name] = "$(set only if an earlier && or || ran it)"
|
|
694
|
+
if i in settle_at:
|
|
695
|
+
cwd, env, dir_stack = settle_at.pop(i)
|
|
696
|
+
# Compound-command bodies may not run (or run repeatedly): a cd or
|
|
697
|
+
# export inside one holds within it, and is unknown after it. Branches
|
|
698
|
+
# are exclusive: `else`/`elif` and each case arm start from the state
|
|
699
|
+
# the condition (or `case WORD in`) left, never from the branch before.
|
|
700
|
+
top = control_stack[-1] if control_stack else None
|
|
701
|
+
if case_pattern:
|
|
702
|
+
# `a|b)` of a case arm: pattern words, not commands or subshells.
|
|
703
|
+
if tok == "esac":
|
|
704
|
+
case_pattern = False
|
|
705
|
+
else:
|
|
706
|
+
case_pattern = tok != ")"
|
|
707
|
+
i += 1
|
|
708
|
+
continue
|
|
709
|
+
if tok in CONTROL_OPENERS and at_command_start(tokens, i):
|
|
710
|
+
control_stack.append(Branches(tok, cwd, env, dir_stack))
|
|
711
|
+
elif top and top.kind == "case" and tok == "in" and top.start is None:
|
|
712
|
+
top.start = (cwd, dict(env), list(dir_stack))
|
|
713
|
+
case_pattern = True
|
|
714
|
+
elif top and tok in ("then", "do") and at_command_start(tokens, i):
|
|
715
|
+
top.start = (cwd, dict(env), list(dir_stack))
|
|
716
|
+
elif top and (
|
|
717
|
+
(top.kind == "if" and tok in ("else", "elif") and at_command_start(tokens, i))
|
|
718
|
+
or (top.kind == "case" and tok in CASE_ENDS)
|
|
719
|
+
):
|
|
720
|
+
top.note(cwd, env, dir_stack)
|
|
721
|
+
if tok == ";&":
|
|
722
|
+
# Falls through: the next arm runs on this arm's state.
|
|
723
|
+
pass
|
|
724
|
+
elif tok == ";;&":
|
|
725
|
+
# The next arm may run after this one, or alone.
|
|
726
|
+
cwd, env, dir_stack = top.blend(cwd, env, dir_stack)
|
|
727
|
+
else:
|
|
728
|
+
cwd, env, dir_stack = top.restart()
|
|
729
|
+
case_pattern = top.kind == "case"
|
|
730
|
+
elif top and tok in ("fi", "done", "esac") and at_command_start(tokens, i):
|
|
731
|
+
control_stack.pop()
|
|
732
|
+
top.note(cwd, env, dir_stack)
|
|
733
|
+
cwd, env, dir_stack = top.after()
|
|
734
|
+
# `( cd other && git push )` must not leak its cd to later commands.
|
|
735
|
+
if tok == "(":
|
|
736
|
+
scope_stack.append((cwd, dict(env), list(dir_stack)))
|
|
737
|
+
i += 1
|
|
738
|
+
continue
|
|
739
|
+
if tok == ")":
|
|
740
|
+
if scope_stack:
|
|
741
|
+
cwd, env, dir_stack = scope_stack.pop()
|
|
742
|
+
i += 1
|
|
743
|
+
continue
|
|
744
|
+
shell_word = tok in ("cd", "pushd", "popd", "export", "unset") or assignment_only(tokens, i)
|
|
745
|
+
if shell_word and at_command_start(tokens, i):
|
|
746
|
+
saved, j = (cwd, dict(env), list(dir_stack)), i + 1
|
|
747
|
+
if tok in ("cd", "pushd"):
|
|
748
|
+
physical = False
|
|
749
|
+
while j < len(tokens) and tokens[j] in CD_OPTS:
|
|
750
|
+
if tokens[j] in ("-L", "-P"):
|
|
751
|
+
physical = tokens[j] == "-P"
|
|
752
|
+
j += 1
|
|
753
|
+
# `cd -- dir`: the option terminator, not a destination.
|
|
754
|
+
if j < len(tokens) and tokens[j] == "--":
|
|
755
|
+
j += 1
|
|
756
|
+
if j < len(tokens) and tokens[j] not in SEPARATORS:
|
|
757
|
+
if tok == "pushd":
|
|
758
|
+
dir_stack.append(cwd)
|
|
759
|
+
cwd = cd_dir(tokens[j], cwd, cd_path(tokens, i, env))
|
|
760
|
+
if not isinstance(cwd, Unresolved):
|
|
761
|
+
# Bash's cd is logical by default: `cd link; cd ..`
|
|
762
|
+
# returns to link's parent, not its target's, so
|
|
763
|
+
# resolve `..` on the path text. `-P` follows links.
|
|
764
|
+
cwd = os.path.realpath(cwd) if physical else os.path.normpath(cwd)
|
|
765
|
+
j += 1
|
|
766
|
+
while j < len(tokens) and is_redirect(tokens[j]):
|
|
767
|
+
j += 2
|
|
768
|
+
if j < len(tokens) and tokens[j] not in SEPARATORS:
|
|
769
|
+
# `cd a b`: bash refuses it, zsh swaps a for b in $PWD.
|
|
770
|
+
cwd = Unresolved(f"{tok} with more than one operand")
|
|
771
|
+
while j < len(tokens) and tokens[j] not in SEPARATORS:
|
|
772
|
+
j += 1
|
|
773
|
+
else:
|
|
774
|
+
# Bare `cd` goes to $HOME; bare `pushd` swaps the top two.
|
|
775
|
+
cwd = Unresolved("~" if tok == "cd" else "pushd")
|
|
776
|
+
elif tok == "popd":
|
|
777
|
+
cwd = dir_stack.pop() if dir_stack else Unresolved("popd")
|
|
778
|
+
else:
|
|
779
|
+
# An unexported shell variable is kept under "\0NAME", so it
|
|
780
|
+
# rides along with every saved and restored state; a later
|
|
781
|
+
# bare `export NAME` publishes it.
|
|
782
|
+
if tok not in ("export", "unset"):
|
|
783
|
+
j = i
|
|
784
|
+
unexport = False
|
|
785
|
+
while j < len(tokens) and tokens[j] not in SEPARATORS:
|
|
786
|
+
m = ENV_ASSIGN.match(tokens[j])
|
|
787
|
+
name = m.group(1) if m else tokens[j]
|
|
788
|
+
if tok == "export" and re.fullmatch(r"-[fnp]+", tokens[j]):
|
|
789
|
+
unexport |= "n" in tokens[j]
|
|
790
|
+
elif tok == "unset":
|
|
791
|
+
env.pop(name, None)
|
|
792
|
+
env.pop("\0" + name, None)
|
|
793
|
+
elif name not in TRACKED_ENV:
|
|
794
|
+
pass
|
|
795
|
+
elif unexport:
|
|
796
|
+
# `export -n NAME[=value]`: kept, but no longer exported.
|
|
797
|
+
value = m.group(2) if m else env.get(name, env.get("\0" + name))
|
|
798
|
+
env.pop(name, None)
|
|
799
|
+
if value is not None:
|
|
800
|
+
env["\0" + name] = value
|
|
801
|
+
elif tok == "export":
|
|
802
|
+
if m:
|
|
803
|
+
env[name] = m.group(2)
|
|
804
|
+
env.pop("\0" + name, None)
|
|
805
|
+
elif "\0" + name in env:
|
|
806
|
+
env[name] = env.pop("\0" + name)
|
|
807
|
+
elif name in env or name == "CDPATH":
|
|
808
|
+
# `NAME=value` alone: an exported (or inherited) one
|
|
809
|
+
# stays exported, and cd reads CDPATH either way.
|
|
810
|
+
env[name] = m.group(2)
|
|
811
|
+
else:
|
|
812
|
+
env["\0" + name] = m.group(2)
|
|
813
|
+
j += 1
|
|
814
|
+
# `cd x | true` changes a forked shell only; so does every command
|
|
815
|
+
# of a backgrounded list, from its `&` on.
|
|
816
|
+
if in_pipeline(tokens, i, j):
|
|
817
|
+
cwd, env, dir_stack = saved
|
|
818
|
+
else:
|
|
819
|
+
end = background_end(tokens, j)
|
|
820
|
+
if end is not None:
|
|
821
|
+
restore_at.setdefault(end, saved)
|
|
822
|
+
else:
|
|
823
|
+
# `false && cd x; git commit`: after the list either state is
|
|
824
|
+
# possible. Within it, anything past an `||` may run because
|
|
825
|
+
# the cd's chain failed (`false && cd x || git commit`).
|
|
826
|
+
end = list_end(tokens, j)
|
|
827
|
+
conditional = runs_conditionally(tokens, i)
|
|
828
|
+
if conditional and end is not None:
|
|
829
|
+
uncertain_at.setdefault(end, saved)
|
|
830
|
+
alt = first_or(tokens, j, end)
|
|
831
|
+
if alt is not None:
|
|
832
|
+
uncertain_at.setdefault(alt, saved)
|
|
833
|
+
stops = alt + 1 < len(tokens) and tokens[alt + 1] in ("exit", "return")
|
|
834
|
+
if not conditional and end is not None and stops:
|
|
835
|
+
# `cd x || exit 1; git commit`: once the list is
|
|
836
|
+
# over, the cd must have worked. `cd x || true`
|
|
837
|
+
# proves nothing, so that stays unknown.
|
|
838
|
+
settle_at[end] = (cwd, dict(env), list(dir_stack))
|
|
839
|
+
i = j
|
|
840
|
+
continue
|
|
841
|
+
if not is_git(tok) or is_argument(tokens, i):
|
|
842
|
+
i += 1
|
|
843
|
+
continue
|
|
844
|
+
call_env = command_env(tokens, i, env)
|
|
845
|
+
# Collect global options WITHOUT judging them: everything here is only
|
|
846
|
+
# relevant if this invocation is one the caller inspects, so an
|
|
847
|
+
# unresolvable path on `git -C $D status` must not be blocked.
|
|
848
|
+
j, raw_cds, raw_git_dir, raw_work_tree, overrides = i + 1, [], None, None, []
|
|
849
|
+
while j < len(tokens) and tokens[j].startswith("-"):
|
|
850
|
+
opt, val = tokens[j], None
|
|
851
|
+
if "=" in opt:
|
|
852
|
+
opt, val = opt.split("=", 1)
|
|
853
|
+
if opt in GIT_GLOBAL_VALUE_OPTS:
|
|
854
|
+
if val is None and j + 1 < len(tokens):
|
|
855
|
+
val = tokens[j + 1]
|
|
856
|
+
j += 2
|
|
857
|
+
else:
|
|
858
|
+
j += 1
|
|
859
|
+
if val is not None:
|
|
860
|
+
if opt == "-C":
|
|
861
|
+
# Cumulative: verified that `git -C outer -C inner` lands
|
|
862
|
+
# in outer/inner, each relative to the one before.
|
|
863
|
+
raw_cds.append(val)
|
|
864
|
+
elif opt == "--git-dir":
|
|
865
|
+
raw_git_dir = val
|
|
866
|
+
elif opt == "--work-tree":
|
|
867
|
+
raw_work_tree = val
|
|
868
|
+
elif opt in ("-c", "--config-env"):
|
|
869
|
+
overrides.append((opt, val))
|
|
870
|
+
else:
|
|
871
|
+
j += 1
|
|
872
|
+
if j < len(tokens) and tokens[j] in wanted:
|
|
873
|
+
args, k = [], j + 1
|
|
874
|
+
while k < len(tokens) and tokens[k] not in SEPARATORS:
|
|
875
|
+
if is_redirect(tokens[k]):
|
|
876
|
+
# `> log`, `>&1`, `2>&1`: the shell's, not git's — and read
|
|
877
|
+
# as a pathspec it would narrow the commit check to nothing.
|
|
878
|
+
k += 2
|
|
879
|
+
continue
|
|
880
|
+
args.append(tokens[k])
|
|
881
|
+
k += 1
|
|
882
|
+
inv_cwd = cwd
|
|
883
|
+
for raw_cd in raw_cds:
|
|
884
|
+
inv_cwd = step_dir(raw_cd, inv_cwd)
|
|
885
|
+
unknown = (
|
|
886
|
+
isinstance(inv_cwd, Unresolved)
|
|
887
|
+
or any(UNRESOLVABLE.search(p) for p in (raw_git_dir, raw_work_tree) if p)
|
|
888
|
+
or any(VALUE_UNRESOLVABLE.search(v) or v.startswith("~") for v in call_env.values())
|
|
889
|
+
)
|
|
890
|
+
if unknown and tokens[j] in soft:
|
|
891
|
+
# A `soft` call only informs a later check; the caller decides
|
|
892
|
+
# what an unknown repository means instead of blocking here.
|
|
893
|
+
yield GitCall(tokens[j], args, Unresolved(tokens[j]), None, None, {}, overrides, i, k)
|
|
894
|
+
i = k
|
|
895
|
+
continue
|
|
896
|
+
inv_cwd = settle(inv_cwd)
|
|
897
|
+
git_dir = resolve_dir(raw_git_dir, inv_cwd) if raw_git_dir is not None else None
|
|
898
|
+
work_tree = resolve_dir(raw_work_tree, inv_cwd) if raw_work_tree is not None else None
|
|
899
|
+
for name, val in call_env.items():
|
|
900
|
+
if VALUE_UNRESOLVABLE.search(val) or val.startswith("~"):
|
|
901
|
+
block(
|
|
902
|
+
f"BLOCKED: {name} is set from a value this guard cannot "
|
|
903
|
+
f"resolve ('{val}').\n" + AMBIGUOUS_REMEDY
|
|
904
|
+
)
|
|
905
|
+
yield GitCall(tokens[j], args, inv_cwd, git_dir, work_tree, call_env, overrides, i, k)
|
|
906
|
+
i = k
|
|
907
|
+
else:
|
|
908
|
+
i = j + 1
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def check_push_args(args):
|
|
912
|
+
positionals, skip_next = [], False
|
|
913
|
+
for arg in args:
|
|
914
|
+
if skip_next:
|
|
915
|
+
skip_next = False
|
|
916
|
+
continue
|
|
917
|
+
if arg in VALUE_OPTS:
|
|
918
|
+
skip_next = True
|
|
919
|
+
continue
|
|
920
|
+
danger = is_dangerous_long(arg)
|
|
921
|
+
if danger == "--mirror":
|
|
922
|
+
block(
|
|
923
|
+
"BLOCKED: 'git push --mirror' force-updates and deletes remote "
|
|
924
|
+
"refs.\n" + FORCE_REMEDY
|
|
925
|
+
)
|
|
926
|
+
if danger == "--force-with-lease":
|
|
927
|
+
block("BLOCKED: 'git push --force-with-lease' is forbidden.\n" + FORCE_REMEDY)
|
|
928
|
+
if danger == "--force":
|
|
929
|
+
block("BLOCKED: 'git push --force' is forbidden.\n" + FORCE_REMEDY)
|
|
930
|
+
# Bundled short flags, e.g. -uf / -fu
|
|
931
|
+
if re.fullmatch(r"-[a-zA-Z]+", arg) and "f" in arg[1:]:
|
|
932
|
+
block("BLOCKED: 'git push -f' is forbidden.\n" + FORCE_REMEDY)
|
|
933
|
+
if arg.startswith("-"):
|
|
934
|
+
continue
|
|
935
|
+
positionals.append(arg)
|
|
936
|
+
# A leading '+' on a REFSPEC forces the update: `git push origin +main`.
|
|
937
|
+
for pos in positionals:
|
|
938
|
+
if pos.startswith("+"):
|
|
939
|
+
block("BLOCKED: a '+' refspec on git push forces the update.\n" + FORCE_REMEDY)
|
|
940
|
+
return positionals
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
# Bash ANSI-C quoting ($'...') and locale quoting ($"...") survive shlex as a
|
|
944
|
+
# literal '$' glued to the contents, so $'--force' tokenises as "$--force" and
|
|
945
|
+
# defeats EVERY token check. If such quoting appears anywhere in a command that
|
|
946
|
+
# also pushes, refuse rather than analyse a string that is not what bash will
|
|
947
|
+
# run. The probe decodes the escapes bash does, so `git $'\x70ush'` is a push.
|
|
948
|
+
ANSI_C = re.compile(r"\$'((?:[^'\\]|\\.)*)'", re.S)
|
|
949
|
+
ANSI_ESCAPE = re.compile(
|
|
950
|
+
r"\\(x[0-9A-Fa-f]{1,2}|u[0-9A-Fa-f]{1,4}|U[0-9A-Fa-f]{1,8}|[0-7]{1,3}|c.|.)", re.S
|
|
951
|
+
)
|
|
952
|
+
ANSI_SIMPLE = {
|
|
953
|
+
"a": "\a", "b": "\b", "e": "\x1b", "E": "\x1b", "f": "\f", "n": "\n",
|
|
954
|
+
"r": "\r", "t": "\t", "v": "\v", "\\": "\\", "'": "'", '"': '"', "?": "?",
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def ansi_char(m):
|
|
959
|
+
s = m.group(1)
|
|
960
|
+
if s[0] in "xuU" and len(s) > 1:
|
|
961
|
+
return chr(int(s[1:], 16))
|
|
962
|
+
if s[0] in "01234567":
|
|
963
|
+
return chr(int(s, 8) & 0xFF)
|
|
964
|
+
if s[0] == "c" and len(s) > 1:
|
|
965
|
+
return chr(ord(s[1]) & 0x1F)
|
|
966
|
+
return ANSI_SIMPLE.get(s, "\\" + s)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def plain_quoting(text):
|
|
970
|
+
"""`text` with $'...' decoded as bash does and $"..." read as "..."."""
|
|
971
|
+
text = ANSI_C.sub(lambda m: shlex.quote(ANSI_ESCAPE.sub(ansi_char, m.group(1))), text)
|
|
972
|
+
return re.sub(r"\$(?=['\"])", "", text)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
if re.search(r"\$['\"]", cmd_raw):
|
|
976
|
+
probe = tokenize(plain_quoting(cmd_raw))
|
|
977
|
+
if probe is None or any(True for _ in iter_git_calls(probe, {"push"})):
|
|
978
|
+
block(
|
|
979
|
+
"BLOCKED: this command mixes ANSI-C/locale quoting ($'...') with a "
|
|
980
|
+
"git push, and the guard cannot faithfully reproduce what bash will "
|
|
981
|
+
"execute.\nRe-run the push with ordinary quoting so its arguments can "
|
|
982
|
+
"be verified."
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
import subprocess
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def git_out(args, call):
|
|
989
|
+
"""stdout of `git_run`, or '' when git could not run (push-config reads)."""
|
|
990
|
+
result = git_run(args, call)
|
|
991
|
+
return result.stdout if result else ""
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def git_strict(args, call):
|
|
995
|
+
"""stdout of `git_run`; BLOCKS if git failed or timed out.
|
|
996
|
+
|
|
997
|
+
The commit check must not read a failed query as "nothing staged".
|
|
998
|
+
"""
|
|
999
|
+
result = git_run(args, call)
|
|
1000
|
+
if result is None or result.returncode != 0:
|
|
1001
|
+
why = "timed out or could not start" if result is None else (
|
|
1002
|
+
(result.stderr.strip().splitlines() or ["no output"])[0]
|
|
1003
|
+
)
|
|
1004
|
+
block(
|
|
1005
|
+
f"BLOCKED: could not inspect the repository this commit targets "
|
|
1006
|
+
f"(git {' '.join(args)}: {why}), so the paired-change check "
|
|
1007
|
+
"cannot run. Fix the repository state or run the commit from "
|
|
1008
|
+
"inside the repository."
|
|
1009
|
+
)
|
|
1010
|
+
return result.stdout
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def git_run(args, call):
|
|
1014
|
+
"""Run a read-only git command against the repository `call` targets.
|
|
1015
|
+
|
|
1016
|
+
Repo selection mirrors the call itself: its cwd (the shell's when None),
|
|
1017
|
+
--git-dir, --work-tree, `-c` overrides and GIT_* variables. Returns the
|
|
1018
|
+
CompletedProcess, or None when git cannot run. (`-c core.worktree=` does
|
|
1019
|
+
NOT move the work tree — verified: git reads that key only from the
|
|
1020
|
+
repository's own config.)
|
|
1021
|
+
"""
|
|
1022
|
+
cwd = call.cwd or SHELL_CWD
|
|
1023
|
+
if not os.path.isdir(cwd):
|
|
1024
|
+
block(
|
|
1025
|
+
f"BLOCKED: cannot inspect the target directory '{cwd}'.\n"
|
|
1026
|
+
+ AMBIGUOUS_REMEDY
|
|
1027
|
+
)
|
|
1028
|
+
prefix = [f"--git-dir={call.git_dir}"] if call.git_dir else []
|
|
1029
|
+
prefix += [f"--work-tree={call.work_tree}"] if call.work_tree else []
|
|
1030
|
+
# --config-env is skipped: its variable lives in the shell, so passing it
|
|
1031
|
+
# on would make git fail here and read nothing.
|
|
1032
|
+
prefix += [a for opt, val in call.overrides if opt == "-c" for a in (opt, val)]
|
|
1033
|
+
# The call's GIT_* set is complete (exports, prefixes, `env -u`), so the
|
|
1034
|
+
# hook's own copies must not leak back in underneath it.
|
|
1035
|
+
# HOOK_INPUT stays out too: over the platform's exec cap git would not start.
|
|
1036
|
+
base_env = {k: v for k, v in os.environ.items() if k not in GIT_REPO_ENV and k != "HOOK_INPUT"}
|
|
1037
|
+
try:
|
|
1038
|
+
return subprocess.run(
|
|
1039
|
+
["git"] + prefix + args,
|
|
1040
|
+
capture_output=True, text=True, timeout=10,
|
|
1041
|
+
cwd=cwd, env={**base_env, **call.env},
|
|
1042
|
+
)
|
|
1043
|
+
except Exception:
|
|
1044
|
+
return None
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def config_force_refspec(remote, call):
|
|
1048
|
+
"""True if remote.<remote>.push is configured with a forcing '+' refspec."""
|
|
1049
|
+
vals = git_out(["config", "--get-all", f"remote.{remote}.push"], call).splitlines()
|
|
1050
|
+
return any(v.strip().startswith("+") for v in vals)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
# git commit's long options (git 2.5x), mapped to whether the value may be a
|
|
1054
|
+
# SEPARATE token. Git accepts any unambiguous abbreviation (`--mess x` is
|
|
1055
|
+
# `--message x`), so a value-taking option is matched by prefix: a missed one
|
|
1056
|
+
# would read its value as a pathspec and narrow what the check inspects.
|
|
1057
|
+
COMMIT_LONG = {
|
|
1058
|
+
"--all": False, "--patch": False, "--reuse-message": True,
|
|
1059
|
+
"--reedit-message": True, "--fixup": True, "--squash": True,
|
|
1060
|
+
"--reset-author": False, "--short": False, "--branch": False,
|
|
1061
|
+
"--porcelain": False, "--long": False, "--null": False, "--file": True,
|
|
1062
|
+
"--author": True, "--date": True, "--message": True, "--template": True,
|
|
1063
|
+
"--signoff": False, "--no-signoff": False, "--trailer": True,
|
|
1064
|
+
"--verify": False, "--allow-empty": False, "--allow-empty-message": False,
|
|
1065
|
+
"--cleanup": True, "--edit": False, "--no-edit": False, "--amend": False,
|
|
1066
|
+
"--no-post-rewrite": False, "--include": False, "--only": False,
|
|
1067
|
+
"--pathspec-from-file": True, "--pathspec-file-nul": False,
|
|
1068
|
+
"--untracked-files": False, "--verbose": False, "--quiet": False,
|
|
1069
|
+
"--dry-run": False, "--status": False, "--no-status": False,
|
|
1070
|
+
"--gpg-sign": False, "--no-gpg-sign": False, "--interactive": False,
|
|
1071
|
+
"--unified": True, "--inter-hunk-context": True, "--ahead-behind": False,
|
|
1072
|
+
"--post-rewrite": False,
|
|
1073
|
+
}
|
|
1074
|
+
# Short options whose value is the rest of the bundle or the next token, and
|
|
1075
|
+
# those whose optional value can only be attached (`-S<keyid>`, `-uno`).
|
|
1076
|
+
COMMIT_VALUE_SHORT = set("mFcCtU")
|
|
1077
|
+
COMMIT_ATTACHED_SHORT = set("Su")
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def commit_long(arg):
|
|
1081
|
+
base = arg.split("=", 1)[0]
|
|
1082
|
+
if base in COMMIT_LONG:
|
|
1083
|
+
return base
|
|
1084
|
+
matches = [o for o in COMMIT_LONG if o.startswith(base)]
|
|
1085
|
+
return matches[0] if len(matches) == 1 else None
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def parse_commit_args(args):
|
|
1089
|
+
"""(stage_all, include, pathspecs, from_file, only, interactive, dry) for
|
|
1090
|
+
`git commit <args>`."""
|
|
1091
|
+
stage_all = include = from_file = only = interactive = dry = False
|
|
1092
|
+
paths, k = [], 0
|
|
1093
|
+
while k < len(args):
|
|
1094
|
+
arg = args[k]
|
|
1095
|
+
k += 1
|
|
1096
|
+
if arg == "--":
|
|
1097
|
+
paths += args[k:]
|
|
1098
|
+
break
|
|
1099
|
+
if arg.startswith("--"):
|
|
1100
|
+
opt = commit_long(arg)
|
|
1101
|
+
stage_all |= opt == "--all"
|
|
1102
|
+
include |= opt == "--include"
|
|
1103
|
+
only |= opt == "--only"
|
|
1104
|
+
from_file |= opt == "--pathspec-from-file"
|
|
1105
|
+
interactive |= opt in ("--patch", "--interactive")
|
|
1106
|
+
dry |= opt == "--dry-run"
|
|
1107
|
+
if opt and COMMIT_LONG[opt] and "=" not in arg:
|
|
1108
|
+
k += 1
|
|
1109
|
+
continue
|
|
1110
|
+
if arg.startswith("-") and len(arg) > 1:
|
|
1111
|
+
for pos, ch in enumerate(arg[1:], 1):
|
|
1112
|
+
if ch == "n":
|
|
1113
|
+
# Caught here too: the text check above only sees a
|
|
1114
|
+
# literal `git commit`, not `git -C dir commit -n`.
|
|
1115
|
+
block(NO_VERIFY_ALIAS)
|
|
1116
|
+
stage_all |= ch == "a"
|
|
1117
|
+
include |= ch == "i"
|
|
1118
|
+
only |= ch == "o"
|
|
1119
|
+
interactive |= ch == "p"
|
|
1120
|
+
if ch in COMMIT_VALUE_SHORT:
|
|
1121
|
+
k += pos == len(arg) - 1
|
|
1122
|
+
break
|
|
1123
|
+
if ch in COMMIT_ATTACHED_SHORT:
|
|
1124
|
+
break
|
|
1125
|
+
continue
|
|
1126
|
+
paths.append(arg)
|
|
1127
|
+
return stage_all, include, paths, from_file, only, interactive, dry
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
class IndexState:
|
|
1131
|
+
"""What earlier commands IN THIS SAME LINE did to one repository's index.
|
|
1132
|
+
|
|
1133
|
+
The hook runs before the whole line, so `git add schema.sql && git
|
|
1134
|
+
commit` would otherwise be judged on the index as it was before the add.
|
|
1135
|
+
`may` is every path an earlier add could have staged (counted for a
|
|
1136
|
+
paired change's `changes` side); `adds` holds, per add, the paths it
|
|
1137
|
+
certainly leaves changed against HEAD (counted for the `requires` side,
|
|
1138
|
+
and only when that add is sure to have run before the commit — see
|
|
1139
|
+
`runs_before`). `trusted` drops to False once anything else may have
|
|
1140
|
+
changed the index or work tree (reset, restore, stash, checkout, an
|
|
1141
|
+
earlier commit…): from then on only an add after that point can prove a
|
|
1142
|
+
required path is present.
|
|
1143
|
+
"""
|
|
1144
|
+
|
|
1145
|
+
def __init__(self, trusted=True):
|
|
1146
|
+
self.may, self.adds, self.trusted = set(), [], trusted
|
|
1147
|
+
self.replaced = set()
|
|
1148
|
+
# Set once an earlier command may have moved HEAD (a commit, a branch
|
|
1149
|
+
# switch…): a later add's diff against the PRE-LINE HEAD then proves
|
|
1150
|
+
# nothing — `git commit -- required && git add -A && git commit`
|
|
1151
|
+
# would credit the second commit with the first one's required path.
|
|
1152
|
+
self.head_moved = not trusted
|
|
1153
|
+
|
|
1154
|
+
def distrust(self):
|
|
1155
|
+
self.adds = [(end, set()) for end, _ in self.adds]
|
|
1156
|
+
self.trusted = False
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
STAGERS = {"add", "stage"}
|
|
1160
|
+
# Other subcommands that can change the index or the work tree.
|
|
1161
|
+
INDEX_WRITERS = {
|
|
1162
|
+
"reset", "restore", "rm", "stash", "checkout", "switch", "mv", "clean",
|
|
1163
|
+
"read-tree", "update-index", "apply", "am", "cherry-pick", "revert",
|
|
1164
|
+
"merge", "rebase", "pull", "commit",
|
|
1165
|
+
}
|
|
1166
|
+
index_states, unknown_repo_writes, moved_git_dirs = {}, [False], set()
|
|
1167
|
+
# The `changes` side of every paired change an index writer in a repository
|
|
1168
|
+
# this guard cannot resolve may have staged.
|
|
1169
|
+
unknown_repo_stages = set()
|
|
1170
|
+
# rev-parse --absolute-git-dir per repository selection, keyed on everything
|
|
1171
|
+
# git_run passes git. The hook runs before the line, so one answer holds for
|
|
1172
|
+
# every call in it, however often a commit line asks.
|
|
1173
|
+
git_dirs = {}
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def repo_key(call):
|
|
1177
|
+
"""Identity of the index `call` uses: its git dir plus GIT_INDEX_FILE."""
|
|
1178
|
+
if isinstance(call.cwd, Unresolved) or not os.path.isdir(call.cwd or SHELL_CWD):
|
|
1179
|
+
return None
|
|
1180
|
+
index_file = call.env.get("GIT_INDEX_FILE")
|
|
1181
|
+
if index_file is not None and not os.path.isabs(index_file):
|
|
1182
|
+
# Git resolves a relative one from where it runs, which this guard
|
|
1183
|
+
# would have to reproduce to tell two such indexes apart.
|
|
1184
|
+
block(
|
|
1185
|
+
f"BLOCKED: GIT_INDEX_FILE='{index_file}' is relative, so the guard "
|
|
1186
|
+
"cannot tell which index this commit line stages into.\nUse an "
|
|
1187
|
+
"absolute GIT_INDEX_FILE path."
|
|
1188
|
+
)
|
|
1189
|
+
selection = (
|
|
1190
|
+
call.cwd or SHELL_CWD, call.git_dir, call.work_tree,
|
|
1191
|
+
tuple(tuple(o) for o in call.overrides), tuple(sorted(call.env.items())),
|
|
1192
|
+
)
|
|
1193
|
+
if selection not in git_dirs:
|
|
1194
|
+
git_dirs[selection] = git_run(["rev-parse", "--absolute-git-dir"], call)
|
|
1195
|
+
result = git_dirs[selection]
|
|
1196
|
+
if result is None or result.returncode != 0:
|
|
1197
|
+
return None
|
|
1198
|
+
git_dir = result.stdout.strip()
|
|
1199
|
+
if index_file is not None and os.path.realpath(index_file) == os.path.realpath(
|
|
1200
|
+
os.path.join(git_dir, "index")
|
|
1201
|
+
):
|
|
1202
|
+
index_file = None # the default index, spelled out
|
|
1203
|
+
return (git_dir, index_file)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def index_state(call):
|
|
1207
|
+
key = repo_key(call)
|
|
1208
|
+
if key not in index_states:
|
|
1209
|
+
moved = key is not None and key[0] in moved_git_dirs
|
|
1210
|
+
index_states[key] = IndexState(trusted=not unknown_repo_writes[0] and not moved)
|
|
1211
|
+
return index_states[key]
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def note_head_move(call):
|
|
1215
|
+
"""HEAD moved in `call`'s repository: every index of it (the default one
|
|
1216
|
+
and any GIT_INDEX_FILE) now differs from HEAD in ways not read here."""
|
|
1217
|
+
key = repo_key(call)
|
|
1218
|
+
if key is not None:
|
|
1219
|
+
moved_git_dirs.add(key[0])
|
|
1220
|
+
for other, state in index_states.items():
|
|
1221
|
+
if other == key or (other is not None and key is not None and other[0] == key[0]):
|
|
1222
|
+
state.distrust()
|
|
1223
|
+
state.head_moved = True
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def head_base(call):
|
|
1227
|
+
"""HEAD, or the empty tree on an unborn branch (rev-parse exits 1 with no
|
|
1228
|
+
output); blocks with git's reason on any other failure."""
|
|
1229
|
+
head = git_run(["rev-parse", "--verify", "-q", "HEAD"], call)
|
|
1230
|
+
if head is not None and head.returncode == 0:
|
|
1231
|
+
return "HEAD"
|
|
1232
|
+
if head is not None and head.returncode == 1 and not head.stdout.strip():
|
|
1233
|
+
return git_strict(["hash-object", "-t", "tree", os.devnull], call).strip()
|
|
1234
|
+
git_strict(["rev-parse", "--verify", "HEAD"], call)
|
|
1235
|
+
return "HEAD"
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def runs_before(end, pos):
|
|
1239
|
+
"""True if the command ending at tokens[end] certainly ran, and succeeded,
|
|
1240
|
+
before the commit at tokens[pos]: they share one AND list joined only by
|
|
1241
|
+
`&&`, with no `||` or pipe earlier in it. `false && git add m; git commit`
|
|
1242
|
+
skips the add; `x || git add m && git commit` may too; after `;` a failed
|
|
1243
|
+
add still lets the commit run.
|
|
1244
|
+
"""
|
|
1245
|
+
between = [t for t in tokens[end:pos] if t in SEPARATORS]
|
|
1246
|
+
if not between or any(t != "&&" for t in between):
|
|
1247
|
+
return False
|
|
1248
|
+
k = end - 1
|
|
1249
|
+
while k >= 0 and tokens[k] not in (";", "\n", "&", "(", ")"):
|
|
1250
|
+
if tokens[k] in ("||", "|", "|&"):
|
|
1251
|
+
return False
|
|
1252
|
+
k -= 1
|
|
1253
|
+
return True
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
# `git add` options that leave the staged content exactly the listed changes.
|
|
1257
|
+
ADD_EXACT_SHORT, ADD_EXACT_LONG = set("Auvf"), {
|
|
1258
|
+
"--all", "--update", "--verbose", "--force", "--no-ignore-removal",
|
|
1259
|
+
"--ignore-errors",
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def note_add(call):
|
|
1264
|
+
"""Record in the call's IndexState what this `git add` will stage."""
|
|
1265
|
+
if isinstance(call.cwd, Unresolved):
|
|
1266
|
+
return note_unknown_write()
|
|
1267
|
+
stage_all = update = dry = partial = False
|
|
1268
|
+
paths, k = [], 0
|
|
1269
|
+
while k < len(call.args):
|
|
1270
|
+
arg = call.args[k]
|
|
1271
|
+
k += 1
|
|
1272
|
+
if arg == "--":
|
|
1273
|
+
paths += call.args[k:]
|
|
1274
|
+
break
|
|
1275
|
+
if arg.startswith("--"):
|
|
1276
|
+
base = arg.split("=", 1)[0]
|
|
1277
|
+
stage_all |= base in ("--all", "--no-ignore-removal")
|
|
1278
|
+
update |= base == "--update"
|
|
1279
|
+
dry |= "--dry-run".startswith(base) and len(base) > 3
|
|
1280
|
+
# -p/-i/-e/-N, --pathspec-from-file, or anything unrecognised:
|
|
1281
|
+
# what gets staged cannot be known, so nothing counts as certain.
|
|
1282
|
+
partial |= base not in ADD_EXACT_LONG
|
|
1283
|
+
continue
|
|
1284
|
+
if arg.startswith("-") and len(arg) > 1:
|
|
1285
|
+
stage_all |= "A" in arg
|
|
1286
|
+
update |= "u" in arg
|
|
1287
|
+
dry |= "n" in arg
|
|
1288
|
+
partial |= not set(arg[1:]) <= ADD_EXACT_SHORT | {"n"}
|
|
1289
|
+
continue
|
|
1290
|
+
paths.append(arg)
|
|
1291
|
+
if dry or not (paths or stage_all or update or partial):
|
|
1292
|
+
return
|
|
1293
|
+
if any(VALUE_UNRESOLVABLE.search(p) for p in paths) or (partial and not paths):
|
|
1294
|
+
# Paths the shell computes: anything may be staged, nothing certainly.
|
|
1295
|
+
paths, partial = [":/"], True
|
|
1296
|
+
spec = paths or [":/"]
|
|
1297
|
+
# After the add, the index holds the work tree's content for these paths,
|
|
1298
|
+
# so what the commit records is the work tree against HEAD — a required
|
|
1299
|
+
# path staged earlier but restored in the work tree is UNSTAGED by this add.
|
|
1300
|
+
against_head = set(git_strict(
|
|
1301
|
+
["diff", "--name-only", head_base(call), "--"] + spec, call,
|
|
1302
|
+
).splitlines())
|
|
1303
|
+
if not update:
|
|
1304
|
+
against_head |= set(git_strict(
|
|
1305
|
+
["ls-files", "--others", "--exclude-standard", "--full-name", "--"] + spec,
|
|
1306
|
+
call,
|
|
1307
|
+
).splitlines())
|
|
1308
|
+
against_index = set(git_strict(["diff", "--name-only", "--"] + spec, call).splitlines())
|
|
1309
|
+
state = index_state(call)
|
|
1310
|
+
state.may |= against_head | against_index
|
|
1311
|
+
if partial:
|
|
1312
|
+
# `add -i` can revert, and an unknown option may do anything: what an
|
|
1313
|
+
# earlier add certainly staged no longer holds.
|
|
1314
|
+
state.distrust()
|
|
1315
|
+
state.adds.append((call.end, set() if partial or state.head_moved else against_head))
|
|
1316
|
+
# Index entries this add replaces: the pre-line reading of them no longer
|
|
1317
|
+
# holds (their new state is in `adds`, when the add certainly ran).
|
|
1318
|
+
state.replaced |= set(git_strict(
|
|
1319
|
+
["diff", "--cached", "--name-only", "--"] + spec, call,
|
|
1320
|
+
).splitlines())
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def note_unknown_write():
|
|
1324
|
+
"""A write to a repository the guard cannot resolve: distrust them all."""
|
|
1325
|
+
unknown_repo_writes[0] = True
|
|
1326
|
+
for state in index_states.values():
|
|
1327
|
+
state.distrust()
|
|
1328
|
+
state.head_moved = True
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def moves_head(call):
|
|
1332
|
+
"""Whether this index writer may move HEAD. `git reset [HEAD] [-- paths]`,
|
|
1333
|
+
`git checkout -- paths`, restore, rm, mv, clean, stash, apply and the
|
|
1334
|
+
plumbing writers leave it; anything else is assumed to move it."""
|
|
1335
|
+
if call.sub in ("commit", "merge", "rebase", "pull", "cherry-pick", "revert", "am", "switch"):
|
|
1336
|
+
return True
|
|
1337
|
+
if call.sub in ("reset", "checkout"):
|
|
1338
|
+
if call.sub == "reset" and any(a.split("=")[0] in ("--soft", "--hard", "--merge", "--keep") for a in call.args):
|
|
1339
|
+
return True
|
|
1340
|
+
positional = []
|
|
1341
|
+
for arg in call.args:
|
|
1342
|
+
if arg == "--":
|
|
1343
|
+
break
|
|
1344
|
+
if not arg.startswith("-"):
|
|
1345
|
+
positional.append(arg)
|
|
1346
|
+
return any(p != "HEAD" for p in positional)
|
|
1347
|
+
return False
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
GLOB = re.compile(r"[*?\[]")
|
|
1351
|
+
# Writers that bring in other commits' changes; which files is not read here.
|
|
1352
|
+
APPLIERS = {"read-tree", "update-index", "apply", "am", "cherry-pick", "revert", "merge", "rebase", "pull"}
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def split_args(args, value_opts):
|
|
1356
|
+
"""(options, operands before `--`, operands after `--`)."""
|
|
1357
|
+
opts, pos, after, k = [], [], None, 0
|
|
1358
|
+
while k < len(args):
|
|
1359
|
+
arg = args[k]
|
|
1360
|
+
k += 1
|
|
1361
|
+
if after is not None:
|
|
1362
|
+
after.append(arg)
|
|
1363
|
+
elif arg == "--":
|
|
1364
|
+
after = []
|
|
1365
|
+
elif arg.startswith("-") and len(arg) > 1:
|
|
1366
|
+
opts.append(arg)
|
|
1367
|
+
k += arg in value_opts
|
|
1368
|
+
else:
|
|
1369
|
+
pos.append(arg)
|
|
1370
|
+
return opts, pos, after or []
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def revision(word, call):
|
|
1374
|
+
"""True if `word` names a commit in `call`'s repository."""
|
|
1375
|
+
if call is None:
|
|
1376
|
+
return True
|
|
1377
|
+
result = git_run(["rev-parse", "--verify", "--quiet", word + "^{commit}"], call)
|
|
1378
|
+
return result is not None and result.returncode == 0
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def changed_from(source, paths, call):
|
|
1382
|
+
"""The paths taking `paths` from `source` may change against HEAD, or
|
|
1383
|
+
None where that is unknown (no repository, or git failed)."""
|
|
1384
|
+
if call is None:
|
|
1385
|
+
return None
|
|
1386
|
+
result = git_run(["diff", "--name-only", source, head_base(call), "--"] + paths, call)
|
|
1387
|
+
if result is None or result.returncode != 0:
|
|
1388
|
+
return None
|
|
1389
|
+
return result.stdout.splitlines()
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def changes_in(found):
|
|
1393
|
+
"""The paired changes whose `changes` side a path in `found` ends with
|
|
1394
|
+
(every one when `found` is None: unknown)."""
|
|
1395
|
+
if found is None:
|
|
1396
|
+
return list(PAIRED)
|
|
1397
|
+
return [pair for pair in PAIRED if any(p.endswith(pair.changes) for p in found)]
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def differs_in_changes(source, paths, call):
|
|
1401
|
+
"""The paired changes whose `changes` side taking `paths` from `source`
|
|
1402
|
+
may change against HEAD (every one where the repository is unknown)."""
|
|
1403
|
+
return changes_in(changed_from(source, paths, call))
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def names_changes(paths, call):
|
|
1407
|
+
"""The paired changes whose tracked `changes` side pathspecs `paths` may
|
|
1408
|
+
cover (every one for a glob, pathspec magic or an unknown repository)."""
|
|
1409
|
+
if any(GLOB.search(p) or p.startswith(":") for p in paths) or call is None:
|
|
1410
|
+
return list(PAIRED)
|
|
1411
|
+
named = [pair for pair in PAIRED if any(pair.changes in p for p in paths)]
|
|
1412
|
+
if len(named) == len(PAIRED):
|
|
1413
|
+
return named
|
|
1414
|
+
result = git_run(["ls-files", "--"] + paths, call)
|
|
1415
|
+
if result is None or result.returncode != 0:
|
|
1416
|
+
return list(PAIRED)
|
|
1417
|
+
tracked = changes_in(result.stdout.splitlines())
|
|
1418
|
+
return [pair for pair in PAIRED if pair in named or pair in tracked]
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def may_stage_changes(call, known=True):
|
|
1422
|
+
"""The paired changes whose `changes` side index writer `call` may leave
|
|
1423
|
+
staged where the pre-line index does not show it (`git restore
|
|
1424
|
+
--source=HEAD~1 --staged -- schema.sql`). Unstaging, stashing away and
|
|
1425
|
+
branch creation cannot, so ordinary `git reset && git add … && git
|
|
1426
|
+
commit` stays allowed."""
|
|
1427
|
+
repo = call if known else None
|
|
1428
|
+
sub = call.sub
|
|
1429
|
+
if sub == "clean":
|
|
1430
|
+
return []
|
|
1431
|
+
if sub in APPLIERS:
|
|
1432
|
+
return list(PAIRED)
|
|
1433
|
+
if sub == "stash":
|
|
1434
|
+
_, pos, _ = split_args(call.args, {"-m", "--message"})
|
|
1435
|
+
return list(PAIRED) if pos and pos[0] in ("apply", "pop", "branch") else []
|
|
1436
|
+
if sub in ("rm", "mv"):
|
|
1437
|
+
_, pos, after = split_args(call.args, set())
|
|
1438
|
+
return names_changes(pos + after, repo)
|
|
1439
|
+
if sub == "restore":
|
|
1440
|
+
_, pos, after = split_args(call.args, {"-s", "--source"})
|
|
1441
|
+
source = None
|
|
1442
|
+
for k, arg in enumerate(call.args):
|
|
1443
|
+
if arg == "--":
|
|
1444
|
+
break
|
|
1445
|
+
if arg in ("-s", "--source"):
|
|
1446
|
+
source = call.args[k + 1] if k + 1 < len(call.args) else None
|
|
1447
|
+
elif arg.startswith("--source="):
|
|
1448
|
+
source = arg.split("=", 1)[1]
|
|
1449
|
+
elif arg.startswith("-s") and not arg.startswith("--"):
|
|
1450
|
+
source = arg[2:]
|
|
1451
|
+
if source is not None:
|
|
1452
|
+
return differs_in_changes(source, pos + after, repo)
|
|
1453
|
+
# Without a source, --staged copies HEAD to the index (an unstage),
|
|
1454
|
+
# but the work-tree default copies the index out, which a later
|
|
1455
|
+
# `commit -a` records: count it when it may cover a `changes` path.
|
|
1456
|
+
def short(flag):
|
|
1457
|
+
return any(re.fullmatch(r"-[a-zA-Z]+", o) and flag in o for o in call.args)
|
|
1458
|
+
|
|
1459
|
+
staged_only = ("--staged" in call.args or short("S")) and not (
|
|
1460
|
+
"--worktree" in call.args or short("W")
|
|
1461
|
+
)
|
|
1462
|
+
return [] if staged_only else names_changes(pos + after, repo)
|
|
1463
|
+
if sub in ("reset", "checkout", "switch"):
|
|
1464
|
+
opts, pos, after = split_args(call.args, {"-b", "-B", "-c", "-C", "--orphan"})
|
|
1465
|
+
if sub == "switch" or any(o in ("-b", "-B", "--orphan") for o in opts) and not after:
|
|
1466
|
+
return []
|
|
1467
|
+
pos = [p for p in pos if not (sub == "reset" and p == "HEAD")]
|
|
1468
|
+
if not pos:
|
|
1469
|
+
# `reset [HEAD] [-- paths]` unstages; `checkout -- paths` copies
|
|
1470
|
+
# the index to the work tree, which a later `commit -a` records.
|
|
1471
|
+
return names_changes(after, repo) if sub == "checkout" else []
|
|
1472
|
+
if not revision(pos[0], repo):
|
|
1473
|
+
return [] if sub == "reset" else names_changes(pos + after, repo)
|
|
1474
|
+
if sub == "checkout" and len(pos) == 1 and not after:
|
|
1475
|
+
return [] # a branch switch: the index follows the new HEAD
|
|
1476
|
+
return differs_in_changes(pos[0], pos[1:] + after, repo)
|
|
1477
|
+
return list(PAIRED)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def note_index_write(call):
|
|
1481
|
+
if isinstance(call.cwd, Unresolved):
|
|
1482
|
+
unknown_repo_stages.update(pair.changes for pair in may_stage_changes(call, known=False))
|
|
1483
|
+
return note_unknown_write()
|
|
1484
|
+
state = index_state(call)
|
|
1485
|
+
state.distrust()
|
|
1486
|
+
if moves_head(call):
|
|
1487
|
+
note_head_move(call)
|
|
1488
|
+
for pair in may_stage_changes(call):
|
|
1489
|
+
# A marker, not a path: it starts with "(" and ends with `changes`.
|
|
1490
|
+
state.may.add(f"(staged by git {call.sub}) {pair.changes}")
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def committed_paths(call):
|
|
1494
|
+
"""(may_side, certain_side): files `call` (a `git commit`) may record,
|
|
1495
|
+
and files it certainly records, read from ITS repository."""
|
|
1496
|
+
state = index_state(call)
|
|
1497
|
+
for changes in sorted(unknown_repo_stages):
|
|
1498
|
+
state.may.add(f"(staged by git in an unresolved repository) {changes}")
|
|
1499
|
+
stage_all, include, paths, from_file, only, interactive, _ = parse_commit_args(call.args)
|
|
1500
|
+
if only and not paths and not from_file:
|
|
1501
|
+
# `git commit --only --amend` (or `--only --allow-empty`) with no paths
|
|
1502
|
+
# records none of the index — git documents it as message-only.
|
|
1503
|
+
return [], []
|
|
1504
|
+
if from_file:
|
|
1505
|
+
block(
|
|
1506
|
+
"BLOCKED: `git commit --pathspec-from-file` hides which paths it "
|
|
1507
|
+
"commits, so the paired-change check cannot see them. Pass the "
|
|
1508
|
+
"paths on the command line instead."
|
|
1509
|
+
)
|
|
1510
|
+
for path in paths:
|
|
1511
|
+
if VALUE_UNRESOLVABLE.search(path) or path.startswith("~"):
|
|
1512
|
+
block(
|
|
1513
|
+
f"BLOCKED: cannot statically resolve the commit pathspec '{path}', "
|
|
1514
|
+
"so the paired-change check cannot see what it commits. Pass "
|
|
1515
|
+
"literal paths."
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
def names(extra):
|
|
1519
|
+
return git_strict(["diff", "--name-only"] + extra, call).splitlines()
|
|
1520
|
+
|
|
1521
|
+
def certain(found):
|
|
1522
|
+
# Read before the line runs: only trusted while nothing else wrote,
|
|
1523
|
+
# and never for an entry an earlier add in the line replaced.
|
|
1524
|
+
return [p for p in found if p not in state.replaced] if state.trusted else []
|
|
1525
|
+
|
|
1526
|
+
if interactive:
|
|
1527
|
+
# `--patch` / `--interactive` stage from the work tree while the commit
|
|
1528
|
+
# runs: any tracked change (and, through --interactive, any untracked
|
|
1529
|
+
# file) may be recorded. Which hunks is chosen at the prompt, and
|
|
1530
|
+
# --interactive can even unstage, so no required path is certain.
|
|
1531
|
+
untracked = git_strict(
|
|
1532
|
+
["ls-files", "--others", "--exclude-standard", "--full-name", ":/"], call,
|
|
1533
|
+
).splitlines()
|
|
1534
|
+
return names(["--cached"]) + names([]) + untracked + sorted(state.may), []
|
|
1535
|
+
|
|
1536
|
+
in_paths = []
|
|
1537
|
+
if paths:
|
|
1538
|
+
# A pathspec commits those paths' WORKING-TREE content, so compare that
|
|
1539
|
+
# to HEAD: a required path staged but restored in the work tree is
|
|
1540
|
+
# discarded, and must not count.
|
|
1541
|
+
in_paths = names([head_base(call), "--"] + paths)
|
|
1542
|
+
if not include:
|
|
1543
|
+
# Without --include, ONLY those paths are committed; the index
|
|
1544
|
+
# (and anything an earlier add put in it) stays behind.
|
|
1545
|
+
# An earlier writer that may change a `changes` path (marked,
|
|
1546
|
+
# not named by path) can reach these paths too.
|
|
1547
|
+
marked = [p for p in state.may if p.startswith("(")]
|
|
1548
|
+
return in_paths + marked, certain(in_paths)
|
|
1549
|
+
# With --include, the rest of the index is committed too.
|
|
1550
|
+
excluded = [f":(exclude){p}" for p in paths]
|
|
1551
|
+
staged = names(["--cached", "--", ":/"] + excluded)
|
|
1552
|
+
elif stage_all:
|
|
1553
|
+
# `git commit -a` records tracked files' WORK-TREE content, so compare
|
|
1554
|
+
# that to HEAD (a staged-then-restored required path is not committed).
|
|
1555
|
+
staged = names([head_base(call)])
|
|
1556
|
+
else:
|
|
1557
|
+
staged = names(["--cached"])
|
|
1558
|
+
found = staged + in_paths
|
|
1559
|
+
added = [p for end, must in state.adds if runs_before(end, call.pos) for p in must]
|
|
1560
|
+
return found + sorted(state.may), certain(found) + added
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
# Judge the words bash will pass: $'...' decoded, so `git commit $'-a'` is -a
|
|
1564
|
+
# and `git $'\x63ommit'` is a commit, for every check below.
|
|
1565
|
+
tokens = tokenize(plain_quoting(cmd_raw))
|
|
1566
|
+
if tokens is None:
|
|
1567
|
+
# Unparseable (unbalanced quotes). Fall back to the conservative regex scan
|
|
1568
|
+
# rather than silently allowing the push through.
|
|
1569
|
+
for push in re.finditer(r"git\s+push[^;&|\n]*", cmd):
|
|
1570
|
+
if re.search(r"(--force|--force-with-lease|\s-[a-zA-Z]*f[a-zA-Z]*(\s|$)|\s\+\S)", push.group(0)):
|
|
1571
|
+
block("BLOCKED: force push detected in an unparseable command.\n" + FORCE_REMEDY)
|
|
1572
|
+
# The paired-change check needs the repository the commit targets, which
|
|
1573
|
+
# only the tokens can give; refuse rather than read the wrong index.
|
|
1574
|
+
if PAIRED and re.search(r"git\b[^;&|\n]*\scommit\b", cmd):
|
|
1575
|
+
block(
|
|
1576
|
+
"BLOCKED: this git commit could not be parsed (unbalanced quotes), so "
|
|
1577
|
+
"the guard cannot tell which repository it commits to.\n"
|
|
1578
|
+
+ AMBIGUOUS_REMEDY
|
|
1579
|
+
)
|
|
1580
|
+
sys.exit(0)
|
|
1581
|
+
|
|
1582
|
+
# Quoting must not launder --no-verify or an abbreviation of it: `'--no-veri'`
|
|
1583
|
+
# survives the quote-stripped text check above as nothing at all, but git sees
|
|
1584
|
+
# the unquoted word. Checked on the arguments of each git invocation, skipping
|
|
1585
|
+
# message values (`-m '--no-verify'` is text, not a flag); any other value
|
|
1586
|
+
# that happens to spell the flag is refused too, which costs nothing.
|
|
1587
|
+
MESSAGE_OPTS = {"-m", "-F", "--message", "--file"}
|
|
1588
|
+
for i, tok in enumerate(tokens):
|
|
1589
|
+
if not is_git(tok):
|
|
1590
|
+
continue
|
|
1591
|
+
skip = False
|
|
1592
|
+
for word in tokens[i + 1:]:
|
|
1593
|
+
if word in SEPARATORS or word == "--":
|
|
1594
|
+
break
|
|
1595
|
+
if skip:
|
|
1596
|
+
skip = False
|
|
1597
|
+
continue
|
|
1598
|
+
base = word.split("=", 1)[0]
|
|
1599
|
+
if len(base) >= len("--no-ve") and "--no-verify".startswith(base):
|
|
1600
|
+
block(NO_VERIFY)
|
|
1601
|
+
long_opt = commit_long(word) if word.startswith("--") and "=" not in word else None
|
|
1602
|
+
skip = (
|
|
1603
|
+
word in MESSAGE_OPTS
|
|
1604
|
+
or long_opt in ("--message", "--file")
|
|
1605
|
+
or bool(re.fullmatch(r"-[a-zA-Z]*[mF]", word))
|
|
1606
|
+
)
|
|
1607
|
+
|
|
1608
|
+
remotes_cache = {}
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def known_remotes(call):
|
|
1612
|
+
key = (call.cwd or "", call.git_dir or "", tuple(sorted(call.env.items())))
|
|
1613
|
+
if key not in remotes_cache:
|
|
1614
|
+
remotes_cache[key] = set(git_out(["remote"], call).split())
|
|
1615
|
+
return remotes_cache[key]
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
# With no paired changes nothing reads a commit's repository, so a commit in
|
|
1619
|
+
# one this guard cannot resolve is soft too: only its options are judged.
|
|
1620
|
+
calls = list(iter_git_calls(
|
|
1621
|
+
tokens, {"push"} | STAGERS | INDEX_WRITERS,
|
|
1622
|
+
soft=STAGERS | INDEX_WRITERS - ({"commit"} if PAIRED else set()),
|
|
1623
|
+
))
|
|
1624
|
+
last_commit = max((n for n, c in enumerate(calls) if c.sub == "commit"), default=-1)
|
|
1625
|
+
for n, call in enumerate(calls):
|
|
1626
|
+
if call.sub in STAGERS | INDEX_WRITERS - {"commit"}:
|
|
1627
|
+
# Only matters to a commit later in this line; otherwise leave it be.
|
|
1628
|
+
if PAIRED and n < last_commit:
|
|
1629
|
+
if call.sub in STAGERS:
|
|
1630
|
+
note_add(call)
|
|
1631
|
+
else:
|
|
1632
|
+
note_index_write(call)
|
|
1633
|
+
continue
|
|
1634
|
+
if call.sub == "push":
|
|
1635
|
+
check_config_overrides(call.overrides)
|
|
1636
|
+
positionals = check_push_args(call.args)
|
|
1637
|
+
# A configured `+` push refspec force-updates even a bare `git push`.
|
|
1638
|
+
# Read it from the repo the push actually targets, not the hook's cwd.
|
|
1639
|
+
remotes = known_remotes(call)
|
|
1640
|
+
target = next((p for p in positionals if p in remotes), None)
|
|
1641
|
+
for remote in ([target] if target else list(remotes) or ["origin"]):
|
|
1642
|
+
if config_force_refspec(remote, call):
|
|
1643
|
+
block(
|
|
1644
|
+
f"BLOCKED: remote.{remote}.push is configured with a forcing "
|
|
1645
|
+
"'+' refspec, so even a plain `git push` rewrites the remote.\n"
|
|
1646
|
+
+ FORCE_REMEDY
|
|
1647
|
+
)
|
|
1648
|
+
continue
|
|
1649
|
+
if parse_commit_args(call.args)[6]:
|
|
1650
|
+
# `git commit --dry-run` records nothing and leaves the index alone.
|
|
1651
|
+
continue
|
|
1652
|
+
if not PAIRED:
|
|
1653
|
+
# parse_commit_args above has refused `-n`; nothing else reads a commit.
|
|
1654
|
+
continue
|
|
1655
|
+
# Paired changes (config `pairedChanges`): a commit that may record a path
|
|
1656
|
+
# ending in `changes` must also certainly record a path containing
|
|
1657
|
+
# `requires`. Read the index of the repository the commit runs in, not the
|
|
1658
|
+
# hook's: a subagent in its own worktree could otherwise commit the one
|
|
1659
|
+
# without the other past this check. This is the blocking half; the
|
|
1660
|
+
# consumer should keep its own CI check behind it, since a static reading
|
|
1661
|
+
# of one line cannot see every way a file changes.
|
|
1662
|
+
may_side, certain_side = committed_paths(call)
|
|
1663
|
+
# This commit consumes the index: a later commit in the line cannot trust it.
|
|
1664
|
+
index_state(call).distrust()
|
|
1665
|
+
note_head_move(call)
|
|
1666
|
+
for pair in PAIRED:
|
|
1667
|
+
if any(p.endswith(pair.changes) for p in may_side) and not any(
|
|
1668
|
+
pair.requires in p for p in certain_side
|
|
1669
|
+
):
|
|
1670
|
+
block(paired_refusal(pair))
|
|
1671
|
+
|
|
1672
|
+
sys.exit(0)
|