Files
gstack/lib/restamp.ts
T
cadbd0af15 v1.91.116.0 feat: field list 2 wave B: gstack-ship-queue, approval classes, state-machine review, pregate timing check, update-check main moves (#3235)
* fix(capy): the install trailer says behind=<n> and its fix, so a stale install shows in every relayed trailer (field list 2 item 13, #2378)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy): a lease records its holder's start identity, so a pid reused after a wake is reclaimed; a skipped session-start upgrade ends fail behind, never ok (GBRA-105, GBRA-106)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): stale needs proof (unreadable or malformed identities keep the lease), ps identity is timezone-independent, and reclaim never deletes a concurrent renewal

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): enforced round cap and spend budget, deferred close, public export without machine paths, opt-in durable checkpoints (field list 2 W5-W7)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): one directory lock for publish, prune and reclaim; reclaim rereads the record judged stale, so a renewal is never deleted or hidden

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): the directory lock names its holder; broken only when the holder is dead or its pid reused, unlock removes only its own lock, touch exits 3 under a live holder

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(lease): breakers serialize on .lock.break and reread the lock before removing it; gstack-skill-start reports a lease the held lock kept out

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(lease): name the double-crash limit of breaking a dead lock

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(capy): --for review-voice install profile, no-Drive bootstrap command, reviewer prompt trailer (field list 2 W11)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 89bf556c11)

* test: pin review-voice row, helpers row and the no-Drive fallback in existing install tests

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7f6c70459a)

* test: the DRIVER template names the reviewer trailer relay (pregate lane)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2545d9a6d2)

* test(context-budget): capy skill eager ceiling for the review-voice subagent paragraph (W11; ratchet protocol, capture value)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(eval-plan): prereg diagnostics dry run (W8) and .gstack/eval-models.json lint (W9), WIP

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit b509096218)

* feat(eval-plan): W8/W9 tests, docs/eval-models.md, troubleshooting anchors, /eval-plan steps

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 9335e45de2)

* test(eval-plan): eval-plan/SKILL.md names only defined codes and accepted flags

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a7a974d268)

* test(review-voice): the launch line points at the verdict's bun-only directory (#3233's launch contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(third-party-actions): the Aside drive-offer detector matches the product name, not the English idiom (paid run 38988927989 tpa-absent-linux false positive)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): export refuses artifact paths outside the run before writing; a deferred close keeps earlier undecided findings; reopening CEO grants Eng its rerun (review of #3234)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(restamp): gate-ahead keeps the lane CHANGELOG entry when both PRs took the same tentative stamp (GBRA-102 diagnosis fixture)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 43c75a6bb6)

* test(tree-receipt): a real gbrain stamp pair is same-modulo-stamps under gbrain committed policy (W1 acceptance)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 6cf53f9fed)

* feat(ship-policy): gate_command, full_e2e and min_gstack_version; unknown keys stay fail-closed; validate prints introducing versions

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 878a7123d7)

* feat(ship-queue): gstack-ship-queue ready, stamp, verify and gate-ahead (WIP, tests next)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 409a5957ad)

* test(ship-queue): stamp journal and resume, verify from a separate clone, gate-ahead attempts, ready

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit ade554087b)

* docs(ship-policy): Run a shared merge queue; new keys and compatibility rule; troubleshooting anchors; /ship pointer

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 7fc73e8892)

* fix(ship): shared-queue pointer rides the show-effective ship: line (ship skeleton has 2 bytes of parity headroom)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 5b33d4a015)

* test(windows): curate the shared-queue tests out of the Windows subset (shebang spawns)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 8deee14728)

* test(fixtures): register the gbrain stamp-pair fixture as free-only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 563a07aa03)

* test(ship-queue): receipt digest contract names lib/ship-queue.ts for its pregate lane

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 501637c258)

* feat(pregate): timing check flags elapsed-time bounds and sleep-then-assert in added test lines (W4)

lib/pregate/checks/timing.ts prints TIMING_ASSERTION: <file>:<line> <pattern>
and a TIMING_ASSERTIONS summary; gstack-timing-ok: <reason> in any comment
syntax on the line or the line above escapes one. It warns
(PREGATE_TIMING_ASSERTION): measured on gstack (9 of 47 test-touching
commits, 263 hits in the tree) and gbrain (37 of 95 commits; 31 of 38 sites
of the five timing-flake fixes), blocking would stop a fifth to two fifths
of test changes. Adds the /review checklist rule and the /investigate
timing-flake playbook.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit a3b86b10b2)

* feat(update-check): an equal VERSION with a moved main is an upgrade, via the compare API (W12)

When VERSION matches but ls-remote's main SHA differs from this checkout's
HEAD, bin/gstack-update-check asks the GitHub compare API (status, ahead_by)
through the existing receipted curl and prints
UPGRADE_AVAILABLE <ver> <ver>+<n> <sha>; a diverged or unreadable compare is
CHECK_FAILED (unknown), and behind/identical is up to date. Never fetches.

The cache gains an IDENTITY line (head, compared remote SHA, upstream): an
outcome replays only for the same HEAD and upstream, a same-remote refresh
reuses it without another compare, and a git checkout ignores a legacy
one-line cache (non-git installs keep the one-line format). The snooze is
keyed by the SHA. gstack-upgrade's question, snooze note and What's New step
handle the four-field line; gstack-skill-end reports it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 421e66d2a4)

* fix(pregate): treat planning and evidence markdown as an empty lane

A docs-only diff with no .gstack/pregate.json failed lanes as
PREGATE_NO_LANE. Planning and evidence Markdown is now a docs/no-tests
empty lane. Unmapped source files still fail closed.

Co-authored-by: Nick <surlymo@apache.org>
(cherry picked from commit e8793e59e8)

* fix(pregate): keep test-read markdown on its test lane

Unmarked Markdown that no test depends on is a docs/no-tests empty lane.
A file a test reads, including a fixture, still selects that test through
the import graph. Unmapped source and unreferenced test-tree files still
fail closed.

Co-authored-by: Nick <surlymo@apache.org>
(cherry picked from commit 7aabbddf3d)

* test(pregate): reviewer edges for the docs/no-tests empty lane (#3215)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 2fb3fa8613)

* test(pregate): requires-remote on a gbrain-shaped repo (ci:ubicloud, UBI_OWNER) (W10)

A base pregate.json with gbrain's dependencies and remote_gate
"bun run ci:ubicloud" (a package script whose stub needs UBICLOUD_API_KEY,
names VMs from UBI_OWNER and prints PREGATE_REMOTE). Under 8 cores the test
tier is one obligation naming the command; --remote passes the caller's
UBI_OWNER and key through and clears it on the report; without the key the
gate's own error shows and the obligation stays uncleared. #3190's path
needed no change.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 4d69ff02df)

* test(pregate): the ship receipt counts the timing check (7 checks)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit cb03ef67e7)

* test(context-budget): gstack-upgrade eager ceiling for the W12 equal-version upgrade question (ratchet protocol, capture value)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(approval-classes): gstack-approval-classes and ship-receipt citation enforcement (W2, WIP)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 847c7dae3d)

* feat(approval-classes): tests, docs, /review and /ship wiring (W2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 269bd5b8b5)

* feat(review): state-machines specialist and gstack-state-matrix round-trip check with a captured #6792 regression (W3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 1a634979c6)

* fix(review-army): keep the --simplification force flag pinned; approval test timeouts

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 789d392bce)

* test(free-fixtures): register the state-matrix-6792 fixture

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 1bacef296d)

* test(state-matrix): cover state-machines NEVER_GATE in gstack-specialist-stats

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
(cherry picked from commit 859063d56f)

* test(fixtures): register the pregate-timing fixtures as free-only (W4)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship-queue): full_e2e needs bound gate evidence in ready and verify, CI reads every check-run page and commit status, next: quotes its paths (review #3235 P1-1, P1-5, P2-1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): timing reads a native diff and never passes an unread one

The timing check's git diff honored diff.external, textconv and
diff.noprefix, so a configured driver hid every added line and the check
passed. Run it with --no-ext-diff --no-textconv and explicit a/ b/
prefixes, and report a git diff that fails or does not finish as
incomplete (PREGATE_INCOMPLETE), which a warn downgrade does not soften.

Review of #3235, sibling of P1-4 (lib/pregate/checks/timing.ts:189).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(approval-classes): only affirmative APPROVED evidence counts, explicit tokens are authoritative, native diffs fail closed (review #3235 P1-2, P1-3, P1-4)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(state-matrix): detect reads the native diff, ignoring diff.external and textconv (review #3235 P1-4)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(e2e): review-army-state-matrix behavior case on the #6792 fixture (field list 2, W3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(review-army): tighten the State Machines dispatch line so /ship stays under its parity size ratio with main's #3231 review-army text

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(approval-classes): only top-level approval lines count; quoted, fenced, indented-code and commented text never does (review #3235 r2)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(approval-classes): approval evidence is the first non-blank line only, at column 0 (review #3235 r3)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(autoplan-budget): bind the reservation expectation to lib/autoplan-budget's default estimate (test-of-test ratchet)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep gstack-upgrade under its size budget, exempt state-matrix's fetch hint, fit the periodic plan's 37 slices

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(evals): the docs observer accepts the spawned-prefixed gstack-skill-start --help read

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate capy-review-voice out of the Windows subset (POSIX launcher contract)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry for field list 2 wave A

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.115.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: the scope-signal clarity check follows helper-printed signals; the hermetic update-check fixture writes the identity cache

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* refactor: move the release-path result codes to lib/result-codes-release.ts (module-size ratchet); fit the periodic plan's 38 slices

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: changelog entry for field list 2 wave B

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.116.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: copy lib/result-codes-release.ts wherever a fixture copies lib/result-codes.ts; the scope-signal check requires the helper's computed emission

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: state-matrix stats test resolves its slug under its own state root (home containment)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate pregate-docs-lane-review out of the Windows subset (runBin shebang, like the other pregate fixtures)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(windows): curate pregate-remote-gbrain out of the Windows subset (runBin shebang, like pregate-remote)

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>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Nick <surlymo@apache.org>
2026-10-17 03:32:16 +00:00

603 lines
38 KiB
TypeScript

/**
* restamp — the whole stamp in one step (plan C2), behind bin/gstack-restamp.
*
* Every stage runs in an isolated staging worktree seeded from the working
* tree's content fingerprint (bin/gstack-wtree): the version writer
* (bin/gstack-version-bump write, composed, never grown), the policy's
* release_tool under its contract (enumerated allowed outputs, pre/post
* images), the CHANGELOG re-heading (lib/changelog-check.ts), the exact
* `(was X)` / `since: X` / `vX` rewrites inside `stamp_paths`. A policy
* `restamp_command` (the repo's own restamp) runs first instead of the
* version writer, on a clean checkout of HEAD (the caller's tree must be
* clean), under the same output confinement. The branch is
* written only after every stage validated, file by file against the
* fingerprinted preimage, with every write journaled in
* .gstack/tmp/restamp-journal.json so a failure after any stage rolls back
* without touching unrelated edits. `--dry-run` prints the diff and writes
* nothing. `--after <pr>` stamps a merge of the branch and its predecessor in
* a throwaway worktree (gate-ahead) and records .gstack/tmp/gate-ahead.json.
* Policy comes from origin/<base> (lib/ship-policy.ts); repo-declared
* commands run only with allowRepoCommands (the /ship flag).
*/
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { checkChangelog, mergeChangelogs, parseChangelog, reheadChangelog } from './changelog-check';
import { type ResultCodeName } from './result-codes';
import { runScriptSync, wtreeFingerprint } from './script-invocation';
import {
SHIP_POLICY_PATH, VERSION_SLOT, assertInsideRoot, declaredCommands, isUnavailable, loadPolicyFromBase, loadPolicyFromHead, matchesAny,
type LoadedPolicy, type QueueMode, type ShipPolicy,
} from './ship-policy';
import { bumpVersion, cmpVersion, extractVersion, fmtVersion, parseVersion, versionWidth, type Bump } from './version-source';
export const JOURNAL_REL = '.gstack/tmp/restamp-journal.json';
export const GATE_AHEAD_REL = '.gstack/tmp/gate-ahead.json';
const BIN_DIR = path.resolve(import.meta.dir, '..', 'bin');
const NPM_LOCKFILES = ['package-lock.json', 'npm-shrinkwrap.json'];
export type StageName = 'restamp_command' | 'version' | 'release_tool' | 'changelog' | 'stamp_paths' | 'mirror' | 'apply';
export interface JournalWrite { path: string; pre_sha256: string | null; post_sha256: string | null; applied: boolean }
export interface StageRecord { name: StageName; status: 'pending' | 'done' | 'skipped' | 'failed'; detail?: string; writes?: JournalWrite[] }
export interface Journal {
schema_version: 1; started_at: string; status: 'staging' | 'applying' | 'complete' | 'failed' | 'rolled_back';
inputs: { old_version: string; new_version: string; base_ref: string; base_sha: string; policy: string; wtree: string; queue_mode: QueueMode; after?: GateAheadRecord | null };
stages: StageRecord[];
}
export interface GateAheadRecord {
schema_version: 1; pr: string | null; predecessor_pr: string; predecessor_head: string; branch_head: string; gated_head: string;
gated_tree: string; version: string; worktree: string; evidence_branch_key: string; created_at: string;
}
export interface RestampOptions {
repoRoot: string; base: string; version?: string; next?: boolean; bump?: Bump; queueMode?: QueueMode;
after?: { pr: string; ref?: string }; pr?: string; expectBase?: string; expectOrder?: string[]; was?: string;
policyFrom: 'base' | 'head'; allowRepoCommands: boolean; dryRun: boolean; now?: Date; env?: NodeJS.ProcessEnv;
gh?: (args: string[]) => { ok: boolean; out: string }; failAfter?: string; gateWorktree?: string;
}
export interface RestampResult {
status: 'stamped' | 'noop' | 'dry-run' | 'refused' | 'failed' | 'gate-ahead';
code?: ResultCodeName; message?: string; fix?: string;
oldVersion?: string; newVersion?: string; base?: { ref: string; sha: string }; policy?: string; queueMode?: QueueMode;
claims?: string; stages: StageRecord[]; diff?: string; files?: string[]; journal?: string; gateAhead?: GateAheadRecord;
mergeCondition?: string; commands?: Array<{ key: string; command: string; cwd: string }>; expected?: unknown; actual?: unknown; recompute?: string;
}
function sha256(buf: Buffer | string): string { return createHash('sha256').update(buf).digest('hex'); }
function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; out: string; err: string; status: number | null } {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60_000, env: env ?? process.env });
return { ok: r.status === 0, out: (r.stdout ?? '').replace(/\n$/, ''), err: (r.stderr ?? '').trim(), status: r.status };
}
class Refusal extends Error { constructor(public code: ResultCodeName, message: string, public extra: Partial<RestampResult> = {}) { super(message); } }
export function ensureTmpExcluded(repoRoot: string): void {
const ex = git(repoRoot, ['rev-parse', '--git-path', 'info/exclude']);
if (!ex.ok) return;
const file = path.isAbsolute(ex.out) ? ex.out : path.join(repoRoot, ex.out);
fs.mkdirSync(path.dirname(file), { recursive: true });
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
if (!text.split('\n').includes('/.gstack/tmp/')) fs.appendFileSync(file, (text && !text.endsWith('\n') ? '\n' : '') + '/.gstack/tmp/\n');
}
/** The configured version source at a committed revision: `.gstack/version-path` there, else VERSION. */
export function versionAtRef(repoRoot: string, ref: string): { rel: string; version: string } | { error: string } {
const pin = git(repoRoot, ['show', `${ref}:.gstack/version-path`]);
const rel = pin.ok && pin.out.split('\n')[0]?.trim() ? pin.out.split('\n')[0]!.trim() : 'VERSION';
if (rel.includes('..') || path.isAbsolute(rel)) return { error: `.gstack/version-path at ${ref} escapes the repository (${rel})` };
const show = git(repoRoot, ['show', `${ref}:${rel}`]);
if (!show.ok) return { error: `${rel} does not exist at ${ref}` };
const v = extractVersion(show.out + '\n', rel);
if (!v || !parseVersion(v)) return { error: `${rel} at ${ref} holds no parsable version` };
return { rel, version: v };
}
function bumped(version: string, level: Bump): string {
const w = versionWidth(version);
return fmtVersion(bumpVersion(parseVersion(version)!, level, w), w);
}
function loadPolicy(o: RestampOptions): { loaded: LoadedPolicy; label: string } {
if (o.policyFrom === 'head') {
if (!o.allowRepoCommands) throw new Refusal('REPO_COMMANDS_NOT_ALLOWED', '--policy-from head is the unreviewed bootstrap path and needs --allow-repo-commands; nothing written');
const loaded = loadPolicyFromHead(o.repoRoot);
return { loaded, label: 'head (unreviewed)' };
}
const loaded = loadPolicyFromBase(o.repoRoot, o.base);
if (isUnavailable(loaded)) throw new Refusal('POLICY_SOURCE_UNAVAILABLE', `policy source unavailable (${loaded.detail}); nothing written`, { fix: `git fetch origin ${o.base} --depth=1` });
return { loaded, label: loaded.present ? `base ${loaded.source.label}` : `none (defaults; ${loaded.source.label})` };
}
function queueOrderCheck(o: RestampOptions, baseSha: string): void {
if (!o.expectOrder?.length) return;
const gh = o.gh ?? ((args: string[]) => { const r = spawnSync('gh', args, { cwd: o.repoRoot, encoding: 'utf8', timeout: 60_000 }); return { ok: r.status === 0, out: r.stdout ?? '' }; });
const mine = o.pr ?? o.after?.pr ?? null;
const idx = mine ? o.expectOrder.indexOf(mine) : -1;
const ahead = idx >= 0 ? o.expectOrder.slice(0, idx) : o.expectOrder;
const behind = idx >= 0 ? o.expectOrder.slice(idx + 1) : [];
const actual: Record<string, string> = {};
for (const pr of [...ahead, ...behind]) {
const r = gh(['pr', 'view', pr, '--json', 'state,mergeCommit,headRefOid']);
let info: any = null;
try { info = JSON.parse(r.out); } catch {}
if (!r.ok || !info) { actual[pr] = 'unknown'; continue; }
const merged = info.state === 'MERGED';
const inBase = merged && info.mergeCommit?.oid ? git(o.repoRoot, ['merge-base', '--is-ancestor', info.mergeCommit.oid, baseSha]).ok : false;
actual[pr] = merged ? (inBase ? 'merged' : 'merged-not-in-base') : String(info.state ?? 'unknown').toLowerCase();
}
const stale = [...ahead.filter(pr => actual[pr] !== 'merged'), ...behind.filter(pr => actual[pr]?.startsWith('merged'))];
if (stale.length) {
throw new Refusal('QUEUE_STALE', `queue order moved: ${stale.map(pr => `#${pr}=${actual[pr]}`).join(', ')}`, {
expected: { order: o.expectOrder, base: o.expectBase ?? null }, actual: { prs: actual, base: baseSha },
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha}${mine ? ` --pr ${mine}` : ''} --expect-order ${o.expectOrder.join(',')}`,
});
}
}
interface Plan {
policy: ShipPolicy; policyLabel: string; queueMode: QueueMode; oldVersion: string; newVersion: string; versionRel: string;
baseVersion: string; branchStamp: string | null; forkVersion: string; baseSha: string; baseRef: string; claims: string; level: Bump; predecessor?: { pr: string; head: string };
}
function planVersions(o: RestampOptions, loaded: LoadedPolicy, policyLabel: string): Plan {
const baseRef = `origin/${o.base}`;
const baseSha = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]);
if (!baseSha.ok) throw new Refusal('POLICY_SOURCE_UNAVAILABLE', `${baseRef} is not a resolvable commit; nothing written`, { fix: `git fetch origin ${o.base} --depth=1` });
if (o.expectBase && !baseSha.out.startsWith(o.expectBase)) {
throw new Refusal('QUEUE_STALE', `base moved: expected ${o.expectBase}, ${baseRef} is ${baseSha.out}`, {
expected: { base: o.expectBase }, actual: { base: baseSha.out },
recompute: `gstack-restamp --next --base ${o.base} --expect-base ${baseSha.out.slice(0, 12)}${o.expectOrder?.length ? ` --expect-order ${o.expectOrder.join(',')}` : ''}`,
});
}
queueOrderCheck(o, baseSha.out);
const policy = loaded.policy;
const queueMode = o.queueMode ?? policy.queue_mode;
const level: Bump = o.bump ?? (policy.bump === 'patch' ? 'patch' : 'micro');
const atBase = versionAtRef(o.repoRoot, baseSha.out);
if ('error' in atBase) throw new Refusal('RESTAMP_VERSION_SOURCE', `${atBase.error}; nothing written`);
const head = versionAtRef(o.repoRoot, 'HEAD');
const working = fs.existsSync(path.join(o.repoRoot, atBase.rel)) ? extractVersion(fs.readFileSync(path.join(o.repoRoot, atBase.rel), 'utf8'), atBase.rel) : '';
const oldVersion = working && parseVersion(working) ? working : 'error' in head ? atBase.version : head.version;
let newVersion: string;
let claims = 'n/a';
let predecessor: Plan['predecessor'];
if (o.after) {
const ref = o.after.ref ?? `refs/pull/${o.after.pr}/head`;
const predHead = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
if (!predHead.ok) throw new Refusal('RESTAMP_PREDECESSOR_MOVED', `predecessor #${o.after.pr} head ${ref} is not resolvable; fetch it first; nothing written`);
const predV = versionAtRef(o.repoRoot, predHead.out);
if ('error' in predV) throw new Refusal('RESTAMP_VERSION_SOURCE', `${predV.error}; nothing written`);
newVersion = bumped(predV.version, level);
predecessor = { pr: o.after.pr, head: predHead.out };
claims = 'ignored (gate-ahead relative to predecessor)';
} else if (o.version) {
if (!parseVersion(o.version)) throw new Refusal('RESTAMP_VERSION_SOURCE', `--version ${o.version} is not MAJOR.MINOR.PATCH[.MICRO]; nothing written`);
newVersion = o.version;
} else if (queueMode === 'stamp-at-merge') {
newVersion = bumped(atBase.version, level);
claims = 'ignored (stamp-at-merge)';
} else {
const r = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-next-version'), '--base', o.base, '--bump', level, '--current-version', atBase.version], { cwd: o.repoRoot, encoding: 'utf8', timeout: 120_000, env: o.env ?? process.env });
let q: any = null;
try { q = JSON.parse(r.stdout ?? ''); } catch {}
const usable = r.status === 0 && q && typeof q.version === 'string' && parseVersion(q.version) && (q.offline === false || q.fallback === 'git');
newVersion = usable ? q.version : bumped(atBase.version, level);
claims = usable ? `read (claim-at-open; queue ${JSON.stringify(q.claimed ?? q.queue ?? [])})` : 'unverified (claim-at-open; gstack-next-version unusable, local arithmetic)';
}
// The branch's own tentative stamp: its VERSION when it moved past base; else nothing (mentions come from the lines it added).
const branchStamp = oldVersion !== atBase.version ? oldVersion : null;
return { policy, policyLabel, queueMode, oldVersion, newVersion, versionRel: atBase.rel, baseVersion: atBase.version, branchStamp, forkVersion: forkVersion(o.repoRoot, baseRef, atBase.version), baseSha: baseSha.out, baseRef, claims, level, predecessor };
}
/** The version writer's own files: the version source, the manifest (root or pinned) and the npm lockfiles beside it. */
export function versionFiles(root: string, versionRel: string): string[] {
const pin = path.join(root, '.gstack', 'package-json-path');
const pkgRel = fs.existsSync(pin) ? (fs.readFileSync(pin, 'utf8').split('\n')[0]?.trim() || 'package.json') : 'package.json';
const dir = path.dirname(pkgRel) === '.' ? '' : path.dirname(pkgRel) + '/';
return [versionRel, pkgRel, ...NPM_LOCKFILES.map(l => dir + l)];
}
function allowedOutput(plan: Plan, root: string, rel: string): boolean {
const p = plan.policy;
if (versionFiles(root, plan.versionRel).includes(rel)) return true;
if (p.changelog && rel === p.changelog) return true;
return matchesAny(p.stamp_paths, rel) || matchesAny(p.stamp_files, rel) || matchesAny(p.release_outputs, rel);
}
interface Hunk { removed: string[]; added: string[]; at: number }
export interface FileDiff { path: string; binary: boolean; hunks: Hunk[]; status: 'M' | 'A' | 'D' | 'R' }
/** Parse `git diff --unified=0` into per-file hunks of removed/added lines (shared with lib/tree-receipt.ts). */
export function parseUnifiedDiff(text: string): FileDiff[] {
const files: FileDiff[] = [];
let cur: FileDiff | null = null;
let hunk: Hunk | null = null;
for (const line of text.split('\n')) {
if (line.startsWith('diff --git ')) {
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
cur = { path: m?.[2] ?? line.slice(11), binary: false, hunks: [], status: 'M' };
files.push(cur);
hunk = null;
} else if (!cur) continue;
else if (line.startsWith('new file mode')) cur.status = 'A';
else if (line.startsWith('deleted file mode')) cur.status = 'D';
else if (line.startsWith('rename to ')) cur.status = 'R';
else if (line.startsWith('Binary files')) cur.binary = true;
else if (line.startsWith('@@')) { hunk = { removed: [], added: [], at: Number(/^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line)?.[1] ?? 0) }; cur.hunks.push(hunk); }
else if (hunk && line.startsWith('-') && !line.startsWith('---')) hunk.removed.push(line.slice(1));
else if (hunk && line.startsWith('+') && !line.startsWith('+++')) hunk.added.push(line.slice(1));
}
return files;
}
/** The lines a branch itself added, per stamp file: `git diff --unified=0 <since> -- <files>` in `cwd` against its working tree. */
export function branchAddedLines(cwd: string, since: string, files: string[]): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
if (files.length === 0) return out;
const diff = git(cwd, ['diff', '--unified=0', '--no-color', '--no-ext-diff', since, '--', ...files]);
for (const f of parseUnifiedDiff(diff.out)) {
const set = out.get(f.path) ?? new Set<string>();
for (const h of f.hunks) for (const a of h.added) set.add(a);
out.set(f.path, set);
}
return out;
}
/**
* The branch's tentative stamps: versions it mentions in a stamp pattern, on
* lines it added itself, newer than the version at its fork point and not
* the allocated one. A released version is never newer than the fork point
* of a branch written against it, and the repo's own version width must
* match, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
*/
export function tentativeMentions(added: Map<string, Set<string>>, forkVersion: string, newVersion: string): string[] {
const fork = parseVersion(forkVersion);
if (!fork) return [];
const width = versionWidth(forkVersion);
const found = new Set<string>();
for (const lines of added.values()) {
for (const text of lines) {
for (const m of text.matchAll(/(?:\(was |since:\s*|(?<![A-Za-z0-9.])v)(\d+(?:\.\d+){1,3})(?![0-9.])/g)) {
const raw = m[1]!;
const v = parseVersion(raw);
if (!v || versionWidth(raw) !== width || raw === newVersion || cmpVersion(v, fork) <= 0) continue;
found.add(raw);
}
}
}
return [...found];
}
/** The version at the branch's original fork point: the first parent of its oldest own commit (base when it has none). */
export function forkVersion(repoRoot: string, baseRef: string, fallback: string): string {
const first = git(repoRoot, ['rev-list', '--reverse', '--first-parent', `${baseRef}..HEAD`]).out.split('\n')[0]?.trim();
if (!first) return fallback;
const v = versionAtRef(repoRoot, `${first}^`);
return 'version' in v ? v.version : fallback;
}
/**
* Exact `(was X)`, `since: X`, `vX` rewrites plus the policy's literal
* `stamp_patterns` templates (one `{version}` slot, matched at a version
* boundary); anything else in the file stays.
*/
export function rewriteStamps(text: string, oldVersion: string, newVersion: string, templates: readonly string[] = []): { text: string; count: number } {
const esc = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const v = esc(oldVersion);
const patterns: Array<[RegExp, string]> = [
[new RegExp(`\\(was ${v}\\)`, 'g'), `(was ${newVersion})`],
[new RegExp(`(since:\\s*)${v}(?![0-9.])`, 'g'), `$1${newVersion}`],
[new RegExp(`(?<![A-Za-z0-9.])v${v}(?![0-9.])`, 'g'), `v${newVersion}`],
...templates.map(t => { const [pre, post] = t.split(VERSION_SLOT) as [string, string]; return [new RegExp(`${esc(pre)}(?<![0-9.])${v}(?![0-9.])${esc(post)}`, 'g'), `${pre}${newVersion}${post}`.replace(/\$/g, '$$$$')] as [RegExp, string]; }),
];
let count = 0;
let out = text;
for (const [re, rep] of patterns) {
count += (out.match(re) ?? []).length;
out = out.replace(re, rep);
}
return { text: out, count };
}
/** Paths the stages changed: worktree vs the seeded index (never index vs HEAD, which is the user's own uncommitted work). */
function changedPaths(staging: string): string[] {
const modified = git(staging, ['diff', '--name-only', '-z']).out.split('\0');
const untracked = git(staging, ['ls-files', '--others', '--exclude-standard', '-z']).out.split('\0');
return [...new Set([...modified, ...untracked])].filter(p => p && !p.startsWith('.restamp-pre/') && p !== 'node_modules');
}
/** A release tool needs the repo's installed dependencies; an ignored top-level node_modules is linked, never copied. */
function linkIgnoredDeps(repoRoot: string, staging: string): void {
const nm = path.join(repoRoot, 'node_modules');
if (!fs.existsSync(nm) || fs.existsSync(path.join(staging, 'node_modules'))) return;
if (!git(repoRoot, ['check-ignore', '-q', 'node_modules']).ok) return;
try { fs.symlinkSync(nm, path.join(staging, 'node_modules'), 'dir'); } catch {}
}
/**
* The repo's own restamp (`restamp_command`), first in the staging worktree:
* the target version as `$1` and in GSTACK_RESTAMP_VERSION. Whatever it
* stages or commits there is folded back into worktree edits against the
* seeded tree; its outputs are confined like release_tool's, and the
* version file must hold the target version. Any failure writes nothing.
*/
function runRestampCommand(command: string, plan: Plan, staging: string, env: NodeJS.ProcessEnv, before: Set<string>, s: StageRecord): void {
const seeded = git(staging, ['write-tree']).out;
const r = spawnSync('sh', ['-c', `${command} "$1"`, 'sh', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 600_000, env: { ...env, GSTACK_RESTAMP_VERSION: plan.newVersion } });
git(staging, ['read-tree', seeded]);
if (r.status !== 0) { s.status = 'failed'; s.detail = `${r.stderr ?? ''}${r.stdout ?? ''}`.trim().slice(0, 300); throw new Refusal('RESTAMP_RELEASE_TOOL_FAILED', `restamp_command exited ${r.status}: ${s.detail}; nothing written to the branch`); }
const outside = changedPaths(staging).filter(p => !before.has(p) && !allowedOutput(plan, staging, p));
if (outside.length) { s.status = 'failed'; s.detail = outside.join(', '); throw new Refusal('RESTAMP_OUTPUT_OUTSIDE_ALLOWED', `restamp_command wrote outside the allowed set: ${outside.join(', ')}; nothing written to the branch`); }
const file = path.join(staging, plan.versionRel);
const got = fs.existsSync(file) ? extractVersion(fs.readFileSync(file, 'utf8'), plan.versionRel) : '';
if (got !== plan.newVersion) { s.status = 'failed'; s.detail = `${plan.versionRel}=${got || '(missing)'}`; throw new Refusal('RESTAMP_VERSION_SOURCE', `restamp_command left ${plan.versionRel} at ${got || '(missing)'}, expected ${plan.newVersion}; nothing written`); }
s.status = 'done'; s.detail = `${command} ${plan.newVersion}`;
}
function runStages(o: RestampOptions, plan: Plan, staging: string, since: string, stages: StageRecord[]): void {
const stage = (name: StageName): StageRecord => { const s: StageRecord = { name, status: 'pending' }; stages.push(s); return s; };
const injected = (name: string) => { if (o.failAfter === name) throw new Error(`injected failure after ${name}`); };
const env = { ...(o.env ?? process.env), GSTACK_RESTAMP_STAGING: '1' };
const before = new Set(changedPaths(staging));
const command = plan.policy.restamp_command;
if (command) runRestampCommand(command, plan, staging, env, before, stage('restamp_command'));
const v = stage('version');
if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; }
else {
const w = spawnSync(process.execPath, [path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env });
if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); }
v.status = 'done'; v.detail = `wrote ${plan.newVersion}`;
}
injected('version');
const rt = stage('release_tool');
if (plan.policy.release_tool) {
const r = spawnSync('sh', ['-c', plan.policy.release_tool], { cwd: staging, encoding: 'utf8', timeout: 600_000, env });
if (r.status !== 0) { rt.status = 'failed'; rt.detail = (r.stderr ?? r.stdout ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_RELEASE_TOOL_FAILED', `release_tool exited ${r.status}: ${rt.detail}; nothing written to the branch`); }
const outside = changedPaths(staging).filter(p => !before.has(p) && !allowedOutput(plan, staging, p));
if (outside.length) { rt.status = 'failed'; rt.detail = outside.join(', '); throw new Refusal('RESTAMP_OUTPUT_OUTSIDE_ALLOWED', `release_tool wrote outside the allowed set: ${outside.join(', ')}; nothing written to the branch`); }
rt.status = 'done'; rt.detail = plan.policy.release_tool;
} else { rt.status = 'skipped'; rt.detail = 'no release_tool in policy'; }
injected('release_tool');
const cl = stage('changelog');
const clRel = plan.policy.changelog;
const clPath = clRel ? path.join(staging, clRel) : null;
if (clPath && fs.existsSync(clPath)) {
const date = (o.now ?? new Date()).toISOString().slice(0, 10);
const from = o.was ?? plan.branchStamp;
const r = reheadChangelog(fs.readFileSync(clPath, 'utf8'), { from, to: plan.newVersion, date, file: clRel! });
if (r.error) { cl.status = 'failed'; cl.detail = r.error.message; throw new Refusal(r.error.code, `${clRel}: ${r.error.message}; nothing written to the branch`); }
if (r.moved) fs.writeFileSync(clPath, r.text);
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!);
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`;
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
injected('changelog');
const sp = stage('stamp_paths');
const stampGlobs = [...plan.policy.stamp_paths, ...plan.policy.stamp_files];
const templates = plan.policy.stamp_patterns;
if (stampGlobs.length === 0) { sp.status = 'skipped'; sp.detail = 'no stamp_paths in policy'; }
else {
const files = git(staging, ['ls-files', '-z']).out.split('\0').filter(f => f && matchesAny(stampGlobs, f));
for (const rel of files) {
const why = assertInsideRoot(staging, rel);
if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`);
}
const added = branchAddedLines(staging, since, files);
const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion));
const tentative = new Set(tentativeMentions(added, plan.forkVersion, plan.newVersion).filter(v => !explicit.has(v)));
let touched = 0;
let total = 0;
for (const rel of files) {
const abs = path.join(staging, rel);
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
const own = added.get(rel);
if (explicit.size === 0 && !own) continue;
let count = 0;
const out = fs.readFileSync(abs, 'utf8').split('\n').map(line => {
let text = line;
for (const old of explicit) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; }
if (own?.has(line)) for (const old of tentative) { const r = rewriteStamps(text, old, plan.newVersion, templates); text = r.text; count += r.count; }
return text;
}).join('\n');
if (count) { fs.writeFileSync(abs, out); touched += 1; total += count; }
}
const olds = [...explicit, ...tentative];
sp.status = olds.length ? 'done' : 'skipped';
sp.detail = olds.length ? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})` : `no tentative mention newer than the fork version ${plan.forkVersion}`;
}
injected('stamp_paths');
const mi = stage('mirror');
mi.status = 'skipped'; mi.detail = plan.policy.mirror ? `tier 2: declared ${plan.policy.mirror}, not run` : 'no mirror in policy';
}
function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] {
const apply: StageRecord = { name: 'apply', status: 'pending', writes: [] };
journal.stages.push(apply);
journal.status = 'applying';
const save = () => fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n');
const writes = apply.writes!;
const rollback = (reason: string): never => {
for (const w of [...writes].reverse()) {
if (!w.applied) continue;
const abs = path.join(o.repoRoot, w.path);
const cur = fs.existsSync(abs) ? sha256(fs.readFileSync(abs)) : null;
if (cur !== w.post_sha256) { journal.status = 'failed'; save(); throw new Refusal('RESTAMP_CONFLICT', `${reason}; rollback stopped: ${w.path} no longer holds the recorded postimage`); }
if (w.pre_sha256 === null) fs.rmSync(abs, { force: true });
else fs.writeFileSync(abs, fs.readFileSync(path.join(staging, '.restamp-pre', w.path)));
w.applied = false;
}
journal.status = 'rolled_back'; apply.status = 'failed'; apply.detail = reason; save();
throw new Refusal('RESTAMP_CONFLICT', `${reason}; rolled back ${writes.length} write(s)`);
};
fs.mkdirSync(path.join(staging, '.restamp-pre'), { recursive: true });
for (const rel of files) {
const why = assertInsideRoot(o.repoRoot, rel);
if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`);
}
let n = 0;
for (const rel of files) {
const real = path.join(o.repoRoot, rel);
const stagingFile = path.join(staging, rel);
const exists = fs.existsSync(real);
const fingerprinted = git(o.repoRoot, ['rev-parse', '--verify', '--quiet', `${wtree}:${rel}`]);
if (exists) {
const cur = git(o.repoRoot, ['hash-object', real]).out;
if (fingerprinted.ok && cur !== fingerprinted.out) rollback(`${rel} changed in the working tree while the restamp staged`);
fs.mkdirSync(path.dirname(path.join(staging, '.restamp-pre', rel)), { recursive: true });
fs.copyFileSync(real, path.join(staging, '.restamp-pre', rel));
} else if (fingerprinted.ok) rollback(`${rel} was removed from the working tree while the restamp staged`);
const w: JournalWrite = { path: rel, pre_sha256: exists ? sha256(fs.readFileSync(real)) : null, post_sha256: null, applied: false };
writes.push(w);
save();
if (fs.existsSync(stagingFile)) {
const bytes = fs.readFileSync(stagingFile);
fs.mkdirSync(path.dirname(real), { recursive: true });
const tmp = `${real}.restamp.${process.pid}.tmp`;
fs.writeFileSync(tmp, bytes);
fs.renameSync(tmp, real);
w.post_sha256 = sha256(bytes);
} else { fs.rmSync(real, { force: true }); w.post_sha256 = null; }
w.applied = true;
n += 1;
save();
if (o.failAfter === `apply:${n}`) rollback(`injected failure after apply:${n}`);
}
apply.status = 'done'; apply.detail = `${writes.length} file(s)`;
journal.status = 'complete';
save();
return writes;
}
function removeWorktree(repoRoot: string, dir: string): void {
git(repoRoot, ['worktree', 'remove', '--force', dir]);
fs.rmSync(dir, { recursive: true, force: true });
git(repoRoot, ['worktree', 'prune']);
}
export function restamp(o: RestampOptions): RestampResult {
const stages: StageRecord[] = [];
let staging: string | null = null;
try {
const { loaded, label } = loadPolicy(o);
if (loaded.errors.length) throw new Refusal(loaded.errors[0]!.code, `${SHIP_POLICY_PATH} (${label}): ${loaded.errors[0]!.message} at ${loaded.errors[0]!.path}; nothing written`);
const commands = declaredCommands(loaded.policy);
if (commands.length && !o.allowRepoCommands) {
throw new Refusal('REPO_COMMANDS_NOT_ALLOWED', `repo commands not executed (pass --allow-repo-commands): ${commands.map(c => `${c.key}: ${c.command}`).join('; ')}; nothing written`, { commands });
}
const plan = planVersions(o, loaded, label);
const headSha = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const common: Partial<RestampResult> = {
oldVersion: plan.oldVersion, newVersion: plan.newVersion, base: { ref: plan.baseRef, sha: plan.baseSha }, policy: plan.policyLabel,
queueMode: plan.queueMode, claims: plan.claims, mergeCondition: headSha, commands,
};
ensureTmpExcluded(o.repoRoot);
const journalPath = path.join(o.repoRoot, JOURNAL_REL);
if (!o.after && fs.existsSync(journalPath)) {
try {
const prior: Journal = JSON.parse(fs.readFileSync(journalPath, 'utf8'));
if (prior.inputs?.new_version === plan.newVersion && prior.status !== 'complete' && prior.inputs.old_version !== plan.newVersion) { plan.oldVersion = prior.inputs.old_version; plan.branchStamp = prior.inputs.old_version !== plan.baseVersion ? prior.inputs.old_version : plan.branchStamp; }
} catch {}
}
if (o.after) return gateAhead(o, plan, stages, common);
if (plan.policy.restamp_command) {
const dirty = git(o.repoRoot, ['status', '--porcelain', '--untracked-files=all']).out.split('\n').filter(Boolean).map(l => l.slice(3));
if (dirty.length) throw new Refusal('RESTAMP_CONFLICT', `restamp_command needs a clean HEAD, index and worktree (it runs on a clean checkout of HEAD); commit or remove: ${dirty.slice(0, 10).join(', ')}${dirty.length > 10 ? ' …' : ''}; nothing written`);
}
const fp = wtreeFingerprint({ cwd: o.repoRoot });
if ('error' in fp) throw new Refusal('RESTAMP_CONFLICT', `cannot fingerprint the working tree (${fp.error}); nothing written`);
const wtree = fp.tree;
staging = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-restamp-staging-'));
fs.rmSync(staging, { recursive: true, force: true });
const add = git(o.repoRoot, ['worktree', 'add', '--detach', '-q', staging, 'HEAD']);
if (!add.ok) throw new Refusal('RESTAMP_CONFLICT', `cannot create the staging worktree: ${add.err}; nothing written`);
const seed = git(staging, ['read-tree', '--reset', '-u', wtree]);
if (!seed.ok) throw new Refusal('RESTAMP_CONFLICT', `cannot seed the staging worktree: ${seed.err}; nothing written`);
linkIgnoredDeps(o.repoRoot, staging);
runStages(o, plan, staging, git(o.repoRoot, ['merge-base', plan.baseRef, 'HEAD']).out || plan.baseSha, stages);
const files = changedPaths(staging).sort();
git(staging, ['add', '-A']);
const diff = git(staging, ['diff', '--cached', wtree]).out;
if (files.length === 0) return { ...common, status: 'noop', stages, diff: '', files: [] };
if (o.dryRun) return { ...common, status: 'dry-run', stages, diff, files };
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
const journal: Journal = {
schema_version: 1, started_at: (o.now ?? new Date()).toISOString(), status: 'staging',
inputs: { old_version: plan.branchStamp ?? plan.oldVersion, new_version: plan.newVersion, base_ref: plan.baseRef, base_sha: plan.baseSha, policy: plan.policyLabel, wtree, queue_mode: plan.queueMode },
stages: stages.map(s => ({ ...s })),
};
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n');
applyToBranch(o, staging, wtree, files, journal, journalPath);
return { ...common, status: 'stamped', stages: journal.stages, diff, files, journal: journalPath };
} catch (e: any) {
if (e instanceof Refusal) return { status: e.code === 'RESTAMP_CONFLICT' ? 'failed' : 'refused', code: e.code, message: e.message, stages, ...e.extra };
return { status: 'failed', code: 'RESTAMP_CONFLICT', message: `${e?.message ?? e}; see ${JOURNAL_REL}`, stages };
} finally {
if (staging) removeWorktree(o.repoRoot, staging);
}
}
/**
* Gate-ahead: merge the branch and the predecessor's head in a throwaway
* worktree, stamp N+1 relative to the predecessor there, commit, and record
* the predecessor head and the evidence branch key. The branch is untouched.
*/
function gateAhead(o: RestampOptions, plan: Plan, stages: StageRecord[], common: Partial<RestampResult>): RestampResult {
const pred = plan.predecessor!;
const branchHead = git(o.repoRoot, ['rev-parse', 'HEAD']).out;
const dir = o.gateWorktree ?? path.join(o.repoRoot, '.gstack', 'tmp', 'gate-ahead-worktree');
if (fs.existsSync(dir)) removeWorktree(o.repoRoot, dir);
fs.mkdirSync(path.dirname(dir), { recursive: true });
const add = git(o.repoRoot, ['worktree', 'add', '--detach', '-q', dir, branchHead]);
if (!add.ok) throw new Refusal('RESTAMP_CONFLICT', `cannot create the gate-ahead worktree: ${add.err}; nothing written`);
const env = { ...(o.env ?? process.env), GIT_AUTHOR_NAME: 'gstack-restamp', GIT_AUTHOR_EMAIL: 'restamp@gstack.local', GIT_COMMITTER_NAME: 'gstack-restamp', GIT_COMMITTER_EMAIL: 'restamp@gstack.local' };
const merge = git(dir, ['-c', 'commit.gpgsign=false', 'merge', '--no-ff', '--no-edit', '-q', pred.head], env);
if (!merge.ok) {
const conflicts = git(dir, ['diff', '--name-only', '--diff-filter=U']).out.split('\n').filter(Boolean);
const stampFiles = versionFiles(dir, plan.versionRel);
const resolvable = conflicts.length > 0 && conflicts.every(c => stampFiles.includes(c) || c === plan.policy.changelog);
let unresolved: string[] = resolvable ? [] : conflicts;
if (resolvable) {
for (const c of conflicts) {
if (c === plan.policy.changelog) {
const merged = mergeChangelogs(git(dir, ['show', `:2:${c}`]).out + '\n', git(dir, ['show', `:3:${c}`]).out + '\n');
if (merged === null) { unresolved = [c]; break; }
fs.writeFileSync(path.join(dir, c), merged);
} else git(dir, ['checkout', '--theirs', '--', c]);
}
}
if (unresolved.length) {
removeWorktree(o.repoRoot, dir);
throw new Refusal('RESTAMP_MERGE_CONFLICT', `merge of HEAD and predecessor #${pred.pr} (${pred.head.slice(0, 12)}) conflicts in ${unresolved.join(', ') || 'unknown files'}; nothing written`);
}
git(dir, ['add', '--', ...conflicts]);
const done = git(dir, ['-c', 'commit.gpgsign=false', 'commit', '-q', '--no-edit'], env);
if (!done.ok) { removeWorktree(o.repoRoot, dir); throw new Refusal('RESTAMP_MERGE_CONFLICT', `cannot complete the gate-ahead merge: ${done.err}; nothing written`); }
}
runStages(o, plan, dir, pred.head, stages);
if (o.dryRun) { const diff = git(dir, ['diff']).out; removeWorktree(o.repoRoot, dir); return { ...common, status: 'dry-run', stages, diff, files: changedPaths(dir) }; }
git(dir, ['add', '-A']);
const commit = git(dir, ['-c', 'commit.gpgsign=false', 'commit', '-q', '--allow-empty', '-m', `restamp v${plan.newVersion} (gate-ahead after #${pred.pr})`], env);
if (!commit.ok) { removeWorktree(o.repoRoot, dir); throw new Refusal('RESTAMP_CONFLICT', `cannot commit the gate-ahead stamp: ${commit.err}`); }
const gatedHead = git(dir, ['rev-parse', 'HEAD']).out;
const gatedTree = git(dir, ['rev-parse', 'HEAD^{tree}']).out;
const slug = runScriptSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd: o.repoRoot, encoding: 'utf8', timeout: 30_000 });
const branchKey = /^BRANCH=(.+)$/m.exec(slug.stdout ?? '')?.[1]?.trim() ?? git(o.repoRoot, ['branch', '--show-current']).out;
const record: GateAheadRecord = {
schema_version: 1, pr: o.pr ?? null, predecessor_pr: pred.pr, predecessor_head: pred.head, branch_head: branchHead, gated_head: gatedHead,
gated_tree: gatedTree, version: plan.newVersion, worktree: dir, evidence_branch_key: branchKey, created_at: (o.now ?? new Date()).toISOString(),
};
const file = path.join(o.repoRoot, GATE_AHEAD_REL);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(record, null, 2) + '\n');
return { ...common, status: 'gate-ahead', stages, gateAhead: record, files: [], diff: git(dir, ['show', '--stat', '--format=', 'HEAD']).out, journal: file };
}