@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.
- package/package.json +1 -1
- package/skills/workit-ship/SKILL.md +20 -20
- package/skills/workit-ship/references/release.md +26 -0
- package/src/core/branch-policy.ts +15 -0
- package/src/core/methods.ts +12 -11
- package/src/core/route-intent.ts +185 -45
- package/src/forge/github.ts +30 -0
- package/src/forge/gitlab.ts +33 -0
- package/src/forge/types.ts +10 -0
- package/src/git/ops.ts +161 -46
- package/src/git/release.ts +719 -0
- package/src/hooks/hosts/cursor.ts +3 -1
- package/src/hooks/policy.ts +11 -2
- package/src/hooks/raw-git.ts +1152 -146
- package/src/hooks/shell-words.ts +383 -28
package/src/hooks/raw-git.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
/**
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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 (
|
|
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
|
-
});
|
|
626
|
+
if (segment.group === "close") {
|
|
627
|
+
const saved = groups.pop();
|
|
628
|
+
if (saved) ({ dir, stack } = saved);
|
|
161
629
|
continue;
|
|
162
630
|
}
|
|
163
|
-
if (
|
|
164
|
-
|
|
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
|
-
|
|
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) });
|
|
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
|
-
|
|
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
|
|
270
|
-
|
|
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
|
|
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);
|
|
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
|
|
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
|
|
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
|
|
524
|
-
|
|
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
|
|
528
|
-
const
|
|
529
|
-
const listed = spawnSync("git", ["ls-remote", "--heads", remote
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
583
|
-
|
|
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
|
-
|
|
587
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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);
|
|
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
|
|