diff --git a/bin/gstack-restamp b/bin/gstack-restamp index 83ff234b3..ef6e44c7b 100755 --- a/bin/gstack-restamp +++ b/bin/gstack-restamp @@ -15,8 +15,8 @@ * 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 - * (RESTAMP_NOT_STAGED). Idempotent; --dry-run writes nothing. Workflow command: ends with the + * .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'; @@ -57,7 +57,7 @@ function flag(args: string[], name: string): string | undefined { const STAGING_REL = '.gstack/tmp/restamp-staging.json'; interface Pending { files: string[]; blobs: Record } -interface Staging { staged?: string[]; failure?: string } +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. */ @@ -78,19 +78,24 @@ function ownUnstaged(root: string): string[] { 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; null when there is nothing to stage. */ +/** + * 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 changed = pending.files.filter(f => now[f] !== pending.blobs[f]); - if (changed.length) return { failure: `${changed.join(', ')} changed since restamp wrote ${changed.length === 1 ? 'it' : 'them'}; nothing staged` }; - const add = gitIn(root, ['add', '-A', '--', ...pending.files]); - if (add.status !== 0) return { failure: `git add of ${pending.files.join(' ')} failed (${(add.stderr || add.stdout).trim().split('\n')[0]}); the stamp stays written and ${STAGING_REL} names it` }; + 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', '--', ...pending.files]).stdout.split('\0').filter(Boolean) }; + return { staged: gitIn(root, ['diff', '--cached', '--name-only', '-z', '--', ...files]).stdout.split('\0').filter(Boolean), dropped }; } function main(argv: string[]): number { @@ -119,7 +124,9 @@ function main(argv: string[]): number { }; const writes = !after && !options.dryRun; const own = writes ? ownUnstaged(repoRoot) : []; - const clash = own.length ? (restamp({ ...options, dryRun: true }).files ?? []).filter(f => own.includes(f)) : []; + 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; @@ -130,7 +137,7 @@ function report(r: RestampResult, json: boolean, staging: Staging | null = null) 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 ?? {}), result: status }) + '\n'); + 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`); @@ -148,6 +155,7 @@ function report(r: RestampResult, json: boolean, staging: Staging | null = null) 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`); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 55fd4e40e..2e2fafa1c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4492,7 +4492,9 @@ restamp staged, push, and rerun the verify. **Fix.** Clear what blocked `git add` (a stale `.git/index.lock`, for example) and rerun `gstack-restamp`; the rerun is a noop that stages the files -it wrote. A file changed since restamp wrote it is not staged; restamp again. +it wrote. When any of them changed since restamp wrote it (you committed by +hand, switched branches or merged the base), the marker is stale: the noop +rerun drops it and stages nothing, and a stamp still owed is restamped as usual. ### `RESTAMP_UNSTAGED_EDITS` diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index ae6959770..6cff97042 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -389,7 +389,7 @@ export const RELEASE_RESULT_CODES = { RESTAMP_NOT_STAGED: { anchor: 'restamp-not-staged', summary: 'gstack-restamp wrote the stamp but could not stage it, so a bare git commit would leave the stamp out', - fix: 'clear what blocked git add (an index.lock, a changed file), then rerun gstack-restamp: the noop rerun stages the files it wrote', + fix: 'clear what blocked git add (an index.lock), then rerun gstack-restamp: the noop rerun stages the files it wrote, or drops the marker when they changed since', }, RESTAMP_UNSTAGED_EDITS: { anchor: 'restamp-unstaged-edits', diff --git a/lib/ship-queue-train-land.ts b/lib/ship-queue-train-land.ts index 219e0e24c..b1c97ae5a 100644 --- a/lib/ship-queue-train-land.ts +++ b/lib/ship-queue-train-land.ts @@ -269,7 +269,7 @@ function trainTakeover(env: TrainEnv, t: TrainRecord, i: number, lock: LandLock) function settleLock(s: Slot, out: TrainOut): TrainOut { const held = s.lock.held!; const midway = s.t.state === 'merging' && s.t.merged.length > 0; - const keep = (phase: LandLockPhase, line: string) => (held.set(phase) ? `LOCK: ${phase} could not be written (another lander took the lock)` : line); + const keep = (phase: LandLockPhase, line: string) => { const failed = held.set(phase); return failed ? `LOCK: ${phase} could not be written (${failed.message})` : line; }; let line: string; if (activeFreeze(s.env.repo.keys)) line = keep('freeze', 'LOCK: freeze (kept for the repair train the freeze admits; --break-lock never breaks it)'); else if (midway && (out.exit === 0 || !s.lock.effect)) line = keep('between', `LOCK: between (train ${s.t.nonce} keeps the land lock for its next slot)`); diff --git a/test/gstack-restamp-queue.test.ts b/test/gstack-restamp-queue.test.ts index 63d939674..3f599fe7b 100644 --- a/test/gstack-restamp-queue.test.ts +++ b/test/gstack-restamp-queue.test.ts @@ -659,4 +659,26 @@ describe('gstack-restamp stages what it writes (#3531)', () => { expect(again.out).toMatch(/^STAGED: .*VERSION/m); expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false); }); + + // fails_when=a staging marker left by a failure the lane then cleared by hand makes every later noop restamp exit 1 with RESTAMP_NOT_STAGED, whose fix (rerun) can never clear it (outside voice, PR #3737); why_new=the test above reruns on the same unchanged files; seam=.git/index.lock; no_claim=how the lane cleared the failure + test('a stale staging marker (its files changed since: a hand commit, then another stamped branch) is dropped by the noop rerun instead of failing it', () => { + const repo = fixture(); + makePr(repo, 'pr-a'); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + repo.git('commit', '-q', '-m', 'stamp pr-a'); + makePr(repo, 'pr-d'); + const lock = path.join(repo.work, '.git', 'index.lock'); + fs.writeFileSync(lock, ''); + expect(restamp(repo, ['--next']).err).toContain('(RESTAMP_NOT_STAGED)'); + fs.rmSync(lock, { force: true }); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'stamp pr-d by hand'); + repo.git('checkout', '-q', 'pr-a'); + const noop = restamp(repo, ['--next']); + expect(noop.status, noop.err).toBe(EXIT.ok); + expect(noop.err).not.toContain('RESTAMP_NOT_STAGED'); + expect(noop.out).toMatch(/^NOTE: .*CHANGELOG\.md.* changed since an earlier restamp wrote (it|them); not staged/m); + expect(fs.existsSync(path.join(repo.work, '.gstack/tmp/restamp-staging.json'))).toBe(false); + expect(restamp(repo, ['--next']).status).toBe(EXIT.ok); + }); });