@brainervirus/workit-core 8.12.2 → 8.12.3

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.
@@ -26,12 +26,11 @@ import os from "node:os";
26
26
  import path from "node:path";
27
27
  import { releaseBranch, resolveBranchPolicyFor, validateBranchNameFor } from "../core/branch";
28
28
  import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
29
- import { pushRemoteName } from "../git/rev";
30
29
  import { appendHookObserved, readLedger } from "../ledger";
31
30
  import { resolveStore, resolveTaskKey, type StoreLocation } from "../store/paths";
32
31
  import type { HookDecision, HookInput } from "./protocol";
33
32
  import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
34
- import { segmentsOf, type ShellDialect } from "./shell-words";
33
+ import { segmentsOf, ShellAnalysisError, type Segment, type ShellDialect } from "./shell-words";
35
34
 
36
35
  const NONE: HookDecision = { kind: "none" };
37
36
 
@@ -43,10 +42,53 @@ export type RawInvocation = {
43
42
  tool: "git" | "gh" | "glab";
44
43
  dir: string | null | undefined;
45
44
  args: string[];
45
+ /** git's global options that change its configuration (`-c k=v`,
46
+ * `--config-env`, `--git-dir`): replayed when its config is read. */
47
+ config?: string[];
48
+ /** Assignments before the command that change git's configuration
49
+ * (`GIT_CONFIG_*`, `GIT_DIR`): replayed the same way. */
50
+ env?: Record<string, string>;
51
+ /** Its standard input when the command line spells it out: a heredoc, a
52
+ * here-string or a literal `echo …|`/`printf …|`. */
53
+ stdin?: string;
46
54
  };
47
55
 
48
- const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh"]);
49
- const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "nice", "&"]);
56
+ const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "yash", "fish"]);
57
+ const POWERSHELLS = new Set(["pwsh", "powershell"]);
58
+ const EVALS = new Set(["eval", "iex", "invoke-expression"]);
59
+ /**
60
+ * Wrappers that run the command after their options: the options that take
61
+ * a value, and how many operands come before the command (`timeout 30`).
62
+ */
63
+ const WRAPPERS: Record<string, { values: string; operands?: number }> = {
64
+ sudo: { values: "-u -g -h -p -C -D -R -T -r -t -U --user --group --host --prompt --chdir" },
65
+ doas: { values: "-u -C" },
66
+ command: { values: "" },
67
+ builtin: { values: "" },
68
+ env: { values: "-u -C --unset --chdir" },
69
+ nohup: { values: "" },
70
+ setsid: { values: "" },
71
+ time: { values: "-f -o --format --output" },
72
+ exec: { values: "-a" },
73
+ nice: { values: "-n --adjustment" },
74
+ ionice: { values: "-c -n -p --class --classdata" },
75
+ stdbuf: { values: "-i -o -e --input --output --error" },
76
+ chronic: { values: "" },
77
+ unbuffer: { values: "" },
78
+ wsl: { values: "-d -u --cd --distribution --user" },
79
+ timeout: { values: "-s -k --signal --kill-after", operands: 1 },
80
+ flock: { values: "-w -E --timeout --conflict-exit-code", operands: 1 },
81
+ coproc: { values: "" },
82
+ taskset: { values: "", operands: 1 },
83
+ watch: { values: "-n -d --interval --differences -q --equexit" },
84
+ "&": { values: "" },
85
+ };
86
+ const WRAPPER_VALUES = new Map(
87
+ Object.entries(WRAPPERS).map(([name, wrapper]) => [name, new Set(wrapper.values.split(" "))]),
88
+ );
89
+ /** Shell keywords that open a compound command: the command follows them. */
90
+ const KEYWORDS = new Set(["if", "then", "else", "elif", "while", "until", "do", "!", "{"]);
91
+ const DECLARATIONS = new Set(["export", "local", "declare", "readonly", "typeset"]);
50
92
  const SCRIPT_FLAG = /^-[a-zA-Z]*c[a-zA-Z]*$/;
51
93
  const CD = new Set(["cd", "chdir", "set-location", "sl"]);
52
94
  const PUSHD = new Set(["pushd", "push-location"]);
@@ -61,19 +103,37 @@ const GIT_VALUE_OPTIONS = new Set([
61
103
  "--config-env",
62
104
  "--super-prefix",
63
105
  ]);
106
+ /** git global options that change which configuration it reads. */
107
+ const GIT_CONFIG_OPTIONS = new Set(["-c", "--config-env", "--git-dir"]);
108
+ /** Environment that changes which configuration git reads; nothing that runs code. */
109
+ const GIT_CONFIG_ENV = /^GIT_(?:CONFIG\w*|DIR|COMMON_DIR|WORK_TREE)$/;
110
+ const ASSIGNMENT = /^([A-Za-z_]\w*)=([\s\S]*)$/;
111
+ /** Nested scripts (`bash -c`, `eval`, aliases) are followed this deep. */
112
+ const MAX_DEPTH = 4;
113
+ /** One word expands to at most this many (loop lists, brace lists). */
114
+ const MAX_EXPANSIONS = 16;
64
115
 
65
116
  type Dir = string | null | undefined;
66
117
 
67
118
  const isAbsolute = (value: string) =>
68
119
  path.isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
69
120
 
70
- const joinDir = (base: Dir, next: string): Dir => {
121
+ /** `$PWD`, `$(pwd)`, `$(git rev-parse --show-toplevel)`: the directory the shell is in. */
122
+ const HERE =
123
+ /^(?:\$PWD|\$\{PWD\}|\$\(\s*pwd\s*\)|`pwd`|\$\(\s*git\s+rev-parse\s+--show-toplevel\s*\))(?=$|[\\/])/;
124
+
125
+ const joinDir = (base: Dir, raw: string): Dir => {
126
+ const next = raw.replace(HERE, ".");
71
127
  if (base === undefined || /[$`*?]/.test(next) || next === "-") return undefined;
72
128
  const expanded =
73
129
  next === "~" || next.startsWith("~/") ? path.join(os.homedir(), next.slice(1)) : next;
74
130
  return base === null || isAbsolute(expanded) ? expanded : path.join(base, expanded);
75
131
  };
76
132
 
133
+ /** An inner script's directory, relative to the one it was started in. */
134
+ const rebase = (base: Dir, inner: Dir): Dir =>
135
+ inner === null ? base : inner === undefined ? undefined : joinDir(base, inner);
136
+
77
137
  /** The directory operand of `cd`/`Set-Location` (`-Path x`, `-LiteralPath x`). */
78
138
  const cdTarget = (words: string[]): string | null => {
79
139
  const args = words.slice(1);
@@ -82,98 +142,504 @@ const cdTarget = (words: string[]): string | null => {
82
142
  return args.find((word) => word === "-" || !word.startsWith("-")) ?? null;
83
143
  };
84
144
 
85
- /** Drop env assignments, wrappers (`sudo`, `env -i`, `timeout 30`) and their options. */
86
- const stripPrefixes = (input: string[]): string[] => {
145
+ /**
146
+ * Drop env assignments, compound-command keywords (`if`, `do`, `{`) and
147
+ * wrappers (`sudo`, `env -i`, `timeout 30`) with their options. The
148
+ * assignments that change git's configuration are kept aside.
149
+ */
150
+ const stripPrefixes = (input: string[]): { words: string[]; env: Record<string, string> } => {
87
151
  let words = input;
152
+ const env: Record<string, string> = {};
88
153
  for (;;) {
89
154
  const first = words[0];
90
- if (first === undefined) return words;
91
- if (/^\w+=/.test(first)) words = words.slice(1);
92
- else if (PREFIXES.has(first)) {
155
+ if (first === undefined) return { words, env };
156
+ const assignment = ASSIGNMENT.exec(first);
157
+ if (assignment) {
158
+ if (GIT_CONFIG_ENV.test(assignment[1])) env[assignment[1]] = assignment[2];
93
159
  words = words.slice(1);
94
- while (words[0]?.startsWith("-")) words = words.slice(words[0] === "-u" ? 2 : 1);
95
- } else if (first === "timeout") {
160
+ } else if (KEYWORDS.has(first)) words = words.slice(1);
161
+ // `function f { …` defines f to run its body: the body is read where it stands.
162
+ else if (first === "function") words = words.slice(2);
163
+ else if (Object.hasOwn(WRAPPERS, commandName(first))) {
164
+ const name = commandName(first);
165
+ const values = WRAPPER_VALUES.get(name)!;
96
166
  words = words.slice(1);
97
- while (words[0]?.startsWith("-")) words = words.slice(1);
98
- words = words.slice(1);
99
- } else return words;
167
+ // A short cluster takes a value when its last option does (`sudo -iu root`).
168
+ const takesValue = (option: string) =>
169
+ values.has(option) || (/^-[a-zA-Z]{2,}$/.test(option) && values.has(`-${option.at(-1)}`));
170
+ while (words[0]?.startsWith("-") && words[0] !== "--")
171
+ words = words.slice(takesValue(words[0]) ? 2 : 1);
172
+ if (words[0] === "--") words = words.slice(1);
173
+ words = words.slice(WRAPPERS[name].operands ?? 0);
174
+ } else return { words, env };
100
175
  }
101
176
  };
102
177
 
103
178
  const commandName = (word: string | undefined) =>
104
179
  (word?.split(/[\\/]/).at(-1) ?? "").toLowerCase().replace(/\.exe$/, "");
105
180
 
181
+ const VARIABLE = /\$(?:\{(\w+)\}|(\w+))/g;
182
+
183
+ /** One word with the known variables substituted (a loop list gives several). */
184
+ const expandWord = (word: string, vars: ReadonlyMap<string, string[]>): string[] => {
185
+ if (!word.includes("$")) return [word];
186
+ let results = [""];
187
+ let last = 0;
188
+ for (const match of word.matchAll(VARIABLE)) {
189
+ const values = vars.get(match[1] ?? match[2]) ?? [match[0]];
190
+ const before = word.slice(last, match.index);
191
+ results = results
192
+ .flatMap((result) => values.map((value) => result + before + value))
193
+ .slice(0, MAX_EXPANSIONS);
194
+ last = match.index + match[0].length;
195
+ }
196
+ return results.map((result) => result + word.slice(last));
197
+ };
198
+
199
+ /** Every command line a segment stands for once its known variables are substituted. */
200
+ const substitute = (words: string[], vars: ReadonlyMap<string, string[]>): string[][] => {
201
+ if (vars.size === 0) return [words];
202
+ let lines: string[][] = [[]];
203
+ for (const word of words) {
204
+ const values = expandWord(word, vars);
205
+ lines = lines
206
+ .flatMap((line) => values.map((value) => [...line, value]))
207
+ .slice(0, MAX_EXPANSIONS);
208
+ }
209
+ return lines;
210
+ };
211
+
212
+ /**
213
+ * A segment that only assigns (`B=x`, `export B=x`) or opens a `for` loop
214
+ * over literal words: the variables it sets. Null for any other segment.
215
+ */
216
+ const assignments = (
217
+ words: string[],
218
+ vars: ReadonlyMap<string, string[]>,
219
+ ): Array<[string, string[]]> | null => {
220
+ if (words[0] === "for" && words[2] === "in" && /^\w+$/.test(words[1] ?? "")) {
221
+ const values = words.slice(3).flatMap((word) => expandWord(word, vars));
222
+ return [[words[1], values.slice(0, MAX_EXPANSIONS)]];
223
+ }
224
+ let rest = words;
225
+ if (DECLARATIONS.has(rest[0] ?? "")) {
226
+ rest = rest.slice(1);
227
+ while (rest[0]?.startsWith("-")) rest = rest.slice(1);
228
+ }
229
+ if (rest.length === 0 || !rest.every((word) => ASSIGNMENT.test(word))) return null;
230
+ return rest.map((word) => {
231
+ const [, name, value] = ASSIGNMENT.exec(word)!;
232
+ return [name, expandWord(value, vars)];
233
+ });
234
+ };
235
+
236
+ /** What a literal producer piped into the next command writes (`echo x |`, `printf 'x\n' |`). */
237
+ const literalOutput = (segment: Segment | undefined): string | undefined => {
238
+ if (!segment?.pipe) return undefined;
239
+ const { words } = stripPrefixes(segment.words);
240
+ const name = commandName(words[0]);
241
+ if (name === "echo") {
242
+ let args = words.slice(1);
243
+ while (/^-[neE]+$/.test(args[0] ?? "")) args = args.slice(1);
244
+ return args.join(" ");
245
+ }
246
+ if (name === "printf") {
247
+ const [format = "", ...args] = words.slice(1);
248
+ return (args.length ? args.join("\n") : format).replace(/\\n/g, "\n");
249
+ }
250
+ if (name === "cat" && words.length === 1 && segment.stdin) return segment.stdin.join("\n");
251
+ return undefined;
252
+ };
253
+
254
+ const stdinOf = (segment: Segment, previous: Segment | undefined): string | undefined =>
255
+ segment.stdin ? segment.stdin.join("\n") : literalOutput(previous);
256
+
257
+ /** xargs options that take a value (`-n 1`, `-d '\n'`). */
258
+ const XARGS_VALUES = new Set(
259
+ ["-n", "-L", "-l", "-P", "-d", "-E", "-e", "-s", "-a", "--arg-file", "--delimiter"].concat([
260
+ "--max-args",
261
+ "--max-lines",
262
+ "--max-procs",
263
+ "--eof",
264
+ "--max-chars",
265
+ "--process-slot-var",
266
+ ]),
267
+ );
268
+
269
+ /**
270
+ * The commands `xargs …` runs: its command with the input appended, or
271
+ * with `-I R` replaced per input line. Input that is not spelled out on
272
+ * the command line adds nothing, like a `$VAR` name it stays unjudged.
273
+ */
274
+ const xargsCommands = (words: string[], input: string | undefined): string[][] => {
275
+ let replace: string | null = null;
276
+ let index = 1;
277
+ for (; index < words.length; index++) {
278
+ const word = words[index];
279
+ if (word === "--") {
280
+ index++;
281
+ break;
282
+ }
283
+ if (!word.startsWith("-")) break;
284
+ if (word === "-I") replace = words[++index] ?? "{}";
285
+ else if (word === "--replace" || word === "-i") replace = "{}";
286
+ else if (word.startsWith("--replace=")) replace = word.slice("--replace=".length);
287
+ else if (/^-[Ii]./.test(word)) replace = word.slice(2);
288
+ else if (XARGS_VALUES.has(word)) index++;
289
+ }
290
+ const command = words.slice(index);
291
+ if (command.length === 0) return [];
292
+ if (input === undefined) return [command];
293
+ if (replace === null) return [[...command, ...input.split(/\s+/).filter(Boolean)]];
294
+ const token = replace;
295
+ return input
296
+ .split("\n")
297
+ .map((line) => line.trim())
298
+ .filter(Boolean)
299
+ .slice(0, MAX_EXPANSIONS)
300
+ .map((line) => command.map((word) => word.replaceAll(token, line)));
301
+ };
302
+
303
+ /** The commands `find … -exec cmd … ;` runs. */
304
+ const findCommands = (words: string[]): string[][] => {
305
+ const found: string[][] = [];
306
+ for (let index = 1; index < words.length; index++) {
307
+ if (!/^-(?:exec|execdir|ok|okdir)$/.test(words[index])) continue;
308
+ const end = words.findIndex((word, at) => at > index && (word === ";" || word === "+"));
309
+ found.push(words.slice(index + 1, end < 0 ? undefined : end));
310
+ if (end < 0) break;
311
+ index = end;
312
+ }
313
+ return found;
314
+ };
315
+
316
+ /** PowerShell parameters that take a value; any other is a switch. */
317
+ const POWERSHELL_VALUES =
318
+ /^-(?:f|file|ex|executionpolicy|wd|workingdirectory|config|configurationname|o|outputformat|if|inputformat|inp|w|windowstyle|settingsfile|custompipename|psconsolefile)$/;
319
+
320
+ /**
321
+ * The script `pwsh`/`powershell` runs: `-Command …` (the rest of the line),
322
+ * `-CommandWithArgs`, or `-EncodedCommand` (base64 UTF-16LE). Parameter
323
+ * names match case-insensitively and by unambiguous prefix. Null for a
324
+ * script file or an interactive shell.
325
+ */
326
+ const powershellScript = (words: string[], stdin?: string): string | null => {
327
+ for (let index = 1; index < words.length; index++) {
328
+ const word = words[index];
329
+ if (!/^[-/]/.test(word)) return null;
330
+ const flag = word.replace(/^[-/]+/, "").toLowerCase();
331
+ if (!flag) return null;
332
+ if (flag === "e" || flag === "ec" || (flag.length >= 2 && "encodedcommand".startsWith(flag))) {
333
+ try {
334
+ return Buffer.from(words[index + 1] ?? "", "base64").toString("utf16le");
335
+ } catch {
336
+ return null;
337
+ }
338
+ }
339
+ if (flag === "cwa" || (flag.length >= 8 && "commandwithargs".startsWith(flag)))
340
+ return words[index + 1] ?? null;
341
+ if ("command".startsWith(flag))
342
+ return words[index + 1] === "-" ? (stdin ?? null) : words.slice(index + 1).join(" ");
343
+ if (POWERSHELL_VALUES.test(`-${flag}`)) {
344
+ if (flag === "f" || flag === "file") return null;
345
+ index++;
346
+ }
347
+ }
348
+ return null;
349
+ };
350
+
351
+ /** The script `cmd /c …` (or `/k`) runs. */
352
+ const cmdScript = (words: string[]): string | null => {
353
+ const at = words.findIndex((word, index) => index > 0 && /^\/[ck]$/i.test(word));
354
+ if (at > 0) return words.slice(at + 1).join(" ");
355
+ const attached = words.find((word, index) => index > 0 && /^\/[ck]./i.test(word));
356
+ return attached
357
+ ? [attached.slice(2), ...words.slice(words.indexOf(attached) + 1)].join(" ")
358
+ : null;
359
+ };
360
+
361
+ /** What a `<( … )` operand reads as a file: a literal producer's output (`<(echo 'x')`). */
362
+ const processSubstitutionText = (word: string | undefined): string | undefined => {
363
+ const inner = /^<\(([\s\S]*)\)$/.exec(word ?? "")?.[1];
364
+ if (inner === undefined) return undefined;
365
+ const segments = segmentsOf(inner);
366
+ return segments.length === 1 ? literalOutput({ ...segments[0], pipe: true }) : undefined;
367
+ };
368
+
369
+ /**
370
+ * The script a posix shell runs: `-c '…'`, a `<( … )` script operand, else
371
+ * its stdin when it has no script operand.
372
+ */
373
+ const shellScript = (words: string[], stdin: string | undefined): string | undefined => {
374
+ const flag = words.findIndex((word, index) => index > 0 && SCRIPT_FLAG.test(word));
375
+ if (flag > 0) return words[flag + 1] === "--" ? words[flag + 2] : words[flag + 1];
376
+ const operand = words.slice(1).find((word) => !word.startsWith("-"));
377
+ if (operand !== undefined) return processSubstitutionText(operand);
378
+ return stdin;
379
+ };
380
+
381
+ /** The command `script -c '…'` (util-linux) runs. */
382
+ const scriptCommand = (words: string[]): string | undefined => {
383
+ for (let index = 1; index < words.length; index++) {
384
+ const word = words[index];
385
+ if (word === "--command" || /^-[a-zA-Z]*c$/.test(word)) return words[index + 1];
386
+ if (word.startsWith("--command=")) return word.slice("--command=".length);
387
+ }
388
+ return undefined;
389
+ };
390
+
391
+ /** GNU parallel options that take a value. */
392
+ const PARALLEL_VALUES = new Set(["-j", "--jobs", "-N", "-n", "-S", "--sshlogin", "-P", "--delay"]);
393
+
394
+ /** The commands `parallel cmd … ::: a b` runs: one per argument after `:::`. */
395
+ const parallelCommands = (words: string[]): string[][] => {
396
+ let index = 1;
397
+ while (index < words.length && words[index].startsWith("-"))
398
+ index += PARALLEL_VALUES.has(words[index]) ? 2 : 1;
399
+ const separator = words.indexOf(":::", index);
400
+ const command = words.slice(index, separator < 0 ? undefined : separator);
401
+ if (separator < 0) return command.length ? [command] : [];
402
+ return words
403
+ .slice(separator + 1)
404
+ .filter((arg) => !arg.startsWith(":::"))
405
+ .slice(0, MAX_EXPANSIONS)
406
+ .map((arg) =>
407
+ command.some((word) => word.includes("{}"))
408
+ ? command.map((word) => word.replaceAll("{}", arg))
409
+ : [...command, arg],
410
+ );
411
+ };
412
+
413
+ /** PowerShell `Start-Process` parameters that take a value, besides -FilePath and -ArgumentList. */
414
+ const START_PROCESS_VALUES =
415
+ /^-(?:workingdirectory|wd|redirectstandard\w*|verb|windowstyle|credential|environment)$/i;
416
+
417
+ /** The command line `Start-Process git -ArgumentList 'push','origin'` starts. */
418
+ const startProcessCommand = (words: string[]): string[] | null => {
419
+ let file: string | undefined;
420
+ let args: string | undefined;
421
+ const positionals: string[] = [];
422
+ for (let index = 1; index < words.length; index++) {
423
+ const word = words[index];
424
+ const flag = word.startsWith("-") ? word.slice(1).toLowerCase() : null;
425
+ if (flag === null) positionals.push(word);
426
+ else if (flag && "filepath".startsWith(flag)) file = words[++index];
427
+ else if (flag && (flag === "args" || "argumentlist".startsWith(flag))) args = words[++index];
428
+ else if (START_PROCESS_VALUES.test(word)) index++;
429
+ }
430
+ file ??= positionals.shift();
431
+ args ??= positionals.shift();
432
+ if (!file) return null;
433
+ return [file, ...(args ?? "").split(/[\s,]+/).filter(Boolean)];
434
+ };
435
+
436
+ /** `env -S 'cmd args'` (`--split-string`): the command line it splits, or null. */
437
+ const splitString = (line: string[]): string | null => {
438
+ if (commandName(line[0]) !== "env") return null;
439
+ for (let index = 1; index < line.length; index++) {
440
+ const word = line[index];
441
+ if (word === "-S" || word === "--split-string") return line.slice(index + 1).join(" ");
442
+ if (word.startsWith("--split-string=")) return line.slice(index).join(" ").slice(15);
443
+ if (/^-S./.test(word)) return [word.slice(2), ...line.slice(index + 1)].join(" ");
444
+ if (!word.startsWith("-") && !ASSIGNMENT.test(word)) return null;
445
+ }
446
+ return null;
447
+ };
448
+
449
+ /** git config options that read, list or remove: nothing is set. */
450
+ const CONFIG_READS =
451
+ /^(?:--get\S*|-l|--list|--unset\S*|--remove-section|--rename-section|-e|--edit|--show-\S+|--name-only)$/;
452
+
453
+ /**
454
+ * The `-c` options equivalent to what one git invocation sets for the ones
455
+ * after it in the same command: `remote add|set-url <name> <url>` and
456
+ * `config [scope] <key> <value>` (`config set <key> <value>`).
457
+ */
458
+ const configSetBy = (args: readonly string[]): string[] => {
459
+ const positionals = args.filter((arg, index) => index > 0 && !arg.startsWith("-"));
460
+ if (args[0] === "remote" && (positionals[0] === "add" || positionals[0] === "set-url")) {
461
+ const [, name, url] = positionals;
462
+ const key = args.includes("--push") ? "pushurl" : "url";
463
+ return name && url ? ["-c", `remote.${name}.${key}=${url}`] : [];
464
+ }
465
+ if (args[0] !== "config" || args.some((arg) => CONFIG_READS.test(arg))) return [];
466
+ const [key, value] = positionals[0] === "set" ? positionals.slice(1) : positionals;
467
+ return key?.includes(".") && value !== undefined && positionals.length <= 3
468
+ ? ["-c", `${key}=${value}`]
469
+ : [];
470
+ };
471
+
472
+ /**
473
+ * A git invocation from the words after `git`: global options are read up
474
+ * to the subcommand; `-C` moves the directory and the configuration options
475
+ * are kept so an alias is resolved under them.
476
+ */
477
+ const gitInvocation = (
478
+ words: string[],
479
+ dir: Dir,
480
+ env: Record<string, string> = {},
481
+ stdin?: string,
482
+ inherited: string[] = [],
483
+ ): RawInvocation => {
484
+ let here = dir;
485
+ const config = [...inherited];
486
+ let gitDir = env.GIT_DIR;
487
+ let index = 0;
488
+ for (; index < words.length; index++) {
489
+ const word = words[index];
490
+ if (word === "-C") here = words[index + 1] ? joinDir(here, words[++index]) : here;
491
+ else if (GIT_CONFIG_OPTIONS.has(word)) {
492
+ if (word === "--git-dir") gitDir = words[index + 1];
493
+ if (words[index + 1] !== undefined) config.push(word, words[++index]);
494
+ } else if (/^--(?:config-env|git-dir)=/.test(word)) {
495
+ if (word.startsWith("--git-dir=")) gitDir = word.slice("--git-dir=".length);
496
+ config.push(word);
497
+ } else if (GIT_VALUE_OPTIONS.has(word)) index++;
498
+ else if (!word.startsWith("-")) break;
499
+ }
500
+ // `--git-dir x/.git` (or GIT_DIR) works on the repository at x, wherever the shell is.
501
+ if (gitDir) here = joinDir(here, gitDir.replace(/(?:^|[\\/]+)\.git[\\/]*$/, "") || ".");
502
+ return {
503
+ tool: "git",
504
+ dir: here,
505
+ args: words.slice(index),
506
+ ...(config.length ? { config } : {}),
507
+ ...(Object.keys(env).length ? { env } : {}),
508
+ ...(stdin === undefined ? {} : { stdin }),
509
+ };
510
+ };
511
+
106
512
  /**
107
513
  * Every git/gh/glab invocation in a shell command: `&&`/`;`/`|` chains,
108
514
  * `cd`/`pushd`/`popd` before it (scoped to `( … )` groups), `git -C dir`,
109
- * env prefixes and `bash -c '…'` scripts.
515
+ * env prefixes, compound commands (`if`, `for`, `{ …; }`), literal
516
+ * variables and loop lists, command substitutions, `bash -c '…'`, `eval`,
517
+ * `xargs`, `find -exec`, `pwsh -Command`/`-EncodedCommand`, `cmd /c` and
518
+ * shells fed a heredoc or a literal pipe. One hook call asks several
519
+ * times for the same command: the last answers are kept.
110
520
  */
111
521
  export function rawInvocations(
112
522
  command: string,
113
523
  dialect: ShellDialect = "posix",
114
524
  depth = 0,
115
- ): RawInvocation[] {
525
+ ): readonly RawInvocation[] {
526
+ if (depth > 0) return walk(command, dialect, depth);
527
+ const key = `${dialect}\0${command}`;
528
+ const cached = RECENT.get(key);
529
+ if (cached) return cached;
530
+ let found: RawInvocation[];
531
+ try {
532
+ found = walk(command, dialect, depth);
533
+ } catch (error) {
534
+ throw error instanceof ShellAnalysisError
535
+ ? error
536
+ : new ShellAnalysisError(error instanceof Error ? error.message : String(error));
537
+ }
538
+ RECENT.set(key, found);
539
+ if (RECENT.size > 8) RECENT.delete(RECENT.keys().next().value as string);
540
+ return found;
541
+ }
542
+
543
+ /** The invocations of the last few commands parsed (pure: same text, same answer). */
544
+ const RECENT = new Map<string, readonly RawInvocation[]>();
545
+
546
+ function walk(command: string, dialect: ShellDialect, depth: number): RawInvocation[] {
116
547
  const found: RawInvocation[] = [];
117
548
  let dir: Dir = null;
118
549
  const groups: Array<{ dir: Dir; stack: Dir[] }> = [];
119
550
  let stack: Dir[] = [];
120
- for (const segment of segmentsOf(command, dialect)) {
121
- if (segment.group === "open") {
122
- groups.push({ dir, stack: [...stack] });
123
- continue;
124
- }
125
- if (segment.group === "close") {
126
- const saved = groups.pop();
127
- if (saved) ({ dir, stack } = saved);
128
- continue;
129
- }
130
- const words = stripPrefixes(segment.words);
551
+ const vars = new Map<string, string[]>();
552
+ // `export GIT_DIR=…` and earlier `git remote add` / `git config` in the
553
+ // same command change what the git invocations after them see.
554
+ const exported: Record<string, string> = {};
555
+ const sessionConfig: string[] = [];
556
+ const nested = (script: string | null | undefined, inner: ShellDialect) => {
557
+ if (!script || depth >= MAX_DEPTH) return;
558
+ for (const invocation of rawInvocations(script, inner, depth + 1))
559
+ found.push({ ...invocation, dir: rebase(dir, invocation.dir) });
560
+ };
561
+ const visit = (input: string[], stdin: string | undefined, hops: number) => {
562
+ // PowerShell `$x = git …`: the assignment runs the command.
563
+ const line = /^\$[\w:]+$/.test(input[0] ?? "") && input[1] === "=" ? input.slice(2) : input;
564
+ const split = splitString(line);
565
+ if (split !== null) return nested(split, "posix");
566
+ const { words, env } = stripPrefixes(line);
131
567
  const name = commandName(words[0]);
568
+ if (!name) return;
569
+ const runs = (lines: string[][]) => {
570
+ if (hops < MAX_DEPTH) for (const next of lines) visit(next, undefined, hops + 1);
571
+ };
132
572
  if (CD.has(name)) {
133
573
  const target = cdTarget(words);
134
574
  dir = target === null ? os.homedir() : joinDir(dir, target);
135
- continue;
136
- }
137
- if (PUSHD.has(name)) {
575
+ } else if (PUSHD.has(name)) {
138
576
  const target = cdTarget(words);
139
577
  stack.push(dir);
140
578
  dir = target === null || target === "-" ? undefined : joinDir(dir, target);
141
- continue;
579
+ } else if (POPD.has(name)) dir = stack.length ? stack.pop() : undefined;
580
+ else if (EVALS.has(name)) {
581
+ // `Invoke-Expression -Command '…'`: the parameter name is not script.
582
+ const args = /^-c(?:o(?:m(?:m(?:a(?:n(?:d)?)?)?)?)?)?$/i.test(words[1] ?? "")
583
+ ? words.slice(2)
584
+ : words.slice(1);
585
+ nested(args.join(" "), dialect);
586
+ } else if (SHELLS.has(name)) nested(shellScript(words, stdin), "posix");
587
+ else if ((name === "source" || name === ".") && dialect === "posix")
588
+ nested(processSubstitutionText(words[1]), "posix");
589
+ else if (name === "trap" && dialect === "posix" && !/^-[lp]$/.test(words[1] ?? ""))
590
+ nested(words[1], "posix");
591
+ else if (name === "script") nested(scriptCommand(words), "posix");
592
+ else if (name === "parallel") runs(parallelCommands(words));
593
+ else if (dialect === "powershell" && /^(?:start-process|saps|start)$/.test(name)) {
594
+ const started = startProcessCommand(words);
595
+ if (started) runs([started]);
596
+ } else if (POWERSHELLS.has(name)) nested(powershellScript(words, stdin), "powershell");
597
+ else if (name === "cmd") nested(cmdScript(words), "powershell");
598
+ else if (name === "xargs" || name === "find")
599
+ runs(name === "xargs" ? xargsCommands(words, stdin) : findCommands(words));
600
+ else if (name === "gh" || name === "glab")
601
+ found.push({
602
+ tool: name,
603
+ dir,
604
+ args: words.slice(1),
605
+ ...(stdin === undefined ? {} : { stdin }),
606
+ });
607
+ else if (name === "git") {
608
+ const invocation = gitInvocation(
609
+ words.slice(1),
610
+ dir,
611
+ { ...exported, ...env },
612
+ stdin,
613
+ sessionConfig,
614
+ );
615
+ found.push(invocation);
616
+ sessionConfig.push(...configSetBy(invocation.args));
142
617
  }
143
- if (POPD.has(name)) {
144
- dir = stack.length ? stack.pop() : undefined;
618
+ };
619
+ const segments = segmentsOf(command, dialect);
620
+ for (const [index, segment] of segments.entries()) {
621
+ if (segment.group === "open") {
622
+ groups.push({ dir, stack: [...stack] });
145
623
  continue;
146
624
  }
147
- if (SHELLS.has(name) && depth < 3) {
148
- const flag = words.findIndex((word, index) => index > 0 && SCRIPT_FLAG.test(word));
149
- const script = flag > 0 ? words[flag + 1] : undefined;
150
- if (script)
151
- for (const inner of rawInvocations(script, "posix", depth + 1))
152
- found.push({
153
- ...inner,
154
- dir:
155
- inner.dir === null
156
- ? dir
157
- : inner.dir === undefined
158
- ? undefined
159
- : joinDir(dir, inner.dir),
160
- });
625
+ if (segment.group === "close") {
626
+ const saved = groups.pop();
627
+ if (saved) ({ dir, stack } = saved);
161
628
  continue;
162
629
  }
163
- if (name === "gh" || name === "glab") {
164
- found.push({ tool: name, dir, args: words.slice(1) });
630
+ if (segment.unanalyzable) throw new ShellAnalysisError(segment.unanalyzable);
631
+ for (const sub of segment.subs ?? []) nested(sub, "posix");
632
+ const set = assignments(segment.words, vars);
633
+ if (set) {
634
+ for (const [name, values] of set) {
635
+ vars.set(name, values);
636
+ if (segment.words[0] === "export" && GIT_CONFIG_ENV.test(name) && values[0] !== undefined)
637
+ exported[name] = values[0];
638
+ }
165
639
  continue;
166
640
  }
167
- if (name !== "git") continue;
168
- let here = dir;
169
- let index = 1;
170
- for (; index < words.length; index++) {
171
- const word = words[index];
172
- if (word === "-C") here = words[index + 1] ? joinDir(here, words[++index]) : here;
173
- else if (GIT_VALUE_OPTIONS.has(word)) index++;
174
- else if (!word.startsWith("-")) break;
175
- }
176
- found.push({ tool: "git", dir: here, args: words.slice(index) });
641
+ const stdin = stdinOf(segment, segments[index - 1]);
642
+ for (const line of substitute(segment.words, vars)) visit(line, stdin, 0);
177
643
  }
178
644
  return found;
179
645
  }
@@ -242,15 +708,70 @@ export type RawAction =
242
708
  allBranches?: true;
243
709
  /** `--mirror`: every ref, deletions included. */
244
710
  mirror?: true;
711
+ /** No refspec: push.default and remote.<name>.push decide what goes. */
712
+ implicit?: true;
713
+ /** Glob refspecs (`refs/heads/*:refs/heads/*`), expanded against local refs. */
714
+ globs?: Array<{ source: string; destination: string }>;
715
+ /** `:` (or push.default=matching): every branch both sides have. */
716
+ matching?: true;
245
717
  }
246
- | { kind: "merge"; forge: "gh" | "glab" }
718
+ | { kind: "merge"; forge: "gh" | "glab"; via?: "api" }
247
719
  | { kind: "pr-create"; forge: "gh" | "glab" }
248
720
  | { kind: "pr-read"; forge: "gh" | "glab" };
249
721
 
250
722
  /** git push options that take the next word as their value. */
251
723
  const PUSH_VALUE_OPTIONS = new Set(["-o", "--push-option", "--receive-pack", "--exec", "--repo"]);
252
724
 
253
- const pushAction = (args: string[]): RawAction | null => {
725
+ type PushTarget = { name: string | null | undefined; tag?: string };
726
+
727
+ /** The destination of one push refspec: a branch, `null` for HEAD, undefined for another namespace. */
728
+ /** `$(git branch --show-current)` and its spellings: the current branch. */
729
+ const CURRENT_BRANCH =
730
+ /^\$\(\s*git\s+(?:branch\s+--show-current|rev-parse\s+--abbrev-ref\s+HEAD|symbolic-ref\s+--short\s+HEAD)\s*\)$/;
731
+
732
+ const pushDestination = (destination: string): PushTarget => {
733
+ if (destination === "HEAD" || destination === "@" || CURRENT_BRANCH.test(destination))
734
+ return { name: null };
735
+ if (destination.startsWith("refs/heads/")) return { name: destination.slice(11) };
736
+ if (destination.startsWith("refs/tags/")) return { name: undefined, tag: destination.slice(10) };
737
+ if (destination.startsWith("refs/")) return { name: undefined };
738
+ return { name: destination };
739
+ };
740
+
741
+ /** The parts of a push the refspecs name (`[+]<src>[:<dst>]`). */
742
+ const pushRefspecs = (specs: readonly string[]) => {
743
+ let force = false;
744
+ let matching = false;
745
+ const targets: Array<string | null> = [];
746
+ const tags: string[] = [];
747
+ const deleted: string[] = [];
748
+ const globs: Array<{ source: string; destination: string }> = [];
749
+ for (const raw of specs) {
750
+ if (raw.startsWith("+")) force = true;
751
+ const spec = raw.replace(/^\+/, "");
752
+ if (spec.startsWith("^")) continue;
753
+ if (spec === ":") {
754
+ matching = true;
755
+ continue;
756
+ }
757
+ const colon = spec.indexOf(":");
758
+ const source = colon >= 0 ? spec.slice(0, colon) : spec;
759
+ const destination = colon >= 0 ? spec.slice(colon + 1) : spec;
760
+ if (!destination) continue;
761
+ if (destination.includes("*")) {
762
+ globs.push({ source, destination });
763
+ continue;
764
+ }
765
+ const { name, tag } = pushDestination(destination);
766
+ if (tag !== undefined) tags.push(tag);
767
+ if (name === undefined) continue;
768
+ targets.push(name);
769
+ if (colon === 0 && name !== null) deleted.push(name);
770
+ }
771
+ return { force, matching, targets, tags, deleted, globs };
772
+ };
773
+
774
+ export const pushAction = (args: string[]): RawAction | null => {
254
775
  let force = false;
255
776
  let lease = false;
256
777
  let wholesale = false;
@@ -259,6 +780,8 @@ const pushAction = (args: string[]): RawAction | null => {
259
780
  let allBranches = false;
260
781
  let mirror = false;
261
782
  let remote: string | null = null;
783
+ // The last of --dry-run (or an abbreviation, or -n) and --no-dry-run wins.
784
+ let dryRun = false;
262
785
  const positionals: string[] = [];
263
786
  for (let index = 1; index < args.length; index++) {
264
787
  const arg = args[index];
@@ -266,8 +789,10 @@ const pushAction = (args: string[]): RawAction | null => {
266
789
  positionals.push(...args.slice(index + 1));
267
790
  break;
268
791
  }
269
- if (arg === "--dry-run" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg)) return null;
270
- if (arg === "--repo") remote = args[++index] ?? null;
792
+ if (/^--dr(?:y(?:-(?:r(?:un?)?)?)?)?$/.test(arg) || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(arg))
793
+ dryRun = true;
794
+ else if (/^--no-dr(?:y(?:-(?:r(?:un?)?)?)?)?$/.test(arg)) dryRun = false;
795
+ else if (arg === "--repo") remote = args[++index] ?? null;
271
796
  else if (arg.startsWith("--repo=")) remote = arg.slice("--repo=".length);
272
797
  else if (PUSH_VALUE_OPTIONS.has(arg)) index++;
273
798
  else if (arg === "--force" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg)) force = true;
@@ -278,44 +803,28 @@ const pushAction = (args: string[]): RawAction | null => {
278
803
  else if (arg === "--mirror") mirror = wholesale = true;
279
804
  else if (!arg.startsWith("-")) positionals.push(arg);
280
805
  }
806
+ if (dryRun) return null;
281
807
  // `--repo` names the remote; positionals are then all refspecs.
282
808
  const specs = remote === null ? positionals.slice(1) : positionals;
283
809
  remote ??= positionals[0] ?? null;
284
- const targets: Array<string | null> = [];
285
- const tags: string[] = [];
286
- const deleted: string[] = [];
287
- for (const raw of specs) {
288
- if (raw.startsWith("+")) force = true;
289
- const spec = raw.replace(/^\+/, "");
290
- const colon = spec.indexOf(":");
291
- const destination = colon >= 0 ? spec.slice(colon + 1) : spec;
292
- if (!destination) continue;
293
- const name =
294
- destination === "HEAD"
295
- ? null
296
- : destination.startsWith("refs/heads/")
297
- ? destination.slice(11)
298
- : destination.startsWith("refs/")
299
- ? undefined
300
- : destination;
301
- if (destination.startsWith("refs/tags/")) tags.push(destination.slice(10));
302
- if (name === undefined) continue;
303
- targets.push(name);
304
- if (colon === 0 && name !== null) deleted.push(name);
305
- }
306
- // No refspec pushes the current branch (push.default simple/current).
307
- if (specs.length === 0 && !wholesale) targets.push(null);
810
+ const parsed = pushRefspecs(specs);
811
+ // No refspec pushes what push.default says (the current branch by default).
812
+ const implicit = specs.length === 0 && !wholesale;
813
+ if (implicit) parsed.targets.push(null);
308
814
  return {
309
815
  kind: "push",
310
- targets,
311
- blindForce: force && !lease,
816
+ targets: parsed.targets,
817
+ blindForce: (force || parsed.force) && !lease,
312
818
  remote,
313
- ...(tags.length ? { tags } : {}),
819
+ ...(parsed.tags.length ? { tags: parsed.tags } : {}),
314
820
  ...(allTags ? { allTags: true as const } : {}),
315
821
  ...(deletes ? { deletes: true as const } : {}),
316
- ...(deleted.length ? { deleted } : {}),
822
+ ...(parsed.deleted.length ? { deleted: parsed.deleted } : {}),
317
823
  ...(allBranches ? { allBranches: true as const } : {}),
318
824
  ...(mirror ? { mirror: true as const } : {}),
825
+ ...(implicit ? { implicit: true as const } : {}),
826
+ ...(parsed.globs.length ? { globs: parsed.globs } : {}),
827
+ ...(parsed.matching ? { matching: true as const } : {}),
319
828
  };
320
829
  };
321
830
 
@@ -375,22 +884,120 @@ const asksHelp = (invocation: RawInvocation): boolean => {
375
884
  return false;
376
885
  };
377
886
 
887
+ /** `gh api`/`glab api` options that take a value, long and short. */
888
+ const API_VALUES = new Set(
889
+ ["--method", "--header", "--raw-field", "--field", "--jq", "--template", "--input"].concat([
890
+ "--hostname",
891
+ "--preview",
892
+ "--cache",
893
+ "--output",
894
+ ]),
895
+ );
896
+ const API_SHORT_VALUES = "XHfFqtp";
897
+ const API_FIELDS = new Set(["-f", "-F", "--raw-field", "--field"]);
898
+ /** REST merges: a PR (`pulls/<n>/merge`), a branch into another (`merges`), an MR. */
899
+ const API_MERGE = {
900
+ gh: /(?:^|\/)(?:pulls\/[^/?#]+\/merge|merges)\/?(?:[?#]|$)/i,
901
+ glab: /(?:^|\/)merge_requests\/[^/?#]+\/merge\/?(?:[?#]|$)/i,
902
+ };
903
+ /** GraphQL mutations that merge. */
904
+ const GRAPHQL_MERGE =
905
+ /\b(?:mergePullRequest|enablePullRequestAutoMerge|mergeBranch|mergeRequestAccept)\b/;
906
+
907
+ /**
908
+ * `gh api`/`glab api` that merges: a write (any method but GET/HEAD; fields
909
+ * or `--input` without `-X` make it a POST) to a merge endpoint, or a GraphQL
910
+ * merge mutation. Options are read wherever they stand; a help flag does not
911
+ * excuse a merge (it can be another option's value).
912
+ */
913
+ const apiMerges = (tool: "gh" | "glab", args: string[], stdin?: string): boolean => {
914
+ const request = { method: null as string | null, body: false };
915
+ let endpoint: string | null = null;
916
+ const values: string[] = [];
917
+ const take = (name: string, value: string) => {
918
+ if (name === "-X" || name === "--method") request.method = value.toUpperCase();
919
+ if (API_FIELDS.has(name) || name === "--input") request.body = true;
920
+ values.push(value);
921
+ // `-F query=@-`, `--input -`: the value is read from stdin.
922
+ const fromStdin = name === "--input" ? value === "-" : /^(?:[^=]*=)?@-$/.test(value);
923
+ if (fromStdin && stdin !== undefined) values.push(stdin);
924
+ };
925
+ for (let index = 1; index < args.length; index++) {
926
+ const arg = args[index];
927
+ if (arg === "--") {
928
+ endpoint ??= args[index + 1] ?? null;
929
+ break;
930
+ }
931
+ if (arg.startsWith("--")) {
932
+ const eq = arg.indexOf("=");
933
+ const name = eq > 0 ? arg.slice(0, eq) : arg;
934
+ if (API_VALUES.has(name)) take(name, eq > 0 ? arg.slice(eq + 1) : (args[++index] ?? ""));
935
+ } else if (arg.startsWith("-") && arg.length > 1) {
936
+ // A cluster of short flags; the first one that takes a value ends it.
937
+ for (let at = 1; at < arg.length; at++) {
938
+ if (!API_SHORT_VALUES.includes(arg[at])) continue;
939
+ take(`-${arg[at]}`, at + 1 < arg.length ? arg.slice(at + 1) : (args[++index] ?? ""));
940
+ break;
941
+ }
942
+ } else endpoint ??= arg;
943
+ }
944
+ if (!endpoint) return false;
945
+ endpoint = endpoint.replace(/(?<!:)\/{2,}/g, "/");
946
+ if (/(?:^|\/)graphql\/?$/i.test(endpoint))
947
+ return values.some((value) => GRAPHQL_MERGE.test(value));
948
+ const verb = request.method ?? (request.body ? "POST" : "GET");
949
+ return verb !== "GET" && verb !== "HEAD" && API_MERGE[tool].test(endpoint);
950
+ };
951
+
952
+ /** `git subtree push --prefix=<dir> <remote> <branch>`: a push of `<branch>` to `<remote>`. */
953
+ const subtreePush = (args: string[]): RawAction | null => {
954
+ const positionals: string[] = [];
955
+ for (let index = 2; index < args.length; index++) {
956
+ const arg = args[index];
957
+ if (arg === "-P" || arg === "--prefix" || arg === "-m" || arg === "--message") index++;
958
+ else if (!arg.startsWith("-")) positionals.push(arg);
959
+ }
960
+ const [remote, branch] = positionals;
961
+ return remote && branch ? pushAction(["push", remote, `HEAD:refs/heads/${branch}`]) : null;
962
+ };
963
+
378
964
  /** The workit meaning of one invocation; null for read-only, help or unrelated ones. */
379
965
  export function rawAction(invocation: RawInvocation): RawAction | null {
380
966
  const args = invocation.args;
967
+ if (invocation.tool !== "git" && args[0] === "api")
968
+ return apiMerges(invocation.tool, args, invocation.stdin)
969
+ ? { kind: "merge", forge: invocation.tool, via: "api" }
970
+ : null;
381
971
  if (asksHelp(invocation)) return null;
382
- const [first, second] = args;
972
+ const [first] = args;
383
973
  if (invocation.tool === "git") {
974
+ const second = args[1];
384
975
  if (first === "commit")
385
976
  return args.includes("--dry-run")
386
977
  ? null
387
978
  : { kind: "commit", amend: args.includes("--amend") };
388
979
  if (first === "push") return pushAction(args);
980
+ // `git send-pack <remote> <refspec>…` pushes like `git push`.
981
+ if (first === "send-pack")
982
+ return pushAction([
983
+ "push",
984
+ ...args.slice(1).filter((arg) => arg !== "--stdin"),
985
+ ...(args.includes("--stdin") ? (invocation.stdin ?? "").split(/\s+/).filter(Boolean) : []),
986
+ ]);
987
+ if (first === "subtree" && second === "push") return subtreePush(args);
389
988
  return null;
390
989
  }
391
990
  const forge = invocation.tool;
392
991
  const noun = forge === "gh" ? "pr" : "mr";
393
992
  if (first !== noun) return null;
993
+ // The verb stands after any options (`gh pr -R o/r merge 1`).
994
+ const values = VALUE_OPTIONS[forge];
995
+ let second: string | undefined;
996
+ for (let index = 1; index < args.length && second === undefined; index++) {
997
+ const arg = args[index];
998
+ if (values.has(arg)) index++;
999
+ else if (!arg.startsWith("-")) second = arg;
1000
+ }
394
1001
  if (second === "merge") return { kind: "merge", forge };
395
1002
  if (second === "create") return { kind: "pr-create", forge };
396
1003
  if (["view", "checks", "status", "ci", "list"].includes(second ?? ""))
@@ -432,18 +1039,6 @@ export const guardedBranch = (dir: string, branch: string): boolean => {
432
1039
  }
433
1040
  };
434
1041
 
435
- /** A push names the remote workit itself pushes to (or none, the default). */
436
- const toPushRemote = (repo: Repo, remote: string | null): boolean => {
437
- if (remote === null) return true;
438
- try {
439
- // No known push remote: the named one may well be it.
440
- const pushRemote = pushRemoteName(repo.dir, repo.branch);
441
- return pushRemote === null || pushRemote === remote;
442
- } catch {
443
- return false;
444
- }
445
- };
446
-
447
1042
  /** HEAD's commit read from the git dir (loose or packed ref), or null. */
448
1043
  const headSha = (repo: Repo): string | null => {
449
1044
  const git = repo.location.git;
@@ -468,6 +1063,271 @@ const headSha = (repo: Repo): string | null => {
468
1063
  }
469
1064
  };
470
1065
 
1066
+ // ---------------------------------------------------------------------------
1067
+ // git configuration (one `git config --list` per directory and command line)
1068
+
1069
+ type GitConfig = Map<string, string[]>;
1070
+ /** Configuration read once per hook call, keyed by directory and replayed options. */
1071
+ type ConfigCache = Map<string, GitConfig>;
1072
+
1073
+ /**
1074
+ * The configuration an invocation runs under in `dir`, its `-c`/
1075
+ * `--config-env`/`--git-dir` options and GIT_CONFIG_* assignments
1076
+ * replayed. Reading config runs nothing. Empty on any failure.
1077
+ */
1078
+ const gitConfig = (dir: string, invocation: RawInvocation, cache: ConfigCache): GitConfig => {
1079
+ const key = JSON.stringify([dir, invocation.config ?? [], invocation.env ?? {}]);
1080
+ const cached = cache.get(key);
1081
+ if (cached) return cached;
1082
+ const config: GitConfig = new Map();
1083
+ const listed = spawnSync("git", [...(invocation.config ?? []), "config", "-z", "--list"], {
1084
+ cwd: dir,
1085
+ encoding: "utf8",
1086
+ timeout: 3_000,
1087
+ windowsHide: true,
1088
+ env: { ...process.env, ...invocation.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" },
1089
+ });
1090
+ for (const entry of (listed.status === 0 ? listed.stdout : "").split("\0")) {
1091
+ if (!entry) continue;
1092
+ const newline = entry.indexOf("\n");
1093
+ const name = newline < 0 ? entry : entry.slice(0, newline);
1094
+ config.set(name, [
1095
+ ...(config.get(name) ?? []),
1096
+ newline < 0 ? "true" : entry.slice(newline + 1),
1097
+ ]);
1098
+ }
1099
+ cache.set(key, config);
1100
+ return config;
1101
+ };
1102
+
1103
+ const configValue = (config: GitConfig, key: string) => config.get(key)?.at(-1);
1104
+
1105
+ /** git's own commands (`git --list-cmds=main`): an alias never shadows one. */
1106
+ const GIT_COMMANDS = new Set(
1107
+ (
1108
+ "add am annotate apply archive bisect blame branch bugreport bundle cat-file check-attr " +
1109
+ "check-ignore check-mailmap check-ref-format checkout checkout-index cherry cherry-pick " +
1110
+ "citool clean clone column commit commit-graph commit-tree config count-objects credential " +
1111
+ "daemon describe diagnose diff diff-files diff-index diff-tree difftool fast-export " +
1112
+ "fast-import fetch fetch-pack filter-branch fmt-merge-msg for-each-ref for-each-repo " +
1113
+ "format-patch fsck gc get-tar-commit-id grep gui hash-object help hook index-pack init " +
1114
+ "instaweb interpret-trailers lfs log ls-files ls-remote ls-tree mailinfo mailsplit " +
1115
+ "maintenance merge merge-base merge-file merge-index merge-tree mergetool mktag mktree " +
1116
+ "multi-pack-index mv name-rev notes pack-objects pack-refs patch-id prune pull push " +
1117
+ "range-diff read-tree rebase reflog remote repack replace request-pull rerere reset " +
1118
+ "restore rev-list rev-parse revert rm send-email shortlog show show-branch show-ref " +
1119
+ "sparse-checkout stage stash status stripspace submodule subtree switch symbolic-ref tag " +
1120
+ "update-index update-ref var verify-commit verify-pack verify-tag version whatchanged " +
1121
+ "worktree write-tree"
1122
+ ).split(" "),
1123
+ );
1124
+
1125
+ /** The commands workit judges: what help.autocorrect may turn a typo into. */
1126
+ const JUDGED_COMMANDS = [
1127
+ "push",
1128
+ "commit",
1129
+ "switch",
1130
+ "checkout",
1131
+ "branch",
1132
+ "worktree",
1133
+ "stash",
1134
+ "update-ref",
1135
+ "symbolic-ref",
1136
+ "fetch",
1137
+ "pull",
1138
+ "subtree",
1139
+ ];
1140
+
1141
+ /** Edits between two words, a swap of neighbours counting as one (git's typos). */
1142
+ const editDistance = (a: string, b: string): number => {
1143
+ let before: number[] = [];
1144
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
1145
+ for (let i = 1; i <= a.length; i++) {
1146
+ const row = [i];
1147
+ for (let j = 1; j <= b.length; j++) {
1148
+ row[j] = Math.min(
1149
+ previous[j] + 1,
1150
+ row[j - 1] + 1,
1151
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
1152
+ );
1153
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
1154
+ row[j] = Math.min(row[j], before[j - 2] + 1);
1155
+ }
1156
+ [before, previous] = [previous, row];
1157
+ }
1158
+ return previous[b.length];
1159
+ };
1160
+
1161
+ /** With help.autocorrect set to run, the judged command a typo runs as (unique and within 2 edits). */
1162
+ const autocorrected = (name: string, config: GitConfig): string | null => {
1163
+ const setting = (configValue(config, "help.autocorrect") ?? "0").toLowerCase();
1164
+ if (setting !== "immediate" && !/^-?[1-9]\d*$/.test(setting)) return null;
1165
+ let best: string | null = null;
1166
+ let bestDistance = 3;
1167
+ let tie = false;
1168
+ for (const command of JUDGED_COMMANDS) {
1169
+ const distance = editDistance(name, command);
1170
+ if (distance < bestDistance) [best, bestDistance, tie] = [command, distance, false];
1171
+ else if (distance === bestDistance) tie = true;
1172
+ }
1173
+ return tie ? null : best;
1174
+ };
1175
+
1176
+ const shellQuote = (word: string) => `'${word.replaceAll("'", "'\\''")}'`;
1177
+
1178
+ /** A `!` alias's `$1`…`$9`, `$@` and `$*` with the arguments git hands it. */
1179
+ const positional = (script: string, args: readonly string[]): string => {
1180
+ const word = (arg: string) => (/^[\w./@+:=-]+$/.test(arg) ? arg : shellQuote(arg));
1181
+ return script
1182
+ .replace(/\$\{?([1-9])\}?/g, (_, n: string) => word(args[Number(n) - 1] ?? ""))
1183
+ .replace(/\$\{?[@*]\}?/g, args.map(word).join(" "));
1184
+ };
1185
+
1186
+ /**
1187
+ * A git invocation as git runs it once its alias is resolved: aliases from
1188
+ * the repository and user config, `-c alias.x=…` and GIT_CONFIG_* all count,
1189
+ * and expand recursively. A `!` alias is a shell script, read like any
1190
+ * other command line. With help.autocorrect set to run, a near-miss of a
1191
+ * judged command runs as that command. git's own commands spawn nothing.
1192
+ */
1193
+ export const expandGitAliases = (
1194
+ root: string,
1195
+ invocation: RawInvocation,
1196
+ cache: ConfigCache = new Map(),
1197
+ depth = 0,
1198
+ ): RawInvocation[] => {
1199
+ const sub = invocation.args[0];
1200
+ if (
1201
+ invocation.tool !== "git" ||
1202
+ invocation.dir === undefined ||
1203
+ !sub ||
1204
+ sub.startsWith("-") ||
1205
+ GIT_COMMANDS.has(sub) ||
1206
+ depth >= MAX_DEPTH
1207
+ )
1208
+ return [invocation];
1209
+ const dir = path.resolve(root, invocation.dir ?? ".");
1210
+ if (!fs.existsSync(dir)) return [invocation];
1211
+ const config = gitConfig(dir, invocation, cache);
1212
+ const alias = configValue(config, `alias.${sub.toLowerCase()}`);
1213
+ if (alias === undefined) {
1214
+ const corrected = autocorrected(sub, config);
1215
+ return corrected
1216
+ ? [{ ...invocation, args: [corrected, ...invocation.args.slice(1)] }]
1217
+ : [invocation];
1218
+ }
1219
+ const rest = invocation.args.slice(1);
1220
+ const expanded = alias.startsWith("!")
1221
+ ? // A shell alias inherits the outer -c options and environment.
1222
+ rawInvocations([positional(alias.slice(1), rest), ...rest.map(shellQuote)].join(" ")).map(
1223
+ (inner) => ({
1224
+ ...inner,
1225
+ dir: rebase(invocation.dir, inner.dir),
1226
+ ...(inner.tool === "git" && (invocation.config || inner.config)
1227
+ ? { config: [...(invocation.config ?? []), ...(inner.config ?? [])] }
1228
+ : {}),
1229
+ ...(inner.tool === "git" && (invocation.env || inner.env)
1230
+ ? { env: { ...invocation.env, ...inner.env } }
1231
+ : {}),
1232
+ }),
1233
+ )
1234
+ : [
1235
+ gitInvocation(
1236
+ [...(segmentsOf(alias)[0]?.words ?? []), ...rest],
1237
+ invocation.dir,
1238
+ invocation.env,
1239
+ invocation.stdin,
1240
+ invocation.config,
1241
+ ),
1242
+ ];
1243
+ return expanded.flatMap((inner) => expandGitAliases(root, inner, cache, depth + 1));
1244
+ };
1245
+
1246
+ /** A remote URL in a comparable form: no scheme, credentials, port, trailing `/` or `.git`. */
1247
+ const normalizedUrl = (url: string, dir: string): string => {
1248
+ const trimmed = url.trim();
1249
+ if (/^file:\/\//i.test(trimmed))
1250
+ return path
1251
+ .resolve(dir, trimmed.slice(7))
1252
+ .replace(/\/+$/, "")
1253
+ .replace(/\.git$/, "");
1254
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(trimmed);
1255
+ const scp = !scheme && /^(?:[^@/\\]+@)?[^/\\:]{2,}:/.test(trimmed);
1256
+ // A local path keeps its case.
1257
+ if (!scheme && !scp)
1258
+ return path
1259
+ .resolve(dir, trimmed)
1260
+ .replace(/\/+$/, "")
1261
+ .replace(/\.git$/, "");
1262
+ const value = scheme
1263
+ ? trimmed
1264
+ .slice(scheme[0].length)
1265
+ .replace(/^[^@/]*@/, "")
1266
+ .replace(/^([^/:]+):\d+(?=\/)/, "$1")
1267
+ : trimmed.replace(/^[^@/]*@/, "").replace(":", "/");
1268
+ // A forge ignores case, doubled slashes and `www.` (`git@host:/Owner/Repo`).
1269
+ return value
1270
+ .toLowerCase()
1271
+ .replace(/\/{2,}/g, "/")
1272
+ .replace(/^www\./, "")
1273
+ .replace(/\/+$/, "")
1274
+ .replace(/\.git$/, "");
1275
+ };
1276
+
1277
+ /** `url.<base>.pushInsteadOf`/`insteadOf`: the longest matching prefix is rewritten. */
1278
+ const rewrittenUrl = (url: string, config: GitConfig): string => {
1279
+ for (const kind of ["pushinsteadof", "insteadof"]) {
1280
+ let best: { base: string; prefix: string } | null = null;
1281
+ for (const [key, prefixes] of config) {
1282
+ if (!key.startsWith("url.") || !key.endsWith(`.${kind}`)) continue;
1283
+ const base = key.slice(4, -(kind.length + 1));
1284
+ for (const prefix of prefixes)
1285
+ if (url.startsWith(prefix) && prefix.length > (best?.prefix.length ?? -1))
1286
+ best = { base, prefix };
1287
+ }
1288
+ if (best) return best.base + url.slice(best.prefix.length);
1289
+ }
1290
+ return url;
1291
+ };
1292
+
1293
+ /** Where a remote name or URL pushes, comparable across names and spellings. */
1294
+ const pushUrl = (config: GitConfig, remote: string, dir: string): string => {
1295
+ const configured =
1296
+ configValue(config, `remote.${remote}.pushurl`) ?? configValue(config, `remote.${remote}.url`);
1297
+ return normalizedUrl(rewrittenUrl(configured ?? remote, config), dir);
1298
+ };
1299
+
1300
+ /** The remote workit pushes to, from the same config `pushRemoteName` reads. */
1301
+ const pushRemoteOf = (config: GitConfig, branch: string | null): string | null => {
1302
+ const remotes = new Set<string>();
1303
+ for (const key of config.keys()) {
1304
+ const name = /^remote\.(.+)\.url$/.exec(key)?.[1];
1305
+ if (name) remotes.add(name);
1306
+ }
1307
+ for (const candidate of [
1308
+ branch ? configValue(config, `branch.${branch}.pushremote`) : undefined,
1309
+ configValue(config, "remote.pushdefault"),
1310
+ branch ? configValue(config, `branch.${branch}.remote`) : undefined,
1311
+ ])
1312
+ if (candidate && candidate !== "." && remotes.has(candidate)) return candidate;
1313
+ if (remotes.has("origin")) return "origin";
1314
+ return remotes.size === 1 ? [...remotes][0] : null;
1315
+ };
1316
+
1317
+ /**
1318
+ * A push names the remote workit itself pushes to: none (the default), its
1319
+ * name, or another name or URL that pushes to the same place (a second
1320
+ * remote, an insteadOf rewrite, the URL spelled out).
1321
+ */
1322
+ const toPushRemote = (repo: Repo, remote: string | null, config: GitConfig): boolean => {
1323
+ // None, or one the shell computes (`"$(git remote get-url origin)"`): it may well be it.
1324
+ if (remote === null || /[$`]/.test(remote)) return true;
1325
+ // No known push remote: the named one may well be it.
1326
+ const pushRemote = pushRemoteOf(config, repo.branch);
1327
+ if (pushRemote === null || pushRemote === remote) return true;
1328
+ return pushUrl(config, remote, repo.dir) === pushUrl(config, pushRemote, repo.dir);
1329
+ };
1330
+
471
1331
  // ---------------------------------------------------------------------------
472
1332
  // pre-tool: refuse or nudge
473
1333
 
@@ -475,7 +1335,7 @@ const quote = (text: string) => `\`${text}\``;
475
1335
 
476
1336
  const refusal = (action: RawAction, branch: string | null, dir?: string): string => {
477
1337
  if (action.kind === "merge")
478
- return `workit: raw ${quote(action.forge === "gh" ? "gh pr merge" : "glab mr merge")} bypasses the merge grant and the verdict gate. Run ${quote("workit pr merge")} instead (it checks both; ${quote("workit help pr")}).`;
1338
+ return `workit: raw ${quote(action.via === "api" ? `${action.forge} api` : action.forge === "gh" ? "gh pr merge" : "glab mr merge")}${action.via === "api" ? " merge" : ""} bypasses the merge grant and the verdict gate. Run ${quote("workit pr merge")} instead (it checks both; ${quote("workit help pr")}).`;
479
1339
  // Finishing a release (git-flow main/develop, the github-flow trunk, a
480
1340
  // custom protected branch): the push has its own granted verb.
481
1341
  const plainPush = action.kind === "push" && !action.blindForce && !action.deletes;
@@ -505,6 +1365,91 @@ const refFacts = (repo: Repo, remote: string): RefFacts => {
505
1365
  return facts;
506
1366
  };
507
1367
 
1368
+ /**
1369
+ * The branches a glob refspec writes (`refs/heads/*:refs/heads/*`): every
1370
+ * local ref its source matches, mapped onto its destination.
1371
+ */
1372
+ const globBranches = (repo: Repo, glob: { source: string; destination: string }): string[] => {
1373
+ const source = glob.source.startsWith("refs/") ? glob.source : `refs/heads/${glob.source}`;
1374
+ const [sourcePrefix, sourceSuffix = ""] = source.split("*");
1375
+ const [destinationPrefix, destinationSuffix = ""] = glob.destination.split("*");
1376
+ const listed = spawnSync(
1377
+ "git",
1378
+ [
1379
+ "for-each-ref",
1380
+ "--format=%(refname)",
1381
+ sourcePrefix.slice(0, sourcePrefix.lastIndexOf("/") + 1),
1382
+ ],
1383
+ { cwd: repo.dir, encoding: "utf8", timeout: 5_000, windowsHide: true },
1384
+ );
1385
+ const names: string[] = [];
1386
+ for (const ref of (listed.stdout ?? "").split("\n")) {
1387
+ if (!ref.startsWith(sourcePrefix) || !ref.endsWith(sourceSuffix)) continue;
1388
+ const middle = ref.slice(sourcePrefix.length, ref.length - sourceSuffix.length);
1389
+ const { name } = pushDestination(destinationPrefix + middle + destinationSuffix);
1390
+ if (name) names.push(name);
1391
+ }
1392
+ return names;
1393
+ };
1394
+
1395
+ /**
1396
+ * The branches a push writes on the remote: its named refspecs, a glob's
1397
+ * matches, and, with no refspec, what `remote.<name>.push` or push.default
1398
+ * picks (`upstream` pushes onto the upstream's name, `matching` onto every
1399
+ * branch both sides have, `nothing` onto none). `null` is the current branch.
1400
+ */
1401
+ const pushedBranches = (
1402
+ action: PushAction,
1403
+ repo: Repo,
1404
+ config: GitConfig,
1405
+ facts: RefFacts,
1406
+ remote: string,
1407
+ ): Array<string | null> => {
1408
+ const targets = action.implicit ? [] : [...action.targets];
1409
+ const globs = [...(action.globs ?? [])];
1410
+ let matching = action.matching === true;
1411
+ const configured = action.implicit ? config.get(`remote.${remote}.push`) : undefined;
1412
+ if (configured?.length) {
1413
+ const parsed = pushRefspecs(configured);
1414
+ targets.push(...parsed.targets);
1415
+ globs.push(...parsed.globs);
1416
+ matching ||= parsed.matching;
1417
+ } else if (action.implicit) {
1418
+ const mode = (configValue(config, "push.default") ?? "simple").toLowerCase();
1419
+ const merge = repo.branch ? configValue(config, `branch.${repo.branch}.merge`) : undefined;
1420
+ if (mode === "matching") matching = true;
1421
+ else if ((mode === "upstream" || mode === "tracking") && merge?.startsWith("refs/heads/"))
1422
+ targets.push(merge.slice(11));
1423
+ else if (mode !== "nothing") targets.push(null);
1424
+ }
1425
+ if (action.allBranches) targets.push(...facts.heads);
1426
+ if (matching) for (const head of facts.heads) if (facts.remoteHeads.has(head)) targets.push(head);
1427
+ for (const glob of globs) targets.push(...globBranches(repo, glob));
1428
+ return targets;
1429
+ };
1430
+
1431
+ /**
1432
+ * The literal names a computed one can stand for: `${X:-main}` is `main`
1433
+ * when X is unset, and `${X}main` is `main` when X is empty.
1434
+ */
1435
+ const literalReadings = (name: string): string[] => {
1436
+ if (!/[$`]/.test(name)) return [name];
1437
+ const withDefaults = name
1438
+ .replace(/\$\{\w+:?[-=]([^}$`]*)\}/g, "$1")
1439
+ .replace(/\$\{\w+\}|\$\w+/g, "");
1440
+ return /[$`]/.test(withDefaults) || !withDefaults ? [] : [withDefaults];
1441
+ };
1442
+
1443
+ /** A protected branch or the default target; `heads/<b>` reaches `refs/heads/<b>` on the remote. */
1444
+ const guardedTarget = (dir: string, target: string): string | null => {
1445
+ for (const branch of literalReadings(target)) {
1446
+ if (guardedBranch(dir, branch)) return branch;
1447
+ const bare = branch.startsWith("heads/") ? branch.slice(6) : null;
1448
+ if (bare && guardedBranch(dir, bare)) return bare;
1449
+ }
1450
+ return null;
1451
+ };
1452
+
508
1453
  /** A bare refspec naming a local tag and no local branch pushes the tag. */
509
1454
  const isTag = (facts: RefFacts, name: string): boolean =>
510
1455
  facts.tags.has(name) && !facts.heads.has(name);
@@ -524,15 +1469,23 @@ const tagRefusal = (action: PushAction, facts: RefFacts): string | null => {
524
1469
  return `workit: raw ${quote(`git push${action.allTags ? " --tags" : ""}`)} of ${tags.length ? `tag ${tags.join(", ")}` : "tags"} bypasses the release grant. Push a release tag with ${quote(`workit git push --release ${named}`)} (v<version> tags whose commit the production branch already has; a tag never moves), under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}).`;
525
1470
  };
526
1471
 
527
- /** The remote already has the branch (ls-remote, bounded; unreachable answers yes: fail open). */
528
- const remoteHas = (repo: Repo, remote: string, branch: string): boolean => {
529
- const listed = spawnSync("git", ["ls-remote", "--heads", remote, `refs/heads/${branch}`], {
1472
+ /** The remote's branches (`ls-remote --heads`, bounded); null when it cannot be read. */
1473
+ const remoteHeads = (repo: Repo, remote: string): Set<string> | null => {
1474
+ const listed = spawnSync("git", ["ls-remote", "--heads", remote], {
530
1475
  cwd: repo.dir,
531
1476
  encoding: "utf8",
532
1477
  timeout: 3_000,
1478
+ windowsHide: true,
533
1479
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
534
1480
  });
535
- return listed.status !== 0 || (listed.stdout ?? "").trim() !== "";
1481
+ if (listed.status !== 0) return null;
1482
+ return new Set(
1483
+ (listed.stdout ?? "")
1484
+ .split("\n")
1485
+ .map((line) => line.split("\t")[1] ?? "")
1486
+ .filter((ref) => ref.startsWith("refs/heads/"))
1487
+ .map((ref) => ref.slice(11)),
1488
+ );
536
1489
  };
537
1490
 
538
1491
  /**
@@ -545,11 +1498,18 @@ const offPolicyPush = (action: PushAction, repo: Repo, facts: RefFacts, remote:
545
1498
  const names = action.allBranches
546
1499
  ? [...facts.heads]
547
1500
  : action.targets.map((target) => target ?? repo.branch);
1501
+ // Asked only when a tracking ref is missing, and listed once per push.
1502
+ // Unreachable answers yes: fail open.
1503
+ let listed: Set<string> | null | undefined;
1504
+ const remoteHas = (branch: string) =>
1505
+ (listed === undefined ? (listed = remoteHeads(repo, remote)) : listed)?.has(branch) ?? true;
548
1506
  for (const branch of names) {
549
- if (!branch || action.deleted?.includes(branch) || isTag(facts, branch)) continue;
1507
+ // A name the shell computes is not judged (like `git switch -c $B`).
1508
+ if (!branch || /[$`]/.test(branch)) continue;
1509
+ if (action.deleted?.includes(branch) || isTag(facts, branch)) continue;
550
1510
  if (facts.remoteHeads.has(branch)) continue;
551
1511
  const check = validateBranchNameFor(repo.dir, branch);
552
- if (check.ok || check.rule !== "allowed_pattern" || remoteHas(repo, remote, branch)) continue;
1512
+ if (check.ok || check.rule !== "allowed_pattern" || remoteHas(branch)) continue;
553
1513
  return `workit: raw ${quote("git push")} onto ${branch}: ${check.error}`;
554
1514
  }
555
1515
  return null;
@@ -577,23 +1537,64 @@ const nudgeFor = (action: RawAction, host: HookInput["host"], session: string):
577
1537
  }
578
1538
  };
579
1539
 
580
- type Located = { action: RawAction; repo: Repo };
1540
+ /**
1541
+ * Why a raw push to workit's push remote is refused, or null: `--mirror`,
1542
+ * a protected branch or the default target, a tag, or a new off-policy name.
1543
+ */
1544
+ const pushRefusal = (action: PushAction, repo: Repo, config: GitConfig): string | null => {
1545
+ if (!toPushRemote(repo, action.remote, config)) return null;
1546
+ if (action.mirror)
1547
+ return `workit: raw ${quote("git push --mirror")} rewrites and deletes every remote ref, protected branches and tags included. Push one branch with ${quote("workit git push")}.`;
1548
+ const remote = action.remote ?? pushRemoteOf(config, repo.branch) ?? "origin";
1549
+ const facts = refFacts(repo, remote);
1550
+ const targets = pushedBranches(action, repo, config, facts, remote);
1551
+ const pushed = { ...action, targets };
1552
+ // `--all` names the new off-policy branch before the protected one it also pushes.
1553
+ const offPolicy = action.allBranches ? offPolicyPush(pushed, repo, facts, remote) : null;
1554
+ if (offPolicy) return offPolicy;
1555
+ for (const target of targets) {
1556
+ const guarded = guardedTarget(repo.dir, target ?? repo.branch ?? "");
1557
+ if (guarded) return refusal(action, guarded, repo.dir);
1558
+ }
1559
+ return (
1560
+ tagRefusal(pushed, facts) ??
1561
+ (action.deletes ? null : offPolicyPush(pushed, repo, facts, remote))
1562
+ );
1563
+ };
1564
+
1565
+ type Located = { action: RawAction; repo: Repo; invocation: RawInvocation };
581
1566
 
582
- /** Raw invocations with a meaning, each with its Workit repository. */
583
- const actionsIn = (cwd: string, command: string, dialect: ShellDialect): Located[] => {
1567
+ /**
1568
+ * Raw invocations with a meaning, each with its Workit repository. A git
1569
+ * subcommand that is not one of git's own is resolved as an alias first
1570
+ * (inside a Workit workspace only: that costs one config read).
1571
+ */
1572
+ const actionsIn = (
1573
+ cwd: string,
1574
+ command: string,
1575
+ dialect: ShellDialect,
1576
+ cache: ConfigCache = new Map(),
1577
+ ): Located[] => {
584
1578
  const out: Located[] = [];
585
1579
  const repos = new Map<string, Repo | null>();
586
- for (const invocation of rawInvocations(command, dialect)) {
587
- if (invocation.dir === undefined) continue;
588
- const action = rawAction(invocation);
589
- if (!action) continue;
590
- const dir = path.resolve(cwd, invocation.dir ?? ".");
1580
+ const repoFor = (relative: string | null): Repo | null => {
1581
+ const dir = path.resolve(cwd, relative ?? ".");
591
1582
  if (!repos.has(dir)) {
592
1583
  const repo = repoAt(dir);
593
1584
  repos.set(dir, repo && isWorkitWorkspace(repo) ? repo : null);
594
1585
  }
595
- const repo = repos.get(dir);
596
- if (repo) out.push({ action, repo });
1586
+ return repos.get(dir) ?? null;
1587
+ };
1588
+ for (const raw of rawInvocations(command, dialect)) {
1589
+ if (raw.dir === undefined) continue;
1590
+ const known = raw.tool !== "git" || GIT_COMMANDS.has(raw.args[0] ?? "");
1591
+ if (!known && !repoFor(raw.dir)) continue;
1592
+ for (const invocation of known ? [raw] : expandGitAliases(cwd, raw, cache)) {
1593
+ if (invocation.dir === undefined) continue;
1594
+ const action = rawAction(invocation);
1595
+ const repo = action ? repoFor(invocation.dir) : null;
1596
+ if (action && repo) out.push({ action, repo, invocation });
1597
+ }
597
1598
  }
598
1599
  return out;
599
1600
  };
@@ -660,29 +1661,18 @@ export function rawGitPre(
660
1661
  options: { nudge?: boolean } = {},
661
1662
  ): HookDecision {
662
1663
  try {
663
- if (!mentionsRawTool(command)) return NONE;
1664
+ // No textual pre-filter: quoting, escapes and encoded scripts hide the
1665
+ // tool's name from one (`g''it`, `pwsh -EncodedCommand`).
664
1666
  const dialect = dialectOf(input);
665
1667
  const nudges = new Map<string, string>();
666
1668
  let store: string | null = null;
667
- for (const { action, repo } of actionsIn(input.cwd, command, dialect)) {
1669
+ const cache: ConfigCache = new Map();
1670
+ for (const { action, repo, invocation } of actionsIn(input.cwd, command, dialect, cache)) {
668
1671
  store ??= repo.location.dir;
669
1672
  if (action.kind === "merge") return deny(refusal(action, null));
670
- if (action.kind === "push" && toPushRemote(repo, action.remote)) {
671
- if (action.mirror)
672
- return deny(
673
- `workit: raw ${quote("git push --mirror")} rewrites and deletes every remote ref, protected branches and tags included. Push one branch with ${quote("workit git push")}.`,
674
- );
675
- for (const target of action.targets) {
676
- const branch = target ?? repo.branch;
677
- if (branch && guardedBranch(repo.dir, branch))
678
- return deny(refusal(action, branch, repo.dir));
679
- }
680
- const remote = action.remote ?? pushRemoteName(repo.dir, repo.branch) ?? "origin";
681
- const facts = refFacts(repo, remote);
682
- const tagDeny = tagRefusal(action, facts);
683
- if (tagDeny) return deny(tagDeny);
684
- const nameDeny = action.deletes ? null : offPolicyPush(action, repo, facts, remote);
685
- if (nameDeny) return deny(nameDeny);
1673
+ if (action.kind === "push") {
1674
+ const refused = pushRefusal(action, repo, gitConfig(repo.dir, invocation, cache));
1675
+ if (refused) return deny(refused);
686
1676
  }
687
1677
  const nudge = nudgeFor(action, input.host, input.session.id);
688
1678
  if (nudge && !nudges.has(nudgeKind(action))) nudges.set(nudgeKind(action), nudge);
@@ -691,13 +1681,27 @@ export function rawGitPre(
691
1681
  if (workitInvocations(command, dialect).length > 0) return NONE;
692
1682
  const shown = unseenNudges(input, nudges, store);
693
1683
  return shown.length ? { kind: "context", text: shown.join("\n") } : NONE;
694
- } catch {
695
- return NONE;
1684
+ } catch (error) {
1685
+ return error instanceof ShellAnalysisError ? analysisDenial(error) : NONE;
696
1686
  }
697
1687
  }
698
1688
 
699
1689
  const deny = (reason: string): HookDecision => ({ kind: "deny", reason, unblock: null });
700
1690
 
1691
+ /**
1692
+ * A command the shell hook could not analyze is refused, not waved through:
1693
+ * an agent-written command that defeats the parser could hide anything.
1694
+ */
1695
+ const SPLIT_IT =
1696
+ "Split it into simpler commands (no deeply nested substitutions or huge brace lists) and run them one at a time.";
1697
+
1698
+ export const analysisDenial = (error: ShellAnalysisError): HookDecision => ({
1699
+ kind: "deny",
1700
+ // Hosts show the reason alone: it carries the unblock too.
1701
+ reason: `workit: the shell hook could not analyze this command (${error.message}), so it does not run. ${SPLIT_IT}`,
1702
+ unblock: SPLIT_IT,
1703
+ });
1704
+
701
1705
  // ---------------------------------------------------------------------------
702
1706
  // recording raw commits (B2)
703
1707