diff --git a/README.md b/README.md index 6a4709d..d2bb549 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,11 @@ git push └─────────────────────────────────────┘ ``` -`git push` stays the main entry point. Pushgate plugs into it through the installed `pre-push` hook; `pushgate push` is an optional friendly wrapper for the same workflow. +`git push` stays the main entry point. Pushgate plugs into it through the +installed `pre-push` hook; `pushgate push` is an optional friendly wrapper for +the same workflow. For long interactive runs, such as warning prompts plus local +AI review, `pushgate push` runs the local Pushgate preflight before opening the +native Git push and then invokes `git push --no-verify` after Pushgate passes. Local deterministic checks can block a push. Warning results require an explicit yes/no confirmation before the push continues. Local AI supports `blocking`, `advisory`, and `off` modes; `blocking` is the default, matching the review gate shown above. CI and PR checks remain the final enforcement point for policy that must survive local hook skips. @@ -225,7 +229,9 @@ git -c pushgate.skip-ai-check=true push git -c pushgate.skip-all-checks=true push ``` -The optional wrapper maps friendly flags to the same one-push config: +The optional wrapper maps friendly flags to the same one-push config. It also +runs Pushgate locally before the native network push, so long checks and warning +prompts do not hold an already-open Git remote session idle: ```bash pushgate push --skip-ai-check diff --git a/bin/pushgate.mjs b/bin/pushgate.mjs index 2eed70b..ef3e4af 100755 --- a/bin/pushgate.mjs +++ b/bin/pushgate.mjs @@ -7827,6 +7827,7 @@ var require_ignore = __commonJS({ // src/cli.ts import { realpathSync } from "node:fs"; +import { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; // src/config/constants.ts @@ -9970,14 +9971,6 @@ var SkipControlError = class extends Error { this.name = new.target.name; } }; -function buildGitPushArgs(pushArgs, state) { - const gitArgs = []; - if (state.active.kind !== "none") { - gitArgs.push("-c", `${state.active.configKey}=true`); - } - gitArgs.push("push", ...pushArgs); - return gitArgs; -} function createSkipControlState(options) { if (options.skipAllChecks) { return { @@ -10445,6 +10438,25 @@ function githubRepository(remoteUrl) { // src/workflows/pre-push.ts import { basename } from "node:path"; +// src/git/repository.ts +async function resolveGitRepositoryRoot(env = process.env) { + const result = await runCommand({ + args: ["rev-parse", "--show-toplevel"], + command: "git", + env + }); + if (result.code === 0) { + return result.stdout.trim(); + } + const stderr = result.stderr.trim(); + throw new Error( + `Pushgate must run inside a Git repository. git rev-parse exited with ${String(result.code)}.${stderr ? ` ${stderr}` : ""}` + ); +} + +// src/version.ts +var PUSHGATE_VERSION = "3.5.0"; + // src/ai/guardrails.ts function evaluateChangedFileGuardrails(options) { if (options.changedFiles.length === 0) { @@ -27162,22 +27174,6 @@ function transcriptEventsForChangedFileGuardrail(decision) { ]; } -// src/git/repository.ts -async function resolveGitRepositoryRoot(env = process.env) { - const result = await runCommand({ - args: ["rev-parse", "--show-toplevel"], - command: "git", - env - }); - if (result.code === 0) { - return result.stdout.trim(); - } - const stderr = result.stderr.trim(); - throw new Error( - `Pushgate must run inside a Git repository. git rev-parse exited with ${String(result.code)}.${stderr ? ` ${stderr}` : ""}` - ); -} - // src/runner/policies.ts var import_ignore2 = __toESM(require_ignore(), 1); var FORBIDDEN_PATH_DETAIL_LIMIT = 5; @@ -27695,93 +27691,6 @@ function requireChangedFileResolution(changedFileResolution) { ); } -// src/version.ts -var PUSHGATE_VERSION = "3.5.0"; - -// src/workflows/run-decisions.ts -function buildPrePushConfigDecision(skipControls) { - if (skipControls.active.kind === "skip-all-checks") { - return { - kind: "skip", - reason: { - configKey: SKIP_ALL_CHECKS_CONFIG_KEY, - control: "skip-all-checks", - kind: "skip-control", - scope: "all-local-checks" - } - }; - } - return { kind: "load-config" }; -} -function buildPrePushRunDecision(config2, skipControls) { - const deterministicPlan = buildDeterministicCheckPlan(config2); - const deterministicChecks = deterministicPlan.runChecks ? { - checkCount: deterministicPlan.checkCount, - kind: "configured" - } : { - kind: "not-configured" - }; - const localAi = getLocalAiDecision(config2, skipControls); - return { - changedFiles: getChangedFileResolutionDecision({ - deterministicChecks, - localAi - }), - deterministicChecks, - localAi - }; -} -function formatRunSkipReason(reason) { - if (reason.kind === "local-ai-mode-off") { - return null; - } - if (reason.control === "skip-all-checks") { - return `Skipping all local Pushgate checks because ${reason.configKey}=true.`; - } - return `Skipping local AI because ${reason.configKey}=true.`; -} -function getLocalAiDecision(config2, skipControls) { - if (config2.ai.mode === "off") { - return { - kind: "skip", - reason: { - kind: "local-ai-mode-off" - } - }; - } - if (skipControls.active.kind === "skip-ai-check") { - return { - kind: "skip", - reason: { - configKey: SKIP_AI_CHECK_CONFIG_KEY, - control: "skip-ai-check", - kind: "skip-control", - scope: "local-ai" - } - }; - } - return { kind: "run" }; -} -function getChangedFileResolutionDecision(options) { - const requiredBy = []; - if (options.deterministicChecks.kind === "configured") { - requiredBy.push("deterministic-checks"); - } - if (options.localAi.kind === "run") { - requiredBy.push("local-ai-review"); - } - if (requiredBy.length === 0) { - return { - kind: "not-required", - requiredBy: [] - }; - } - return { - kind: "required", - requiredBy - }; -} - // src/workflows/terminal.ts import { closeSync, openSync, readSync, writeSync } from "node:fs"; var pendingInputByFd = /* @__PURE__ */ new Map(); @@ -27962,78 +27871,71 @@ function createTerminalWarningConfirmer(options = {}) { }; } -// src/workflows/pre-push.ts -async function runPrePushWorkflow(io) { - const hookContext = buildPrePushContext({ - args: io.hookArgs ?? [], - branch: await readPrePushBranchFromStdin(io.stdin) - }); - const repoRoot = await resolveGitRepositoryRoot(io.env); - writePrePushHeader(io.stdout, repoRoot, hookContext); - const skipControls = await resolveSkipControlState(repoRoot, io.env); - const configDecision = buildPrePushConfigDecision(skipControls); - if (configDecision.kind === "skip") { - writeVisibleSkipReason(io.stdout, configDecision.reason); - return 0; - } - const loaded = await loadConfig(repoRoot); - for (const warning of loaded.warnings) { - io.stdout.write(`[pushgate] Warning: ${warning} -`); - } - const runDecision = buildPrePushRunDecision(loaded.config, skipControls); - const changedFileResolution = await maybeResolveChangedFiles(loaded.config, { - repoRoot, - runDecision +// src/workflows/local-push-gate-run.ts +async function runLocalPushGate(options) { + const localAi = getLocalAiPhaseDecision(options.config, options.skipControls); + const changedFileResolution = await resolveChangedFilesIfRequired({ + config: options.config, + localAi, + repoRoot: options.repoRoot }); - const summary = await runDeterministicChecks({ + const deterministicSummary = await runDeterministicChecks({ changedFileResolution, - config: loaded.config, - env: io.env, - repoRoot, - stdout: io.stdout + config: options.config, + env: options.env, + repoRoot: options.repoRoot, + stdout: options.stdout }); - if (summary.exitCode !== 0) { - return summary.exitCode; + if (deterministicSummary.exitCode !== 0) { + return deterministicSummary.exitCode; } if (!await confirmWarningsBeforeContinuing({ - confirmer: io.warningConfirmer, + confirmer: options.warningConfirmer, phase: "deterministic checks", - stdout: io.stdout, - warningCount: summary.results.filter( + stdout: options.stdout, + warningCount: deterministicSummary.results.filter( (result) => result.status === "warning" ).length })) { return 1; } - const localAiSummary = await runLocalAiPhase( - loaded.config, - runDecision.localAi, + const localAiSummary = await runLocalAiPhase({ changedFileResolution, - { - env: io.env, - repoRoot, - stdout: io.stdout - } - ); + config: options.config, + decision: localAi, + env: options.env, + repoRoot: options.repoRoot, + stdout: options.stdout + }); if (localAiSummary.exitCode !== 0) { return localAiSummary.exitCode; } if (!await confirmWarningsBeforeContinuing({ - confirmer: io.warningConfirmer, + confirmer: options.warningConfirmer, phase: "local AI review", - stdout: io.stdout, + stdout: options.stdout, warningCount: localAiSummary.warningCount })) { return 1; } - writeLine(io.stdout); - writeLine(io.stdout, "Pushgate passed. Git is pushing..."); + writeLine(options.stdout); + writeLine(options.stdout, "Pushgate passed. Changes allowed..."); return 0; } -async function runLocalAiPhase(config2, decision, changedFileResolution, options) { - if (decision.kind === "skip") { - const message = formatRunSkipReason(decision.reason); +async function resolveChangedFilesIfRequired(options) { + const deterministicPlan = buildDeterministicCheckPlan(options.config); + if (!deterministicPlan.needsChangedFileResolution && options.localAi.kind !== "run") { + return null; + } + return await resolveChangedFiles({ + repoRoot: options.repoRoot, + targetBranch: options.config.review.target_branch, + ignorePaths: options.config.ignore_paths + }); +} +async function runLocalAiPhase(options) { + if (options.decision.kind === "skip") { + const message = formatLocalAiSkipReason(options.decision.reason); if (message !== null) { writeSection(options.stdout, "AI review"); writeResultRow(options.stdout, "skipped", message); @@ -28042,14 +27944,14 @@ async function runLocalAiPhase(config2, decision, changedFileResolution, options } writeSection(options.stdout, "AI review"); return await runLocalAiReview({ - aiConfig: config2.ai, + aiConfig: options.config.ai, changedFileResolution: requireChangedFileResolution2( - changedFileResolution, + options.changedFileResolution, "local AI phase" ), env: options.env, repoRoot: options.repoRoot, - reviewConfig: config2.review, + reviewConfig: options.config.review, stdout: options.stdout }); } @@ -28087,21 +27989,26 @@ async function confirmWarningsBeforeContinuing(options) { throw error51; } } -async function maybeResolveChangedFiles(config2, options) { - if (options.runDecision.changedFiles.kind === "not-required") { - return null; +function getLocalAiPhaseDecision(config2, skipControls) { + if (config2.ai.mode === "off") { + return { + kind: "skip", + reason: "local-ai-mode-off" + }; } - return await resolveChangedFiles({ - repoRoot: options.repoRoot, - targetBranch: config2.review.target_branch, - ignorePaths: config2.ignore_paths - }); + if (skipControls.active.kind === "skip-ai-check") { + return { + kind: "skip", + reason: "skip-ai-check" + }; + } + return { kind: "run" }; } -function writeVisibleSkipReason(stdout, reason) { - const message = formatRunSkipReason(reason); - if (message !== null) { - writeResultRow(stdout, "skipped", message); +function formatLocalAiSkipReason(reason) { + if (reason === "local-ai-mode-off") { + return null; } + return `Skipping local AI because ${SKIP_AI_CHECK_CONFIG_KEY}=true.`; } function requireChangedFileResolution2(changedFileResolution, phaseName) { if (changedFileResolution !== null) { @@ -28111,19 +28018,8 @@ function requireChangedFileResolution2(changedFileResolution, phaseName) { `Pushgate could not prepare changed files for the ${phaseName}.` ); } -function writePrePushHeader(stdout, repoRoot, context) { - const lines = [ - `Pushgate v${PUSHGATE_VERSION} - pre-push`, - `Repo: ${basename(repoRoot)}` - ]; - if (context.branch) { - lines.push(`Branch: ${context.branch}`); - } - if (context.remote) { - lines.push(`Remote: ${context.remote}`); - } - writeHeader(stdout, lines); -} + +// src/workflows/pre-push-hook-context.ts function buildPrePushContext(options) { return { branch: options.branch, @@ -28192,6 +28088,54 @@ function readPrePushBranchFromStdin(stdin) { }); } +// src/workflows/pre-push.ts +async function runPrePushWorkflow(io) { + const hookContext = buildPrePushContext({ + args: io.hookArgs ?? [], + branch: await readPrePushBranchFromStdin(io.stdin) + }); + const repoRoot = await resolveGitRepositoryRoot(io.env); + writePrePushHeader(io.stdout, repoRoot, hookContext); + const skipControls = await resolveSkipControlState(repoRoot, io.env); + if (skipControls.active.kind === "skip-all-checks") { + writeSkipAllChecksReason(io.stdout); + return 0; + } + const loaded = await loadConfig(repoRoot); + for (const warning of loaded.warnings) { + io.stdout.write(`[pushgate] Warning: ${warning} +`); + } + return await runLocalPushGate({ + config: loaded.config, + env: io.env, + repoRoot, + stdout: io.stdout, + skipControls, + ...io.warningConfirmer ? { warningConfirmer: io.warningConfirmer } : {} + }); +} +function writeSkipAllChecksReason(stdout) { + writeResultRow( + stdout, + "skipped", + `Skipping all local Pushgate checks because ${SKIP_ALL_CHECKS_CONFIG_KEY}=true.` + ); +} +function writePrePushHeader(stdout, repoRoot, context) { + const lines = [ + `Pushgate v${PUSHGATE_VERSION} - pre-push`, + `Repo: ${basename(repoRoot)}` + ]; + if (context.branch) { + lines.push(`Branch: ${context.branch}`); + } + if (context.remote) { + lines.push(`Remote: ${context.remote}`); + } + writeHeader(stdout, lines); +} + // src/cli.ts var HOOK_PROTOCOL = "1"; var USAGE = `Usage: @@ -28240,8 +28184,17 @@ async function runPrePushCommand(args, io) { async function runPushCommand(args, io) { try { const parsed = parsePushCommandArgs(args); + const preflightExitCode = await runPrePushWorkflow({ + ...io, + env: withSkipControlConfigOverlay(io.env, parsed.skipControls), + hookArgs: hookArgsForPush(parsed.gitPushArgs), + stdin: Readable.from("") + }); + if (preflightExitCode !== 0) { + return preflightExitCode; + } const result = await runGitPush( - buildGitPushArgs(parsed.gitPushArgs, parsed.skipControls), + buildNoVerifyGitPushArgs(parsed.gitPushArgs), { env: io.env } ).catch((error51) => { const spawnError = error51; @@ -28263,6 +28216,48 @@ async function runPushCommand(args, io) { return 1; } } +function hookArgsForPush(gitPushArgs) { + const parsed = parseGitPushArgs(gitPushArgs); + return parsed.remote ? [parsed.remote] : []; +} +function buildNoVerifyGitPushArgs(gitPushArgs) { + return ["push", "--no-verify", ...withoutHookVerificationOptions(gitPushArgs)]; +} +function withoutHookVerificationOptions(gitPushArgs) { + const normalized = []; + let parseOptions = true; + for (const arg of gitPushArgs) { + if (parseOptions && arg === "--") { + parseOptions = false; + normalized.push(arg); + continue; + } + if (parseOptions && (arg === "--verify" || arg === "--no-verify")) { + continue; + } + normalized.push(arg); + } + return normalized; +} +function withSkipControlConfigOverlay(env, skipControls) { + if (skipControls.active.kind === "none") { + return env; + } + const count = parseGitConfigCount(env.GIT_CONFIG_COUNT); + return { + ...env, + GIT_CONFIG_COUNT: String(count + 1), + [`GIT_CONFIG_KEY_${String(count)}`]: skipControls.active.configKey, + [`GIT_CONFIG_VALUE_${String(count)}`]: "true" + }; +} +function parseGitConfigCount(value) { + if (value === void 0) { + return 0; + } + const count = Number(value); + return Number.isInteger(count) && count >= 0 ? count : 0; +} async function writeResolvedGitPushSuccessSummary(gitPushArgs, io) { try { writeGitPushSuccessSummary( diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 20998c0..dcbbf46 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -60,7 +60,7 @@ TypeScript `interface` declarations. |---|---|---| | Hook protocol | `hook/pre-push`, `src/cli.ts` | The hook requires protocol `1` before delegating to the runner. | | `pushgate pre-push` | `src/cli.ts`, `src/workflows/pre-push.ts` | Drains Git hook stdin, runs Pushgate phases, and returns the local verdict. | -| `pushgate push` | `src/cli.ts`, `src/cli/push-args.ts` | Wraps `git push` while mapping friendly skip flags to one-push Git config. | +| `pushgate push` | `src/cli.ts`, `src/cli/push-args.ts` | Runs a local Pushgate preflight, maps friendly skip flags to one-push Git config, then delegates to `git push --no-verify`. | | `.pushgate.yml` v2 | `schemas/pushgate-config-v2.schema.json`, `src/config/*` | Strict user config with normalized defaults before modules consume it. | | Changed-file resolution | `src/path-policy/*` | One normalized changed-file list plus named review and scan ranges. | | Deterministic check summary | `src/runner/deterministic.ts` | Exit code plus per-check results after policies, plugins, and tools run. | diff --git a/docs/architecture/runtime-flow.md b/docs/architecture/runtime-flow.md index 4e1237b..5afd512 100644 --- a/docs/architecture/runtime-flow.md +++ b/docs/architecture/runtime-flow.md @@ -45,7 +45,7 @@ providers. |---|---| | `hook-protocol` | Compatibility handshake for the shell hook. | | `pre-push` | Internal hook entry point that runs the Pushgate workflow. | -| `push` | Optional wrapper around `git push` that maps skip flags to Git config. | +| `push` | Optional wrapper around `git push` that runs local preflight before native push and maps skip flags to Git config. | Unsupported command shapes return usage output with exit code `64`. Runtime workflow failures are rendered through `writePushgateError` and return `1`. @@ -135,3 +135,8 @@ Skip controls are intentionally visible: - `git -c pushgate.skip-all-checks=true push` bypasses all local Pushgate work. - `git -c pushgate.skip-ai-check=true push` keeps deterministic checks and skips only local AI. + +Native `git push` invokes the pre-push hook after Git has begun the push +operation with the remote. `pushgate push` avoids holding that remote session +idle during long local checks by running the Pushgate workflow first and only +opening the native `git push --no-verify` after the local preflight passes. diff --git a/docs/domain/model.md b/docs/domain/model.md index 82be09c..f577cf3 100644 --- a/docs/domain/model.md +++ b/docs/domain/model.md @@ -47,8 +47,9 @@ docs, issues, and code comments. ## Boundary Rules -- `git push` is the normal workflow. `pushgate push` is a convenience wrapper, - not the required path. +- `git push` is the normal workflow. `pushgate push` is a convenience wrapper + that runs the same local Pushgate workflow before opening the native Git push, + then delegates with `git push --no-verify` after Pushgate passes. - `git push --no-verify` bypasses the hook entirely and is outside Pushgate's runtime control. - `pushgate.skip-all-checks` skips all local Pushgate work for one push. diff --git a/src/cli.ts b/src/cli.ts index 0b7da1c..b544cda 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,17 +1,16 @@ import { realpathSync } from "node:fs"; +import { Readable } from "node:stream"; import { fileURLToPath } from "node:url"; import { writePushgateError } from "./cli/errors.js"; import { parsePushCommandArgs } from "./cli/push-args.js"; import { + parseGitPushArgs, resolveGitPushSuccessSummary, runGitPush, writeGitPushSuccessSummary, } from "./git/push.js"; -import { - buildGitPushArgs, - SkipControlError, -} from "./skip-controls.js"; +import { SkipControlError, type SkipControlState } from "./skip-controls.js"; import { runPrePushWorkflow, type PrePushWorkflowIO, @@ -79,9 +78,19 @@ async function runPushCommand( ): Promise { try { const parsed = parsePushCommandArgs(args); + const preflightExitCode = await runPrePushWorkflow({ + ...io, + env: withSkipControlConfigOverlay(io.env, parsed.skipControls), + hookArgs: hookArgsForPush(parsed.gitPushArgs), + stdin: Readable.from(""), + }); + + if (preflightExitCode !== 0) { + return preflightExitCode; + } const result = await runGitPush( - buildGitPushArgs(parsed.gitPushArgs, parsed.skipControls), + buildNoVerifyGitPushArgs(parsed.gitPushArgs), { env: io.env }, ).catch((error: unknown) => { const spawnError = error as NodeJS.ErrnoException; @@ -110,6 +119,67 @@ async function runPushCommand( } } +function hookArgsForPush(gitPushArgs: readonly string[]): readonly string[] { + const parsed = parseGitPushArgs(gitPushArgs); + + return parsed.remote ? [parsed.remote] : []; +} + +function buildNoVerifyGitPushArgs(gitPushArgs: readonly string[]): string[] { + return ["push", "--no-verify", ...withoutHookVerificationOptions(gitPushArgs)]; +} + +function withoutHookVerificationOptions( + gitPushArgs: readonly string[], +): string[] { + const normalized: string[] = []; + let parseOptions = true; + + for (const arg of gitPushArgs) { + if (parseOptions && arg === "--") { + parseOptions = false; + normalized.push(arg); + continue; + } + + if (parseOptions && (arg === "--verify" || arg === "--no-verify")) { + continue; + } + + normalized.push(arg); + } + + return normalized; +} + +function withSkipControlConfigOverlay( + env: NodeJS.ProcessEnv, + skipControls: SkipControlState, +): NodeJS.ProcessEnv { + if (skipControls.active.kind === "none") { + return env; + } + + const count = parseGitConfigCount(env.GIT_CONFIG_COUNT); + + return { + ...env, + GIT_CONFIG_COUNT: String(count + 1), + [`GIT_CONFIG_KEY_${String(count)}`]: skipControls.active.configKey, + [`GIT_CONFIG_VALUE_${String(count)}`]: "true", + }; +} + +function parseGitConfigCount(value: string | undefined): number { + if (value === undefined) { + return 0; + } + + const count = Number(value); + + return Number.isInteger(count) && count >= 0 ? count : 0; +} + async function writeResolvedGitPushSuccessSummary( gitPushArgs: readonly string[], io: CliIO, diff --git a/src/workflows/local-push-gate-run.ts b/src/workflows/local-push-gate-run.ts new file mode 100644 index 0000000..254d9a3 --- /dev/null +++ b/src/workflows/local-push-gate-run.ts @@ -0,0 +1,254 @@ +import { runLocalAiReview } from "../ai/index.js"; +import type { PushgateConfig } from "../config/index.js"; +import { + resolveChangedFiles, + type ChangedFileResolution, +} from "../path-policy/index.js"; +import { + buildDeterministicCheckPlan, + runDeterministicChecks, +} from "../runner/deterministic.js"; +import { + SKIP_AI_CHECK_CONFIG_KEY, + type SkipControlState, +} from "../skip-controls.js"; +import { + writeLine, + writeResultRow, + writeSection, +} from "../terminal/format.js"; +import { + createTerminalWarningConfirmer, + WarningConfirmationError, + type WarningConfirmationPhase, + type WarningConfirmer, +} from "./warning-confirmation.js"; + +export interface LocalPushGateRunOptions { + config: PushgateConfig; + env: NodeJS.ProcessEnv; + repoRoot: string; + skipControls: Pick; + stdout: NodeJS.WritableStream; + warningConfirmer?: WarningConfirmer; +} + +type LocalAiPhaseDecision = + | { + kind: "run"; + } + | { + kind: "skip"; + reason: "local-ai-mode-off" | "skip-ai-check"; + }; + +/** + * Run the local push gate after repository context and config are already loaded. + * + * This module owns the local phase order: changed-file resolution, deterministic + * checks, warning confirmation, local AI review, and the final pass/fail result. + */ +export async function runLocalPushGate( + options: LocalPushGateRunOptions, +): Promise { + const localAi = getLocalAiPhaseDecision(options.config, options.skipControls); + const changedFileResolution = await resolveChangedFilesIfRequired({ + config: options.config, + localAi, + repoRoot: options.repoRoot, + }); + + const deterministicSummary = await runDeterministicChecks({ + changedFileResolution, + config: options.config, + env: options.env, + repoRoot: options.repoRoot, + stdout: options.stdout, + }); + + if (deterministicSummary.exitCode !== 0) { + return deterministicSummary.exitCode; + } + + if ( + !(await confirmWarningsBeforeContinuing({ + confirmer: options.warningConfirmer, + phase: "deterministic checks", + stdout: options.stdout, + warningCount: deterministicSummary.results.filter( + (result) => result.status === "warning", + ).length, + })) + ) { + return 1; + } + + const localAiSummary = await runLocalAiPhase({ + changedFileResolution, + config: options.config, + decision: localAi, + env: options.env, + repoRoot: options.repoRoot, + stdout: options.stdout, + }); + + if (localAiSummary.exitCode !== 0) { + return localAiSummary.exitCode; + } + + if ( + !(await confirmWarningsBeforeContinuing({ + confirmer: options.warningConfirmer, + phase: "local AI review", + stdout: options.stdout, + warningCount: localAiSummary.warningCount, + })) + ) { + return 1; + } + + writeLine(options.stdout); + writeLine(options.stdout, "Pushgate passed. Changes allowed..."); + return 0; +} + +async function resolveChangedFilesIfRequired(options: { + config: PushgateConfig; + localAi: LocalAiPhaseDecision; + repoRoot: string; +}): Promise { + const deterministicPlan = buildDeterministicCheckPlan(options.config); + + if ( + !deterministicPlan.needsChangedFileResolution && + options.localAi.kind !== "run" + ) { + return null; + } + + return await resolveChangedFiles({ + repoRoot: options.repoRoot, + targetBranch: options.config.review.target_branch, + ignorePaths: options.config.ignore_paths, + }); +} + +async function runLocalAiPhase(options: { + changedFileResolution: ChangedFileResolution | null; + config: PushgateConfig; + decision: LocalAiPhaseDecision; + env: NodeJS.ProcessEnv; + repoRoot: string; + stdout: NodeJS.WritableStream; +}): Promise<{ exitCode: number; warningCount: number }> { + if (options.decision.kind === "skip") { + const message = formatLocalAiSkipReason(options.decision.reason); + + if (message !== null) { + writeSection(options.stdout, "AI review"); + writeResultRow(options.stdout, "skipped", message); + } + + return { exitCode: 0, warningCount: 0 }; + } + + writeSection(options.stdout, "AI review"); + + return await runLocalAiReview({ + aiConfig: options.config.ai, + changedFileResolution: requireChangedFileResolution( + options.changedFileResolution, + "local AI phase", + ), + env: options.env, + repoRoot: options.repoRoot, + reviewConfig: options.config.review, + stdout: options.stdout, + }); +} + +async function confirmWarningsBeforeContinuing(options: { + confirmer: WarningConfirmer | undefined; + phase: WarningConfirmationPhase; + stdout: NodeJS.WritableStream; + warningCount: number; +}): Promise { + if (options.warningCount === 0) { + return true; + } + + const confirmer = options.confirmer ?? createTerminalWarningConfirmer(); + + try { + const confirmed = await confirmer({ + phase: options.phase, + warningCount: options.warningCount, + }); + + if (confirmed) { + options.stdout.write( + `Continuing with ${String(options.warningCount)} warning(s) from ${options.phase} after confirmation.\n`, + ); + return true; + } + + options.stdout.write( + `Push blocked because ${options.phase} produced ${String(options.warningCount)} warning(s) and continuation was not confirmed.\n`, + ); + return false; + } catch (error) { + if (error instanceof WarningConfirmationError) { + options.stdout.write(`${error.message}\n`); + options.stdout.write( + "Push blocked because warning confirmation could not be collected.\n", + ); + return false; + } + + throw error; + } +} + +function getLocalAiPhaseDecision( + config: PushgateConfig, + skipControls: Pick, +): LocalAiPhaseDecision { + if (config.ai.mode === "off") { + return { + kind: "skip", + reason: "local-ai-mode-off", + }; + } + + if (skipControls.active.kind === "skip-ai-check") { + return { + kind: "skip", + reason: "skip-ai-check", + }; + } + + return { kind: "run" }; +} + +function formatLocalAiSkipReason( + reason: Extract["reason"], +): string | null { + if (reason === "local-ai-mode-off") { + return null; + } + + return `Skipping local AI because ${SKIP_AI_CHECK_CONFIG_KEY}=true.`; +} + +function requireChangedFileResolution( + changedFileResolution: ChangedFileResolution | null, + phaseName: string, +): ChangedFileResolution { + if (changedFileResolution !== null) { + return changedFileResolution; + } + + throw new Error( + `Pushgate could not prepare changed files for the ${phaseName}.`, + ); +} diff --git a/src/workflows/pre-push-hook-context.ts b/src/workflows/pre-push-hook-context.ts new file mode 100644 index 0000000..0aa08bb --- /dev/null +++ b/src/workflows/pre-push-hook-context.ts @@ -0,0 +1,101 @@ +export interface PrePushHookContext { + branch?: string; + remote?: string; +} + +export function buildPrePushContext(options: { + args: readonly string[]; + branch: string | undefined; +}): PrePushHookContext { + return { + branch: options.branch, + remote: options.args[0], + }; +} + +const MAX_PRE_PUSH_STDIN_LINE_CHARS = 8 * 1024; + +export function parseBranchFromPrePushLine( + line: string, +): string | undefined { + const trimmed = line.trim(); + + if (!trimmed) { + return undefined; + } + + const [localRef] = trimmed.split(/\s+/, 1); + + if (localRef?.startsWith("refs/heads/")) { + return localRef.slice("refs/heads/".length); + } + + return undefined; +} + +/** + * Read Git's pre-push stdin incrementally until the first local branch ref is found. + * + * Git may provide many ref update lines. This reader keeps one bounded line in + * memory at a time and ignores later chunks once the branch is known. + */ +export function readPrePushBranchFromStdin( + stdin: NodeJS.ReadableStream, +): Promise { + return new Promise((resolve, reject) => { + if ((stdin as { isTTY?: boolean }).isTTY) { + resolve(undefined); + return; + } + + let branch: string | undefined; + let line = ""; + let lineOverflowed = false; + + const parseLine = () => { + if (branch !== undefined || lineOverflowed) { + return; + } + + branch = parseBranchFromPrePushLine(line); + }; + + stdin.setEncoding("utf8"); + stdin.on("error", reject); + stdin.on("data", (chunk: string) => { + if (branch !== undefined) { + return; + } + + for (const character of chunk) { + if (character === "\n") { + if (line.endsWith("\r")) { + line = line.slice(0, -1); + } + + parseLine(); + line = ""; + lineOverflowed = false; + continue; + } + + if (lineOverflowed) { + continue; + } + + if (line.length >= MAX_PRE_PUSH_STDIN_LINE_CHARS) { + line = ""; + lineOverflowed = true; + continue; + } + + line += character; + } + }); + stdin.on("end", () => { + parseLine(); + resolve(branch); + }); + stdin.resume(); + }); +} diff --git a/src/workflows/pre-push.ts b/src/workflows/pre-push.ts index 38c3d5e..275f3c6 100644 --- a/src/workflows/pre-push.ts +++ b/src/workflows/pre-push.ts @@ -1,36 +1,28 @@ import { basename } from "node:path"; -import { runLocalAiReview } from "../ai/index.js"; -import { loadConfig, type PushgateConfig } from "../config/index.js"; +import { loadConfig } from "../config/index.js"; import { resolveGitRepositoryRoot } from "../git/repository.js"; import { - resolveChangedFiles, - type ChangedFileResolution, -} from "../path-policy/index.js"; -import { runDeterministicChecks } from "../runner/deterministic.js"; -import { resolveSkipControlState } from "../skip-controls.js"; + resolveSkipControlState, + SKIP_ALL_CHECKS_CONFIG_KEY, +} from "../skip-controls.js"; import { - writeDetail, writeHeader, - writeLine, writeResultRow, - writeSection, } from "../terminal/format.js"; import { PUSHGATE_VERSION } from "../version.js"; +import { runLocalPushGate } from "./local-push-gate-run.js"; import { - buildPrePushConfigDecision, - buildPrePushRunDecision, - formatRunSkipReason, - type LocalAiPhaseDecision, - type PrePushRunDecision, - type RunSkipReason, -} from "./run-decisions.js"; -import { - createTerminalWarningConfirmer, - WarningConfirmationError, - type WarningConfirmationPhase, - type WarningConfirmer, -} from "./warning-confirmation.js"; + buildPrePushContext, + readPrePushBranchFromStdin, + type PrePushHookContext, +} from "./pre-push-hook-context.js"; +import type { WarningConfirmer } from "./warning-confirmation.js"; + +export { + parseBranchFromPrePushLine, + readPrePushBranchFromStdin, +} from "./pre-push-hook-context.js"; export interface PrePushWorkflowIO { env: NodeJS.ProcessEnv; @@ -53,10 +45,9 @@ export async function runPrePushWorkflow( writePrePushHeader(io.stdout, repoRoot, hookContext); const skipControls = await resolveSkipControlState(repoRoot, io.env); - const configDecision = buildPrePushConfigDecision(skipControls); - if (configDecision.kind === "skip") { - writeVisibleSkipReason(io.stdout, configDecision.reason); + if (skipControls.active.kind === "skip-all-checks") { + writeSkipAllChecksReason(io.stdout); return 0; } @@ -66,197 +57,30 @@ export async function runPrePushWorkflow( io.stdout.write(`[pushgate] Warning: ${warning}\n`); } - const runDecision = buildPrePushRunDecision(loaded.config, skipControls); - const changedFileResolution = await maybeResolveChangedFiles(loaded.config, { - repoRoot, - runDecision, - }); - - const summary = await runDeterministicChecks({ - changedFileResolution, + return await runLocalPushGate({ config: loaded.config, env: io.env, repoRoot, stdout: io.stdout, + skipControls, + ...(io.warningConfirmer + ? { warningConfirmer: io.warningConfirmer } + : {}), }); - - if (summary.exitCode !== 0) { - return summary.exitCode; - } - - if ( - !(await confirmWarningsBeforeContinuing({ - confirmer: io.warningConfirmer, - phase: "deterministic checks", - stdout: io.stdout, - warningCount: summary.results.filter( - (result) => result.status === "warning", - ).length, - })) - ) { - return 1; - } - - const localAiSummary = await runLocalAiPhase( - loaded.config, - runDecision.localAi, - changedFileResolution, - { - env: io.env, - repoRoot, - stdout: io.stdout, - }, - ); - - if (localAiSummary.exitCode !== 0) { - return localAiSummary.exitCode; - } - - if ( - !(await confirmWarningsBeforeContinuing({ - confirmer: io.warningConfirmer, - phase: "local AI review", - stdout: io.stdout, - warningCount: localAiSummary.warningCount, - })) - ) { - return 1; - } - - writeLine(io.stdout); - writeLine(io.stdout, "Pushgate passed. Git is pushing..."); - return 0; -} - -async function runLocalAiPhase( - config: PushgateConfig, - decision: LocalAiPhaseDecision, - changedFileResolution: ChangedFileResolution | null, - options: { - env: NodeJS.ProcessEnv; - repoRoot: string; - stdout: NodeJS.WritableStream; - }, -): Promise<{ exitCode: number; warningCount: number }> { - if (decision.kind === "skip") { - const message = formatRunSkipReason(decision.reason); - - if (message !== null) { - writeSection(options.stdout, "AI review"); - writeResultRow(options.stdout, "skipped", message); - } - - return { exitCode: 0, warningCount: 0 }; - } - - writeSection(options.stdout, "AI review"); - - return await runLocalAiReview({ - aiConfig: config.ai, - changedFileResolution: requireChangedFileResolution( - changedFileResolution, - "local AI phase", - ), - env: options.env, - repoRoot: options.repoRoot, - reviewConfig: config.review, - stdout: options.stdout, - }); -} - -async function confirmWarningsBeforeContinuing(options: { - confirmer: WarningConfirmer | undefined; - phase: WarningConfirmationPhase; - stdout: NodeJS.WritableStream; - warningCount: number; -}): Promise { - if (options.warningCount === 0) { - return true; - } - - const confirmer = options.confirmer ?? createTerminalWarningConfirmer(); - - try { - const confirmed = await confirmer({ - phase: options.phase, - warningCount: options.warningCount, - }); - - if (confirmed) { - options.stdout.write( - `Continuing with ${String(options.warningCount)} warning(s) from ${options.phase} after confirmation.\n`, - ); - return true; - } - - options.stdout.write( - `Push blocked because ${options.phase} produced ${String(options.warningCount)} warning(s) and continuation was not confirmed.\n`, - ); - return false; - } catch (error) { - if (error instanceof WarningConfirmationError) { - options.stdout.write(`${error.message}\n`); - options.stdout.write( - "Push blocked because warning confirmation could not be collected.\n", - ); - return false; - } - - throw error; - } -} - -async function maybeResolveChangedFiles( - config: PushgateConfig, - options: { - repoRoot: string; - runDecision: PrePushRunDecision; - }, -): Promise { - if (options.runDecision.changedFiles.kind === "not-required") { - return null; - } - - return await resolveChangedFiles({ - repoRoot: options.repoRoot, - targetBranch: config.review.target_branch, - ignorePaths: config.ignore_paths, - }); -} - -function writeVisibleSkipReason( - stdout: NodeJS.WritableStream, - reason: RunSkipReason, -): void { - const message = formatRunSkipReason(reason); - - if (message !== null) { - writeResultRow(stdout, "skipped", message); - } } -function requireChangedFileResolution( - changedFileResolution: ChangedFileResolution | null, - phaseName: string, -): ChangedFileResolution { - if (changedFileResolution !== null) { - return changedFileResolution; - } - - throw new Error( - `Pushgate could not prepare changed files for the ${phaseName}.`, +function writeSkipAllChecksReason(stdout: NodeJS.WritableStream): void { + writeResultRow( + stdout, + "skipped", + `Skipping all local Pushgate checks because ${SKIP_ALL_CHECKS_CONFIG_KEY}=true.`, ); } -interface PrePushContext { - branch?: string; - remote?: string; -} - function writePrePushHeader( stdout: NodeJS.WritableStream, repoRoot: string, - context: PrePushContext, + context: PrePushHookContext, ): void { const lines = [ `Pushgate v${PUSHGATE_VERSION} - pre-push`, @@ -273,94 +97,3 @@ function writePrePushHeader( writeHeader(stdout, lines); } - -function buildPrePushContext(options: { - args: readonly string[]; - branch: string | undefined; -}): PrePushContext { - return { - branch: options.branch, - remote: options.args[0], - }; -} - -const MAX_PRE_PUSH_STDIN_LINE_CHARS = 8 * 1024; - -export function parseBranchFromPrePushLine( - line: string, -): string | undefined { - const trimmed = line.trim(); - - if (!trimmed) { - return undefined; - } - - const [localRef] = trimmed.split(/\s+/, 1); - - if (localRef?.startsWith("refs/heads/")) { - return localRef.slice("refs/heads/".length); - } - - return undefined; -} - -export function readPrePushBranchFromStdin( - stdin: NodeJS.ReadableStream, -): Promise { - return new Promise((resolve, reject) => { - if ((stdin as { isTTY?: boolean }).isTTY) { - resolve(undefined); - return; - } - - let branch: string | undefined; - let line = ""; - let lineOverflowed = false; - - const parseLine = () => { - if (branch !== undefined || lineOverflowed) { - return; - } - - branch = parseBranchFromPrePushLine(line); - }; - - stdin.setEncoding("utf8"); - stdin.on("error", reject); - stdin.on("data", (chunk: string) => { - if (branch !== undefined) { - return; - } - - for (const character of chunk) { - if (character === "\n") { - if (line.endsWith("\r")) { - line = line.slice(0, -1); - } - - parseLine(); - line = ""; - lineOverflowed = false; - continue; - } - - if (lineOverflowed) { - continue; - } - - if (line.length >= MAX_PRE_PUSH_STDIN_LINE_CHARS) { - line = ""; - lineOverflowed = true; - continue; - } - - line += character; - } - }); - stdin.on("end", () => { - parseLine(); - resolve(branch); - }); - stdin.resume(); - }); -} diff --git a/src/workflows/run-decisions.ts b/src/workflows/run-decisions.ts deleted file mode 100644 index 2e8792d..0000000 --- a/src/workflows/run-decisions.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { PushgateConfig } from "../config/index.js"; -import { buildDeterministicCheckPlan } from "../runner/deterministic.js"; -import { - SKIP_AI_CHECK_CONFIG_KEY, - SKIP_ALL_CHECKS_CONFIG_KEY, - type SkipControlState, -} from "../skip-controls.js"; - -export type RunPhase = "deterministic-checks" | "local-ai-review"; - -export type RunSkipReason = - | { - configKey: typeof SKIP_ALL_CHECKS_CONFIG_KEY; - control: "skip-all-checks"; - kind: "skip-control"; - scope: "all-local-checks"; - } - | { - configKey: typeof SKIP_AI_CHECK_CONFIG_KEY; - control: "skip-ai-check"; - kind: "skip-control"; - scope: "local-ai"; - } - | { - kind: "local-ai-mode-off"; - }; - -export type PrePushConfigDecision = - | { - kind: "load-config"; - } - | { - kind: "skip"; - reason: RunSkipReason; - }; - -export type ChangedFileResolutionDecision = - | { - kind: "required"; - requiredBy: RunPhase[]; - } - | { - kind: "not-required"; - requiredBy: []; - }; - -export type DeterministicChecksDecision = - | { - checkCount: number; - kind: "configured"; - } - | { - kind: "not-configured"; - }; - -export type LocalAiPhaseDecision = - | { - kind: "run"; - } - | { - kind: "skip"; - reason: RunSkipReason; - }; - -export interface PrePushRunDecision { - changedFiles: ChangedFileResolutionDecision; - deterministicChecks: DeterministicChecksDecision; - localAi: LocalAiPhaseDecision; -} - -export function buildPrePushConfigDecision( - skipControls: Pick, -): PrePushConfigDecision { - if (skipControls.active.kind === "skip-all-checks") { - return { - kind: "skip", - reason: { - configKey: SKIP_ALL_CHECKS_CONFIG_KEY, - control: "skip-all-checks", - kind: "skip-control", - scope: "all-local-checks", - }, - }; - } - - return { kind: "load-config" }; -} - -export function buildPrePushRunDecision( - config: PushgateConfig, - skipControls: Pick, -): PrePushRunDecision { - const deterministicPlan = buildDeterministicCheckPlan(config); - const deterministicChecks = deterministicPlan.runChecks - ? ({ - checkCount: deterministicPlan.checkCount, - kind: "configured", - } satisfies DeterministicChecksDecision) - : ({ - kind: "not-configured", - } satisfies DeterministicChecksDecision); - const localAi = getLocalAiDecision(config, skipControls); - - return { - changedFiles: getChangedFileResolutionDecision({ - deterministicChecks, - localAi, - }), - deterministicChecks, - localAi, - }; -} - -export function formatRunSkipReason(reason: RunSkipReason): string | null { - if (reason.kind === "local-ai-mode-off") { - return null; - } - - if (reason.control === "skip-all-checks") { - return `Skipping all local Pushgate checks because ${reason.configKey}=true.`; - } - - return `Skipping local AI because ${reason.configKey}=true.`; -} - -function getLocalAiDecision( - config: PushgateConfig, - skipControls: Pick, -): LocalAiPhaseDecision { - if (config.ai.mode === "off") { - return { - kind: "skip", - reason: { - kind: "local-ai-mode-off", - }, - }; - } - - if (skipControls.active.kind === "skip-ai-check") { - return { - kind: "skip", - reason: { - configKey: SKIP_AI_CHECK_CONFIG_KEY, - control: "skip-ai-check", - kind: "skip-control", - scope: "local-ai", - }, - }; - } - - return { kind: "run" }; -} - -function getChangedFileResolutionDecision(options: { - deterministicChecks: DeterministicChecksDecision; - localAi: LocalAiPhaseDecision; -}): ChangedFileResolutionDecision { - const requiredBy: RunPhase[] = []; - - if (options.deterministicChecks.kind === "configured") { - requiredBy.push("deterministic-checks"); - } - - if (options.localAi.kind === "run") { - requiredBy.push("local-ai-review"); - } - - if (requiredBy.length === 0) { - return { - kind: "not-required", - requiredBy: [], - }; - } - - return { - kind: "required", - requiredBy, - }; -} diff --git a/test/runner.test.ts b/test/runner.test.ts index d2cbee8..2c29938 100644 --- a/test/runner.test.ts +++ b/test/runner.test.ts @@ -446,79 +446,188 @@ test("AI changed-line guardrail blocks provider invocation visibly", async () => }); }); -test("push wrapper maps skip-all-checks to one-command Git config", async () => { - await withGitStub(async ({ argsPath, env, root }) => { - const result = await runRunner( - ["push", "--skip-all-checks", "origin", "feature"], - undefined, - { cwd: root, env }, - ); +test("push wrapper maps skip-all-checks to local preflight before native push", async () => { + await withGitRepo(async (root) => { + await withPreflightGitPushStub(root, async ({ argsPath, env }) => { + const result = await runRunner( + ["push", "--skip-all-checks", "origin", "feature"], + undefined, + { cwd: root, env }, + ); - assert.equal(result.code, 23, formatResult(result)); - assert.deepEqual(await readArgLines(argsPath), [ - "-c", - "pushgate.skip-all-checks=true", - "push", - "origin", - "feature", - ]); + assert.equal(result.code, 0, formatResult(result)); + assert.match( + result.stdout, + /Skipping all local Pushgate checks because pushgate\.skip-all-checks=true/, + ); + assert.match(result.stdout, /native git push output/); + assert.deepEqual(await readArgLines(argsPath), [ + "push", + "--no-verify", + "origin", + "feature", + ]); + }); }); }); -test("push wrapper maps skip-ai-check to one-command Git config", async () => { - await withGitStub(async ({ argsPath, env, root }) => { - const result = await runRunner( - ["push", "--skip-ai-check", "origin", "feature"], - undefined, - { cwd: root, env }, +test("push wrapper maps skip-ai-check to local preflight before native push", async () => { + await withGitRepo(async (root) => { + await writeRepoFile( + root, + ".pushgate.yml", + [ + "version: 2", + "ai:", + " mode: blocking", + " provider: claude", + " providers:", + " claude: {}", + "tools: []", + "", + ].join("\n"), ); + await withPreflightGitPushStub(root, async ({ argsPath, env }) => { + const result = await runRunner( + ["push", "--skip-ai-check", "origin", "feature"], + undefined, + { cwd: root, env }, + ); - assert.equal(result.code, 23, formatResult(result)); - assert.deepEqual(await readArgLines(argsPath), [ - "-c", - "pushgate.skip-ai-check=true", - "push", - "origin", - "feature", - ]); + assert.equal(result.code, 0, formatResult(result)); + assert.match( + result.stdout, + /Skipping local AI because pushgate\.skip-ai-check=true/, + ); + assert.doesNotMatch(result.stdout, /Claude Code CLI was not found on PATH/); + assert.match(result.stdout, /native git push output/); + assert.deepEqual(await readArgLines(argsPath), [ + "push", + "--no-verify", + "origin", + "feature", + ]); + }); }); }); test("push wrapper keeps skip-all precedence when both wrapper flags are present", async () => { - await withGitStub(async ({ argsPath, env, root }) => { - const result = await runRunner( - ["push", "--skip-ai-check", "--skip-all-checks", "origin", "feature"], - undefined, - { cwd: root, env }, - ); + await withGitRepo(async (root) => { + await writeRepoFile(root, ".pushgate.yml", "version: nope\n"); + await withPreflightGitPushStub(root, async ({ argsPath, env }) => { + const result = await runRunner( + ["push", "--skip-ai-check", "--skip-all-checks", "origin", "feature"], + undefined, + { cwd: root, env }, + ); - assert.equal(result.code, 23, formatResult(result)); - assert.deepEqual(await readArgLines(argsPath), [ - "-c", - "pushgate.skip-all-checks=true", - "push", - "origin", - "feature", - ]); + assert.equal(result.code, 0, formatResult(result)); + assert.match( + result.stdout, + /Skipping all local Pushgate checks because pushgate\.skip-all-checks=true/, + ); + assert.deepEqual(await readArgLines(argsPath), [ + "push", + "--no-verify", + "origin", + "feature", + ]); + }); }); }); test("push wrapper forwards Git args after -- without interpreting them as Pushgate flags", async () => { - await withGitStub(async ({ argsPath, env, root }) => { - const result = await runRunner( - ["push", "--", "--skip-ai-check", "origin", "feature"], - undefined, - { cwd: root, env }, + await withGitRepo(async (root) => { + await writeRepoFile( + root, + ".pushgate.yml", + "version: 2\nai:\n mode: off\ntools: []\n", ); + await withPreflightGitPushStub(root, async ({ argsPath, env }) => { + const result = await runRunner( + ["push", "--", "--skip-ai-check", "origin", "feature"], + undefined, + { cwd: root, env }, + ); - assert.equal(result.code, 23, formatResult(result)); - assert.deepEqual(await readArgLines(argsPath), [ - "push", - "--", - "--skip-ai-check", - "origin", - "feature", - ]); + assert.equal(result.code, 0, formatResult(result)); + assert.deepEqual(await readArgLines(argsPath), [ + "push", + "--no-verify", + "--", + "--skip-ai-check", + "origin", + "feature", + ]); + }); + }); +}); + +test("push wrapper runs local preflight before native push and bypasses the hook", async () => { + await withGitRepo(async (root) => { + await writeRepoFile( + root, + ".pushgate.yml", + "version: 2\nai:\n mode: off\ntools: []\n", + ); + await withPreflightGitPushStub(root, async ({ argsPath, env }) => { + const result = await runRunner(["push", "origin", "feature"], undefined, { + cwd: root, + env, + }); + + assert.equal(result.code, 0, formatResult(result)); + assert.match(result.stdout, /Pushgate v\d+\.\d+\.\d+ - pre-push/); + assert.match(result.stdout, /Pushgate passed/); + assert.match(result.stdout, /native git push output/); + assert.deepEqual(await readArgLines(argsPath), [ + "push", + "--no-verify", + "origin", + "feature", + ]); + }); + }); +}); + +test("push wrapper normalizes verify flags before bypassing the native hook", async () => { + await withGitRepo(async (root) => { + await writeRepoFile( + root, + ".pushgate.yml", + "version: 2\nai:\n mode: off\ntools: []\n", + ); + await withPreflightGitPushStub(root, async ({ argsPath, env }) => { + const result = await runRunner( + ["push", "--no-verify", "--verify", "origin", "feature"], + undefined, + { cwd: root, env }, + ); + + assert.equal(result.code, 0, formatResult(result)); + assert.deepEqual(await readArgLines(argsPath), [ + "push", + "--no-verify", + "origin", + "feature", + ]); + }); + }); +}); + +test("push wrapper does not open native push when local preflight fails", async () => { + await withGitRepo(async (root) => { + await writeRepoFile(root, ".pushgate.yml", "version: nope\n"); + await withPreflightGitPushStub(root, async ({ argsPath, env }) => { + const result = await runRunner(["push", "origin", "feature"], undefined, { + cwd: root, + env, + }); + + assert.equal(result.code, 1, formatResult(result)); + assert.match(result.stderr, /Invalid Pushgate v2 config/); + await assert.rejects(readFile(argsPath, "utf8")); + }); }); }); @@ -1159,22 +1268,15 @@ async function withGitStub( } } -async function withGitPushSummaryStub( - options: { - currentBranch: string; - pushExit?: string; - remoteUrl: string; - upstream?: string; - }, +async function withPreflightGitPushStub( + root: string, callback: (context: { + argsPath: string; env: NodeJS.ProcessEnv; - logPath: string; - root: string; }) => Promise, ): Promise { - const root = await mkdtemp(join(tmpdir(), "pushgate-push-summary-stub-")); - const binDir = join(root, "bin"); - const logPath = join(root, "git-calls.txt"); + const binDir = join(root, "preflight-bin"); + const argsPath = join(root, "git-push-args.txt"); await mkdir(binDir, { recursive: true }); await writeFile( @@ -1182,52 +1284,100 @@ async function withGitPushSummaryStub( [ "#!/usr/bin/env bash", "set -eu", - "{ printf 'call'; for arg in \"$@\"; do printf '\\t%s' \"$arg\"; done; printf '\\n'; } >> \"$PUSHGATE_GIT_CALLS_OUT\"", - "if [ \"${1:-}\" = 'push' ] || { [ \"${1:-}\" = '-c' ] && [ \"${3:-}\" = 'push' ]; }; then", + "if [ \"${1:-}\" = 'push' ]; then", + " printf '%s\\n' \"$@\" > \"$PUSHGATE_GIT_PUSH_ARGS_OUT\"", " printf 'native git push output\\n'", " exit \"${PUSHGATE_GIT_PUSH_EXIT:-0}\"", "fi", - "if [ \"${1:-}\" = 'rev-parse' ] && [ \"${2:-}\" = '--abbrev-ref' ] && [ \"${3:-}\" = '--symbolic-full-name' ]; then", - " if [ -n \"${PUSHGATE_GIT_UPSTREAM:-}\" ]; then", - " printf '%s\\n' \"$PUSHGATE_GIT_UPSTREAM\"", - " exit 0", - " fi", - " exit 1", - "fi", - "if [ \"${1:-}\" = 'rev-parse' ] && [ \"${2:-}\" = '--abbrev-ref' ] && [ \"${3:-}\" = 'HEAD' ]; then", - " printf '%s\\n' \"$PUSHGATE_GIT_CURRENT_BRANCH\"", - " exit 0", - "fi", - "if [ \"${1:-}\" = 'remote' ] && [ \"${2:-}\" = 'get-url' ]; then", - " printf '%s\\n' \"$PUSHGATE_GIT_REMOTE_URL\"", - " exit 0", - "fi", - "if [ \"${1:-}\" = 'config' ] && [ \"${2:-}\" = '--get' ]; then", - " printf 'origin\\n'", - " exit 0", - "fi", - "exit 19", + "exec \"$PUSHGATE_REAL_GIT\" \"$@\"", ].join("\n"), ); await chmod(join(binDir, "git"), 0o755); - try { + await callback({ + argsPath, + env: { + ...sanitizeGitLocalEnv(process.env), + PATH: [binDir, process.env.PATH ?? ""].join(delimiter), + PUSHGATE_GIT_PUSH_ARGS_OUT: argsPath, + PUSHGATE_GIT_PUSH_EXIT: "0", + PUSHGATE_REAL_GIT: "/usr/bin/git", + }, + }); +} + +async function withGitPushSummaryStub( + options: { + currentBranch: string; + pushExit?: string; + remoteUrl: string; + upstream?: string; + }, + callback: (context: { + env: NodeJS.ProcessEnv; + logPath: string; + root: string; + }) => Promise, +): Promise { + await withGitRepo(async (root) => { + const binDir = join(root, "bin"); + const logPath = join(root, "git-calls.txt"); + + await writeRepoFile( + root, + ".pushgate.yml", + "version: 2\nai:\n mode: off\ntools: []\n", + ); + await mkdir(binDir, { recursive: true }); + await writeFile( + join(binDir, "git"), + [ + "#!/usr/bin/env bash", + "set -eu", + "{ printf 'call'; for arg in \"$@\"; do printf '\\t%s' \"$arg\"; done; printf '\\n'; } >> \"$PUSHGATE_GIT_CALLS_OUT\"", + "if [ \"${1:-}\" = 'push' ] || { [ \"${1:-}\" = '-c' ] && [ \"${3:-}\" = 'push' ]; }; then", + " printf 'native git push output\\n'", + " exit \"${PUSHGATE_GIT_PUSH_EXIT:-0}\"", + "fi", + "if [ \"${1:-}\" = 'rev-parse' ] && [ \"${2:-}\" = '--abbrev-ref' ] && [ \"${3:-}\" = '--symbolic-full-name' ]; then", + " if [ -n \"${PUSHGATE_GIT_UPSTREAM:-}\" ]; then", + " printf '%s\\n' \"$PUSHGATE_GIT_UPSTREAM\"", + " exit 0", + " fi", + " exit 1", + "fi", + "if [ \"${1:-}\" = 'rev-parse' ] && [ \"${2:-}\" = '--abbrev-ref' ] && [ \"${3:-}\" = 'HEAD' ]; then", + " printf '%s\\n' \"$PUSHGATE_GIT_CURRENT_BRANCH\"", + " exit 0", + "fi", + "if [ \"${1:-}\" = 'remote' ] && [ \"${2:-}\" = 'get-url' ]; then", + " printf '%s\\n' \"$PUSHGATE_GIT_REMOTE_URL\"", + " exit 0", + "fi", + "if [ \"${1:-}\" = 'config' ] && [ \"${2:-}\" = '--get' ]; then", + " printf 'origin\\n'", + " exit 0", + "fi", + "exec \"$PUSHGATE_REAL_GIT\" \"$@\"", + ].join("\n"), + ); + await chmod(join(binDir, "git"), 0o755); + await callback({ env: { - ...process.env, + ...sanitizeGitLocalEnv(process.env), PATH: [binDir, process.env.PATH ?? ""].join(delimiter), PUSHGATE_GIT_CALLS_OUT: logPath, PUSHGATE_GIT_CURRENT_BRANCH: options.currentBranch, PUSHGATE_GIT_PUSH_EXIT: options.pushExit ?? "0", PUSHGATE_GIT_REMOTE_URL: options.remoteUrl, + PUSHGATE_REAL_GIT: "/usr/bin/git", PUSHGATE_GIT_UPSTREAM: options.upstream ?? "", }, logPath, root, }); - } finally { - await rm(root, { recursive: true, force: true }); - } + }); } async function readArgLines(path: string): Promise { diff --git a/test/workflow-run-plan.test.ts b/test/workflow-run-plan.test.ts index 4f68274..2934883 100644 --- a/test/workflow-run-plan.test.ts +++ b/test/workflow-run-plan.test.ts @@ -1,289 +1,263 @@ 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 { Readable, Writable } from "node:stream"; import test from "node:test"; -import type { PushgateConfig } from "../src/config/index.js"; -import { createSkipControlState } from "../src/skip-controls.js"; -import { - buildPrePushConfigDecision, - buildPrePushRunDecision, - formatRunSkipReason, -} from "../src/workflows/run-decisions.js"; +import { sanitizeGitLocalEnv } from "../src/git/environment.js"; +import { runPrePushWorkflow } from "../src/workflows/pre-push.js"; -test("skip-all-checks owns the pre-config decision and visible reason", () => { - const decision = buildPrePushConfigDecision( - createSkipControlState({ skipAllChecks: true, skipAiCheck: true }), - ); +test("skip-all-checks bypasses config loading", async () => { + await withGitRepo(async (repoRoot) => { + await writeRepoFile(repoRoot, ".pushgate.yml", "version: nope\n"); + await checkedRun("git", ["config", "pushgate.skip-all-checks", "true"], { + cwd: repoRoot, + }); - assert.deepEqual(decision, { - kind: "skip", - reason: { - configKey: "pushgate.skip-all-checks", - control: "skip-all-checks", - kind: "skip-control", - scope: "all-local-checks", - }, - }); - if (decision.kind !== "skip") { - assert.fail("Expected skip-all-checks to skip before config loading."); - } - assert.equal( - formatRunSkipReason(decision.reason), - "Skipping all local Pushgate checks because pushgate.skip-all-checks=true.", - ); -}); + const result = await runWorkflowInRepo(repoRoot); -test("skip-ai-check still allows config loading", () => { - assert.deepEqual( - buildPrePushConfigDecision( - createSkipControlState({ skipAllChecks: false, skipAiCheck: true }), - ), - { kind: "load-config" }, - ); + assert.equal(result.code, 0, formatResult(result)); + assert.match( + result.stdout, + /Skipping all local Pushgate checks because pushgate\.skip-all-checks=true/, + ); + assert.equal(result.stderr, ""); + }); }); -test("skips changed-file planning when deterministic checks and local AI are inactive", () => { - const decision = buildPrePushRunDecision( - baseConfig(), - createSkipControlState({ skipAllChecks: false, skipAiCheck: false }), - ); +test("skip-ai-check still loads config", async () => { + await withGitRepo(async (repoRoot) => { + await writeRepoFile(repoRoot, ".pushgate.yml", "version: nope\n"); + await checkedRun("git", ["config", "pushgate.skip-ai-check", "true"], { + cwd: repoRoot, + }); - assert.deepEqual(decision, { - changedFiles: { - kind: "not-required", - requiredBy: [], - }, - deterministicChecks: { - kind: "not-configured", - }, - localAi: { - kind: "skip", - reason: { - kind: "local-ai-mode-off", - }, - }, + await assert.rejects( + () => runWorkflowInRepo(repoRoot), + /Invalid Pushgate v2 config/, + ); }); - if (decision.localAi.kind !== "skip") { - assert.fail("Expected local AI to be skipped when ai.mode is off."); - } - assert.equal(formatRunSkipReason(decision.localAi.reason), null); }); -test("plans changed files for configured deterministic tools and policies", () => { - const decision = buildPrePushRunDecision( - baseConfig({ - policies: { - diff_size: { max_changed_lines: 10, mode: "warning" }, - forbidden_paths: { mode: "blocking", patterns: ["secrets/**"] }, - }, - tools: [ - { - command: ["pnpm", "test"], - fail_fast: true, - mode: "blocking", - name: "test", - run: "changed_files", - timeout_seconds: 60, - }, - ], - }), - createSkipControlState({ skipAllChecks: false, skipAiCheck: false }), - ); +test("inactive deterministic checks and local AI do not resolve changed files", async () => { + await withGitRepo(async (repoRoot) => { + await writeRepoFile( + repoRoot, + ".pushgate.yml", + [ + "version: 2", + "review:", + " target_branch: branch-that-does-not-exist", + "ai:", + " mode: off", + "tools: []", + "", + ].join("\n"), + ); - assert.deepEqual(decision.changedFiles, { - kind: "required", - requiredBy: ["deterministic-checks"], - }); - assert.deepEqual(decision.deterministicChecks, { - checkCount: 3, - kind: "configured", - }); - assert.deepEqual(decision.localAi, { - kind: "skip", - reason: { - kind: "local-ai-mode-off", - }, + const result = await runWorkflowInRepo(repoRoot); + + assert.equal(result.code, 0, formatResult(result)); + assert.match(result.stdout, /\[skip\] No checks configured/); + assert.match(result.stdout, /Pushgate passed\. Changes allowed\.\.\./); + assert.equal(result.stderr, ""); }); }); -test("plans changed files for enabled deterministic plugins", () => { - const decision = buildPrePushRunDecision( - baseConfig({ - plugins: { - gitleaks: { - command: "gitleaks", - enabled: true, - fail_fast: true, - mode: "blocking", - redact: true, - timeout_seconds: 60, - }, - }, - }), - createSkipControlState({ skipAllChecks: false, skipAiCheck: false }), - ); +test("skip-ai-check keeps deterministic changed-file work", async () => { + await withChangedFileRepo(async (repoRoot) => { + await writeRepoFile( + repoRoot, + ".pushgate.yml", + [ + "version: 2", + "ai:", + " mode: blocking", + " provider: claude", + " providers:", + " claude: {}", + "tools:", + " - name: changed-files-tool", + ` command: ${JSON.stringify([process.execPath, "-e", "process.exit(0);"])}`, + " run: changed_files", + "", + ].join("\n"), + ); + await checkedRun("git", ["config", "pushgate.skip-ai-check", "true"], { + cwd: repoRoot, + }); - assert.deepEqual(decision.changedFiles, { - kind: "required", - requiredBy: ["deterministic-checks"], - }); - assert.deepEqual(decision.deterministicChecks, { - checkCount: 1, - kind: "configured", + const result = await runWorkflowInRepo(repoRoot); + + assert.equal(result.code, 0, formatResult(result)); + assert.match(result.stdout, /Running 1 check/); + assert.match(result.stdout, /\[ok\] Changed files tool/); + assert.match( + result.stdout, + /Skipping local AI because pushgate\.skip-ai-check=true/, + ); + assert.doesNotMatch(result.stdout, /Claude Code CLI was not found on PATH/); + assert.equal(result.stderr, ""); }); }); -test("skips disabled deterministic plugins", () => { - const decision = buildPrePushRunDecision( - baseConfig({ - plugins: { - gitleaks: { - command: "gitleaks", - enabled: false, - fail_fast: true, - mode: "blocking", - redact: true, - timeout_seconds: 60, - }, - }, - }), - createSkipControlState({ skipAllChecks: false, skipAiCheck: false }), - ); +interface WorkflowResult { + code: number; + stderr: string; + stdout: string; +} - assert.deepEqual(decision, { - changedFiles: { - kind: "not-required", - requiredBy: [], - }, - deterministicChecks: { - kind: "not-configured", - }, - localAi: { - kind: "skip", - reason: { - kind: "local-ai-mode-off", - }, - }, - }); -}); +async function runWorkflowInRepo(repoRoot: string): Promise { + const previousCwd = process.cwd(); + const stdout = captureOutput(); + const stderr = captureOutput(); -test("plans changed files for active local AI without deterministic checks", () => { - const decision = buildPrePushRunDecision( - baseConfig({ - ai: { - ...baseConfig().ai, - mode: "blocking", - provider: "claude", - }, - }), - createSkipControlState({ skipAllChecks: false, skipAiCheck: false }), - ); + process.chdir(repoRoot); - assert.deepEqual(decision.changedFiles, { - kind: "required", - requiredBy: ["local-ai-review"], - }); - assert.deepEqual(decision.localAi, { - kind: "run", - }); -}); + try { + const code = await runPrePushWorkflow({ + env: sanitizeGitLocalEnv(process.env), + stderr: stderr.stream, + stdin: Readable.from(""), + stdout: stdout.stream, + }); -test("skip-ai-check removes local AI changed-file work", () => { - const decision = buildPrePushRunDecision( - baseConfig({ - ai: { - ...baseConfig().ai, - mode: "advisory", - provider: "copilot", - }, - }), - createSkipControlState({ skipAllChecks: false, skipAiCheck: true }), - ); + return { + code, + stderr: stderr.text(), + stdout: stdout.text(), + }; + } finally { + process.chdir(previousCwd); + } +} - assert.deepEqual(decision, { - changedFiles: { - kind: "not-required", - requiredBy: [], - }, - deterministicChecks: { - kind: "not-configured", - }, - localAi: { - kind: "skip", - reason: { - configKey: "pushgate.skip-ai-check", - control: "skip-ai-check", - kind: "skip-control", - scope: "local-ai", +function captureOutput(): { + stream: Writable; + text(): string; +} { + let output = ""; + + return { + stream: new Writable({ + write(chunk, _encoding, callback) { + output += String(chunk); + callback(); }, + }), + text() { + return output; }, - }); - if (decision.localAi.kind !== "skip") { - assert.fail("Expected skip-ai-check to skip local AI."); + }; +} + +async function withGitRepo( + callback: (repoRoot: string) => Promise, +): Promise { + const repoRoot = await mkdtemp(join(tmpdir(), "pushgate-workflow-")); + + try { + await checkedRun("git", ["init", "--quiet", "--initial-branch=main"], { + cwd: repoRoot, + }); + await callback(repoRoot); + } finally { + await rm(repoRoot, { recursive: true, force: true }); } - assert.equal( - formatRunSkipReason(decision.localAi.reason), - "Skipping local AI because pushgate.skip-ai-check=true.", - ); -}); +} -test("skip-ai-check leaves deterministic changed-file work intact", () => { - const decision = buildPrePushRunDecision( - baseConfig({ - ai: { - ...baseConfig().ai, - mode: "blocking", - provider: "claude", - }, - tools: [ - { - command: ["pnpm", "test"], - fail_fast: true, - mode: "blocking", - name: "test", - run: "changed_files", - timeout_seconds: 60, - }, - ], - }), - createSkipControlState({ skipAllChecks: false, skipAiCheck: true }), - ); +async function withChangedFileRepo( + callback: (repoRoot: string) => Promise, +): Promise { + await withGitRepo(async (repoRoot) => { + await checkedRun("git", ["config", "user.email", "workflow@example.test"], { + cwd: repoRoot, + }); + await checkedRun("git", ["config", "user.name", "Pushgate Workflow"], { + cwd: repoRoot, + }); + await writeRepoFile(repoRoot, "src/changed.ts", "export const value = 1;\n"); + await checkedRun("git", ["add", "--all"], { cwd: repoRoot }); + await checkedRun("git", ["commit", "--quiet", "-m", "baseline"], { + cwd: repoRoot, + }); + await checkedRun("git", ["switch", "--quiet", "-c", "feature"], { + cwd: repoRoot, + }); + await writeRepoFile(repoRoot, "src/changed.ts", "export const value = 2;\n"); + await checkedRun("git", ["add", "--all"], { cwd: repoRoot }); + await checkedRun("git", ["commit", "--quiet", "-m", "feature"], { + cwd: repoRoot, + }); - assert.deepEqual(decision.changedFiles, { - kind: "required", - requiredBy: ["deterministic-checks"], + await callback(repoRoot); }); - assert.deepEqual(decision.localAi, { - kind: "skip", - reason: { - configKey: "pushgate.skip-ai-check", - control: "skip-ai-check", - kind: "skip-control", - scope: "local-ai", - }, +} + +async function writeRepoFile( + repoRoot: string, + relativePath: string, + content: string, +): Promise { + const filePath = join(repoRoot, relativePath); + + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content); +} + +interface CommandOptions { + cwd: string; +} + +async function checkedRun( + command: string, + args: string[], + options: CommandOptions, +): Promise { + const result = await new Promise<{ + code: number | null; + stderr: string; + stdout: string; + }>((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: sanitizeGitLocalEnv(process.env), + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + let stdout = ""; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (data: string) => { + stdout += data; + }); + child.stderr?.on("data", (data: string) => { + stderr += data; + }); + child.on("error", reject); + child.on("close", (code) => { + resolve({ code, stderr, stdout }); + }); }); -}); -function baseConfig( - overrides: Partial = {}, -): PushgateConfig { - return { - ai: { - max_changed_lines: 500, - max_prompt_tokens: 12000, - mode: "off", - providers: {}, - timeout_seconds: 120, - }, - ignore_paths: [], - policies: {}, - plugins: {}, - review: { - context_lines: 10, - max_lines_for_full_file: 300, - target_branch: "main", - }, - tools: [], - version: 2, - ...overrides, - }; + if (result.code !== 0) { + throw new Error(formatResult(result)); + } +} + +function formatResult(result: { + code: number | null; + stderr: string; + stdout: string; +}): string { + return [ + `exit: ${String(result.code)}`, + "stdout:", + result.stdout, + "stderr:", + result.stderr, + ].join("\n"); }