@brainervirus/workit-core 8.12.1 → 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.
- package/package.json +1 -1
- package/src/autonomy.ts +1 -1
- package/src/core/route-intent.ts +185 -45
- package/src/git/ops.ts +17 -6
- package/src/hooks/hosts/cursor.ts +3 -1
- package/src/hooks/policy.ts +11 -2
- package/src/hooks/raw-git.ts +1146 -142
- package/src/hooks/shell-words.ts +383 -28
package/src/hooks/raw-git.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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 (
|
|
148
|
-
const
|
|
149
|
-
|
|
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 (
|
|
164
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
270
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
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
|
|
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
|
|
528
|
-
const
|
|
529
|
-
const listed = spawnSync("git", ["ls-remote", "--heads", remote
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
583
|
-
|
|
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
|
-
|
|
587
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
671
|
-
|
|
672
|
-
|
|
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
|
|