#!/usr/bin/env node /** * cli:preflight-force — git-cleanliness guard for `/ba-develop / --force`. * * `--force` regenerates every Phase 3 artifact IN PLACE (scaffold-component / * scaffold-api-client / i18n overwrite existing files). If the target project's * working tree has uncommitted changes, that regeneration silently CLOBBERS * hand-applied fixes — the reported "green build → red build after --force". * * This guard refuses to proceed on a dirty tree UNLESS the user explicitly * passes `--allow-dirty` (a USER-only escape hatch, like GIT_GUARD_OFF — the * orchestrator must never set it on its own initiative). It only READS git * state (`git status --porcelain` via lib/git); it never writes. * * npx --prefer-offline tsx skills/ba-develop/cli/preflight-force/index.ts \ * --spec '{"projectPath":"","allowDirty":false}' * * Envelope: success=true → safe to enter --force Phase 3; success=false → STOP * and surface the dirty files (do NOT regenerate). */ import { parseArgs } from 'node:util' import { git, isClean, getChangedFiles } from '../../../lib/git.js' import { executeEnvelope, failExecute, printEnvelope } from '../../../lib/output.js' const COMMAND = 'preflight-force' /** * Robust git-repo check that ALSO returns true inside a git WORKTREE (where * `.git` is a file, not a directory — the `.git`-dir probe in lib/git.isGitRepo * misses it). SmartStack's GitFlow uses per-branch worktrees (e.g. `02-Develop`), * so worktree-awareness is required or the guard would silently no-op there. */ async function insideWorkTree(cwd: string): Promise { try { return (await git('rev-parse --is-inside-work-tree', cwd)) === 'true' } catch { return false } } interface PreflightReport { clean: boolean isRepo: boolean dirtyFiles: string[] allowDirty: boolean } async function main(): Promise { const { values } = parseArgs({ options: { spec: { type: 'string' } }, strict: true }) if (!values.spec) { printEnvelope(failExecute(COMMAND, ['--spec is required'])) process.exit(1) return } let raw: { projectPath?: string; allowDirty?: boolean } try { raw = JSON.parse(values.spec) } catch { printEnvelope(failExecute(COMMAND, ['Invalid JSON in --spec'])) process.exit(1) return } if (!raw.projectPath) { printEnvelope(failExecute(COMMAND, ['spec.projectPath is required'])) process.exit(1) return } const projectPath = raw.projectPath const allowDirty = raw.allowDirty === true // Not a git repo (or worktree) → we cannot detect clobbered edits, and there // is nothing to commit-vs-lose. Proceed, but say the guard is inactive. if (!(await insideWorkTree(projectPath))) { printEnvelope( executeEnvelope(COMMAND, { success: true, report: { clean: true, isRepo: false, dirtyFiles: [], allowDirty }, warnings: [ `${projectPath} is not a git repository — --force cannot detect overwritten edits. Proceeding without the guard.`, ], }), ) process.exit(0) return } if (await isClean(projectPath)) { printEnvelope( executeEnvelope(COMMAND, { success: true, report: { clean: true, isRepo: true, dirtyFiles: [], allowDirty }, }), ) process.exit(0) return } const dirtyFiles = await getChangedFiles(projectPath) if (allowDirty) { printEnvelope( executeEnvelope(COMMAND, { success: true, report: { clean: false, isRepo: true, dirtyFiles, allowDirty: true }, warnings: [ `Proceeding with --force on a DIRTY tree (--allow-dirty set by the user). ${dirtyFiles.length} uncommitted file(s) may be overwritten.`, ], }), ) process.exit(0) return } // Dirty and NOT allowed → STOP before any regeneration. printEnvelope( executeEnvelope(COMMAND, { success: false, report: { clean: false, isRepo: true, dirtyFiles, allowDirty: false }, errors: [ `Refusing --force: ${dirtyFiles.length} uncommitted change(s) in ${projectPath} would be overwritten by full regeneration.`, ...dirtyFiles.slice(0, 20).map((f) => ` • ${f}`), ...(dirtyFiles.length > 20 ? [` … and ${dirtyFiles.length - 20} more`] : []), ], nextSteps: [ 'Commit the intended fixes (via `/gitflow commit`), then re-run /ba-develop --force.', 'OR re-run /ba-develop with --allow-dirty to intentionally regenerate over the uncommitted changes.', ], }), ) process.exit(1) } main()