#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-restamp — the whole stamp in one step (plan C2). * * gstack-restamp --next | --version | --after [--predecessor-ref ] * [--base ] [--bump micro|patch|minor|major] [--queue-mode stamp-at-merge|claim-at-open] * [--pr ] [--expect-base ] [--expect-order ] * [--policy-from base|head] [--allow-repo-commands] [--dry-run] [--json] * * Runs gstack-version-bump write, the policy's release_tool, the CHANGELOG * re-heading and the stamp_paths rewrites in an isolated staging worktree, * shows the diff limited to stamp files, and writes the branch only after * every stage validated (journal: .gstack/tmp/restamp-journal.json), then * stages what it wrote (`git add -A -- `, STAGED:), so a bare commit after * a --no-commit base merge cannot drop the stamp (#3531). A stamp file holding * unstaged edits of the caller's own refuses before anything is written * (RESTAMP_UNSTAGED_EDITS); a staging failure leaves the files written and * .gstack/tmp/restamp-staging.json, which the noop rerun stages, or drops when a * file changed since (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the * GSTACK_RESULT line. Logic: lib/restamp.ts; policy: lib/ship-policy.ts. */ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, exitTableHelp, formatResult } from '../lib/headless-artifacts'; import { GATE_AHEAD_REL, JOURNAL_REL, restamp, type RestampResult } from '../lib/restamp'; import { RESULT_CODES, type ResultCodeName } from '../lib/result-codes'; import { resolveBaseBranch } from '../lib/ship-policy'; import type { Bump } from '../lib/version-source'; const HELP = `gstack-restamp — stamp the version, release tool, CHANGELOG and stamp_paths in one step (docs/ship-policy.md) --next base version at origin/ plus the policy bump (stamp-at-merge ignores claims) --version stamp exactly X --after gate-ahead: stamp a merge of HEAD and the predecessor's head in a throwaway worktree --predecessor-ref the predecessor's head (default refs/pull//head; fetch it first) --base base branch (default: origin/HEAD, else main) --bump micro|patch|minor|major (default: policy bump, else micro) --queue-mode stamp-at-merge | claim-at-open (default: policy queue_mode, else claim-at-open) --pr this PR, for --expect-order and the gate-ahead record --was rewrite X on every line of every stamp file, the base's own lines included, and re-head the CHANGELOG section [X] (default: the branch's own stamp everywhere plus the stale mentions on lines the branch added) --expect-base refuse with QUEUE_STALE when origin/ moved --expect-order refuse with QUEUE_STALE when a predecessor is unmerged or a successor merged --policy-from base|head base (default, reviewed) or head (unreviewed; needs --allow-repo-commands) --allow-repo-commands run the policy's release_tool (what /ship passes); without it, commands are listed and exit 3 --dry-run print the full diff, write nothing --json JSON only on stdout Prints STAMP_VERSION: , the stage table, the stamp diff, STAGED: and GSTACK_RESULT: skill=restamp status=complete|refused|incomplete run=${JOURNAL_REL}. ${exitTableHelp()}`; function flag(args: string[], name: string): string | undefined { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; } const STAGING_REL = '.gstack/tmp/restamp-staging.json'; interface Pending { files: string[]; blobs: Record } interface Staging { staged?: string[]; dropped?: string[]; failure?: string } const gitIn = (root: string, args: string[]) => spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 }); /** Each file's blob sha in the worktree (one git call), null for a file that does not exist. */ function blobsOf(root: string, files: string[]): Record { const present = files.filter(f => fs.existsSync(path.join(root, f))); const shas = present.length ? gitIn(root, ['hash-object', '--', ...present]).stdout.trim().split('\n') : []; return Object.fromEntries(files.map(f => [f, present.includes(f) ? shas[present.indexOf(f)] ?? '' : null])); } function readPending(root: string): Pending | null { try { return JSON.parse(fs.readFileSync(path.join(root, STAGING_REL), 'utf8')) as Pending; } catch { return null; } } /** Files with unstaged edits that are the caller's own (not bytes a pending restamp wrote and could not stage). */ function ownUnstaged(root: string): string[] { const pending = readPending(root); const unstaged = gitIn(root, ['diff', '--name-only', '-z']).stdout.split('\0').filter(Boolean); const now = pending ? blobsOf(root, unstaged.filter(f => f in pending.blobs)) : {}; return unstaged.filter(f => !(pending && f in now && pending.blobs[f] === now[f])); } /** * Stage what restamp wrote (`written`), or what a pending marker says is still unstaged; a marker file whose bytes * changed since (a hand commit, another branch, a base merge) is stale for that file and dropped, never a failure. * Null when there is nothing to stage. */ function stage(root: string, written: string[] | null): Staging | null { const marker = path.join(root, STAGING_REL); const pending = written ? { files: written, blobs: blobsOf(root, written) } : readPending(root); if (!pending?.files.length) return null; if (written) { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, JSON.stringify(pending, null, 2) + '\n'); } const now = written ? pending.blobs : blobsOf(root, pending.files); const dropped = pending.files.filter(f => now[f] !== pending.blobs[f]); const files = pending.files.filter(f => !dropped.includes(f)); if (!files.length) { fs.rmSync(marker, { force: true }); return { staged: [], dropped }; } const add = gitIn(root, ['add', '-A', '--', ...files]); if (add.status !== 0) return { failure: `git add of ${files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` }; fs.rmSync(marker, { force: true }); return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...files]).stdout.split('\0').filter(Boolean), dropped }; } function main(argv: string[]): number { if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; } const json = argv.includes('--json'); const modes = ['--next', '--version', '--after'].filter(f => argv.includes(f)); if (modes.length !== 1) { process.stderr.write(`gstack-restamp: pass exactly one of --next, --version , --after \n${HELP}\n`); return EXIT.usage; } const bump = flag(argv, '--bump') as Bump | undefined; if (bump && !['micro', 'patch', 'minor', 'major'].includes(bump)) { process.stderr.write('gstack-restamp: --bump needs micro|patch|minor|major\n'); return EXIT.usage; } const queueMode = flag(argv, '--queue-mode') as 'stamp-at-merge' | 'claim-at-open' | undefined; if (queueMode && !['stamp-at-merge', 'claim-at-open'].includes(queueMode)) { process.stderr.write('gstack-restamp: --queue-mode needs stamp-at-merge|claim-at-open\n'); return EXIT.usage; } const policyFrom = (flag(argv, '--policy-from') ?? 'base') as 'base' | 'head'; if (!['base', 'head'].includes(policyFrom)) { process.stderr.write('gstack-restamp: --policy-from needs base|head\n'); return EXIT.usage; } const rootProbe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 15_000 }); if (rootProbe.status !== 0) { process.stderr.write('gstack-restamp: not inside a git repository\n'); return EXIT.usage; } const repoRoot = rootProbe.stdout.trim(); const after = flag(argv, '--after'); const base = resolveBaseBranch(repoRoot, flag(argv, '--base')); const options = { repoRoot, base, version: flag(argv, '--version'), next: argv.includes('--next'), bump, queueMode, after: after ? { pr: after, ref: flag(argv, '--predecessor-ref') } : undefined, pr: flag(argv, '--pr'), expectBase: flag(argv, '--expect-base'), was: flag(argv, '--was'), expectOrder: flag(argv, '--expect-order')?.split(',').map(s => s.trim()).filter(Boolean), policyFrom, allowRepoCommands: argv.includes('--allow-repo-commands'), dryRun: argv.includes('--dry-run'), failAfter: process.env.GSTACK_RESTAMP_FAIL_AFTER, }; const writes = !after && !options.dryRun; const own = writes ? ownUnstaged(repoRoot) : []; const dry = own.length ? restamp({ ...options, dryRun: true }) : null; if (dry && (dry.status === 'refused' || dry.status === 'failed')) return report(dry, json); const clash = (dry?.files ?? []).filter(f => own.includes(f)); if (clash.length) return report({ status: 'refused', code: 'RESTAMP_UNSTAGED_EDITS', message: `${clash.join(', ')} ${clash.length === 1 ? 'holds' : 'hold'} unstaged edits of your own, which staging the stamp would sweep into the stamp commit; nothing written`, stages: [] }, json); const result = restamp(options); const staging = writes && (result.status === 'stamped' || result.status === 'noop') ? stage(repoRoot, result.status === 'stamped' ? result.files ?? [] : null) : null; return report(result, json, staging); } function report(r: RestampResult, json: boolean, staging: Staging | null = null): number { const status = r.status === 'refused' ? 'refused' : r.status === 'failed' || staging?.failure ? 'incomplete' : 'complete'; const code = r.status === 'refused' ? EXIT.refused : r.status === 'failed' || staging?.failure ? EXIT.fail : EXIT.ok; if (json) { process.stdout.write(JSON.stringify({ ...r, ...(staging ?? {}), ...(staging?.failure && !r.code ? { code: 'RESTAMP_NOT_STAGED' } : {}), result: status }) + '\n'); return code; } if (staging?.failure) process.stderr.write(`gstack-restamp: ${staging.failure}; fix: ${RESULT_CODES.RESTAMP_NOT_STAGED.fix} (RESTAMP_NOT_STAGED)\n`); if (r.code) { const row = RESULT_CODES[r.code as ResultCodeName]; process.stderr.write(`gstack-restamp: ${r.message}; fix: ${r.fix ?? row.fix} (${r.code})\n`); if (r.recompute) process.stderr.write(`recompute: ${r.recompute}\n`); if (r.commands?.length && r.code === 'REPO_COMMANDS_NOT_ALLOWED') for (const c of r.commands) process.stdout.write(`COMMAND: ${c.key}=${JSON.stringify(c.command)} cwd=${c.cwd}\n`); } else { process.stdout.write(`RESTAMP: ${r.oldVersion} -> ${r.newVersion} (${r.status}; queue_mode=${r.queueMode} base=${r.base?.ref}@${r.base?.sha?.slice(0, 12)} policy=${r.policy})\n`); if (r.queueMode === 'stamp-at-merge' && r.status !== 'gate-ahead') process.stdout.write(`ship, next in queue at ${r.newVersion}\n`); process.stdout.write(`STAMP_VERSION: ${r.newVersion}\nclaims: ${r.claims}\n`); for (const s of r.stages) process.stdout.write(`stage ${s.name}: ${s.status}${s.detail ? ` (${s.detail})` : ''}\n`); if (r.gateAhead) process.stdout.write(`GATE_AHEAD: head=${r.gateAhead.gated_head} tree=${r.gateAhead.gated_tree} predecessor=#${r.gateAhead.predecessor_pr}@${r.gateAhead.predecessor_head.slice(0, 12)} worktree=${r.gateAhead.worktree} evidence_branch=${r.gateAhead.evidence_branch_key}\n`); if (r.diff) process.stdout.write(r.diff + (r.diff.endsWith('\n') ? '' : '\n')); process.stdout.write(`FILES: ${(r.files ?? []).join(' ') || '(none)'}\n`); if (staging?.staged) process.stdout.write(`STAGED: ${staging.staged.join(' ') || '(none)'}\n`); if (staging?.dropped?.length) process.stdout.write(`NOTE: ${staging.dropped.join(' ')} changed since an earlier restamp wrote ${staging.dropped.length === 1 ? 'it' : 'them'}; not staged, and that run's staging marker is dropped\n`); if (r.mergeCondition) process.stdout.write(`MERGE_CONDITION: sha=${r.mergeCondition}\n`); if (r.status === 'gate-ahead' && r.gateAhead) process.stdout.write(`next: run the verification gate in ${r.gateAhead.worktree}, then \`gstack-evidence bundle --out \` there; the branch is untouched. At this PR's turn rerun \`gstack-restamp --next\` here and read \`gstack-tree-receipt --from ${GATE_AHEAD_REL}\` before deciding what to rerun.\n`); else if ((r.status === 'stamped' || r.status === 'noop') && r.queueMode === 'stamp-at-merge') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; commit and push only the stamp diff above (FILES), with no edit of your own: the train's verify accepts the CHANGELOG only as the base's plus this PR's entry re-headed; merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`); else if (r.status === 'stamped' || r.status === 'noop') process.stdout.write(`next: NEW_VERSION=${r.newVersion}; record the release decision, then write the CHANGELOG entry text only (the heading is already ${r.newVersion}); merge with the expected head ${r.mergeCondition ?? 'sha'} and recompute on a stale queue.\n`); } process.stdout.write(formatResult('restamp', status, r.journal ?? JOURNAL_REL) + '\n'); return code; } process.exitCode = main(process.argv.slice(2));