diff --git a/AGENTS.md b/AGENTS.md index 04db963..173338d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Each rule below names the check that fails when it breaks. A rule marked *text o | The suite gives the same result on any terminal | environment normalization, guard test, and a hostile `TERM` in CI | `test/setup.ts`, `test/terminal-env.test.ts`, `.github/workflows/ci.yml` | | Tests pass a relative `--since` and read a pinned clock | `no-literal-since`, `no-ambient-clock`, restricted date imports; `TZ` and `MDCLI_TODAY` pinned | `eslint-rules/`, `eslint.config.js`, `test/setup.ts` | | Strings become numbers only through the strict helpers | `no-loose-number` | `eslint-rules/no-loose-number.js` | +| `scripts/` spawns git, gh and bun only through `scripts/git.ts`, which also owns the `--base` default; diffs are parsed only by `scripts/behavior-change.ts` | `no-raw-spawn` | `eslint-rules/no-raw-spawn.js`, `scripts/git.ts` | | A category name resolves through its single owner (active wins, ambiguous and inactive refused) | `no-category-name-resolution` | `eslint-rules/no-category-name-resolution.js` | | Review notes never reach a commit (item 19) | `git ls-files 'REVIEW-*.md'` must be empty | `scripts/verify.sh`, `.gitignore` | | The private-data guard covers commit messages and the PR body, not only the tracked tree | `private messages` step (CI `verify`, rerun on a PR body edit) | `scripts/check-private-messages.ts`, `scripts/verify.sh`, `.github/workflows/ci.yml` | @@ -58,7 +59,7 @@ Each rule below names the check that fails when it breaks. A rule marked *text o Every PR that changes `src/` fills the **Prova** section of the PR template. It shows the CLI commands run against the mock (`scripts/mock-api.ts`, fake config in `/tmp`), never against a real account, on `main` (before) and on the branch (after), with their outputs, proving the behavior the issue asks for. When it changes `src/` or `bin/`, the proof also shows the output of the 3 runs of `bun scripts/new-tests-fail-on-main.ts --base --files `; one red run under load is not proof (issue #235). A behavior-preserving PR that skips the gate cites the exemption (the `refactor`/`test`/`chore` title or the body reason) instead of the failing runs. The `prova` CI check reads the PR body and fails when the section, the `Web:` line or one of the two blocks is missing; it runs on `opened`, `synchronize`, `reopened` and `edited`. The `Web:` line carries a snippet from the web bundle (`$HOME/.mdcli-team/ref/mdb.js`) that shows the behavior, or `sem equivalente na web`. Paraphrasing the web inside the proof without that line no longer counts. -`bun scripts/risk.ts` classifies the diff against `origin/main` and prints `{ "risky": bool, "reasons": [...] }`; it exits 2 when the PR touches authentication, session or credentials, data writes (create, update, delete, `entries import run`, or any POST/PUT/DELETE call to the API), or a security surface: `scripts/private-terms.ts`, `scripts/private-terms-hashes.ts`, `scripts/private-terms-history.ts`, `scripts/private-terms-cli.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms.test.ts`, `test/private-terms-corpus.test.ts`, `scripts/check-private-messages.ts`, `test/check-private-messages.test.ts`, `scripts/verify.sh`, `src/utils/parse.ts` (the guard parses git output with it), or anything under `eslint-rules/`. A comment-only change in a security surface stays safe. A risky PR: +`bun scripts/risk.ts` classifies the diff against `origin/main` and prints `{ "risky": bool, "reasons": [...] }`; it exits 2 when the PR touches authentication, session or credentials, data writes (create, update, delete, `entries import run`, or any POST/PUT/DELETE call to the API), or a security surface: `scripts/private-terms.ts`, `scripts/private-terms-hashes.ts`, `scripts/private-terms-history.ts`, `scripts/private-terms-cli.ts`, `scripts/private-terms-corpus.ts`, `test/private-terms.test.ts`, `test/private-terms-corpus.test.ts`, `scripts/check-private-messages.ts`, `test/check-private-messages.test.ts`, `scripts/verify.sh`, `src/utils/parse.ts` (the guard parses git output with it), `scripts/git.ts` and `scripts/behavior-change.ts` (the guard spawns git and parses its diffs through them), or anything under `eslint-rules/`. A comment-only change in a security surface stays safe. A risky PR: - runs `/blast-radius` and proves the security fact in a test or in `scripts/verify.sh`; - waits for the maintainer's decision before merge. diff --git a/eslint-rules/index.js b/eslint-rules/index.js index fa4bf4b..ad285ae 100644 --- a/eslint-rules/index.js +++ b/eslint-rules/index.js @@ -10,6 +10,7 @@ import noDueAnchorFallback from "./no-due-anchor-fallback.js"; import noLiteralSince from "./no-literal-since.js"; import noLooseNumber from "./no-loose-number.js"; import noRawDash from "./no-raw-dash.js"; +import noRawSpawn from "./no-raw-spawn.js"; import noRawCents from "./no-raw-cents.js"; import noRawCurrency from "./no-raw-currency.js"; import noShortOptionEquals from "./no-short-option-equals.js"; @@ -30,6 +31,7 @@ export default { "no-raw-cents": noRawCents, "no-raw-currency": noRawCurrency, "no-raw-dash": noRawDash, + "no-raw-spawn": noRawSpawn, "no-short-option-equals": noShortOptionEquals, "no-tautological-expect": noTautologicalExpect, }, diff --git a/eslint-rules/no-raw-spawn.js b/eslint-rules/no-raw-spawn.js new file mode 100644 index 0000000..877ecc6 --- /dev/null +++ b/eslint-rules/no-raw-spawn.js @@ -0,0 +1,54 @@ +// Every gate in scripts/ grew its own Bun.spawnSync wrapper around git, gh or +// bun, each with its own error text and `origin/main` default, and the fix for +// the oven-sh/bun#34069 hang reached only one of them (#260). scripts/git.ts +// owns child processes; the rest of scripts/ goes through it. +const SPAWNERS = new Set(["spawn", "spawnSync"]); +const CHILD_PROCESS = new Set(["child_process", "node:child_process"]); + +export default { + meta: { + type: "problem", + docs: { description: "Spawn child processes in scripts/ only through scripts/git.ts" }, + messages: { + rawSpawn: + "Spawn through scripts/git.ts (git, gitBytes, gitResult, gitQuiet, spawn, spawnAsync) instead of Bun.{{name}}; it owns the error text, the path quoting and the oven-sh/bun#34069 workaround.", + bunShell: "Spawn through scripts/git.ts instead of the Bun shell ($); it owns child processes in scripts/.", + childProcess: "Spawn through scripts/git.ts instead of {{source}}; it owns child processes in scripts/.", + }, + schema: [], + }, + create(context) { + const checkSource = (node) => { + if (node.source?.type === "Literal" && CHILD_PROCESS.has(node.source.value)) { + context.report({ node, messageId: "childProcess", data: { source: node.source.value } }); + } + }; + return { + MemberExpression(node) { + const name = node.property.type === "Identifier" && !node.computed ? node.property.name : node.property.value; + if (node.object.type === "Identifier" && node.object.name === "Bun" && SPAWNERS.has(name)) { + context.report({ node, messageId: "rawSpawn", data: { name } }); + } + }, + ImportDeclaration(node) { + checkSource(node); + if (node.source.value === "bun" && node.specifiers.some((s) => s.type === "ImportSpecifier" && s.imported.name === "$")) { + context.report({ node, messageId: "bunShell" }); + } + }, + ExportAllDeclaration: checkSource, + ExportNamedDeclaration: checkSource, + ImportExpression(node) { + if (node.source.type === "Literal" && CHILD_PROCESS.has(node.source.value)) { + context.report({ node, messageId: "childProcess", data: { source: node.source.value } }); + } + }, + CallExpression(node) { + const [arg] = node.arguments; + if (node.callee.type === "Identifier" && node.callee.name === "require" && arg?.type === "Literal" && CHILD_PROCESS.has(arg.value)) { + context.report({ node, messageId: "childProcess", data: { source: arg.value } }); + } + }, + }; + }, +}; diff --git a/eslint-rules/scope-exemptions.json b/eslint-rules/scope-exemptions.json index 1704000..3758308 100644 --- a/eslint-rules/scope-exemptions.json +++ b/eslint-rules/scope-exemptions.json @@ -12,6 +12,10 @@ "no-literal-since": { "src/**": "The rule guards fixed dates written in tests; commands read the --since value the user passed." }, + "no-raw-spawn": { + "src/**": "The rule guards the gate infrastructure in scripts/; the CLI's own children (self-update, browser login, the import editor) belong to src/.", + "test/**": "Tests drive the scripts as a user does (bun scripts/x.ts) and build throwaway git repositories; scripts/git.ts owns only the gates' children." + }, "no-short-option-equals": { "src/**": "preprocessShortOptions runs only in bin/cli.ts, before commander parses argv.", "test/**": "Tests exercise the preprocessing through the real CLI (test/cli-short-option-equals.test.ts), not by importing it." diff --git a/eslint.config.js b/eslint.config.js index beff0f9..16d6109 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -117,6 +117,13 @@ export default tseslint.config( files: ["src/**/*.ts", "test/**/*.ts", "bin/**/*.ts"], rules: { "local/no-bare-process-exit": "error" }, }, + { + // scripts/ spawns git, gh and bun only through scripts/git.ts (#260). + // src/** and test/** are exempt in eslint-rules/scope-exemptions.json. + files: ["scripts/**/*.ts"], + ignores: ["scripts/git.ts"], + rules: { "local/no-raw-spawn": "error" }, + }, { ignores: ["node_modules", "dist"], } diff --git a/scripts/behavior-change.ts b/scripts/behavior-change.ts index 5620743..0128e1e 100644 --- a/scripts/behavior-change.ts +++ b/scripts/behavior-change.ts @@ -1,13 +1,18 @@ -// Single owner for "does this diff change behavior?" (#236). prova, the -// fails-on-main gate and risk each ask that question with their own path -// slice; the diff parsing, the comment lexer and the type-only detection live -// here so a false positive is fixed once instead of in three copies. +// Single owner for "does this diff change behavior?" (#236) and for parsing a +// unified diff (#260). prova, the fails-on-main gate and risk each ask that +// question with their own path slice; check-eslint-disables and the private +// terms corpus read the numbered lines. The diff parsing, the comment lexer and +// the type-only detection live here so a false positive is fixed once. + +import { parseInteger } from '../src/utils/parse.js'; type LineKind = 'add' | 'remove' | 'context'; export interface DiffLine { kind: LineKind; text: string; + /** The line number on the side that holds it: old for a removal, new otherwise. */ + line: number; } export interface DiffFile { @@ -31,29 +36,49 @@ export interface BehaviorScope extends BehaviorOptions { const TYPE_DECLARATION = /^\s*(?:export\s+)?(?:declare\s+)?(?:interface\s+\w|type\s+\w+\s*[=<]|import\s+type\b)/; const TYPE_OPEN = /^\s*(?:export\s+)?(?:declare\s+)?(?:interface|type)\s+\w+/; -/** The unified diff split into files and hunks, as the question needs it. */ +function hunkStart(digits: string, header: string): number { + const line = parseInteger(digits); + if (line === undefined) throw new Error(`unreadable diff hunk header: ${header}`); + return line; +} + +/** + * The unified diff split into files and hunks, as the question needs it. A + * header git quotes (a path with a tab or a quote) still opens a file, so its + * lines are never dropped: the raw header stands in for the path. + */ export function parseDiff(text: string): DiffFile[] { const files: DiffFile[] = []; let file: DiffFile | undefined; let hunk: DiffLine[] | undefined; + let oldLine = 0; + let newLine = 0; for (const raw of text.split('\n')) { - const header = /^diff --git a\/(.+) b\/(.+)$/.exec(raw); - if (header) { - file = { path: header[2] === '/dev/null' ? header[1] : header[2], hunks: [] }; + if (raw.startsWith('diff --git ')) { + const header = /^diff --git a\/(.+) b\/(.+)$/.exec(raw); + file = { path: header ? header[2] : raw.slice('diff --git '.length), hunks: [] }; files.push(file); hunk = undefined; continue; } if (!file) continue; - if (raw.startsWith('@@')) { + const start = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); + if (start) { hunk = []; file.hunks.push(hunk); + oldLine = hunkStart(start[1], raw); + newLine = hunkStart(start[2], raw); continue; } if (!hunk) continue; - if (raw.startsWith('+')) hunk.push({ kind: 'add', text: raw.slice(1) }); - else if (raw.startsWith('-')) hunk.push({ kind: 'remove', text: raw.slice(1) }); - else if (raw.startsWith(' ')) hunk.push({ kind: 'context', text: raw.slice(1) }); + if (raw.startsWith('+')) { + hunk.push({ kind: 'add', text: raw.slice(1), line: newLine++ }); + } else if (raw.startsWith('-')) { + hunk.push({ kind: 'remove', text: raw.slice(1), line: oldLine++ }); + } else if (raw.startsWith(' ')) { + hunk.push({ kind: 'context', text: raw.slice(1), line: newLine++ }); + oldLine++; + } } return files; } diff --git a/scripts/check-eslint-disables.ts b/scripts/check-eslint-disables.ts index 7509d81..8d22538 100644 --- a/scripts/check-eslint-disables.ts +++ b/scripts/check-eslint-disables.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import tseslint from 'typescript-eslint'; -import { parseInteger } from '../src/utils/parse.js'; +import { parseDiff } from './behavior-change.js'; +import { DEFAULT_BASE, git, gitResult } from './git.js'; // A copied eslint-disable is how the bare-exit pattern spread (#155, #187). // Compare the branch with main and fail on any added directive without a @@ -12,12 +13,6 @@ const DIRECTIVE = /^\s*eslint-disable(?:-next-line|-line)?\b/; const REASON = /\s--\s+\S/; const LINTED = /\.(?:[cm]?[jt]sx?)$/; -function hunkStart(digits: string, header: string): number { - const line = parseInteger(digits); - if (line === undefined) throw new Error(`unreadable diff hunk header: ${header}`); - return line; -} - interface AddedDisable { path: string; line: number; @@ -41,38 +36,14 @@ interface DiffLine { type Side = 'old' | 'new'; export type ContentReader = (path: string, side: Side) => string | undefined; -function parseDiff(diff: string): { added: DiffLine[]; removed: DiffLine[] } { +function changedLines(diff: string): { added: DiffLine[]; removed: DiffLine[] } { const added: DiffLine[] = []; const removed: DiffLine[] = []; - let path = ''; - let oldLine = 0; - let newLine = 0; - for (const raw of diff.split('\n')) { - if (raw.startsWith('diff --git ')) { - // `diff --git a/ b/`: the b/ side names the file after the change. - path = raw.split(' b/', 2)[1] ?? ''; - continue; - } - if (raw.startsWith('+++ b/')) { - path = raw.slice('+++ b/'.length); - continue; - } - const hunk = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw); - if (hunk) { - oldLine = hunkStart(hunk[1], raw); - newLine = hunkStart(hunk[2], raw); - continue; - } - if (path === '' || !LINTED.test(path)) continue; - if (raw.startsWith('+')) { - added.push({ path, line: newLine, text: raw.slice(1) }); - newLine += 1; - } else if (raw.startsWith('-')) { - removed.push({ path, line: oldLine, text: raw.slice(1) }); - oldLine += 1; - } else if (raw.startsWith(' ')) { - oldLine += 1; - newLine += 1; + for (const file of parseDiff(diff)) { + if (!LINTED.test(file.path)) continue; + for (const { kind, line, text } of file.hunks.flat()) { + if (kind === 'add') added.push({ path: file.path, line, text }); + else if (kind === 'remove') removed.push({ path: file.path, line, text }); } } return { added, removed }; @@ -107,7 +78,7 @@ export function directiveLines(content: string): Set { /** Counts the directives a diff adds and removes, reading the file at each side. */ export function countDisables(diff: string, read: ContentReader): DiffCount { - const { added, removed } = parseDiff(diff); + const { added, removed } = changedLines(diff); const cache = new Map>(); const directiveAt = (path: string, line: number, side: Side): Directive | undefined => { const key = `${side}:${path}`; @@ -131,7 +102,7 @@ export function countDisables(diff: string, read: ContentReader): DiffCount { } function gitShow(ref: string, path: string): string | undefined { - const result = Bun.spawnSync(['git', 'show', `${ref}:${path}`], { stdout: 'pipe', stderr: 'pipe' }); + const result = gitResult(['show', `${ref}:${path}`]); return result.exitCode === 0 ? result.stdout.toString() : undefined; } @@ -171,19 +142,15 @@ function parseOptions(argv: string[]): Options { } function gitDiff(base: string, head: string | undefined): string { - const args = ['git', 'diff', '--unified=0', '--no-renames', '--no-color', base]; + const args = ['diff', '--unified=0', '--no-renames', '--no-color', base]; if (head !== undefined) args.push(head); - const result = Bun.spawnSync(args, { stdout: 'pipe', stderr: 'pipe' }); - if (result.exitCode !== 0) { - throw new Error(`git diff against ${base}${head ? `..${head}` : ''} failed: ${result.stderr.toString().trim()}`); - } - return result.stdout.toString(); + return git(args); } function main(): void { try { const options = parseOptions(process.argv.slice(2)); - const base = options.base ?? 'origin/main'; + const base = options.base ?? DEFAULT_BASE; const range = `${base}${options.head ? `..${options.head}` : ''}`; const { added, removed, undocumented } = countDisables(gitDiff(base, options.head), reader(base, options.head)); if (undocumented.length > 0) { diff --git a/scripts/check-private-messages.ts b/scripts/check-private-messages.ts index e3b7deb..b07c7cd 100644 --- a/scripts/check-private-messages.ts +++ b/scripts/check-private-messages.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { DEFAULT_BASE, spawnAsync } from './git.js'; import { KEYED_TERM_HASHES, requirePrivateTermsKey, scanText } from './private-terms.js'; export interface MessageSource { @@ -27,31 +28,13 @@ export function messageHits( return hits; } -interface RunResult { - stdout: string; - stderr: string; - code: number | null; -} - -// Bun 1.4.2 can hang a spawnSync call under `bun test --parallel` (oven-sh/bun#34069), -// so every child here goes through Bun.spawn, which the same reports clear. -async function run(command: string[], cwd: string, timeout?: number): Promise { - const options = { cwd, stdout: 'pipe' as const, stderr: 'pipe' as const, ...(timeout === undefined ? {} : { timeout }) }; - const proc = Bun.spawn(command, options); - const [stdout, stderr, code] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - return { stdout, stderr, code }; -} - export async function commitSources(base: string, root: string = join(import.meta.dirname, '..')): Promise { - const { stdout, stderr, code } = await run( + // Async: this runs inside `bun test --parallel`, where spawnSync can hang (see spawnAsync). + const { stdout, stderr, exitCode } = await spawnAsync( ['git', '--no-pager', 'log', `${base}..HEAD`, '--format=%H%x00%B%x00'], - root + { cwd: root } ); - if (code !== 0) throw new Error(`git log ${base}..HEAD failed: ${stderr.trim()}`); + if (exitCode !== 0) throw new Error(`git log ${base}..HEAD failed: ${stderr.trim()}`); const parts = stdout.split('\0'); const sources: MessageSource[] = []; // git joins records with a newline, so the next record's SHA carries it. @@ -72,9 +55,9 @@ export async function pullRequestSource(): Promise { if (body !== undefined) return body.trim() === '' ? undefined : { label: 'PR body', text: body }; const command = ['gh', 'pr', 'view', '--json', 'body']; try { - const { stdout, stderr, code } = await run(command, process.cwd(), GH_TIMEOUT_MS); - if (code !== 0) { - console.error(`private-data: gh pr view failed (${stderr.trim() || `exit ${code ?? 'unknown'}`}); PR body not checked`); + const { stdout, stderr, exitCode } = await spawnAsync(command, { timeout: GH_TIMEOUT_MS }); + if (exitCode !== 0) { + console.error(`private-data: gh pr view failed (${stderr.trim() || `exit ${exitCode}`}); PR body not checked`); return undefined; } const parsed = JSON.parse(stdout) as { body?: unknown }; @@ -92,7 +75,7 @@ interface Options { } function parseOptions(argv: string[]): Options { - const options: Options = { base: 'origin/main' }; + const options: Options = { base: DEFAULT_BASE }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; if (arg === '--base') options.base = argv[++index] ?? options.base; diff --git a/scripts/git.ts b/scripts/git.ts new file mode 100644 index 0000000..f0b89d4 --- /dev/null +++ b/scripts/git.ts @@ -0,0 +1,100 @@ +// Single owner of child processes in scripts/ (#260). Each gate used to spawn +// git, gh and bun through its own Bun.spawnSync wrapper, with its own error +// text, its own quoting and its own `origin/main` default, so a fix (path +// quoting, the oven-sh/bun#34069 hang) landed in one copy and missed the rest. +// local/no-raw-spawn keeps every other file in scripts/ on this module. + +/** The revision every gate compares the branch against unless `--base` says otherwise. */ +export const DEFAULT_BASE = 'origin/main'; + +export interface SpawnResult { + exitCode: number; + stdout: Buffer; + stderr: Buffer; +} + +export interface SpawnOptions { + cwd?: string; + stdin?: Uint8Array; +} + +/** Runs a child to completion with piped output; the caller reads the exit code. */ +export function spawn(command: readonly string[], options: SpawnOptions = {}): SpawnResult { + const result = Bun.spawnSync([...command], { + cwd: options.cwd, + stdin: options.stdin, + stdout: 'pipe', + stderr: 'pipe', + }); + return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }; +} + +export interface TextResult { + exitCode: number; + stdout: string; + stderr: string; +} + +/** + * The async path for code that runs inside `bun test --parallel`: Bun 1.4.2 + * can hang a spawnSync call there (oven-sh/bun#34069), and Bun.spawn clears + * the same reports. + */ +export async function spawnAsync( + command: readonly string[], + options: { cwd?: string; timeout?: number } = {} +): Promise { + const proc = Bun.spawn([...command], { + cwd: options.cwd, + stdout: 'pipe', + stderr: 'pipe', + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, stdout, stderr }; +} + +// Non-ASCII paths stay readable in every diff, ls-files and grep the gates parse. +const GIT = ['git', '-c', 'core.quotepath=false'] as const; + +/** git with the exit code left to the caller (a grep without matches exits 1). */ +export function gitResult(args: readonly string[], cwd?: string, stdin?: Uint8Array): SpawnResult { + return spawn([...GIT, ...args], { cwd, stdin }); +} + +function failure(args: readonly string[], cwd: string | undefined, result: SpawnResult): Error { + const where = cwd === undefined ? '' : ` in ${cwd}`; + return new Error(`git ${args.join(' ')} failed${where}: ${result.stderr.toString().trim()}`); +} + +/** git's raw stdout; throws with git's stderr when it exits non-zero. */ +export function gitBytes(args: readonly string[], cwd?: string, stdin?: Uint8Array): Buffer { + const result = gitResult(args, cwd, stdin); + if (result.exitCode !== 0) throw failure(args, cwd, result); + return result.stdout; +} + +/** git's stdout as text; throws with git's stderr when it exits non-zero. */ +export function git(args: readonly string[], cwd?: string): string { + return gitBytes(args, cwd).toString(); +} + +/** git's trimmed stdout, or undefined when it exits non-zero. */ +export function gitQuiet(args: readonly string[], cwd?: string): string | undefined { + const result = gitResult(args, cwd); + return result.exitCode === 0 ? result.stdout.toString().trim() : undefined; +} + +/** True when `ref` names a commit; an unborn HEAD names none. */ +export function revisionExists(ref: string, cwd?: string): boolean { + return gitQuiet(['rev-parse', '--verify', '--quiet', ref], cwd) !== undefined; +} + +/** The repository root of `cwd`, so a subdirectory still sees the whole tree. */ +export function repoRoot(cwd: string = process.cwd()): string { + return git(['rev-parse', '--show-toplevel'], cwd).trim(); +} diff --git a/scripts/new-tests-fail-on-main.ts b/scripts/new-tests-fail-on-main.ts index b0a2225..071c7b6 100644 --- a/scripts/new-tests-fail-on-main.ts +++ b/scripts/new-tests-fail-on-main.ts @@ -2,6 +2,7 @@ import { appendFileSync, cpSync, existsSync, mkdtempSync, rmSync, symlinkSync } import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { changesBehavior, parseDiff } from './behavior-change.js'; +import { DEFAULT_BASE, git, repoRoot, spawn } from './git.js'; import { parseInteger } from '../src/utils/parse.js'; import { decideGate, exemptionSummary, invalidExemptionSummary } from './fails-on-main-exemption.js'; @@ -74,7 +75,7 @@ interface Options { } function parseOptions(argv: string[]): Options { - const options: Options = { base: 'origin/main', repeat: DEFAULT_REPEAT }; + const options: Options = { base: DEFAULT_BASE, repeat: DEFAULT_REPEAT }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; if (arg === '--base') { @@ -92,18 +93,6 @@ function parseOptions(argv: string[]): Options { return options; } -function git(args: string[], cwd: string): string { - const result = Bun.spawnSync(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }); - if (result.exitCode !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString().trim()}`); - } - return result.stdout.toString(); -} - -function repoRoot(): string { - return git(['rev-parse', '--show-toplevel'], process.cwd()).trim(); -} - function runTests(root: string, base: string, files: string[], repeat: number): RunResult[] { const dir = mkdtempSync(join(tmpdir(), 'mdcli-fails-on-main-')); const worktree = join(dir, 'worktree'); @@ -120,11 +109,7 @@ function runTests(root: string, base: string, files: string[], repeat: number): for (const file of files) { for (let run = 1; run <= repeat; run++) { console.log(`fails-on-main: ${file} run ${run} of ${repeat}`); - const result = Bun.spawnSync(['bun', 'test', `./${file}`, '--timeout', '20000'], { - cwd: worktree, - stdout: 'pipe', - stderr: 'pipe', - }); + const result = spawn(['bun', 'test', `./${file}`, '--timeout', '20000'], { cwd: worktree }); const output = `${result.stdout.toString()}\n${result.stderr.toString()}`; process.stdout.write(result.stdout); process.stderr.write(result.stderr); diff --git a/scripts/private-terms-cli.ts b/scripts/private-terms-cli.ts index 2fc3619..8ea5b0b 100644 --- a/scripts/private-terms-cli.ts +++ b/scripts/private-terms-cli.ts @@ -1,12 +1,6 @@ +import { repoRoot } from './git.js'; import { KEYED_TERM_HASHES, requirePrivateTermsKey, scanTrackedFiles } from './private-terms.js'; -/** The repository root of `cwd`, so a subdirectory still scans the whole tree. */ -function repoRoot(cwd: string): string { - const top = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { cwd }); - if (top.exitCode !== 0) throw new Error(`git rev-parse --show-toplevel failed in ${cwd}`); - return top.stdout.toString().trim(); -} - function main(): void { try { const key = requirePrivateTermsKey(); @@ -15,7 +9,7 @@ function main(): void { 'guarda de dados privados: aviso: MDCLI_PRIVATE_TERMS_KEY ausente; termos numéricos de baixa entropia não foram verificados' ); } - const hits = scanTrackedFiles(repoRoot(process.cwd())); + const hits = scanTrackedFiles(repoRoot()); if (hits.length === 0) { console.log('✓ guarda de dados privados: nenhum termo privado nos arquivos versionados'); return; diff --git a/scripts/private-terms-corpus.ts b/scripts/private-terms-corpus.ts index 8060d2d..3ff86f2 100644 --- a/scripts/private-terms-corpus.ts +++ b/scripts/private-terms-corpus.ts @@ -15,7 +15,8 @@ import { termHash, } from './private-terms.js'; -import { deletedLines, git, headCorpus, type DeletedLine, type HeadCorpus } from './private-terms-history.js'; +import { DEFAULT_BASE, git } from './git.js'; +import { deletedLines, headCorpus, type DeletedLine, type HeadCorpus } from './private-terms-history.js'; export { deletedLines } from './private-terms-history.js'; @@ -263,7 +264,7 @@ export function guardChanged(base: string, head: string, root: string): boolean } function parseOptions(argv: string[]): { base: string; head: string } { - const options = { base: 'origin/main', head: 'HEAD' }; + const options = { base: DEFAULT_BASE, head: 'HEAD' }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; if (arg === '--base') options.base = argv[++index] ?? options.base; diff --git a/scripts/private-terms-history.ts b/scripts/private-terms-history.ts index ea3f96d..19fd773 100644 --- a/scripts/private-terms-history.ts +++ b/scripts/private-terms-history.ts @@ -1,4 +1,5 @@ -import { parseInteger } from '../src/utils/parse.js'; +import { parseDiff } from './behavior-change.js'; +import { git, gitResult } from './git.js'; import { normalizeTerm } from './private-terms.js'; export interface DeletedLine { @@ -13,49 +14,20 @@ export interface HeadCorpus { readonly linesByToken: ReadonlyMap; } -export function git(args: readonly string[], root: string): string { - const result = Bun.spawnSync(['git', '-c', 'core.quotepath=false', ...args], { cwd: root }); - if (result.exitCode !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString().trim()}`); - } - return result.stdout.toString(); -} - /** The old side of every changed line, with its path and line number. */ export function deletedLines(base: string, head: string, root: string): DeletedLine[] { const diff = git( ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-renames', `${base}...${head}`], root ); - const lines: DeletedLine[] = []; - let file = ''; - let line = 0; - let inHunk = false; - for (const raw of diff.split('\n')) { - if (raw.startsWith('diff --git ')) { - inHunk = false; - } else if (raw.startsWith('@@ ')) { - inHunk = true; - const match = /^@@ -(\d+)(?:,\d+)? /.exec(raw); - line = match ? parseInteger(match[1]) ?? 0 : 0; - } else if (inHunk) { - if (!raw.startsWith('-')) continue; - lines.push({ file, line, text: raw.slice(1) }); - line++; - } else if (raw.startsWith('--- ')) { - file = raw.slice(4).replace(/^a\//, ''); - if (file === '/dev/null') file = ''; - } - } - return lines; + return parseDiff(diff).flatMap((file) => + file.hunks.flat().flatMap(({ kind, line, text }) => (kind === 'remove' ? [{ file: file.path, line, text }] : [])) + ); } /** Every normalized line of `head`, indexed by its tokens for phrase lookups. */ export function headCorpus(head: string, root: string): HeadCorpus { - const result = Bun.spawnSync( - ['git', '-c', 'core.quotepath=false', 'grep', '--no-color', '-h', '-I', '-e', '.', head, '--', '.'], - { cwd: root } - ); + const result = gitResult(['grep', '--no-color', '-h', '-I', '-e', '.', head, '--', '.'], root); const tokens = new Set(); const lines: string[] = []; const linesByToken = new Map(); diff --git a/scripts/private-terms.ts b/scripts/private-terms.ts index ea66e70..f833fad 100644 --- a/scripts/private-terms.ts +++ b/scripts/private-terms.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parseInteger } from '../src/utils/parse.js'; +import { gitBytes, revisionExists } from './git.js'; import { ALLOWED_PHRASE_HASHES, KEYED_TERM_HASHES, PRIVATE_TERM_HASHES } from './private-terms-hashes.js'; export { ALLOWED_PHRASE_HASHES, KEYED_TERM_HASHES, PRIVATE_TERM_HASHES } from './private-terms-hashes.js'; @@ -213,15 +214,9 @@ const MAX_SCAN_BYTES = 1_000_000; type TreeEntry = { type: string; object: string; path: string }; -function git(root: string, args: string[], stdin?: Uint8Array): Buffer { - const result = Bun.spawnSync(['git', ...args], { cwd: root, stdin }); - if (result.exitCode !== 0) throw new Error(`git ${args.join(' ')} failed in ${root}: ${result.stderr.toString().trim()}`); - return result.stdout; -} - /** Entries of `ref`'s tree, with their raw paths, as `-z` leaves them unquoted. */ function commitEntries(root: string, ref: string): TreeEntry[] { - const listed = git(root, ['ls-tree', '-r', '-z', '--full-tree', ref]).toString(); + const listed = gitBytes(['ls-tree', '-r', '-z', '--full-tree', ref], root).toString(); const entries: TreeEntry[] = []; for (const record of listed.split('\0')) { if (record === '') continue; @@ -237,7 +232,7 @@ function commitEntries(root: string, ref: string): TreeEntry[] { function blobContents(root: string, objects: string[]): Map { const contents = new Map(); if (objects.length === 0) return contents; - const output = git(root, ['cat-file', '--batch'], Buffer.from(`${objects.join('\n')}\n`)); + const output = gitBytes(['cat-file', '--batch'], root, Buffer.from(`${objects.join('\n')}\n`)); let offset = 0; for (const object of objects) { const newline = output.indexOf(0x0a, offset); @@ -258,7 +253,7 @@ function blobContents(root: string, objects: string[]): Map { /** Entries of the index (`git ls-files -s`), which holds the staged content. */ function indexEntries(root: string): TreeEntry[] { const entries: TreeEntry[] = []; - for (const record of git(root, ['ls-files', '-s', '-z']).toString().split('\0')) { + for (const record of gitBytes(['ls-files', '-s', '-z'], root).toString().split('\0')) { if (record === '') continue; const tab = record.indexOf('\t'); const [mode, object, stage] = record.slice(0, tab).split(' '); @@ -311,7 +306,7 @@ function worktreeHits( key: string | undefined ): PrivateTermHit[] { const hits: PrivateTermHit[] = []; - for (const path of git(root, ['diff', '--name-only', '-z']).toString().split('\0').filter(Boolean)) { + for (const path of gitBytes(['diff', '--name-only', '-z'], root).toString().split('\0').filter(Boolean)) { let content: Buffer; try { content = readFileSync(join(root, path)); @@ -324,11 +319,6 @@ function worktreeHits( return hits; } -/** True when `ref` names a commit; an unborn HEAD has no tree to scan. */ -function commitExists(root: string, ref: string): boolean { - return Bun.spawnSync(['git', 'rev-parse', '--verify', '--quiet', ref], { cwd: root }).exitCode === 0; -} - /** * Private terms in the published commit (`ref`, HEAD by default), the index * and the working-tree copy of every tracked path. The commit catches a term @@ -346,7 +336,7 @@ export function scanTrackedFiles( key: string | undefined = privateTermsKey() ): PrivateTermHit[] { const seen = new Set(); - const commit = ref === 'HEAD' && !commitExists(root, ref) ? [] : commitEntries(root, ref); + const commit = ref === 'HEAD' && !revisionExists(ref, root) ? [] : commitEntries(root, ref); const hits = [ ...scanEntries(root, commit, terms, allowed, keyed, key, seen), ...scanEntries(root, indexEntries(root), terms, allowed, keyed, key, seen), diff --git a/scripts/prova.ts b/scripts/prova.ts index c366456..065a599 100644 --- a/scripts/prova.ts +++ b/scripts/prova.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { changesBehavior, parseDiff } from './behavior-change.js'; +import { DEFAULT_BASE, git } from './git.js'; export interface ProvaStatus { present: boolean; @@ -92,14 +93,7 @@ export function checkProva(body: string, diff: string): ProvaCheck { } function changedDiff(base: string): string { - const result = Bun.spawnSync( - ['git', 'diff', '--no-renames', '--no-color', `${base}...HEAD`, '--', 'src'], - { stdout: 'pipe', stderr: 'pipe' }, - ); - if (result.exitCode !== 0) { - throw new Error(`git diff against ${base} failed: ${result.stderr.toString().trim()}`); - } - return result.stdout.toString(); + return git(['diff', '--no-renames', '--no-color', `${base}...HEAD`, '--', 'src']); } interface Options { @@ -109,7 +103,7 @@ interface Options { } function parseOptions(argv: string[]): Options { - const options: Options = { base: 'origin/main' }; + const options: Options = { base: DEFAULT_BASE }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; if (arg === '--base') { diff --git a/scripts/replay-gates.ts b/scripts/replay-gates.ts index a2590f3..46fc0d4 100644 --- a/scripts/replay-gates.ts +++ b/scripts/replay-gates.ts @@ -1,4 +1,5 @@ import { parseInteger } from '../src/utils/parse.js'; +import { DEFAULT_BASE, git, gitQuiet, repoRoot, revisionExists } from './git.js'; import { decideGate } from './fails-on-main-exemption.js'; import { changesProduct } from './new-tests-fail-on-main.js'; import { checkProva } from './prova.js'; @@ -120,24 +121,12 @@ export function replayGates(commits: readonly CommitInput[], gates: readonly Gat return { commits: replayed, fired, skipped: decisions.length - fired }; } -function git(args: string[], root: string): string { - const result = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' }); - if (result.exitCode !== 0) { - throw new Error(`git ${args.join(' ')} failed: ${result.stderr.toString().trim()}`); - } - return result.stdout.toString(); -} - -function gitQuiet(args: string[], root: string): string | undefined { - const result = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' }); - return result.exitCode === 0 ? result.stdout.toString().trim() : undefined; -} - function defaultRevision(root: string): string { - for (const candidate of ['origin/main', 'main', 'HEAD']) { - if (gitQuiet(['rev-parse', '--verify', '--quiet', candidate], root) !== undefined) return candidate; + const candidates = [DEFAULT_BASE, 'main', 'HEAD']; + for (const candidate of candidates) { + if (revisionExists(candidate, root)) return candidate; } - throw new Error('no origin/main, main or HEAD revision found'); + throw new Error(`no ${candidates.join(', ')} revision found`); } /** @@ -226,7 +215,7 @@ function format(report: ReplayReport, rev: string): string { function main(): void { try { const options = parseOptions(process.argv.slice(2)); - const root = git(['rev-parse', '--show-toplevel'], process.cwd()).trim(); + const root = repoRoot(); const rev = options.rev ?? defaultRevision(root); const report = replayGates(readCommits(rev, options.commits, root), selectGates(options.gates)); console.log(options.json ? JSON.stringify({ rev, ...report }, null, 2) : format(report, rev)); diff --git a/scripts/risk-label.ts b/scripts/risk-label.ts index 6de79e5..f3c31da 100644 --- a/scripts/risk-label.ts +++ b/scripts/risk-label.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import type { RiskReport } from './risk.js'; +import { spawn } from './git.js'; export const RISK_LABEL = 'risco'; @@ -66,7 +67,7 @@ interface CommandResult { } function gh(args: string[]): CommandResult { - const result = Bun.spawnSync(['gh', ...args], { stdout: 'pipe', stderr: 'pipe' }); + const result = spawn(['gh', ...args]); const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim(); return { ok: result.exitCode === 0, output }; } diff --git a/scripts/risk.ts b/scripts/risk.ts index 6491a16..f3300d4 100644 --- a/scripts/risk.ts +++ b/scripts/risk.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { behaviorIndexes, fileChangesBehavior, parseDiff, type DiffFile } from './behavior-change.js'; +import { DEFAULT_BASE, git } from './git.js'; export type RiskTopic = 'auth' | 'write' | 'security'; @@ -39,6 +40,9 @@ const SECURITY_SURFACES: readonly SecuritySurface[] = [ // The guard parses git output with these helpers (#262), so a change here // changes what the guard reads even when no guard file moves. { path: /^src\/utils\/parse\.ts$/, detail: 'parse helper the private-data guard imports changed' }, + // The guard spawns git and parses its diffs through these modules (#260). + { path: /^scripts\/git\.ts$/, detail: 'child-process owner the private-data guard imports changed' }, + { path: /^scripts\/behavior-change\.ts$/, detail: 'diff parser the private-data guard imports changed' }, ]; const TEST_PATH = /^(?:test\/|.*\.(?:test|spec)\.[cm]?[jt]sx?$)/; @@ -144,14 +148,7 @@ export function classifyRisk(diff: string): RiskReport { } function gitDiff(base: string): string { - const result = Bun.spawnSync( - ['git', 'diff', '--no-renames', '--no-color', `${base}...HEAD`], - { stdout: 'pipe', stderr: 'pipe' }, - ); - if (result.exitCode !== 0) { - throw new Error(`git diff against ${base} failed: ${result.stderr.toString().trim()}`); - } - return result.stdout.toString(); + return git(['diff', '--no-renames', '--no-color', `${base}...HEAD`]); } interface Options { @@ -160,7 +157,7 @@ interface Options { } function parseOptions(argv: string[]): Options { - const options: Options = { base: 'origin/main' }; + const options: Options = { base: DEFAULT_BASE }; for (let index = 0; index < argv.length; index++) { const arg = argv[index]; if (arg === '--base') { diff --git a/test/behavior-change.test.ts b/test/behavior-change.test.ts index 3b64da4..41af666 100644 --- a/test/behavior-change.test.ts +++ b/test/behavior-change.test.ts @@ -22,23 +22,51 @@ describe('parseDiff', () => { { path: 'src/a.ts', hunks: [[ - { kind: 'context', text: 'const a = 1;' }, - { kind: 'remove', text: 'const b = 2;' }, - { kind: 'add', text: 'const b = 3;' }, + { kind: 'context', text: 'const a = 1;', line: 1 }, + { kind: 'remove', text: 'const b = 2;', line: 2 }, + { kind: 'add', text: 'const b = 3;', line: 2 }, ]], }, { path: 'src/b.ts', hunks: [[ - { kind: 'remove', text: '// old' }, - { kind: 'add', text: '// new' }, + { kind: 'remove', text: '// old', line: 1 }, + { kind: 'add', text: '// new', line: 1 }, ]], }, ]); }); test('keeps an added line whose content starts with ++', () => { - expect(parseDiff(file('src/a.ts', ['+++i;']))[0]?.hunks[0]).toEqual([{ kind: 'add', text: '++i;' }]); + expect(parseDiff(file('src/a.ts', ['+++i;']))[0]?.hunks[0]).toEqual([{ kind: 'add', text: '++i;', line: 1 }]); + }); + + test('numbers each line on its own side across hunks', () => { + const diff = ['diff --git a/src/a.ts b/src/a.ts', '@@ -10,2 +20,3 @@', ' keep', '-gone', '+one', '+two', '@@ -40 +51 @@', '-old', '+new'].join('\n'); + expect(parseDiff(diff)[0]?.hunks).toEqual([ + [ + { kind: 'context', text: 'keep', line: 20 }, + { kind: 'remove', text: 'gone', line: 11 }, + { kind: 'add', text: 'one', line: 21 }, + { kind: 'add', text: 'two', line: 22 }, + ], + [ + { kind: 'remove', text: 'old', line: 40 }, + { kind: 'add', text: 'new', line: 51 }, + ], + ]); + }); + + // A path with a quote or a tab makes git quote the header; dropping that + // file would hide its lines from risk and the private-terms corpus. + test('keeps the lines of a file whose header git quotes', () => { + const diff = ['diff --git "a/src/a\\tb.ts" "b/src/a\\tb.ts"', '@@ -1 +1 @@', '-old', '+new'].join('\n'); + expect(parseDiff(diff)).toEqual([ + { + path: '"a/src/a\\tb.ts" "b/src/a\\tb.ts"', + hunks: [[{ kind: 'remove', text: 'old', line: 1 }, { kind: 'add', text: 'new', line: 1 }]], + }, + ]); }); }); diff --git a/test/eslint-no-raw-spawn.test.ts b/test/eslint-no-raw-spawn.test.ts new file mode 100644 index 0000000..9f3b807 --- /dev/null +++ b/test/eslint-no-raw-spawn.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { ESLint } from 'eslint'; + +// Lints snippets with the repo's real eslint.config.js, as eslint-local-rules.test.ts +// does; this rule has its own file to keep both under the max-lines limit. +const eslint = new ESLint({ cwd: `${import.meta.dir}/..` }); + +async function localRules(code: string, filePath: string): Promise { + const [result] = await eslint.lintText(`${code}\n`, { filePath }); + const fatal = result.messages.find((m) => m.fatal); + if (fatal) throw new Error(fatal.message); + return result.messages.flatMap((m) => (m.ruleId?.startsWith('local/') ? [m.ruleId] : [])); +} + +const GATE = 'scripts/risk.ts'; +const OWNER = 'scripts/git.ts'; +const RULE = 'local/no-raw-spawn'; + +describe('local/no-raw-spawn', () => { + test('fires on Bun.spawnSync and Bun.spawn in a gate, as the old wrappers wrote them', async () => { + // The shape every gate copied before #260 (replay-gates, risk, prova, private-terms). + const wrapper = "export function git(args: string[], root: string): string {\n const result = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' });\n return result.stdout.toString();\n}"; + expect(await localRules(wrapper, GATE)).toEqual([RULE]); + expect(await localRules("export const proc = Bun.spawn(['gh', 'pr', 'view']);", GATE)).toEqual([RULE]); + expect(await localRules("export const run = Bun['spawnSync'];", GATE)).toEqual([RULE]); + }); + + test('fires on child_process and the Bun shell in a gate', async () => { + expect(await localRules("import { execFileSync } from 'node:child_process';\nexport const run = execFileSync;", GATE)).toEqual([RULE]); + expect(await localRules("import { spawnSync } from 'child_process';\nexport const run = spawnSync;", GATE)).toEqual([RULE]); + expect(await localRules("import { $ } from 'bun';\nexport const run = $;", GATE)).toEqual([RULE]); + expect(await localRules("export const load = () => import('node:child_process');", GATE)).toEqual([RULE]); + }); + + test('does not fire in the owner, or on another Bun API or spawn name in a gate', async () => { + expect(await localRules("export const result = Bun.spawnSync(['git', 'status']);", OWNER)).toEqual([]); + expect(await localRules("export const file = Bun.file('package.json');", GATE)).toEqual([]); + expect(await localRules("import { spawn } from './git.js';\nexport const result = spawn(['gh', 'auth', 'status']);", GATE)).toEqual([]); + }); +}); diff --git a/test/fixtures/lint-rules/no-raw-spawn.before.json b/test/fixtures/lint-rules/no-raw-spawn.before.json new file mode 100644 index 0000000..05a40c1 --- /dev/null +++ b/test/fixtures/lint-rules/no-raw-spawn.before.json @@ -0,0 +1,7 @@ +{ + "rule": "no-raw-spawn", + "origin": "1045a83", + "source": "scripts/replay-gates.ts", + "note": "Each gate in scripts/ spawned git through its own Bun.spawnSync wrapper; replay-gates had two copies of it (#260).", + "code": "const result = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' });" +} diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 0000000..e6a63eb --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { git, gitBytes, gitQuiet, revisionExists } from '../scripts/git.js'; + +function withRepo(run: (repo: string) => void): void { + const repo = mkdtempSync(join(tmpdir(), 'mdcli-git-owner-')); + try { + git(['init', '-q', '-b', 'main'], repo); + run(repo); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +} + +describe('scripts/git.ts', () => { + // Without core.quotepath=false git prints "\303\247" for "ç", and a gate that + // matches paths by name (risk, prova, the corpus) misses the file. + test('lists a non-ASCII path as written, for every caller', () => { + withRepo((repo) => { + writeFileSync(join(repo, 'ação.ts'), 'export {};\n'); + git(['add', 'ação.ts'], repo); + expect(git(['ls-files'], repo)).toBe('ação.ts\n'); + expect(git(['diff', '--cached', '--name-only'], repo)).toBe('ação.ts\n'); + }); + }); + + test("throws git's own stderr with the command and the directory", () => { + withRepo((repo) => { + expect(() => gitBytes(['rev-parse', '--verify', 'no-such-ref'], repo)).toThrow( + `git rev-parse --verify no-such-ref failed in ${repo}: fatal: Needed a single revision` + ); + }); + }); + + test('reports an unborn HEAD as a missing revision instead of throwing', () => { + withRepo((repo) => { + expect(revisionExists('HEAD', repo)).toBe(false); + expect(gitQuiet(['rev-parse', '--verify', '--quiet', 'HEAD'], repo)).toBeUndefined(); + git(['-c', 'user.name=test', '-c', 'user.email=test@example.com', 'commit', '-q', '--allow-empty', '-m', 'first'], repo); + expect(revisionExists('HEAD', repo)).toBe(true); + }); + }); +});