// The pure half of the so-hud protection gates (#1551): reading a shell command, a tool's output // and a peer's scope. Nothing here touches `$`, the engine or node:, so every rule runs inside the // mod engine and is testable on its own. Ported from an internal mod; only its general rules are // carried over. import { scanHeredocs, type HeredocStage } from './heredoc-scan' // ---- Reading a shell command ------------------------------------------------ // One part of a shell word: a double-quoted run, a single-quoted run, a backslash escape, plain text, // or a quote that never closes (kept as written). const WORD_PART = /"((?:[^"\\]|\\.)*)"|'([^']*)'|\\(.)|([^"'\\]+)|(["'\\])/gs /** A shell word with its quotes and escapes removed, as the shell passes it on: `"HEAD:ma"in` is `HEAD:main`. */ function dequote(word: string): string { return word.replace(WORD_PART, (_m: string, dq?: string, sq?: string, esc?: string, plain?: string, lone?: string) => dq !== undefined ? dq.replace(/\\(["\\$`])/g, '$1') : (sq ?? esc ?? plain ?? lone ?? '')) } /** * Splits one segment into shell words: each word is a run of `"…"`, `'…'` and unquoted characters * joined together, quotes and escapes removed. Good enough to read flags and paths. */ export function tokenize(text: string): string[] { return rawWords(text).map(dequote) } /** The shell words of a segment as written, quotes and escapes kept. */ function rawWords(text: string): string[] { return [...text.matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\]+|["'\\])+/gs)].map(m => m[0]) } // An output redirection or descriptor copy as written: `2>&1`, `>/dev/null`, `&>>log`, `<&3`, or an // operator alone (`>`, `2>`, `&>`) whose target is the next word. Input redirections (`< .env`) stay // words: the file they name is read by the command. const REDIRECT = /^(?:(?:\d+|&)?>[>|&]?|\d*<&)(.*)$/s // An input redirection as written: `file`, a here-string `<<|<)(.*)$/s /** * The raw words without their output redirections — and, when `input`, their input redirections: * the shell removes them before the command sees its words, so `git 2>/dev/null push` is `git push`. * Checked before quote removal, so a quoted `">"` stays an argument; `>(…)` and `<(…)` (process * substitution) stay words too. */ function dropRedirections(raw: readonly string[], input: boolean): string[] { const out: string[] = [] for (let i = 0; i < raw.length; i += 1) { const word = raw[i] ?? '' const target = REDIRECT.exec(word)?.[1] ?? (input ? IN_REDIRECT.exec(word)?.[1] : undefined) if (target === undefined || target.startsWith('(')) out.push(word) else if (target === '') i += 1 } return out } // Where a redirection or process substitution starts after its `<` or `>`, longest form first. const REDIRECT_OP = /<<<|<<-?|<>|<&|<\(|<|>\(|>>|>&|>\||>/y // The characters that end a redirection target unquoted in every shell; also where the next one may // start. An unquoted `&` inside a target starts `&>` (a lone one already ended the segment): `x&>y`. const TARGET_END = /[<>()&]/g const ANGLE = /[<>]/g /** The word with every quoted run and backslash escape blanked to `_`, same length: what is left is unquoted. */ function maskQuoted(word: string): string { return word.replace(WORD_PART, (m: string, _dq?: string, _sq?: string, _esc?: string, plain?: string) => (plain === undefined ? '_'.repeat(m.length) : m)) } /** * Whether an output redirection writes nothing: `>`, `>>` or `&>` (any descriptor) onto exactly * `/dev/null`, or `>&` copying stdout or stderr onto the other. Every other form, target or descriptor * (`>&3`, `3>&1`, `&>>`, `<>`, `<&`, `>|`, a quoted or `$` target) counts as a write. */ function harmlessOutput(op: string, fd: string, target: string): boolean { if (op === '>' || op === '>>' || op === '&>') return target === '/dev/null' return op === '>&' && (fd === '' || fd === '1' || fd === '2') && (target === '1' || target === '2') } /** * Whether a segment carries an output redirection that may write a file (#1569, MR !133): `git log * --format=… >> .git/config` wrote the push configuration the facts never saw. Read quote-aware over the * same words as {@link readCommand}, also where the reader keeps the word (`--short>>.git/config`), so a * quoted `"a > b"` is no redirection. Input redirections (`<`, `N<`, `<<`, `<<<`) and process * substitutions (`<(…)`, `>(…)`, read by {@link gitInSubstitution}) write nothing here. One pass per word. */ function writesOutput(segment: string): boolean { // An operator whose target is the next word: `> .git/HEAD`. `in` skips that word, `out` judges it. let pending: { kind: 'in' | 'out'; op: string; fd: string } | null = null for (const word of rawWords(stripGroup(segment))) { const masked = maskQuoted(word) let at = 0 // The target runs to the next unquoted `<`, `>`, `(`, `)` or `&`: `/dev/null)` closes a `$(…)`. const targetEnd = (from: number): number => { TARGET_END.lastIndex = from return TARGET_END.exec(masked)?.index ?? word.length } if (pending !== null) { at = targetEnd(0) if (pending.kind === 'out' && !harmlessOutput(pending.op, pending.fd, word.slice(0, at))) return true pending = null } for (;;) { ANGLE.lastIndex = at const angle = ANGLE.exec(masked)?.index if (angle === undefined) break REDIRECT_OP.lastIndex = angle let op = REDIRECT_OP.exec(masked)?.[0] ?? '' let opEnd = angle + op.length if (op === '<(' || op === '>(') { at = opEnd continue } // `&>` / `&>>`: the `&` belongs to the operator; otherwise the digits in front are its descriptor. let fd = '' if (op.startsWith('>') && masked[angle - 1] === '&') op = `&${op}` else { let start = angle while (start > at && /\d/.test(masked[start - 1] ?? '')) start -= 1 fd = masked.slice(start, angle) } if (op === '&>' && masked[opEnd] === '>') { op = '&>>' opEnd += 1 } const kind = op.startsWith('<') && op !== '<>' && op !== '<&' ? 'in' : 'out' if (opEnd >= word.length) { pending = { kind, op, fd } break } at = targetEnd(opEnd) if (kind === 'out' && !harmlessOutput(op, fd, word.slice(opEnd, at))) return true } } // An output operator with no target left (`git log >`, cut at a `|`): fail-closed. return pending?.kind === 'out' } /** The words of a segment as the command receives them: redirections dropped (input ones when `input`), quotes removed. */ function argWords(text: string, input = false): string[] { return dropRedirections(rawWords(text), input).map(dequote) } // Words that can come before the real command without changing what it does. `eval` with unquoted // words runs exactly those words; its quoted form is read by {@link extractInner}. `if`, `while`, // `until` and `elif` run their condition as a command, `then`, `do` and `else` their body. const PREFIXES = new Set(['command', 'exec', 'nohup', 'time', 'if', 'elif', 'while', 'until', 'then', 'do', 'else', '!', 'eval']) // The options of the transparent prefixes and runners that take a separate value. const ENV_VALUED = new Set(['-u', '--unset']) const SUDO_VALUED = new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '--user', '--group', '--host', '--prompt', '--close-from', '--chdir', '--role', '--type', '--other-user', '--command-timeout']) const NICE_VALUED = new Set(['-n', '--adjustment']) const TIMEOUT_VALUED = new Set(['-s', '--signal', '-k', '--kill-after']) const RUNNER_VALUED = new Set(['-p', '--package']) // caffeinate's -t and -w , also as the last letter of a group (`-it 5`). const CAFFEINATE_VALUED = /^-[a-z]*[tw]$/ // A variable assignment word: `NAME=value`, `NAME+=value`, and an array element `NAME[i]=value` (zsh: `HOME[1,-1]=x` // rewrites HOME, #1571); group 1 is the name. const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?\+?=/ /** * A segment without the `(`/`{` in front and the `)`/`}` behind it. Trimmed by hand: `\s*[)}]+$` * retried from every blank of a long run, quadratic on attacker-controlled length. */ function stripGroup(segment: string): string { const text = segment.trim().replace(/^[({]+\s*/, '') let end = text.length while (end > 0 && (text[end - 1] === ')' || text[end - 1] === '}')) end -= 1 return end === text.length ? text : text.slice(0, end).trimEnd() } /** * The command a segment runs, read with an index (a `shift()` per word was quadratic: 535 ms for 50k * characters of `npx -y`): its words with redirections, leading `VAR=x`, `env -u X`, `sudo`, `nice`, * `timeout`, `caffeinate`, the prefixes above and a runner's own options removed, and the names those * leading assignments set. `input` also drops input redirections (the outward reader). */ function readCommand(segment: string, input: boolean): { words: string[]; assigned: string[] } { const words = argWords(stripGroup(segment), input) const assigned: string[] = [] let i = 0 // Skips options (and, for those `valued` names, their value) up to the first word that is none, or past `--`. // `env -u NAME` / `--unset=NAME` removes NAME for the command: recorded like an assignment (`env -u HOME git push`). const skipOptions = (valued: ReadonlySet | RegExp, unsets = false): void => { while ((words[i] ?? '').startsWith('-')) { const option = words[i] ?? '' i += 1 if (option === '--') break if (unsets && option.startsWith('--unset=')) assigned.push(option.slice('--unset='.length)) else if (unsets && /^-u./.test(option)) assigned.push(option.slice(2)) if (valued instanceof RegExp ? valued.test(option) : valued.has(option)) { if (unsets) assigned.push(words[i] ?? '') i += 1 } } } for (;;) { const word = words[i] if (word === undefined) break const assignment = ASSIGNMENT.exec(word) if (assignment !== null || PREFIXES.has(word)) { if (assignment !== null) assigned.push(assignment[1] ?? '') i += 1 continue } if (word === 'env' || word === 'sudo' || word === 'nice') { i += 1 skipOptions(word === 'env' ? ENV_VALUED : word === 'sudo' ? SUDO_VALUED : NICE_VALUED, word === 'env') continue } if (word === 'timeout' || word === 'gtimeout') { i += 1 skipOptions(TIMEOUT_VALUED) i += 1 // the duration continue } if (word === 'caffeinate') { i += 1 skipOptions(CAFFEINATE_VALUED) continue } break } // npx vercel, npx --yes npm publish, pnpm dlx vercel, npm exec -- changeset: the tool is the next // word after the runner's own options. if (words[i] === 'npx' || words[i] === 'bunx') { i += 1 skipOptions(RUNNER_VALUED) } else if ((words[i] === 'pnpm' && (words[i + 1] === 'dlx' || words[i + 1] === 'exec')) || (words[i] === 'npm' && (words[i + 1] === 'exec' || words[i + 1] === 'x'))) { i += 2 skipOptions(RUNNER_VALUED) } return { words: words.slice(i), assigned } } /** The words of one segment, redirections, leading `VAR=x`, `env -u X`, `sudo`, `nice`, `timeout`, `caffeinate` and the prefixes above removed. */ export function commandWords(segment: string): string[] { return readCommand(segment, false).words } /** `\` + newline removed, as the shell does before it reads words: `git \push` is `git push`. */ function joinContinuations(command: string): string { return command.replace(/\\\r?\n/g, '') } // ---- What the shell never runs: comments and data heredocs (#1572, #1574) ----- // The characters after which an unquoted `#` starts a comment (the start of a word). const WORD_BREAK = /[\s;&|()<>]/ // A heredoc operator and its delimiter: `<`, `&&`, `||`, `;`, `&` and // simple quotes — no `|` into another command, no second heredoc, no `$`, backtick or backslash. const HEREDOC_REST = /^(?:[^'"`$\\|<\n]|'[^'\n]*'|"[^"\\$`\n]*"|\|\|)*$/ // A `${name}` that nests nothing; any other `${` stops the view like `$(`. const PLAIN_BRACE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/y // Heredocs whose closing line was not found before the view gives up (each miss reads to the end of the line). const MAX_HEREDOC_MISSES = 4 // An `eval` word: what it is given is parsed again as shell code, so nothing quoted behind it is text. const EVAL_WORD = /(?:^|[\s;&|(){}])eval(?=\s)/ /** * Whether the command a heredoc or `$(cat <<'EOF' …)` feeds treats it as text: `cat` printing it (no file * written), `git commit` (its message), or a forge's issue/merge-request/pull-request command (a body). Never a * shell, an interpreter, `gh api` (a GraphQL body) or anything else (#1574: `bash <<'EOF'` still runs its body). */ function readsAsText(words: readonly string[], writes: boolean): boolean { const [first, ...args] = words const cmd = baseName(first ?? '') if (cmd === 'cat') return !writes && args.every(arg => arg === '-') if (cmd === 'git') { const git = readGit(args, null) return git.sub === 'commit' && git.config.length === 0 } return (cmd === 'gh' || cmd === 'glab') && (args[0] === 'issue' || args[0] === 'mr' || args[0] === 'pr') } /** * Whether the body of a heredoc with an unquoted delimiter runs nothing: no backtick, `$(`, `$((`, `$[` or `${…}` other * than `${name}` — a plain `$name` only expands. (A quoted delimiter keeps every body literal.) */ function inertBody(body: string): boolean { return !/`|\$[([]|\$\{(?![A-Za-z_][A-Za-z0-9_]*\})/.test(body) } /** The index just past the closing line of a heredoc whose body starts at `from`, or -1 (`tabs`: `<<-`, leading tabs stripped). */ function heredocEnd(line: string, from: number, delimiter: string, tabs: boolean): number { let at = from while (at <= line.length) { const nl = line.indexOf('\n', at) const end = nl === -1 ? line.length : nl const text = line.slice(at, end) if ((tabs ? text.replace(/^\t+/, '') : text) === delimiter) return end if (nl === -1) return -1 at = nl + 1 } return -1 } /** * The line as the shell runs it, for every reader below: each comment removed, and each heredoc that is only text * replaced by the word `<_` (an input redirection the readers drop), its body and closing line removed — so a commit * message, an issue body or a comment that mentions `git push` is no command, and `git commit -m "$(cat <<'EOF' … * EOF)"` (Claude Code's own recipe) is one git step, not a `cat` before the push (#1574). A heredoc is text only with * a quoted delimiter (or an unquoted one with an {@link inertBody}) and a reader per * {@link readsAsText}; `$(cat <<'EOF' … EOF)` only inside double quotes, its closing `)` right after the closing line. * Read with the quote tracking of {@link scanLine}: from the first newline inside a quote or construct that nests its * own quoting (`$(`, a backtick, `${…}` other than `${name}`, `$'`, `<(`, `>(`, a heredoc that is not text) the rest * is kept as written — over-reading only asks. A `#` starts a comment at the start of a word outside quotes: a push * between `# bash -c '` and `# '` runs (#1572 E1). */ function dataView(line: string): string { const out: string[] = [] let quote: '"' | "'" | null = null let segStart = 0 let misses = 0 let copied = 0 let consumerAt = -1 let consumerText = false const flush = (to: number): void => { if (to > copied) out.push(line.slice(copied, to)) copied = to } for (let i = 0; i < line.length; i += 1) { const ch = line[i] ?? '' if (quote === "'") { if (ch === "'") quote = null else if (ch === '\n') break continue } if (ch === '\\') { i += 1 continue } if (ch === '\n' && quote !== null) break if (ch === '`') break if (ch === '$') { const next = line[i + 1] if (next === '{') { PLAIN_BRACE.lastIndex = i if (!PLAIN_BRACE.test(line)) break i = PLAIN_BRACE.lastIndex - 1 continue } if (next === "'") break if (next !== '(') continue CAT_HEREDOC.lastIndex = i const cat = CAT_HEREDOC.exec(line) const delimiter = cat?.[2] ?? cat?.[3] if (cat === null || delimiter === undefined || quote !== '"' || misses >= MAX_HEREDOC_MISSES) break const end = heredocEnd(line, CAT_HEREDOC.lastIndex, delimiter, cat[1] === '-') const close = end === -1 ? null : /^\s*\)/.exec(line.slice(end, end + 64)) if (close === null) { misses += 1 break } // Judged once per segment: many substitutions in one quoted word would re-read its growing prefix (quadratic). if (consumerAt !== segStart) { const prefix = line.slice(segStart, i) const consumer = readCommand(prefix, true).words consumerAt = segStart // `eval git commit -m "$(cat …)"` parses the message again as shell code. consumerText = readsAsText(consumer, false) && baseName(consumer[0] ?? '') !== 'cat' && !EVAL_WORD.test(prefix) } if (!consumerText) break flush(i) out.push('_') copied = end + close[0].length i = copied - 1 continue } if (quote === '"') { if (ch === '"') quote = null continue } if (ch === '"' || ch === "'") quote = ch else if ((ch === '<' || ch === '>') && line[i + 1] === '(') break else if (ch === '#' && (i === 0 || WORD_BREAK.test(line[i - 1] ?? ''))) { flush(i) const nl = line.indexOf('\n', i) copied = nl === -1 ? line.length : nl i = copied - 1 } else if (ch === '<' && line[i + 1] === '<' && line[i + 2] === '<') i += 2 else if (ch === '<' && line[i + 1] === '<') { HEREDOC_OP.lastIndex = i const op = HEREDOC_OP.exec(line) const delimiter = op?.[2] ?? op?.[3] ?? op?.[4] ?? op?.[5] if (op === null || delimiter === undefined || misses >= MAX_HEREDOC_MISSES) break const opEnd = HEREDOC_OP.lastIndex const nl = line.indexOf('\n', opEnd) const rest = line.slice(opEnd, nl === -1 ? line.length : nl) if (nl === -1 || !HEREDOC_REST.test(rest)) break const end = heredocEnd(line, nl + 1, delimiter, op[1] === '-') if (end === -1) { misses += 1 break } const reader = `${line.slice(segStart, i)} ${rest}` const body = line.slice(nl + 1, end) const literal = op[5] === undefined || inertBody(body) if (!literal || !readsAsText(readCommand(reader, true).words, writesOutput(reader))) break flush(i) out.push(`<_${rest}`) copied = end i = end - 1 } else if (ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '(' || ch === ')') segStart = i + 1 } flush(line.length) return out.join('') } /** * One pass over a line. `marked` is the line with each background `&` turned into `;`, same length: a * lone `&` ends a command like `;` does (`true & git push`). Not `&&`, `&>`, `>&`, `<&` or `|&`, and * not after a backslash or inside quotes, where `&` is a character (`curl "…?a=1&b=2"`, a commit * message) — but only while the quote tracking is certain. It stops being certain (`doubt`) at the * first newline inside an open quote (a heredoc body or a multi-line string: which of the two, this * cannot tell, and resetting the quote there flipped parity on the next line, so `"msgx=1"; git * status & git switch main` hid the `&`) and at the first construct that nests its own quoting or is * no shell word: `$(`, `${`, `$'`, a backtick, `<(`, `>(`, `<<`. From there every lone `&` is cut, * quoted or not: over-reading finds more commands, never fewer. * `unsure[i]` is 1 where position `i` may sit inside a quote — or after doubt began, or after an * unquoted `(` or `{` (a group: a `cd` there may not reach what follows); `unsure[length]` is the state * at the end. */ function scanLine(line: string): { marked: string; unsure: Uint8Array } { const out: string[] = new Array(line.length) const unsure = new Uint8Array(line.length + 1) let quote: string | null = null let doubt = false let group = false for (let i = 0; i < line.length; i += 1) { const ch = line[i] ?? '' const next = line[i + 1] ?? '' if (!doubt && ((ch === '$' && (next === '(' || next === '{' || next === "'")) || ch === '`' || ((ch === '<' || ch === '>') && next === '(') || (ch === '<' && next === '<') || (ch === '\n' && quote !== null))) doubt = true unsure[i] = doubt || group || quote !== null ? 1 : 0 out[i] = ch if (ch === '&' && (quote === null || doubt) && !/[&<>|]/.test(line[i - 1] ?? '') && !/[&>]/.test(next)) out[i] = ';' else if (quote === "'") { if (ch === "'") quote = null } else if (ch === '\\') { i += 1 if (i < line.length) { out[i] = line[i] ?? '' unsure[i] = unsure[i - 1] ?? 1 } } else if (quote === '"') { if (ch === '"') quote = null } else if (ch === '"' || ch === "'") quote = ch else if (ch === '(' || ch === '{') group = true } unsure[line.length] = doubt || group || quote !== null ? 1 : 0 return { marked: out.join(''), unsure } } /** The line with each background `&` turned into `;` ({@link scanLine}). */ function markBackground(line: string): string { return scanLine(line).marked } // Where splitSegments cuts: `&&`, `||`, `|` and `|&`, `;`, newlines (a background `&` is a `;` by then). const SEPARATOR = /&&|\|\||\|&?|;|\n/g /** The command line cut at `&&`, `||`, `;`, `&`, `|`, `|&` and newlines. Quotes are honoured for `&` only: detection only. */ export function splitSegments(command: string): string[] { return markBackground(joinContinuations(command)).split(SEPARATOR) } // A separator as written; '' is the start or end of the line. type Sep = '' | '&&' | '||' | '|' | '|&' | ';' | '\n' | '&' // The separators that end an and-or list (`a && b || c`): what follows runs whatever the list returned. const LIST_END: ReadonlySet = new Set([';', '\n', '&']) const PIPE: ReadonlySet = new Set(['|', '|&']) /** * A segment as {@link splitSegments} cuts it, with the separators around it. `sure`: at neither end can * it sit inside a quote, after doubt began, or inside a group ({@link scanLine}) — so it is a command * of its own in this shell, not a piece of a string a separator inside quotes cut out. `start`: its index in the line. */ type Seg = { text: string; before: Sep; after: Sep; sure: boolean; start: number } function segmentsOf(command: string): Seg[] { const line = joinContinuations(command) const { marked, unsure } = scanLine(line) const segs: Seg[] = [] let start = 0 let before: Sep = '' const push = (end: number, after: Sep): void => { segs.push({ text: line.slice(start, end), before, after, sure: unsure[start] === 0 && unsure[end] === 0, start }) } for (const m of marked.matchAll(SEPARATOR)) { const sep = (m[0] === ';' && line[m.index] === '&' ? '&' : m[0]) as Sep push(m.index, sep) start = m.index + m[0].length before = sep } push(line.length, '') return segs } /** The pipelines of a command line, each a list of its stages. */ export function splitPipelines(command: string): string[][] { return markBackground(joinContinuations(command)).split(/&&|\|\||;|\n/).map(pipeline => pipeline.split(/\|&?/)) } // A quoted command line run by another shell (`bash -c "…"`, `sh -lc '…'`), by a runner's shell mode // (`npx -c '…'`, `pnpm exec -c '…'`) or by `eval "…"`. Its separators sit inside quotes, which // splitSegments does not honour, so it is cut out first and read as a command line of its own. // The shell runs one word after `-c` (the rest become $0, $1, …); `eval` joins all its words. A word // that starts with `-` (`bash -c -- "…"`) or a shell this cannot cut is left whole: {@link unreadableOutward} reads it. const SHELL_WORD = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\;&|()<>]+)+` const INNER_SOURCE = String.raw`(?= MAX_DEPTH) return { text: line, inners } const text = line.replace(INNER, (_match: string, shellWord?: string, evalWords?: string) => { if (shellWord !== undefined) { const expanded = shellWord.replace(/"(?:[^"\\]|\\.)*"|'[^']*'|\\./g, part => (part.startsWith("'") ? '' : part)) inners.push({ command: dequote(shellWord), sameShell: false, expanded }) } else { const command = tokenize(evalWords ?? '').join(' ') inners.push({ command, sameShell: true, expanded: command }) } return ` __SOHUD_INNER_${inners.length - 1}__ ` }) return { text, inners } } /** The inner command line a segment runs, when its first word is a placeholder. */ function innerAt(word: string, inners: readonly Inner[]): Inner | null { const m = INNER_WORD.exec(word) return m ? (inners[Number(m[1])] ?? null) : null } function baseName(word: string): string { return word.replace(/^\\/, '').split('/').pop() ?? word } /** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */ function joinDir(dir: string | null, arg: string | undefined): string { if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) { return arg ?? '~' } return dir ? `${dir}/${arg}` : arg } /** The positional arguments, skipping each option and, for the options in `valued`, its value. */ export function positionals(args: readonly string[], valued: ReadonlySet): string[] { const out: string[] = [] for (let i = 0; i < args.length; i += 1) { const arg = args[i] ?? '' if (arg === '--') { out.push(...args.slice(i + 1)) break } if (arg.startsWith('-') && arg !== '-') { if (valued.has(arg)) i += 1 continue } out.push(arg) } return out } /** The value of `--name value`, `--name=value` or `-n value`, or undefined. */ export function optionValue(args: readonly string[], ...names: string[]): string | undefined { for (let i = 0; i < args.length; i += 1) { const arg = args[i] ?? '' for (const name of names) { if (arg === name) return args[i + 1] if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1) } } return undefined } // ---- Actions with an effect outside this machine ----------------------------- export type OutwardKind = 'push' | 'merge' | 'publish' | 'deploy' | 'release' export const KIND_LABEL: Record = { push: 'Push', merge: 'Merge', publish: 'Paket veröffentlichen', deploy: 'Produktiv-Deploy', release: 'Release', } export type Outward = { kind: OutwardKind /** The detected action. */ label: string /** The command's own words after the subcommand. */ args: string[] /** * Where a `cd` earlier on the line moved to; null means the session folder. A `cd`, `pushd` or * `popd` whose effect on this command is not certain — inside `( )`, `{ }` or quotes, in a pipeline * stage, after `||` or followed by one before the command, ending a background list, behind a * prefix (`if`, `then`, `command`, `VAR=x`), after `&&` in an earlier list, an option or `-`/`+N` — * makes it {@link UNKNOWN_DIR} (or a folder below it), which resolves to no folder, so the push asks. */ dir: string | null /** For merges and releases: which forge CLI. */ forge?: 'glab' | 'gh' /** For publishes and deploys: which tool. */ tool?: string /** * For pushes: every git invocation earlier on the line as a step ({@link gitStep}), child shells and * command substitutions included: any of them may have moved what the facts measured. A git step run * in a folder other than {@link dir}, or in an unknown one, is recorded as ` [other folder]`: its * hooks and its configuration are not the ones {@link PushFacts.hooks} and {@link PushFacts.repoConfig} * measured (`git -C ../b diff` runs ../b's `diff.external`, #1573 review). */ gitBefore?: string[] /** * For pushes: some earlier command on the line is not a git call — in this shell, a child shell or a * command substitution (`gh pr checkout 5 && …`, `./switch.sh; …`, `x=$(./x)`) — so it may have moved * HEAD or written the push configuration (`printf … >> .git/config`): every push form behind it asks. * `cd`/`pushd`/`popd` (read as {@link dir}) and a bare assignment (`x=1`) run no command and do not set it. * Also set by an earlier output redirection on any segment, git steps included ({@link writesOutput}): * `git log --format=… >> .git/config`, `cd . > .git/HEAD`, `exec 3>>.git/config`. */ otherBefore?: true /** * For pushes: an earlier `git commit` on the line signs explicitly (`-S`, `-S`, `--gpg-sign`, a short * option bundle holding `S`; the last of these and `--no-gpg-sign` wins), so it runs `gpg.program` whatever * `commit.gpgSign` says ({@link repoConfigStandard}). `tag -s` and `merge -S` are no local step. */ signsBefore?: true /** * For pushes: git configuration the push runs with that the measured facts did not see — each * `-c` / `--config-env` entry as written (`key=value`), `--git-dir` / `--work-tree` / `--namespace` * as `--option=value`, every other global option as written, the names of `GIT_CONFIG*` / * `GIT_DIR`-style variables anywhere on the line, and the name of every variable assigned, exported, * declared or unset on the line (in front of the push or as a statement) that is not * {@link HARMLESS_VAR} (`HOME=x` moves the config git reads), plus `source`/`.`/`alias`/`hash` as written. */ gitConfig?: string[] /** For pushes: the destination is not read from git's own words (an unreadable line, a forge API ref write): always asks. */ opaque?: true } // stepDirs: the folder each git step of gitBefore ran in, by its index. type WalkState = { dir: string | null; gitBefore: string[]; stepDirs: Map; otherBefore: boolean; signs: boolean; gitEnv: Set } // Variables that route a push past what the facts measured: extra config, another repository. const GIT_ENV = /(? inners[Number(n)]?.command ?? m) } /** * The fail-closed fallback after the walker (#1570): the outward kind of the first segment that names an outward * action ({@link classKindIn}) but formed no hit and was not judged ({@link judged}); null when there is none. A * denylist of spellings does not converge (each review found new wrappers: `time -p`, `flock … bash -c`, `nohup --`, * `sh <<<'…'`, `python3 -c`, `case x in a|x)`), so this asks for every segment it cannot account for instead. Not read * as code: a segment whose command is in {@link DATA_COMMANDS} (unless its output is piped into a command outside * {@link PIPE_DATA}: `echo git push origin main | sh`), one with no command (`msg="deploy"`), and what {@link dataView} * removed. A placeholder of a cut-out `bash -c '…'` that is not the first word (`flock /tmp/l bash -c "…"`) counts * as code; a first-word one is read as its own line. Inline code of a {@link GATED_INTERPRETERS} interpreter counts * only on a line with a process API ({@link SPAWN_API}). A git subcommand from a variable (`git ${X}sh`) always asks, * a command word from one asks when its subcommand is an outward word ({@link OUTWARD_SUBWORDS}: `${X}t push`). */ function unformedKind(line: string, depth: number): OutwardKind | null { const { text, inners } = extractInner(line, depth) const joined = joinContinuations(text) const segs = segmentsOf(joined) const spawns = SPAWN_API.test(joined) const openers = quoteOpeners(joined, spawns) const words = segs.map(seg => readCommand(seg.text, true).words) // Per segment, the first segment from it on whose cut is outside every quote (or not followed): where its command ends. const realEnd = new Int32Array(segs.length) for (let j = segs.length - 1; j >= 0; j -= 1) { const cut = openers[(segs[j]?.start ?? 0) + (segs[j]?.text.length ?? 0)] ?? UNKNOWN_QUOTE realEnd[j] = cut < 0 || j === segs.length - 1 ? j : (realEnd[j + 1] ?? j) } // The segment holding index `at` (the last one starting at or before it). const segAt = (at: number): number => { let lo = 0 let hi = segs.length - 1 while (lo < hi) { const mid = (lo + hi + 1) >> 1 if ((segs[mid]?.start ?? 0) <= at) lo = mid else hi = mid - 1 } return lo } for (let k = 0; k < segs.length; k += 1) { const seg = segs[k] if (seg === undefined) continue const opener = openers[seg.start] ?? UNKNOWN_QUOTE if (opener === HEREDOC_DATA) continue // A segment cut out of a quoted word (a commit body's line, `-m "a; b"`) is that word's command's argument — // unless an `eval` parses that word again. const quotedIn = opener >= 0 ? segAt(opener) : k const owner = EVAL_WORD.test(segs[quotedIn]?.text ?? '') ? k : quotedIn const [first, ...args] = words[owner] ?? [] if (first === undefined) continue const inner = innerAt(first, inners) if (inner !== null) { if (owner !== k) continue const kind = unformedKind(inner.command, depth + 1) if (kind !== null) return kind continue } const cmd = baseName(first) if (owner === k && cmd === 'git' && (readGit(args, null).sub ?? '').includes('$')) return 'push' if (judged(cmd, args)) continue // The command ends at the first cut outside its quotes: a `|` inside `grep -E "a|b"` is no pipe. const last = realEnd[owner] ?? owner const next = words[last + 1] ?? [] const piped = PIPE.has(segs[last]?.after ?? '') && !PIPE_DATA.has(baseName(next[0] ?? '')) && !judged(baseName(next[0] ?? ''), next.slice(1)) if (DATA_COMMANDS.has(cmd) && !piped) continue if (GATED_INTERPRETERS.has(cmd) && !spawns && !(BACKTICK_RUNNERS.has(cmd) && joined.includes('`'))) continue const code = withInners(seg.text, inners) const kind = classKindIn(code) if (kind !== null) return kind if (owner === k && first.includes('$') && args.slice(0, 2).some(word => OUTWARD_SUBWORDS.has(word))) return 'push' } return null } /** * Per segment of `segs` (as {@link segmentsOf} cut `text`): whether it is text, not a command — cut out of a quoted * word of a {@link DATA_COMMANDS} command whose output no command that runs it reads, the reading {@link unformedKind} * gives the same segment. `grep -E "a|git push|b" f` cut `git push` out of its pattern, and the walker formed a push * from it (#1891). A quoted word that `eval` parses again, or one piped into a shell (`echo "a|git push|b" | sh`), stays * code. */ function quotedDataSegments(text: string, segs: readonly Seg[]): boolean[] { const line = joinContinuations(text) const openers = quoteOpeners(line, SPAWN_API.test(line)) const words = segs.map(seg => readCommand(seg.text, true).words) const realEnd = new Int32Array(segs.length) for (let j = segs.length - 1; j >= 0; j -= 1) { const cut = openers[(segs[j]?.start ?? 0) + (segs[j]?.text.length ?? 0)] ?? UNKNOWN_QUOTE realEnd[j] = cut < 0 || j === segs.length - 1 ? j : (realEnd[j + 1] ?? j) } const segAt = (at: number): number => { let lo = 0 let hi = segs.length - 1 while (lo < hi) { const mid = (lo + hi + 1) >> 1 if ((segs[mid]?.start ?? 0) <= at) lo = mid else hi = mid - 1 } return lo } return segs.map((seg, k) => { const opener = openers[seg.start] ?? UNKNOWN_QUOTE if (opener < 0) return false const owner = segAt(opener) if (owner === k || EVAL_WORD.test(segs[owner]?.text ?? '')) return false const first = words[owner]?.[0] if (first === undefined || !DATA_COMMANDS.has(baseName(first))) return false const last = realEnd[owner] ?? owner const next = words[last + 1] ?? [] return !(PIPE.has(segs[last]?.after ?? '') && !PIPE_DATA.has(baseName(next[0] ?? '')) && !judged(baseName(next[0] ?? ''), next.slice(1))) }) } // A command that may run a file written earlier on the line, in command position (line start or after `;`, `&`, `|`, // `(`, `{`, a newline, `then`/`do`): a shell or interpreter, `source`/`.`, `exec`, `xargs`, or a path like `./x`, `/tmp/x.sh`. const RUNS_FILE = /(?:^|[;&|(){}\n]|\b(?:then|do|else)\s)\s*(?:sudo\s+|env\s+|nohup\s+|exec\s+)*(sh|bash|zsh|dash|ksh|fish|source|\.|python3?|node|deno|bun|tsx|perl|ruby|exec|xargs|\.{0,2}\/\S+)(?=[\s;&|()]|$)/g /** * Whether a line may run a file it wrote ({@link RUNS_FILE}): a runner that is no {@link GATED_INTERPRETERS} one, or a * gated one on a line that can start a process (`spawns`) — `cat > x.mjs <<'EOF' … EOF; node x.mjs` with no process API * runs no git. */ function runsWrittenFile(line: string, spawns: boolean): boolean { for (const m of line.matchAll(RUNS_FILE)) { const runner = m[1] ?? '' if (spawns || !GATED_INTERPRETERS.has(runner) || (BACKTICK_RUNNERS.has(runner) && line.includes('`'))) return true } return false } // Values of {@link quoteOpeners} that are no index: outside every quote, not followed (after a construct that nests its // own quoting), and inside the body of a heredoc read as text. const NO_QUOTE = -1 const UNKNOWN_QUOTE = -2 const HEREDOC_DATA = -3 /** * Per index of `line`: where the quote it sits inside opened, {@link NO_QUOTE}, {@link HEREDOC_DATA} inside the body of * a heredoc with a literal body that `cat` or `tee` reads (also into a file, on a line that runs no file * ({@link runsWrittenFile}): the walker still reads those lines, this only keeps the fallback from asking for them) or a * {@link GATED_INTERPRETERS} one does on a line without `spawns`, * or {@link UNKNOWN_QUOTE} from the first construct whose quoting this does not follow (a backtick, `${…}` other than * `${name}`, `$'`, `<(`, `>(`, any other heredoc). Quotes are followed * across newlines (a multi-line commit message is one word) and into `$(…)`, whose body starts unquoted and whose `)` * restores the quote around it; a `case` or a comment inside it stops the tracking (their `)` would end it early). Every * `$(…)` body is read by {@link unreadRegions} as well. One pass. */ function quoteOpeners(line: string, spawns: boolean): Int32Array { const owner = new Int32Array(line.length + 1).fill(UNKNOWN_QUOTE) let quote: '"' | "'" | null = null let opened = NO_QUOTE let segStart = 0 // One frame per open `$(`: the quote around it, and the plain `(` opened inside it, so its `)` is found. const frames: { quote: '"' | "'" | null; opened: number; parens: number }[] = [] let i = 0 const here = (): number => (quote === null ? NO_QUOTE : opened) for (; i < line.length; i += 1) { const ch = line[i] ?? '' owner[i] = here() if (quote === "'") { if (ch === "'") quote = null continue } if (ch === '\\') { i += 1 if (i < line.length) owner[i] = here() continue } if (ch === '`') break if (ch === '$') { const next = line[i + 1] if (next === "'") break if (next === '(') { frames.push({ quote, opened, parens: 0 }) quote = null owner[i + 1] = NO_QUOTE i += 1 segStart = i + 1 } else if (next === '{') { PLAIN_BRACE.lastIndex = i if (!PLAIN_BRACE.test(line)) break } continue } if (quote === '"') { if (ch === '"') quote = null continue } const frame = frames.at(-1) // Inside `$(…)` a `case` pattern's `)` or a comment's `)` would end the frame early and shift the quotes after it. if (frame !== undefined && (i === 0 || WORD_BREAK.test(line[i - 1] ?? '')) && (ch === '#' || /^case(?=[\s;&|()<>]|$)/.test(line.slice(i, i + 5)))) break if (ch === '"' || ch === "'") { quote = ch opened = i } else if ((ch === '<' || ch === '>') && line[i + 1] === '(') break else if (ch === '(' && frame !== undefined) frame.parens += 1 else if (ch === ')' && frame !== undefined && frame.parens > 0) frame.parens -= 1 else if (ch === ')' && frame !== undefined) { frames.pop() quote = frame.quote opened = frame.opened owner[i] = here() } else if (ch === '<' && line[i + 1] === '<' && line[i + 2] === '<') { owner[i + 1] = NO_QUOTE owner[i + 2] = NO_QUOTE i += 2 } else if (ch === '<' && line[i + 1] === '<') { HEREDOC_OP.lastIndex = i const op = HEREDOC_OP.exec(line) const delimiter = op?.[2] ?? op?.[3] ?? op?.[4] ?? op?.[5] const nl = line.indexOf('\n', HEREDOC_OP.lastIndex) if (op === null || delimiter === undefined || nl === -1) break const rest = line.slice(HEREDOC_OP.lastIndex, nl) const end = HEREDOC_REST.test(rest) ? heredocEnd(line, nl + 1, delimiter, op[1] === '-') : -1 if (end === -1) break const readerText = `${line.slice(segStart, i)} ${rest}` const reader = readCommand(readerText, true).words const tool = baseName(reader[0] ?? '') const literal = op[5] === undefined || inertBody(line.slice(nl + 1, end)) // A body `cat`/`tee` writes to a file is code when the line may run a file (`cat > f <<'EOF' … EOF; sh f`). const written = (tool === 'cat' || tool === 'tee') && !runsWrittenFile(line, spawns) if (!literal || !(written || readsAsText(reader, writesOutput(readerText)) || (GATED_INTERPRETERS.has(tool) && !spawns && !(BACKTICK_RUNNERS.has(tool) && line.includes('`'))))) break owner.fill(NO_QUOTE, i, nl + 1) owner.fill(HEREDOC_DATA, nl + 1, end) i = end - 1 } else if (ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '(' || ch === ')') segStart = i + 1 } if (i >= line.length) owner[line.length] = here() return owner } // ---- Shapes this parser does not read (fail-closed) -------------------------- const UNREADABLE_LABEL = 'nicht lesbare Befehlsform' // Constructs whose effect the word reader above cannot see. Each is judged on the segment it sits in. const UNREAD_CONSTRUCTS = [ /\bxargs\b/g, /\bssh\s/g, /\bwatch\s/g, // The lookbehind anchors the name at a word start: without it the engine retries from every // character of a long word, quadratic (1524 ms on 50k chars, Lead review of !132). /(?() for (const m of line.matchAll(tail)) lastTail.set(spanIndexAt(runs, m.index), m.index) const starts: number[] = [] for (const m of line.matchAll(head)) { const t = lastTail.get(spanIndexAt(runs, m.index)) if (t !== undefined && t >= m.index + m[0].length) starts.push(m.index) } return starts } /** Where a shell is given `-c`: a SHELL_NAME with a C_OPTION after it and no `;`, `&` or `|` between. */ function shellCStarts(line: string): number[] { return headsWithTail(line, runsOf(line, /[;&|]/g), SHELL_NAME, C_OPTION) } type Span = [start: number, end: number] /** The `[start, end)` of every segment of the line, cut at the separators splitSegments cuts at, in order. */ function segmentSpans(line: string): Span[] { const spans: Span[] = [] let start = 0 for (const m of markBackground(line).matchAll(SEPARATOR)) { spans.push([start, m.index]) start = m.index + m[0].length } spans.push([start, line.length]) return spans } /** The index in `spans` of the segment holding `index` (binary search: the last segment starting at or before it). */ function spanIndexAt(spans: readonly Span[], index: number): number { let lo = 0 let hi = spans.length - 1 while (lo < hi) { const mid = (lo + hi + 1) >> 1 if ((spans[mid]?.[0] ?? 0) <= index) lo = mid else hi = mid - 1 } return lo } /** * The `[start, end)` body of every `$(…)` (parentheses balanced, quotes not honoured) and after every * backtick (to the next one), each to the end of the line when unclosed. One pass with a stack, so a * line of 25,000 unclosed `$(` costs no more than one of plain words. */ function substitutionSpans(line: string): Span[] { const spans: Span[] = [] // Per open parenthesis: the index in `spans` of the `$(` it opened, or -1 for a plain `(`. const open: number[] = [] let lastTick = -1 for (let i = 0; i < line.length; i += 1) { const ch = line[i] if (ch === '$' && line[i + 1] === '(') { open.push(spans.push([i + 2, line.length]) - 1) i += 1 } else if (ch === '(') open.push(-1) else if (ch === ')') { const k = open.pop() const span = k === undefined || k === -1 ? undefined : spans[k] if (span !== undefined) span[1] = i } else if (ch === '`') { const prev = lastTick === -1 ? undefined : spans[lastTick] if (prev !== undefined) prev[1] = i lastTick = spans.push([i + 1, line.length]) - 1 } } return spans } /** The spans merged where they overlap or touch, sorted. */ function mergeSpans(spans: Span[]): Span[] { const merged: Span[] = [] for (const [start, end] of [...spans].sort((a, b) => a[0] - b[0])) { const last = merged.at(-1) if (last !== undefined && start <= last[1]) last[1] = Math.max(last[1], end) else merged.push([start, end]) } return merged } /** The text with `$'…'` escapes decoded and every quote and backslash dropped, so `pu"sh"` and `$'\x70ush'` read as `push`. */ function normalizeWords(text: string): string { return text .replace(/\$'((?:[^'\\]|\\.)*)'/g, (_m: string, body: string) => body.replace(/\\x([0-9a-fA-F]{1,2})|\\u([0-9a-fA-F]{1,4})|\\([0-7]{1,3})|\\(.)/g, (_e: string, hex?: string, uni?: string, oct?: string, ch?: string) => hex !== undefined ? String.fromCharCode(parseInt(hex, 16)) : uni !== undefined ? String.fromCharCode(parseInt(uni, 16)) : oct !== undefined ? String.fromCharCode(parseInt(oct, 8)) : (ch ?? ''))) .replace(/["'\\]/g, '') } /** The outward kind a piece of unread text names, or null — wide on purpose: any `deploy` or `publish`, any forge `merge`/`release`. */ function outwardWordIn(text: string): OutwardKind | null { const t = normalizeWords(text) const forge = /\b(?:gh|glab)\b/.test(t) if (/\bgit\b/.test(t) && /\bpush\b/.test(t)) return 'push' if (/\bpublish\b/.test(t)) return 'publish' if (forge && /\b(?:merge|accept)\b/.test(t)) return 'merge' if (/\bdeploy\b/.test(t) || /--prod(?:uction)?\b/.test(t)) return 'deploy' if (forge && /\brelease\b/.test(t)) return 'release' return classKindIn(text) } // The words of unread text, split at blanks and at shell and code punctuation: `os.system(git push)` gives `git`, `push`; // `args.push(x)` stays one word, so a JS `.push(` next to a `git` word is no push. const CODE_WORD = /[^\s()[\]{}<>;|&,`]+/g // git's global options that take the next word as their value (`git -C dir push`). const GIT_VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path', '--attr-source']) const FORGE_VALUED = new Set(['-R', '--repo']) const NONE_VALUED: ReadonlySet = new Set() /** * The outward kind of the first action the classifier holds ({@link classifyWords}) that a piece of unread text spells * as a tool word with its action word in subcommand position, options skipped — or null. `git [options] push`, * `git-push`; `gh|glab [options] mr|pr merge|accept`, `… release create`, `… api` with a method or field and a ref, branch or * merge path; npm/pnpm/yarn/changeset/cargo with a later `publish` word; vercel/vc with `--prod`, `--production`, * `promote` or `rollback`; `supabase db push`, `supabase db reset … --linked`, `supabase migration up … --linked`; * `fly deploy`; `railway up` (#1572 E3/E4). A piece holding `--dry-run` names no publish or supabase write. Linear: one * pass over the words, a later word looked up by its last index. */ function classKindIn(text: string): OutwardKind | null { const plain = normalizeWords(text) const words = plain.match(CODE_WORD) ?? [] const last = new Map() words.forEach((word, i) => last.set(word, i)) const later = (i: number, ...names: string[]): boolean => names.some(name => (last.get(name) ?? -1) > i) const dry = last.has('--dry-run') // Judged for the whole piece, once: a slice per `api` word was quadratic on `gh api gh api …`. // An API call writes only with a method or a field (`gh api repos/o/r/git/refs/heads/main` alone reads). const apiWrites = /(?:^|\s)(?:-X\s*|--method[=\s]+)(?:POST|PUT|PATCH|DELETE)\b|(?:^|\s)(?:-[fF]|--(?:raw-)?field|--input)(?:[=\s]|\S)/i.test(plain) const refWrite = apiWrites && /refs\/heads\/|\bbranch=/.test(plain) const mergePath = apiWrites && /\/merges?\b/.test(plain) // The index of the first word from `j` that is no option (a valued option skips its value too). const skip = (j: number, valued: ReadonlySet): number => { let k = j while ((words[k] ?? '').startsWith('-')) k += valued.has(words[k] ?? '') ? 2 : 1 return k } for (let i = 0; i < words.length; i += 1) { const tool = baseName(words[i] ?? '') if (tool === 'git-push') return 'push' if (tool === 'git' && words[skip(i + 1, GIT_VALUED)] === 'push') return 'push' if (tool === 'gh' || tool === 'glab') { const j = skip(i + 1, FORGE_VALUED) const [sub, action] = [words[j], words[j + 1]] if ((sub === 'mr' || sub === 'pr') && (action === 'merge' || action === 'accept')) return 'merge' if (sub === 'release' && action === 'create') return 'release' if (sub === 'api' && refWrite) return 'push' if (sub === 'api' && mergePath) return 'merge' } if (['npm', 'pnpm', 'yarn', 'changeset', 'cargo'].includes(tool) && later(i, 'publish') && !dry) return 'publish' if ((tool === 'vercel' || tool === 'vc') && later(i, '--prod', '--production', 'promote', 'rollback')) return 'deploy' if (tool === 'supabase' && !dry) { const j = skip(i + 1, NONE_VALUED) const [sub, action] = [words[j], words[j + 1]] if (sub === 'db' && (action === 'push' || (action === 'reset' && later(j, '--linked')))) return 'deploy' if (sub === 'migration' && action === 'up' && later(j, '--linked')) return 'deploy' } if ((tool === 'fly' || tool === 'flyctl') && words[skip(i + 1, NONE_VALUED)] === 'deploy') return 'deploy' if (tool === 'railway' && words[skip(i + 1, NONE_VALUED)] === 'up') return 'deploy' } return null } /** * The pieces of a line this parser does not read: each construct's segment, a substitution's segment * joined with its body, and the whole line for a `$var` command. Each segment is read once however * many constructs it holds, and a substitution's segment and body, with every span overlapping them, * form one contiguous piece — a superset of reading each pair on its own, in linear time (a construct * per match over the whole line was quadratic: 317 ms at 8k characters of `$(`, about 12 s at 50k). */ function unreadRegions(line: string): string[] { const regions: string[] = [] const segments = segmentSpans(line) const constructSegments = new Set() for (const re of UNREAD_CONSTRUCTS) for (const m of line.matchAll(re)) constructSegments.add(spanIndexAt(segments, m.index)) const runs = runsOf(line, /[\n;&|]/g) for (const [head, tail] of UNREAD_PAIRS) for (const at of headsWithTail(line, runs, head, tail)) constructSegments.add(spanIndexAt(segments, at)) for (const at of shellCStarts(line)) { INNER_AT.lastIndex = at if (!INNER_AT.test(line)) constructSegments.add(spanIndexAt(segments, at)) } for (const k of constructSegments) { const span = segments[k] if (span !== undefined) regions.push(line.slice(...span)) } const bodies = substitutionSpans(line) const owners = new Set() for (const [start] of bodies) owners.add(spanIndexAt(segments, start - 1)) const pieces = [...bodies] for (const k of owners) { const span = segments[k] if (span !== undefined) pieces.push(span) } for (const [start, end] of mergeSpans(pieces)) regions.push(line.slice(start, end)) // `p=push; git $p origin main`: a command or git subcommand from a variable is set anywhere on the // line, so the whole line is read — once (once per such segment re-read the line, quadratic). const expanded = (word: string | undefined) => word !== undefined && word.includes('$') && !word.startsWith('$(') // Text cut out of a data command's quoted word is no command (#1891): `grep -E "a|git push|b" "$D/f"` left // `b" "$D/f"`, whose first word held a `$`. const segs = segmentsOf(line) const asText = quotedDataSegments(line, segs) for (const [k, { text: segment }] of segs.entries()) { if (asText[k]) continue const [first, ...args] = readCommand(segment, true).words if (expanded(first) || (baseName(first ?? '') === 'git' && expanded(readGit(args, null).sub))) { regions.push(line) break } } return regions } /** * One fail-closed hit for a line holding a construct this parser does not read (`$(…)`, a backtick, * `xargs`, `find -exec`, `ssh`, `env -S`, `watch`, a function, `case`, a shell by path or one `-c` * cannot be cut from, `$'…'`, a git alias, a command from a variable) whose unread part names an * outward action; null otherwise. Its push always asks ({@link Outward.opaque}). */ export function unreadableOutward(command: string): Outward | null { const kind = unreadKind(dataView(joinContinuations(command))) return kind === null ? null : { kind, label: UNREADABLE_LABEL, args: [], dir: null, opaque: true } } /** The outward kind the first unread region of a line ({@link unreadRegions}, after {@link dataView}) names, or null. */ function unreadKind(line: string): OutwardKind | null { for (const region of unreadRegions(line)) { const kind = outwardWordIn(region) if (kind !== null) return kind } return null } function walkOutward(line: string, depth: number, state: WalkState, hits: Outward[]): void { const { text, inners } = extractInner(line, depth) for (const m of text.matchAll(GIT_ENV)) if (m[1] !== undefined) state.gitEnv.add(m[1]) walkSegments(text, inners, depth, state, hits) } // Where a command substitution or process substitution opens: it runs before its segment's command. const SUBSTITUTION_OPEN = /\$\(|[<>]\(|`/ /** * The git calls inside a segment's `$(…)`, backticks, `<(…)` or `>(…)` count as earlier git calls: * `echo $(git switch main) && git push` moves HEAD before the push runs. Read from the first opener to * the segment's end (over-reading only adds steps, which asks). Its pushes are not collected — the * line is held as unreadable for them ({@link unreadableOutward}) — and its `cd` ends with the * subshell. Past {@link MAX_DEPTH} any git word left counts as a step that is not local, and any * text left as an earlier command that is not git ({@link Outward.otherBefore}). */ function gitInSubstitution(segment: string, inners: readonly Inner[], depth: number, state: WalkState): void { const at = segment.search(SUBSTITUTION_OPEN) if (at === -1) return const body = segment.slice(at + (segment[at] === '`' ? 1 : 2)) if (depth + 1 >= MAX_DEPTH) { if (/\bgit\b|__SOHUD_INNER_/.test(body)) state.gitBefore.push('$(…)') if (/\S/.test(body)) state.otherBefore = true return } const dir = state.dir walkSegments(body, inners, depth + 1, state, []) state.dir = dir } // The options of `git log`, `show` and `diff` that only choose what is printed — an allowlist (MR !133 // HOCH 1): `--output` writes a file (`.git/HEAD`, `.git/config`), `--ext-diff` and `--textconv` run a // configured program, and an option git adds later is not here either. git takes none of these // commands' long options abbreviated (measured, git 2.56.0: `git show --outp=x` is refused). const DISPLAY_OPTIONS = new Set(['--oneline', '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--summary', '--cached', '--staged', '--no-color', '--decorate', '--graph', '--abbrev-commit', '-s', '--no-patch', '-p', '-u', '--patch', '-q', '--quiet', '--', '--no-ext-diff', '--no-textconv']) // …and these, with any value in the same word: a count (`-3`, `-n3`), `--format=`, `--pretty=`, `--color=`, `--max-count=`. const DISPLAY_VALUED = /^(?:-n?\d+$|--(?:format|pretty|color|max-count)=)/ /** * How an earlier git call is recorded for {@link LOCAL_GIT}: its subcommand, with three exact * read-only forms spelled out — `branch --show-current`; `tag --list` for `tag -l` or `tag --list` * with no other word (a pattern or option is not needed for the everyday form, so none is admitted); * and `checkout -- ` for `checkout` whose first word is `--` with at least one path after it * (it restores files and cannot switch the branch; `checkout main -- f` and a bare `checkout --` stay * `checkout`). `fetch` is the local step only with plain words (no option — `--upload-pack`, also as * `--upload=`, runs a program, `--set-upstream` moves the upstream — and no `ext::`-style URL, whose * transport runs a command where `protocol.ext.allow` permits it); `log`, `show` and `diff` only with every option in {@link DISPLAY_OPTIONS} * or {@link DISPLAY_VALUED} (a value given as its own word, `--output x`, is caught by its option). Any * other form is recorded as ` [options]`, which is not local. */ /** * Whether a `git commit` with these words signs explicitly: the last of `-S`/`-S`/`--gpg-sign[=]`/a short * bundle holding `S` (`-aS`), or `--no-gpg-sign`, before `--`. Over-reads a bundle whose `S` is a value (`-mS`): * that only asks, and only where a `gpg.program` is set. */ function signsCommit(rest: readonly string[]): boolean { let signs = false for (const word of rest) { if (word === '--') break if (word === '--gpg-sign' || word.startsWith('--gpg-sign=') || (/^-[^-]/.test(word) && word.includes('S'))) signs = true else if (word === '--no-gpg-sign') signs = false } return signs } // `git commit` options that open no editor, run no pager or diff filter and read no patch interactively — an allowlist // (#1573 input), matched as whole words; the `=`-forms of the valued ones are taken too. Not here: `-e`/`--edit`, // `-c`/`--reedit-message`, `-t`/`--template`, `-p`/`--patch`/`--interactive`, `--squash`, `--fixup=amend:`/`reword:`. const COMMIT_FLAGS = new Set(['-a', '--all', '-q', '--quiet', '-s', '--signoff', '--no-signoff', '-n', '--no-verify', '--verify', '--allow-empty', '--allow-empty-message', '--amend', '--no-edit', '-o', '--only', '-i', '--include', '-v', '--verbose', '--no-verbose', '--status', '--no-status', '--dry-run', '--short', '--porcelain', '--long', '--branch', '-z', '--null', '--reset-author', '--no-post-rewrite', '-S', '--gpg-sign', '--no-gpg-sign', '--untracked-files']) const COMMIT_VALUED = new Set(['-m', '--message', '-F', '--file', '-C', '--reuse-message', '--author', '--date', '--cleanup', '--trailer', '--fixup']) // The message options: with one of them (or `--amend --no-edit`, or a dry run) commit opens no editor. const COMMIT_MESSAGE = new Set(['-m', '--message', '-F', '--file', '-C', '--reuse-message', '--fixup']) // The short options of a bundle (`-am`, `-sm"msg"`): flags, then one that takes the rest of the word or the next word. const COMMIT_SHORT_FLAGS = 'aqsnoivz' const COMMIT_SHORT_VALUED = 'mFCSu' // `git add` options that only choose what is staged; `-p`, `-i`, `-e`, `--renormalize` and the rest ask. const ADD_FLAGS = new Set(['-A', '--all', '--no-all', '-u', '--update', '-f', '--force', '-v', '--verbose', '-n', '--dry-run', '-N', '--intent-to-add', '--ignore-errors', '--ignore-missing', '--ignore-removal', '--no-ignore-removal', '--sparse', '--refresh', '--chmod=+x', '--chmod=-x']) const ADD_SHORT = 'AufvnN' /** Whether a `git commit` runs neither an editor nor an interactive patch read: every option allowed, a message given. */ function plainCommit(rest: readonly string[]): boolean { const seen = new Set() for (let i = 0; i < rest.length; i += 1) { const word = rest[i] ?? '' if (word === '--') break if (!word.startsWith('-') || word === '-') continue const long = word.startsWith('--') ? word.split('=')[0] ?? word : null if (long !== null) { if (long === '--fixup' && /^--fixup=(?:amend|reword):/.test(word)) return false if (COMMIT_FLAGS.has(word) || ((long === '--untracked-files' || long === '--gpg-sign') && word.includes('='))) seen.add(long) else if (COMMIT_VALUED.has(long)) { seen.add(long) if (!word.includes('=')) i += 1 } else return false continue } // A short option or bundle: `-a`, `-am`, `-mmsg`, `-S`, `-Skey`, `-uno`. for (let k = 1; k < word.length; k += 1) { const letter = word[k] ?? '' if (COMMIT_SHORT_FLAGS.includes(letter)) seen.add(`-${letter}`) else if (COMMIT_SHORT_VALUED.includes(letter)) { seen.add(`-${letter}`) // -S and -u take their value only in the same word; -m, -F, -C take the rest of it or the next word. if (k === word.length - 1 && 'mFC'.includes(letter)) i += 1 break } else return false } } const message = [...seen].some(option => COMMIT_MESSAGE.has(option)) return message || (seen.has('--amend') && seen.has('--no-edit')) || ['--dry-run', '--short', '--porcelain'].some(option => seen.has(option)) } /** Whether a `git add` only stages: every option in {@link ADD_FLAGS} or a short bundle of {@link ADD_SHORT}. */ function plainAdd(rest: readonly string[]): boolean { for (const word of rest) { if (word === '--') break if (!word.startsWith('-') || word === '-') continue if (!ADD_FLAGS.has(word) && !(/^-[^-]+$/.test(word) && [...word.slice(1)].every(letter => ADD_SHORT.includes(letter)))) return false } return true } // `--format=`/`--pretty=` placeholders that verify a signature (`%G?`, `%GG`, `%GS`, `%GK`, `%GF`, `%GP`, `%GT`): they run gpg.program. const SIGNATURE_FORMAT = /^--(?:format|pretty)=.*%G/ function gitStep(sub: string | undefined, rest: readonly string[]): string { if (sub === 'commit' && !plainCommit(rest)) return 'commit [options]' if (sub === 'add' && !plainAdd(rest)) return 'add [options]' if (sub === 'branch' && rest.length === 1 && rest[0] === '--show-current') return 'branch --show-current' if (sub === 'tag' && rest.length === 1 && (rest[0] === '-l' || rest[0] === '--list')) return 'tag --list' if (sub === 'checkout' && rest[0] === '--' && rest.length > 1) return 'checkout -- ' if (sub === 'fetch' && rest.some(word => word.startsWith('-') || word.includes('::'))) return 'fetch [options]' const display = (word: string): boolean => !word.startsWith('-') || DISPLAY_OPTIONS.has(word) || DISPLAY_VALUED.test(word) // A signature check runs gpg.program: `log [signature]` is local, but makes the wide scopes' gpg.program count. const signature = (word: string): boolean => word === '--show-signature' || SIGNATURE_FORMAT.test(word) if ((sub === 'log' || sub === 'show') && rest.some(signature)) return rest.every(word => signature(word) || display(word)) ? `${sub} [signature]` : `${sub} [options]` if ((sub === 'log' || sub === 'show' || sub === 'diff') && !rest.every(display)) return `${sub} [options]` return sub ?? '' } /** For each segment, the separator that ends its and-or list ('' at the end of the line). */ function listEnds(segs: readonly Seg[]): Sep[] { const ends: Sep[] = new Array(segs.length) let end: Sep = '' for (let k = segs.length - 1; k >= 0; k -= 1) { const after = segs[k]?.after ?? '' if (after === '' || LIST_END.has(after)) end = after ends[k] = end } return ends } /** * Walks the segments of a line in order. The folder ({@link WalkState.dir}) follows a `cd` only where * its effect on what follows is certain: a plain `cd`, `pushd` or `eval` that is {@link Seg.sure}, not * a pipeline stage and not after `||`. One after `&&`, or in a list that runs in the background, holds * for the rest of its and-or list only (`a && cd x; git push` runs the push wherever `a` left it); an * `||` later in the list (`cd x || cd y`, `cd x || echo; git push`) means it may not have run. Every * other case makes the folder {@link UNKNOWN_DIR}. */ function walkSegments(text: string, inners: readonly Inner[], depth: number, state: WalkState, hits: Outward[]): void { const segs = segmentsOf(text) const ends = listEnds(segs) let moved = false // a folder move happened in this and-or list let movedForList = false // … and it holds only until the list ends const move = (seg: Seg, k: number, to: string | null): void => { if (to === null || !seg.sure || seg.before === '||' || PIPE.has(seg.before) || PIPE.has(seg.after)) state.dir = UNKNOWN_DIR else { state.dir = to if (seg.before === '&&' || ends[k] === '&') movedForList = true } moved = true } const walkSegment = (seg: Seg, k: number): void => { if (LIST_END.has(seg.before)) { if (movedForList) state.dir = UNKNOWN_DIR moved = false movedForList = false } else if (seg.before === '||' && moved) state.dir = UNKNOWN_DIR const segment = seg.text gitInSubstitution(segment, inners, depth, state) const { words, assigned } = readCommand(segment, true) const [first, ...args] = words for (const name of assigned) if (!HARMLESS_VAR.test(name)) state.gitEnv.add(name) if (first === undefined) return // The command as written, prefixes kept: a `cd` behind `then`, `command` or `VAR=x` is not plain. const written = argWords(segment, true) const inner = innerAt(first, inners) if (inner !== null) { const dir = state.dir walkOutward(inner.command, depth + 1, state, hits) // A child shell's `cd` ends with it; `eval` runs in this shell. Its git calls count either way. if (!inner.sameShell) state.dir = dir else if (state.dir !== dir) { const to = state.dir state.dir = dir move(seg, k, written[0] === first ? to : null) } return } const cmd = baseName(first) if (cmd === 'cd' || cmd === 'pushd' || cmd === 'popd') { // `cd`, `cd dir`, `pushd dir`; never `popd`, `cd -`, `pushd +1`, an option or a second word. const arg = args[0] const plain = written[0] === cmd && written.length === words.length && cmd !== 'popd' && args.length <= 1 && (cmd === 'cd' || arg !== undefined) && !/^[-+]/.test(arg ?? '') move(seg, k, plain ? joinDir(state.dir, arg) : null) return } if (DECLARERS.has(cmd)) { // `declare -n r=HOME` makes r a name for HOME: its value is a name being set too. const nameref = args.some(word => /^-[a-zA-Z]*n/.test(word)) for (const word of args) { const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?(?:\+?=(.*))?$/s.exec(word) for (const name of [m?.[1], nameref ? /^[A-Za-z_][A-Za-z0-9_]*$/.exec(m?.[2] ?? '')?.[0] : undefined]) { if (name !== undefined && !HARMLESS_VAR.test(name)) state.gitEnv.add(name) } } } else if (NAME_SETTERS.has(cmd)) { // printf sets a name only with -v; its format and arguments are text. const named = cmd === 'printf' ? args.flatMap((word, j) => (word === '-v' ? [args[j + 1] ?? ''] : word.startsWith('-v') ? [word.slice(2)] : [])) : args.map(word => word.replace(/^-[A-Za-z]*[aA](?=[A-Za-z_])/, '')) for (const word of named) { const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(word)?.[0] if (name !== undefined && !HARMLESS_VAR.test(name)) state.gitEnv.add(name) } } else if (SHELL_STATE.has(cmd)) state.gitEnv.add(cmd) const hit = classifyWords(cmd, args, state.dir) if (hit !== null) hits.push(hit.kind === 'push' ? withPushContext(hit, state) : hit) if (cmd === 'git') { const { sub, rest, dir, config } = readGit(args, state.dir) const step = gitStep(sub, rest) // `git -c diff.external=./x diff` runs a program of its choice: local only with the push's own config allowlist. state.gitBefore.push(config.every(entry => SAFE_CONFIG.test(entry)) ? step : `${step} [${config.join(' ')}]`) // Its hooks and its config are those of the folder it ran in (`cd`, then `-C`): compared with the push's in withPushContext. state.stepDirs.set(state.gitBefore.length - 1, dir) if (sub === 'commit' && signsCommit(rest)) state.signs = true } else if (cmd.startsWith('git-')) { // `git-push`, or a git builtin run by path (`$(git --exec-path)/git-switch main`): never local. state.gitBefore.push(cmd === 'git-push' ? 'push' : cmd) } else if (runsOwnCommand(segment)) state.otherBefore = true } const asText = quotedDataSegments(text, segs) segs.forEach((seg, k) => { if (asText[k]) return walkSegment(seg, k) // Judged after the segment's own hit is recorded, so it counts for the pushes after it: an output // redirection on any segment (a git step, `cd`, `exec`, an assignment) may write .git/config or // .git/HEAD. Ceiling: one on the push itself (`git push … > .git/config`) is not judged — the shell // opens it before git starts, so it can only truncate the file, and git writes into it only after // it read its refs and config. if (writesOutput(seg.text)) state.otherBefore = true }) if (movedForList) state.dir = UNKNOWN_DIR } // A `$(…)` or backtick span holding no quote, parenthesis, backtick or backslash, so where it ends is certain. const PLAIN_SUBSTITUTION = /\$\([^()'"`\\]*\)|`[^`'"\\$]*`/g /** * Whether a segment whose first word is not git runs a command of its own: read again with each plain * substitution as one word, so `h=$(git rev-parse HEAD)` runs none (its body was read by * {@link gitInSubstitution}). Any word left — a command, or a word split out of a substitution this * cannot bound — counts, and so does a git call the first read missed (`h=$(echo x) git switch main`). */ function runsOwnCommand(segment: string): boolean { return readCommand(segment.replace(PLAIN_SUBSTITUTION, '_'), true).words.length > 0 } /** * Resolves a `cd` target to an absolute folder (`bash -c CD_SCRIPT so-hud ` in the session folder; the target is an * argument, never source): prints it and exits 0, or exits non-zero. A relative target that does not start with `.` * exits 3 while `CDPATH` is set (#1573 input): the session's shell looks it up there first, so `cd sub` may land * elsewhere than the folder this would resolve — the push then asks. Ceiling: a zsh `cdpath` set only in the session * shell's own startup files is not in this environment. */ export const CD_SCRIPT = `d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; /*|.|..|./*|../*) ;; *) [ -z "\${CDPATH:-}" ] || exit 3;; esac; unset CDPATH; cd -- "$d" 2>/dev/null && pwd -P` /** Whether a folder the walker could not follow ({@link UNKNOWN_DIR}, or one below it). */ const unknownDir = (dir: string | null): boolean => dir !== null && (dir === UNKNOWN_DIR || dir.startsWith(`${UNKNOWN_DIR}/`)) /** A push hit with what earlier segments of the line changed under the measured facts. */ function withPushContext(hit: Outward, state: WalkState): Outward { const gitConfig = [...(hit.gitConfig ?? []), ...state.gitEnv] // A git step elsewhere ran hooks and config the facts never measured: compared as written, so `-C .` asks too. // A step without a recorded folder (`git-`, `$(…)` past the depth) is not local anyway. const sameDir = (i: number): boolean => !state.stepDirs.has(i) || (state.stepDirs.get(i) === hit.dir && !unknownDir(hit.dir)) return { ...hit, ...(state.gitBefore.length > 0 ? { gitBefore: state.gitBefore.map((step, i) => (sameDir(i) ? step : `${step} [other folder]`)) } : {}), ...(state.otherBefore ? { otherBefore: true as const } : {}), ...(state.signs ? { signsBefore: true as const } : {}), ...(gitConfig.length > 0 ? { gitConfig } : {}), } } // git's own options before the subcommand that point it at another repository or ref namespace. const GIT_REDIRECTS = ['--git-dir', '--work-tree', '--namespace'] // git's global options that change nothing about where a push goes; every other one is reported as config. const GIT_GLOBAL_PLAIN = new Set(['--no-pager', '-P', '-p', '--paginate', '--no-optional-locks']) /** git's global options read off: the subcommand, its words, the folder `-C` moved to, and the config the facts did not see. */ function readGit(args: readonly string[], dir: string | null): { sub: string | undefined; rest: string[]; dir: string | null; config: string[] } { let gitDir = dir const config: string[] = [] let i = 0 while (i < args.length && (args[i] ?? '').startsWith('-')) { const arg = args[i] ?? '' const value = args[i + 1] if (value !== undefined && arg === '-C') { gitDir = joinDir(gitDir, value) i += 2 } else if (value !== undefined && (arg === '-c' || arg === '--config-env')) { config.push(value) i += 2 } else if (value !== undefined && GIT_REDIRECTS.includes(arg)) { config.push(`${arg}=${value}`) i += 2 } else if (value !== undefined && arg === '--attr-source') { config.push(`${arg}=${value}`) i += 2 } else { if (arg.startsWith('--config-env=')) config.push(arg.slice('--config-env='.length)) else if (!GIT_GLOBAL_PLAIN.has(arg)) config.push(arg) i += 1 } } return { sub: args[i], rest: args.slice(i + 1), dir: gitDir, config } } // Without -X, gh and glab send POST as soon as a field or --input is given. const API_FIELDS = /^(?:-f|-F|--field|--raw-field|--input)(?:=|$)|^-[fF]./ const GRAPHQL_MERGE = /\b(?:mergePullRequest|enablePullRequestAutoMerge|mergeBranch|mergeRequestAccept)\b/ const API_REF_MAIN = /(?:^|\/)git\/refs\/heads\/(?:main|master)\/?(?:\?.*)?$/ const decodePath = (word: string): string => { try { return decodeURIComponent(word) } catch { return word } } /** * What a forge API call that writes does, or null: a write to `git/refs/heads/main|master` (or a * `git/refs` create naming it), and any write carrying the field `branch=main|master` (a contents * or commits write onto the default branch), is a push; a REST `…/merge` or `…/merges`, a GraphQL * merge mutation, and a GraphQL body this cannot read (`-F query=@file`, `--input`) are merges. */ function apiAction(apiArgs: readonly string[]): { kind: 'push' | 'merge'; path: string } | null { let method: string | undefined const fields: string[] = [] // Indices of the words that are an option's value, so the endpoint is the first word that is none. const values = new Set() for (let i = 0; i < apiArgs.length; i += 1) { const arg = apiArgs[i] ?? '' if (arg === '-X' || arg === '--method') { method = apiArgs[i + 1] values.add(i + 1) } else if (arg.startsWith('--method=')) method = arg.slice('--method='.length) else if (/^-X./.test(arg)) method = arg.slice(2) else if (arg === '-f' || arg === '-F' || arg === '--field' || arg === '--raw-field') { fields.push(apiArgs[i + 1] ?? '') values.add(i + 1) } else if (/^-[fF]./.test(arg)) fields.push(arg.slice(2)) else if (/^--(?:raw-)?field=/.test(arg)) fields.push(arg.slice(arg.indexOf('=') + 1)) } const writes = method !== undefined ? /^(?:PUT|POST|PATCH|DELETE)$/i.test(method) : apiArgs.some(a => API_FIELDS.test(a)) if (!writes) return null const words = apiArgs.filter(a => !a.startsWith('-')).map(decodePath) const ref = words.find(a => API_REF_MAIN.test(a)) if (ref !== undefined) return { kind: 'push', path: ref } const refs = words.find(a => /(?:^|\/)git\/refs\/?$/.test(a)) if (refs !== undefined && fields.some(v => /^ref=refs\/heads\/(?:main|master)$/.test(v))) return { kind: 'push', path: refs } if (fields.some(v => /^branch=(?:refs\/heads\/)?(?:main|master)$/.test(v))) { return { kind: 'push', path: apiArgs.find((a, i) => !a.startsWith('-') && !values.has(i)) ?? 'api' } } const merge = words.find(a => /\/merges?\/?(?:\?.*)?$/.test(a)) if (merge !== undefined) return { kind: 'merge', path: merge } const unreadBody = apiArgs.some(a => a === '--input' || a.startsWith('--input=')) || fields.some(v => v.startsWith('query=@')) return words.includes('graphql') && (unreadBody || apiArgs.some(a => GRAPHQL_MERGE.test(a))) ? { kind: 'merge', path: 'graphql' } : null } // The options of git push that take a separate value. const PUSH_VALUED = new Set(['-o', '--push-option', '--receive-pack', '--exec', '--repo']) /** * Whether the last dry-run switch before `--` turns dry-run on: `--dry-run`, `--dry-run=true` and, * when `shortN`, a lone `-n` turn it on; `--no-dry-run` and any other `--dry-run=` turn it off. A * value of an option in `valued` (`git push -o -n`) is no switch. */ function isDryRun(args: readonly string[], valued: ReadonlySet, shortN: boolean): boolean { let dry = false for (let i = 0; i < args.length; i += 1) { const arg = args[i] ?? '' if (arg === '--') break if (valued.has(arg)) i += 1 else if (arg === '--dry-run' || arg === '--dry-run=true' || (shortN && arg === '-n')) dry = true else if (arg === '--no-dry-run' || arg.startsWith('--dry-run=')) dry = false } return dry } // npm/pnpm/yarn options before the subcommand that take a separate value (`-w pkg`, `--filter x`). const PM_VALUED = new Set(['-w', '--workspace', '--prefix', '--registry', '--tag', '--otp', '--access', '--userconfig', '--cache', '--filter', '-F', '-C', '--dir', '--cwd', '--loglevel', '--reporter', '--include', '--exclude', '--from', '--since', '-j', '--jobs']) // Subcommands that only name `publish` as a word (`npm help publish`) or a package (`npm i publish`). const PM_NOT_PUBLISHING = new Set(['help', 'view', 'info', 'show', 'v', 'search', 's', 'docs', 'home', 'repo', 'bugs', 'why', 'explain', 'ls', 'list', 'outdated', 'audit', 'install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un']) /** Whether npm/pnpm/yarn publishes: `publish` among the positionals before `--`, so `-w pkg`, `--filter x` or `yarn workspace x` in front cannot hide it. */ function pmPublishes(args: readonly string[]): boolean { const end = args.indexOf('--') const pos = positionals(end === -1 ? args : args.slice(0, end), PM_VALUED) return pos.includes('publish') && !PM_NOT_PUBLISHING.has(pos[0] ?? '') } function classifyWords(cmd: string, args: string[], dir: string | null): Outward | null { const [sub, action] = args if (cmd === 'git' || cmd === 'git-push') { const git = cmd === 'git' ? readGit(args, dir) : { sub: 'push', rest: args, dir, config: [] } if (git.sub !== 'push') return null if (isDryRun(git.rest, PUSH_VALUED, true)) return null return { kind: 'push', label: 'git push', args: git.rest, dir: git.dir, ...(git.config.length > 0 ? { gitConfig: git.config } : {}) } } // Wrappers like glab- speak the same API as glab. if (cmd === 'glab' || cmd === 'gh' || /^glab-[a-z0-9-]+$/.test(cmd)) { const forge = cmd === 'gh' ? 'gh' : 'glab' // `glab mr accept` is glab's alias of `glab mr merge`. if ((sub === 'mr' || sub === 'pr') && (action === 'merge' || action === 'accept')) { return { kind: 'merge', label: `${cmd} ${sub} ${action}`, args: args.slice(2), dir, forge } } if (sub === 'release' && action === 'create') { return { kind: 'release', label: `${cmd} release create`, args: args.slice(2), dir, forge } } const api = sub === 'api' ? apiAction(args.slice(1)) : null if (api !== null) { return { kind: api.kind, label: `${cmd} api ${api.path}`, args: args.slice(1), dir, forge, ...(api.kind === 'push' ? { opaque: true as const } : {}) } } return null } const joined = [cmd, ...args].join(' ') if (isDryRun(args, PM_VALUED, false)) return null if (((cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn') && pmPublishes(args)) || /^changeset publish\b/.test(joined) || /^cargo publish\b/.test(joined)) { return { kind: 'publish', label: [cmd, ...args.slice(0, args.indexOf('publish') + 1)].join(' '), args, dir, tool: cmd } } if (cmd === 'vercel' && (args.includes('--prod') || args.includes('--production') || sub === 'promote' || sub === 'rollback')) { return { kind: 'deploy', label: `vercel ${args.filter(a => !a.startsWith('--token')).join(' ')}`.trim(), args, dir, tool: 'vercel' } } if (cmd === 'supabase' && ((sub === 'db' && (action === 'push' || (action === 'reset' && args.includes('--linked')))) || (sub === 'migration' && action === 'up' && args.includes('--linked')))) { return { kind: 'deploy', label: `supabase ${sub} ${action}`, args, dir, tool: 'supabase' } } if ((cmd === 'fly' || cmd === 'flyctl') && sub === 'deploy') { return { kind: 'deploy', label: 'fly deploy', args, dir, tool: 'fly' } } if (cmd === 'railway' && sub === 'up') { return { kind: 'deploy', label: 'railway up', args, dir, tool: 'railway' } } return null } // ---- Which pushes may skip the confirmation ---------------------------------- const PROTECTED_BRANCHES = ['main', 'master'] // The only options a push may carry and skip the confirmation, matched as exact words. const PUSH_FLAGS = new Set(['-u', '--set-upstream', '-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress']) // The only git steps that may run earlier on the line (see {@link gitStep}): none moves the branch, its upstream or the config. const LOCAL_GIT = new Set(['add', 'commit', 'status', 'log', 'diff', 'show', 'log [signature]', 'show [signature]', 'rev-parse', 'fetch', 'ls-files', 'branch --show-current', 'tag --list', 'checkout -- ']) // The steps of LOCAL_GIT that can fire a hook (pre-/post-commit, reference-transaction, // post-index-change, post-checkout), which runs any program: local only when no hook can run ({@link PushFacts.hooks}). const HOOK_GIT = new Set(['add', 'commit', 'status', 'diff', 'fetch', 'checkout -- ']) // The only `-c` / `--config-env` keys a push may carry and skip the confirmation. const SAFE_CONFIG = /^(?:user\.(?:name|email)|core\.quotepath|color\.[^=]+)(?:=|$)/i const PLAIN_REMOTE = /^[A-Za-z0-9._-]+$/ /** * What the wiring measured with `git` in the push's folder (`hit.dir`), each read independently. * Any read that failed, printed nothing, or was not run is `null` / `false`: the push then needs * confirmation (fail-closed). */ export type PushFacts = { /** `git rev-parse --abbrev-ref HEAD` (`HEAD` when detached). */ current: string | null /** `git rev-parse --abbrev-ref @{u}`, e.g. `origin/feat/x`. */ upstream: string | null /** `git config branch..remote`; null when unset, so {@link pushRemote} falls back to `origin`. */ remote: string | null /** `git remote get-url ` exited 0 with a non-empty URL. */ remoteUrlKnown: boolean /** * Each destination from {@link pushDestinations} for which `git rev-parse --verify --quiet refs/tags/` * exited 0. null when the probes were not run. */ tagRefs: readonly string[] | null /** * The push configuration a previous call may have set (#1569). Optional only so a caller from before * it still type-checks: absent (`undefined`) or `null` (any of its reads failed) asks, never runs. */ pushConfig?: PushConfig | null /** * Whether a hook the earlier git steps can fire exists in the push's folder (#1574): only the hook * names those steps fire are looked up in the hooks directory (`git rev-parse --git-path hooks`), a * verified husky v9 stub counts only when its `.husky/` exists, and a config hook only for those * events; `false` only when measured absent; `null` when a read failed or the layout is unknown. Absent or `null` asks like * `true` — for a push behind a step in {@link HOOK_GIT} (MR !133: a post-commit hook of * `git commit && git push origin HEAD` may switch HEAD or write the push configuration). A hook step * in another folder asks whatever this holds ({@link Outward.gitBefore}). */ hooks?: boolean | null /** * Every configured key with the scope git read it from ({@link REPO_CONFIG_READ}, parsed by * {@link repoConfigEntries}). A key such as `core.fsmonitor`, `diff.external`, `filter..clean` or * `core.sshCommand` makes a local step (`status`, `diff`, `add`, `fetch`) run a program, which may move * HEAD or write the push configuration after the facts were measured (#1573). Absent or `null` (the read * failed, or its output did not parse) asks for every push behind a git step, like a key outside * {@link repoConfigStandard}. */ repoConfig?: RepoConfig | null /** * Whether the index of the push's folder holds a gitlink (mode 160000: a submodule, or an embedded * repository added without `.gitmodules`), counted by {@link GITLINKS_SCRIPT}. `git status`, `git diff` * and `git fetch` step into such a repository and run its own `core.fsmonitor`, filters and transport, * which {@link repoConfig} never read (#1573 review; measured with git 2.56.0: an embedded `sub/` with * its own `core.fsmonitor` ran it four times during the outer repository's `git status` and `git diff`). * `false` only when counted zero; absent or `null` (the count failed) asks like `true` for every push * behind a git step. This also covers `submodule.recurse`, which only acts where gitlinks exist. */ gitlinks?: boolean | null /** * `git rev-parse --symbolic-full-name ` for each source of {@link pushSources}, in order (#1570); null * entry when the read failed or printed nothing (an ambiguous name exits 0 with nothing on stdout, git 2.56.0). * A push naming its branch runs only when each is exactly `refs/heads/`: a symbolic ref `feat/x` → * `refs/heads/main` is pushed as `feat/x -> main`, and a `refs/feat/x` without that branch is pushed to `refs/feat/x` * (beside the branch the name is ambiguous and git refuses the push; both measured, git 2.56.0). Absent * or `null` asks for every push that names a source; `HEAD` and a bare push name none ({@link pushSources}). */ sourceRefs?: readonly (string | null)[] | null } /** * Counts the gitlinks of the index in the folder it runs in (`bash -c