mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(spec,review): fail closed when the coordinator contract cannot be read; restore the omitted-Army sentence The spec --execute spawn captures `gstack-autoplan contract` before piping the archive, so a lane never starts without the contract; the C9 fixture installs its gstack-like checkout (bin, lib, a contract stub) at every host's global root. Review Step 5.8 keeps the pinned 'without claiming specialist coverage' wording. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
111 lines
6.8 KiB
TypeScript
111 lines
6.8 KiB
TypeScript
/**
|
|
* lane-ownership — which planned files an in-flight lane already changes
|
|
* (plan E2). A coordinator running several lanes gives every shared file one
|
|
* owner; `bin/gstack-lane-check` takes the files one lane plans to touch plus
|
|
* the in-flight branches and pull requests, diffs each against its merge base
|
|
* with the base branch, and reports every intersection with the PR number,
|
|
* the branch and the hunk count in that file. A branch that cannot be
|
|
* resolved is reported (`LANE_REF_UNAVAILABLE`), never assumed clear. Planned
|
|
* paths may be globs (`lib/**`); `*` and `**` are the only wildcards.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { globToRegExp } from './ship-policy';
|
|
|
|
export interface LaneRef { branch: string; pr: number | null; ref: string }
|
|
export interface LaneFile { path: string; hunks: number; added: number; deleted: number }
|
|
export interface LaneDiff { lane: LaneRef; head: string | null; base: string | null; files: LaneFile[]; error?: string }
|
|
export interface Intersection { planned: string; file: string; lane: LaneRef; hunks: number }
|
|
export interface LaneCheck {
|
|
schema_version: 1; base: string; planned: string[]; lanes: LaneDiff[]; intersections: Intersection[];
|
|
unresolved: LaneRef[]; verdict: 'clear' | 'conflicts' | 'unresolved';
|
|
}
|
|
|
|
function git(cwd: string, args: string[], opts: { maxBuffer?: number } = {}): { ok: boolean; out: string } {
|
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024 });
|
|
return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, '') };
|
|
}
|
|
|
|
/** Resolve a PR number to its head branch through `gh`; null when gh cannot answer. */
|
|
export function prHeadBranch(cwd: string, pr: number): { branch: string } | { error: string } {
|
|
const r = spawnSync('gh', ['pr', 'view', String(pr), '--json', 'headRefName,state'], { cwd, encoding: 'utf8', timeout: 60_000 });
|
|
if (r.status !== 0) return { error: (r.stderr ?? '').trim() || `gh pr view ${pr} failed` };
|
|
try {
|
|
const parsed = JSON.parse(r.stdout) as { headRefName?: string; state?: string };
|
|
if (!parsed.headRefName) return { error: `PR #${pr} has no head branch` };
|
|
return { branch: parsed.headRefName };
|
|
} catch (e: any) { return { error: `PR #${pr}: ${e.message}` }; }
|
|
}
|
|
|
|
/** The branch's ref to diff: a fetched remote-tracking ref when present, else the local branch. */
|
|
export function resolveRef(cwd: string, branch: string, remote = 'origin'): string | null {
|
|
for (const candidate of [branch.startsWith(`${remote}/`) ? branch : `${remote}/${branch}`, branch]) {
|
|
if (git(cwd, ['rev-parse', '--verify', '--quiet', `${candidate}^{commit}`]).ok) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Files a lane changes against its merge base with `base`, each with its hunk count. */
|
|
export function diffLane(cwd: string, lane: LaneRef, base: string): LaneDiff {
|
|
const head = git(cwd, ['rev-parse', lane.ref]);
|
|
if (!head.ok) return { lane, head: null, base: null, files: [], error: `${lane.ref} is not a commit` };
|
|
const mb = git(cwd, ['merge-base', base, lane.ref]);
|
|
if (!mb.ok) return { lane, head: head.out, base: null, files: [], error: `no merge base between ${base} and ${lane.ref}` };
|
|
const numstat = git(cwd, ['diff', '--numstat', `${mb.out}..${lane.ref}`]);
|
|
if (!numstat.ok) return { lane, head: head.out, base: mb.out, files: [], error: `git diff failed for ${lane.ref}` };
|
|
const files: LaneFile[] = [];
|
|
for (const line of numstat.out.split('\n').filter(Boolean)) {
|
|
const [added, deleted, ...rest] = line.split('\t');
|
|
const file = rest.join('\t');
|
|
const hunkDiff = git(cwd, ['diff', '-U0', `${mb.out}..${lane.ref}`, '--', file]);
|
|
const hunks = hunkDiff.ok ? hunkDiff.out.split('\n').filter(l => l.startsWith('@@')).length : 0;
|
|
files.push({ path: file, hunks: Math.max(hunks, 1), added: added === '-' ? 0 : Number(added), deleted: deleted === '-' ? 0 : Number(deleted) });
|
|
}
|
|
return { lane, head: head.out, base: mb.out, files };
|
|
}
|
|
|
|
export interface LaneCheckOptions { cwd: string; planned: string[]; branches?: string[]; prs?: number[]; base?: string; remote?: string; resolvePr?: (pr: number) => { branch: string } | { error: string } }
|
|
|
|
export function laneCheck(o: LaneCheckOptions): LaneCheck {
|
|
const remote = o.remote ?? 'origin';
|
|
const base = o.base ?? (resolveRef(o.cwd, 'HEAD', remote) && git(o.cwd, ['rev-parse', '--abbrev-ref', `${remote}/HEAD`]).ok
|
|
? git(o.cwd, ['rev-parse', '--abbrev-ref', `${remote}/HEAD`]).out : `${remote}/main`);
|
|
const planned = [...new Set(o.planned.map(p => p.trim()).filter(Boolean))];
|
|
const lanes: LaneDiff[] = [];
|
|
const unresolved: LaneRef[] = [];
|
|
const refs: LaneRef[] = (o.branches ?? []).map(b => ({ branch: b, pr: null, ref: b }));
|
|
for (const pr of o.prs ?? []) {
|
|
const r = (o.resolvePr ?? (n => prHeadBranch(o.cwd, n)))(pr);
|
|
if ('error' in r) { const lane = { branch: `#${pr}`, pr, ref: `#${pr}` }; unresolved.push(lane); lanes.push({ lane, head: null, base: null, files: [], error: r.error }); continue; }
|
|
const existing = refs.find(x => x.branch === r.branch);
|
|
if (existing) existing.pr = pr; else refs.push({ branch: r.branch, pr, ref: r.branch });
|
|
}
|
|
for (const lane of refs) {
|
|
const ref = resolveRef(o.cwd, lane.branch, remote);
|
|
if (!ref) { unresolved.push(lane); lanes.push({ lane, head: null, base: null, files: [], error: `${lane.branch} is not fetched (git fetch ${remote} ${lane.branch})` }); continue; }
|
|
const diff = diffLane(o.cwd, { ...lane, ref }, base);
|
|
if (diff.error) unresolved.push(diff.lane);
|
|
lanes.push(diff);
|
|
}
|
|
const matchers = planned.map(p => ({ planned: p, re: globToRegExp(p) }));
|
|
const intersections: Intersection[] = [];
|
|
for (const lane of lanes) for (const f of lane.files) for (const m of matchers) {
|
|
if (m.re.test(f.path)) intersections.push({ planned: m.planned, file: f.path, lane: lane.lane, hunks: f.hunks });
|
|
}
|
|
const verdict = intersections.length ? 'conflicts' : unresolved.length ? 'unresolved' : 'clear';
|
|
return { schema_version: 1, base, planned, lanes, intersections, unresolved, verdict };
|
|
}
|
|
|
|
export function renderLaneCheck(c: LaneCheck): string {
|
|
const lines: string[] = [];
|
|
for (const lane of c.lanes) {
|
|
const id = lane.lane.pr !== null ? `PR #${lane.lane.pr} (${lane.lane.branch})` : lane.lane.branch;
|
|
lines.push(lane.error ? `LANE: ${id} unresolved: ${lane.error}` : `LANE: ${id} head=${lane.head!.slice(0, 12)} files=${lane.files.length}`);
|
|
}
|
|
for (const x of c.intersections) {
|
|
const id = x.lane.pr !== null ? `PR #${x.lane.pr} (${x.lane.branch})` : x.lane.branch;
|
|
lines.push(`LANE_CONFLICT: ${x.file} planned=${x.planned} in-flight=${id} hunks=${x.hunks}`);
|
|
}
|
|
lines.push(`LANE_CHECK: ${c.verdict} planned=${c.planned.length} lanes=${c.lanes.length} intersections=${c.intersections.length} unresolved=${c.unresolved.length} base=${c.base}`);
|
|
return lines.join('\n') + '\n';
|
|
}
|