@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.
@@ -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)