#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-lane-check — planned files versus in-flight lanes (plan E2). * * gstack-lane-check --planned [,...] [--planned-file ] [--branch ]... [--pr ]... [--base ] [--json] * * Diffs each in-flight branch or pull request against its merge base with the * base branch and prints one `LANE_CONFLICT:` line per planned file it already * changes (PR number, branch, hunk count), then `LANE_CHECK: clear | conflicts | unresolved`. * Exit 0 clear, 1 conflicts, 3 when a lane could not be resolved and nothing conflicts. * Logic: lib/lane-ownership.ts. Codes: LANE_CONFLICT, LANE_REF_UNAVAILABLE (lib/result-codes.ts). */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { laneCheck, renderLaneCheck } from '../lib/lane-ownership'; import { resultLine } from '../lib/result-codes'; const HELP = `gstack-lane-check — one owner per file across in-flight lanes --planned files or globs this lane plans to touch (repeatable; \`lib/**\` and \`*.ts\` wildcards) --planned-file one planned path per line (# comments allowed) --branch an in-flight branch (fetched: origin/ or local) --pr an in-flight pull request; its head branch comes from \`gh pr view\` --base the base to diff against (default origin/HEAD, else origin/main) --json the whole check as one JSON object ${exitTableHelp()}`; function main(argv: string[]): number { if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } const planned: string[] = []; const branches: string[] = []; const prs: number[] = []; let base: string | undefined; for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (a === '--planned') planned.push(...(argv[++i] ?? '').split(',')); else if (a === '--planned-file') { const file = argv[++i]; if (!file || !fs.existsSync(file)) { process.stderr.write(`gstack-lane-check: --planned-file ${file ?? ''} not found\n`); return EXIT.usage; } planned.push(...fs.readFileSync(file, 'utf8').split('\n').map(l => l.replace(/#.*$/, '').trim()).filter(Boolean)); } else if (a === '--branch') branches.push(argv[++i] ?? ''); else if (a === '--pr') { const n = Number(argv[++i]); if (!Number.isInteger(n) || n <= 0) { process.stderr.write('gstack-lane-check: --pr needs a positive number\n'); return EXIT.usage; } prs.push(n); } else if (a === '--base') base = argv[++i]; else if (a !== '--json') { process.stderr.write(`gstack-lane-check: unknown argument ${a}\n${HELP}\n`); return EXIT.usage; } } if (planned.filter(Boolean).length === 0) { process.stderr.write(`gstack-lane-check: --planned or --planned-file is required\n${HELP}\n`); return EXIT.usage; } if (branches.filter(Boolean).length === 0 && prs.length === 0) { process.stderr.write(`gstack-lane-check: name at least one --branch or --pr\n${HELP}\n`); return EXIT.usage; } const root = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 }); if (root.status !== 0) { process.stderr.write('gstack-lane-check: not inside a git repository\n'); return EXIT.usage; } const check = laneCheck({ cwd: root.stdout.trim(), planned, branches: branches.filter(Boolean), prs, base }); if (argv.includes('--json')) process.stdout.write(JSON.stringify(check) + '\n'); else process.stdout.write(renderLaneCheck(check)); for (const lane of check.unresolved) process.stderr.write(resultLine('gstack-lane-check', 'LANE_REF_UNAVAILABLE', lane.pr !== null ? `PR #${lane.pr}` : lane.branch) + '\n'); if (check.intersections.length) { process.stderr.write(resultLine('gstack-lane-check', 'LANE_CONFLICT', `${check.intersections.length} planned file(s) already in flight`) + '\n'); return EXIT.fail; } return check.unresolved.length ? EXIT.refused : EXIT.ok; } process.exitCode = main(process.argv.slice(2));