diff --git a/README.md b/README.md index 8463ab3..ceb0a36 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ ai: model: claude-sonnet-4-20250514 review: - target_branch: main # diff base: git diff ...HEAD + target_branch: main # local ref for git diff ...HEAD context_lines: 10 # surrounding context lines included in the diff max_lines_for_full_file: 300 # below this threshold, full file contents are sent # instead of just the diff for richer context @@ -125,14 +125,14 @@ tools: command: ["bundle", "exec", "brakeman", "--no-pager", "--quiet"] # no {changed_files} → runs on the whole project -# Files and patterns excluded from tool checks and AI review +# Gitignore-like repo-relative paths excluded from tool checks and AI review ignore_paths: - "*.lock" - "dist/**" - "coverage/**" ``` -V2 configs must declare `version: 2`. Core config sections are strict, provider-specific config belongs below `ai.providers.`, and tool commands are argv arrays rather than shell strings. Reviewer focus and default finding-category instructions live with the built-in review prompt rather than the v2 config surface. See `docs/v2-config-schema.md` for the schema boundary and migration behavior for `.push-review.yml`. +V2 configs must declare `version: 2`. Core config sections are strict, provider-specific config belongs below `ai.providers.`, and tool commands are argv arrays rather than shell strings. Reviewer focus and default finding-category instructions live with the built-in review prompt rather than the v2 config surface. See `docs/v2-config-schema.md` for the schema boundary, changed-file policy, and migration behavior for `.push-review.yml`. ## Available templates diff --git a/docs/product-contract-plan.md b/docs/product-contract-plan.md index 8bdc73c..be3e102 100644 --- a/docs/product-contract-plan.md +++ b/docs/product-contract-plan.md @@ -77,8 +77,8 @@ The initial installation path is installer-first: `install.sh` installs the Push ### Local Checks -- Define the base-ref algorithm when the configured target branch is absent locally, a push creates a new branch, or a remote ref differs from local history. -- Freeze changed-file semantics for deleted files, renames, binary files, generated files, ignored paths, extension filters, and filenames with whitespace. +- The changed-file resolver uses the locally resolvable configured target branch and fails explicitly when Git cannot use that ref or find its diff base with `HEAD`; it does not auto-fetch or silently choose a remote or push-range fallback. +- Keep normalized changed-file semantics shared for deleted files, renames, binary files, ignored paths, extension filters, and filenames with whitespace as deterministic and AI consumers land. - Decide check mode defaults and failure handling for missing commands, timeouts, warnings, fail-fast, and checks that must run on the whole repo. - Define which local blocking checks must have a CI mirror and how local-only exceptions are recorded. @@ -98,7 +98,7 @@ The initial installation path is installer-first: `install.sh` installs the Push ### Support And Verification -- Freeze supported platforms and shells before choosing parser, timeout, path glob, and packaging implementations. +- The initial changed-file path-policy layer targets macOS and Linux; Windows and Git Bash support remains a deliberate support boundary for later parser, timeout, path glob, and packaging decisions. - Build a test harness that creates temporary Git repos and stubs checks and AI providers before moving behavior out of the existing Bash hook. - Decide migration and release messaging for old repository names, old config files, old hook output prefixes, and existing install URLs. diff --git a/docs/v2-config-schema.md b/docs/v2-config-schema.md index a35018d..9ec019d 100644 --- a/docs/v2-config-schema.md +++ b/docs/v2-config-schema.md @@ -65,6 +65,25 @@ tools: command: ["npx", "prettier", "--check", "{changed_files}"] ``` +## Changed-File Policy + +The changed-file path policy resolves `review.target_branch` locally and uses +the documented `...HEAD` Git diff range. If that ref is missing +or Git cannot find a merge base with `HEAD`, Pushgate fails with an explicit +diagnostic instead of fetching, guessing a remote variant, or switching to a +different history range. + +`ignore_paths` uses gitignore-like rules against Git's repo-relative paths. +Patterns such as `*.lock` match basenames across the changed tree, while +directory rules such as `dist/**` remove that generated subtree before +deterministic tools or AI consume the shared changed-file list. Tool +`extensions` are suffix filters over the remaining current paths; deleted files +remain in normalized changed-file metadata but are not live argv paths for +later changed-file tool commands. + +The initial path-policy implementation targets macOS and Linux behavior. +Windows and Git Bash path support remain explicit follow-up scope. + ## Review Prompt Legacy `.push-review.yml` stored reviewer `focus`, `blocking_categories`, and diff --git a/package.json b/package.json index 41a452f..39a522f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "ajv": "^8.17.1", + "ignore": "^7.0.5", "yaml": "^2.8.1" }, "devDependencies": { @@ -26,6 +27,10 @@ "./config": { "types": "./dist/config/index.d.ts", "default": "./dist/config/index.js" + }, + "./path-policy": { + "types": "./dist/path-policy/index.d.ts", + "default": "./dist/path-policy/index.js" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be0d197..ba3eeaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: ajv: specifier: ^8.17.1 version: 8.20.0 + ignore: + specifier: ^7.0.5 + version: 7.0.5 yaml: specifier: ^2.8.1 version: 2.9.0 @@ -205,6 +208,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -357,6 +364,8 @@ snapshots: fsevents@2.3.3: optional: true + ignore@7.0.5: {} + json-schema-traverse@1.0.0: {} require-from-string@2.0.2: {} diff --git a/schemas/pushgate-config-v2.schema.json b/schemas/pushgate-config-v2.schema.json index abdf739..3c88e73 100644 --- a/schemas/pushgate-config-v2.schema.json +++ b/schemas/pushgate-config-v2.schema.json @@ -26,7 +26,7 @@ "$ref": "#/definitions/ai" }, "ignore_paths": { - "description": "Glob-like changed-file paths omitted by later Pushgate layers.", + "description": "Gitignore-like repo-relative changed-file paths omitted by later Pushgate layers.", "type": "array", "default": [], "items": { diff --git a/src/path-policy/index.ts b/src/path-policy/index.ts new file mode 100644 index 0000000..01b186c --- /dev/null +++ b/src/path-policy/index.ts @@ -0,0 +1,458 @@ +import { spawn } from "node:child_process"; + +import ignore from "ignore"; + +/** Git file states normalized for downstream Pushgate policy consumers. */ +export type ChangedFileStatus = + | "added" + | "copied" + | "deleted" + | "modified" + | "renamed" + | "type-changed" + | "unmerged" + | "unknown"; + +/** One changed path as reported by the configured Pushgate diff range. */ +export interface ChangedFile { + /** Repository-relative path with Git's slash-separated path spelling. */ + path: string; + /** Prior path when Git identified a rename or copy. */ + previousPath?: string; + /** Normalized status from Git's name-status record. */ + status: ChangedFileStatus; + /** Whether Git's numstat output identifies the diff as binary. */ + binary: boolean; +} + +/** Options consumed by the changed-file resolver. */ +export interface ResolveChangedFilesOptions { + /** Repository root where Git commands should execute. */ + repoRoot?: string; + /** Configured `review.target_branch` ref used for the triple-dot diff. */ + targetBranch: string; + /** Configured gitignore-like `ignore_paths` patterns. */ + ignorePaths?: readonly string[]; +} + +/** File list plus Git metadata needed for later runner diagnostics. */ +export interface ChangedFileResolution { + /** Merge base selected by the `...HEAD` diff contract. */ + diffBase: string; + /** Globally filtered changed files for deterministic and AI consumers. */ + files: ChangedFile[]; + /** Commit selected by the configured target ref at resolution time. */ + targetCommit: string; + /** Configured target branch or ref. */ + targetRef: string; +} + +interface GitRunResult { + code: number | null; + stderr: string; + stdout: Buffer; +} + +/** Base error shape for changed-file Git and policy resolution failures. */ +export class ChangedFilePolicyError extends Error { + /** Stable machine-readable error code for callers to render. */ + readonly code: string; + /** Human-readable context callers can include in diagnostic output. */ + readonly diagnostics: string[]; + + constructor(message: string, code: string, diagnostics: string[] = []) { + super(message); + this.name = new.target.name; + this.code = code; + this.diagnostics = diagnostics; + } +} + +/** Raised when the configured `review.target_branch` cannot resolve locally. */ +export class MissingTargetRefError extends ChangedFilePolicyError { + readonly targetRef: string; + + constructor(targetRef: string) { + super( + `Configured review.target_branch "${targetRef}" cannot be resolved locally. Fetch or create that ref before Pushgate resolves changed files.`, + "PUSHGATE_PATH_TARGET_REF_MISSING", + ); + this.targetRef = targetRef; + } +} + +/** Raised when the configured target and HEAD have no usable merge base. */ +export class MissingDiffBaseError extends ChangedFilePolicyError { + readonly targetRef: string; + + constructor(targetRef: string, detail?: string) { + super( + [ + `No usable diff base exists between review.target_branch "${targetRef}" and HEAD.`, + "Pushgate does not guess a fallback changed-file range.", + detail, + ] + .filter(Boolean) + .join(" "), + "PUSHGATE_PATH_DIFF_BASE_MISSING", + detail ? [detail] : [], + ); + this.targetRef = targetRef; + } +} + +/** Raised when Git cannot inspect or describe the changed-file set. */ +export class GitChangedFilesError extends ChangedFilePolicyError { + readonly gitArgs: readonly string[]; + + constructor(gitArgs: readonly string[], detail: string) { + super( + `Git could not inspect Pushgate changed files with "git ${gitArgs.join( + " ", + )}". ${detail}`, + "PUSHGATE_PATH_GIT_FAILED", + [detail], + ); + this.gitArgs = [...gitArgs]; + } +} + +/** + * Resolve Git changes from the configured target ref to HEAD. + * + * The target must already exist locally. This resolver intentionally keeps + * remote fetch and fallback range decisions out of path-policy execution. + */ +export async function resolveChangedFiles( + options: ResolveChangedFilesOptions, +): Promise { + const repoRoot = options.repoRoot ?? process.cwd(); + const targetCommit = await resolveTargetCommit(repoRoot, options.targetBranch); + const diffBase = await resolveDiffBase( + repoRoot, + options.targetBranch, + targetCommit, + ); + const diffRange = `${targetCommit}...HEAD`; + const nameStatusArgs = [ + "diff", + "--name-status", + "-z", + "--find-renames", + "--no-ext-diff", + diffRange, + ]; + const numstatArgs = [ + "diff", + "--numstat", + "-z", + "--find-renames", + "--no-ext-diff", + diffRange, + ]; + const [nameStatusOutput, numstatOutput] = await Promise.all([ + runGitChecked(repoRoot, nameStatusArgs), + runGitChecked(repoRoot, numstatArgs), + ]); + const binaryPaths = parseBinaryPaths(numstatOutput, numstatArgs); + const files = filterIgnoredChangedFiles( + parseChangedFiles(nameStatusOutput, binaryPaths, nameStatusArgs), + options.ignorePaths ?? [], + ); + + return { + diffBase, + files, + targetCommit, + targetRef: options.targetBranch, + }; +} + +/** Apply v2 `ignore_paths` rules to repository-relative changed paths. */ +export function filterIgnoredChangedFiles( + files: readonly ChangedFile[], + ignorePaths: readonly string[], +): ChangedFile[] { + if (ignorePaths.length === 0) { + return [...files]; + } + + const ignorePathsMatcher = ignore().add(ignorePaths); + + return files.filter((file) => !ignorePathsMatcher.ignores(file.path)); +} + +/** + * Select paths that later deterministic tool commands may receive as argv. + * + * Deleted files stay in the normalized resolver output for diff and AI work, + * but they are not live paths that a changed-file command can receive. + */ +export function selectToolChangedFilePaths( + files: readonly ChangedFile[], + extensions?: readonly string[], +): string[] { + return files + .filter((file) => file.status !== "deleted") + .filter((file) => matchesExtension(file.path, extensions)) + .map((file) => file.path); +} + +async function resolveTargetCommit( + repoRoot: string, + targetRef: string, +): Promise { + const args = ["rev-parse", "--verify", "--quiet", `${targetRef}^{commit}`]; + const result = await runGit(repoRoot, args); + + if (result.code === 0) { + return result.stdout.toString("utf8").trim(); + } + + if (result.code === 1) { + throw new MissingTargetRefError(targetRef); + } + + throw gitFailure(args, result); +} + +async function resolveDiffBase( + repoRoot: string, + targetRef: string, + targetCommit: string, +): Promise { + const args = ["merge-base", targetCommit, "HEAD"]; + const result = await runGit(repoRoot, args); + + if (result.code === 0) { + return result.stdout.toString("utf8").trim(); + } + + throw new MissingDiffBaseError(targetRef, gitResultDetail(result)); +} + +async function runGitChecked( + repoRoot: string, + args: readonly string[], +): Promise { + const result = await runGit(repoRoot, args); + + if (result.code !== 0) { + throw gitFailure(args, result); + } + + return result.stdout; +} + +function parseChangedFiles( + output: Buffer, + binaryPaths: ReadonlySet, + gitArgs: readonly string[], +): ChangedFile[] { + const fields = splitNullFields(output); + const files: ChangedFile[] = []; + + for (let index = 0; index < fields.length; ) { + const rawStatus = requiredField(fields, index, gitArgs, "status"); + const status = normalizeGitStatus(rawStatus); + const needsPreviousPath = status === "renamed" || status === "copied"; + + index += 1; + + if (needsPreviousPath) { + const previousPath = requiredPath(fields, index, gitArgs); + const path = requiredPath(fields, index + 1, gitArgs); + + files.push({ + binary: binaryPaths.has(path), + path, + previousPath, + status, + }); + index += 2; + continue; + } + + const path = requiredPath(fields, index, gitArgs); + + files.push({ + binary: binaryPaths.has(path), + path, + status, + }); + index += 1; + } + + return files; +} + +function parseBinaryPaths( + output: Buffer, + gitArgs: readonly string[], +): Set { + const fields = splitNullFields(output); + const binaryPaths = new Set(); + + for (let index = 0; index < fields.length; index += 1) { + const summary = requiredField(fields, index, gitArgs, "numstat summary"); + const firstTab = summary.indexOf("\t"); + const secondTab = summary.indexOf("\t", firstTab + 1); + + if (firstTab === -1 || secondTab === -1) { + throw malformedGitOutput(gitArgs, "a numstat summary had no tab fields"); + } + + const addedLines = summary.slice(0, firstTab); + const deletedLines = summary.slice(firstTab + 1, secondTab); + let path = summary.slice(secondTab + 1); + + if (path === "") { + // Rename and copy numstat records keep preimage and current paths after + // the summary field so NUL remains the only pathname delimiter. + requiredPath(fields, index + 1, gitArgs); + path = requiredPath(fields, index + 2, gitArgs); + index += 2; + } + + if (addedLines === "-" && deletedLines === "-") { + binaryPaths.add(path); + } + } + + return binaryPaths; +} + +function splitNullFields(output: Buffer): string[] { + if (output.length === 0) { + return []; + } + + const fields = output.toString("utf8").split("\0"); + + if (fields.at(-1) === "") { + fields.pop(); + } + + return fields; +} + +function normalizeGitStatus(rawStatus: string): ChangedFileStatus { + switch (rawStatus[0]) { + case "A": + return "added"; + case "C": + return "copied"; + case "D": + return "deleted"; + case "M": + return "modified"; + case "R": + return "renamed"; + case "T": + return "type-changed"; + case "U": + return "unmerged"; + default: + return "unknown"; + } +} + +function matchesExtension( + path: string, + extensions: readonly string[] | undefined, +): boolean { + if (extensions === undefined) { + return true; + } + + return extensions.some((extension) => path.endsWith(extension)); +} + +function requiredPath( + fields: readonly string[], + index: number, + gitArgs: readonly string[], +): string { + const path = requiredField(fields, index, gitArgs, "path"); + + if (path === "") { + throw malformedGitOutput(gitArgs, "a changed path was empty"); + } + + return path; +} + +function requiredField( + fields: readonly string[], + index: number, + gitArgs: readonly string[], + label: string, +): string { + const field = fields[index]; + + if (field === undefined) { + throw malformedGitOutput(gitArgs, `a ${label} field was missing`); + } + + return field; +} + +function malformedGitOutput( + gitArgs: readonly string[], + detail: string, +): GitChangedFilesError { + return new GitChangedFilesError(gitArgs, `Git returned malformed output: ${detail}.`); +} + +function gitFailure( + gitArgs: readonly string[], + result: GitRunResult, +): GitChangedFilesError { + return new GitChangedFilesError(gitArgs, gitResultDetail(result)); +} + +function gitResultDetail(result: GitRunResult): string { + const stderr = result.stderr.trim(); + + if (stderr) { + return stderr; + } + + return `git exited with ${String(result.code)}.`; +} + +function runGit(repoRoot: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", [...args], { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + let stderr = ""; + + if (!child.stdout || !child.stderr) { + reject(new Error("Git changed-file inspection must capture output.")); + return; + } + + child.stdout.on("data", (data: Buffer) => { + stdout.push(data); + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (data: string) => { + stderr += data; + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ + code, + stderr, + stdout: Buffer.concat(stdout), + }); + }); + }).catch((error: unknown) => { + const detail = error instanceof Error ? error.message : String(error); + + throw new GitChangedFilesError(args, detail); + }); +} diff --git a/test/path-policy.test.ts b/test/path-policy.test.ts new file mode 100644 index 0000000..7946238 --- /dev/null +++ b/test/path-policy.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { + mkdir, + mkdtemp, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +import { + GitChangedFilesError, + MissingDiffBaseError, + MissingTargetRefError, + resolveChangedFiles, + selectToolChangedFilePaths, +} from "../src/path-policy/index.js"; + +test("resolves filtered changed paths and preserves Git path metadata", async () => { + await withFeatureRepo(async (repoRoot) => { + const resolution = await resolveChangedFiles({ + repoRoot, + targetBranch: "main", + ignorePaths: ["*.lock", "dist/**"], + }); + const filesByPath = new Map( + resolution.files.map((file) => [file.path, file]), + ); + + assert.equal(resolution.targetRef, "main"); + assert.match(resolution.targetCommit, /^[0-9a-f]{40}$/); + assert.match(resolution.diffBase, /^[0-9a-f]{40}$/); + + assert.equal(filesByPath.get("src/modified.ts")?.status, "modified"); + assert.equal(filesByPath.get("src/deleted.ts")?.status, "deleted"); + assert.deepEqual(filesByPath.get("src/rename-after.ts"), { + binary: false, + path: "src/rename-after.ts", + previousPath: "src/rename-before.ts", + status: "renamed", + }); + assert.equal( + filesByPath.get("src/file with spaces.ts")?.status, + "added", + ); + assert.equal(filesByPath.get("assets/logo.bin")?.binary, true); + assert.equal(filesByPath.has("packages/app/dependency.lock"), false); + assert.equal(filesByPath.has("dist/generated.ts"), false); + + assert.deepEqual( + selectToolChangedFilePaths(resolution.files, [".ts"]).sort(), + [ + "src/file with spaces.ts", + "src/modified.ts", + "src/rename-after.ts", + ], + ); + }); +}); + +test("reports a configured target ref that does not exist locally", async () => { + await withFeatureRepo(async (repoRoot) => { + await assert.rejects( + resolveChangedFiles({ repoRoot, targetBranch: "develop" }), + (error) => { + assert.ok(error instanceof MissingTargetRefError); + assert.equal(error.code, "PUSHGATE_PATH_TARGET_REF_MISSING"); + assert.match(error.message, /develop/); + return true; + }, + ); + }); +}); + +test("reports histories with no usable merge base", async () => { + await withTempDir("pushgate-path-unrelated-", async (repoRoot) => { + await initRepo(repoRoot); + await writeRepoFile(repoRoot, "main.txt", "main history\n"); + await commitAll(repoRoot, "main"); + + await checkedGit(repoRoot, ["switch", "--quiet", "--orphan", "feature"]); + await writeRepoFile(repoRoot, "feature.txt", "feature history\n"); + await commitAll(repoRoot, "feature"); + + await assert.rejects( + resolveChangedFiles({ repoRoot, targetBranch: "main" }), + (error) => { + assert.ok(error instanceof MissingDiffBaseError); + assert.equal(error.code, "PUSHGATE_PATH_DIFF_BASE_MISSING"); + assert.match(error.message, /does not guess a fallback/); + return true; + }, + ); + }); +}); + +test("reports Git inspection failures before path parsing", async () => { + await withTempDir("pushgate-path-no-repo-", async (repoRoot) => { + await assert.rejects( + resolveChangedFiles({ repoRoot, targetBranch: "main" }), + (error) => { + assert.ok(error instanceof GitChangedFilesError); + assert.equal(error.code, "PUSHGATE_PATH_GIT_FAILED"); + assert.match(error.message, /not a git repository/i); + return true; + }, + ); + }); +}); + +async function withFeatureRepo( + callback: (repoRoot: string) => Promise, +): Promise { + await withTempDir("pushgate-path-feature-", async (repoRoot) => { + await initRepo(repoRoot); + await Promise.all([ + writeRepoFile(repoRoot, "src/modified.ts", "export const base = true;\n"), + writeRepoFile(repoRoot, "src/deleted.ts", "export const remove = true;\n"), + writeRepoFile( + repoRoot, + "src/rename-before.ts", + "export const renamed = true;\n", + ), + ]); + await commitAll(repoRoot, "baseline"); + + await checkedGit(repoRoot, ["switch", "--quiet", "-c", "feature"]); + await checkedGit(repoRoot, ["mv", "src/rename-before.ts", "src/rename-after.ts"]); + await Promise.all([ + writeRepoFile( + repoRoot, + "src/modified.ts", + "export const modified = true;\n", + ), + writeRepoFile( + repoRoot, + "src/file with spaces.ts", + "export const spaced = true;\n", + ), + writeRepoFile(repoRoot, "src/note.md", "# changed\n"), + writeRepoFile(repoRoot, "dist/generated.ts", "generated\n"), + writeRepoFile(repoRoot, "packages/app/dependency.lock", "lock\n"), + writeRepoFile(repoRoot, "assets/logo.bin", Buffer.from([0, 1, 2, 3])), + rm(join(repoRoot, "src", "deleted.ts")), + ]); + await commitAll(repoRoot, "feature changes"); + + await callback(repoRoot); + }); +} + +async function withTempDir( + prefix: string, + callback: (repoRoot: string) => Promise, +): Promise { + const repoRoot = await mkdtemp(join(tmpdir(), prefix)); + + try { + await callback(repoRoot); + } finally { + await rm(repoRoot, { force: true, recursive: true }); + } +} + +async function initRepo(repoRoot: string): Promise { + await checkedGit(repoRoot, ["init", "--quiet", "--initial-branch=main"]); + await checkedGit(repoRoot, [ + "config", + "user.email", + "path-policy@example.test", + ]); + await checkedGit(repoRoot, ["config", "user.name", "Pushgate Path Policy"]); +} + +async function commitAll(repoRoot: string, message: string): Promise { + await checkedGit(repoRoot, ["add", "--all"]); + await checkedGit(repoRoot, ["commit", "--quiet", "-m", message]); +} + +async function writeRepoFile( + repoRoot: string, + relativePath: string, + content: string | Buffer, +): Promise { + const filePath = join(repoRoot, relativePath); + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); +} + +interface GitResult { + code: number | null; + stderr: string; + stdout: string; +} + +async function checkedGit(repoRoot: string, args: string[]): Promise { + const result = await runGit(repoRoot, args); + + if (result.code !== 0) { + throw new Error( + [ + `git ${args.join(" ")} exited with ${String(result.code)}.`, + `stdout:\n${result.stdout}`, + `stderr:\n${result.stderr}`, + ].join("\n"), + ); + } +} + +function runGit(repoRoot: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + let stdout = ""; + + if (!child.stdout || !child.stderr) { + reject(new Error("Path-policy tests must capture Git output.")); + return; + } + + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (data: string) => { + stdout += data; + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (data: string) => { + stderr += data; + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ code, stderr, stdout }); + }); + }); +}