@brainervirus/workit-core 8.12.2 → 8.13.0

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