fix: a stale restamp staging marker is dropped, not a failure; a failed dry run refuses before the real run; settleLock names the real lock-write failure (Step 11 review)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 20:46:47 +00:00
1 parent 87c9ab27d7
commit 5a9b99fcaf
5 files changed
+46 -14

No files matched your search

+19 -11
View File
@@ -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<string, string | null> }
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 <file>\` 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`);
+3 -1
View File
@@ -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.
<a id="restamp-unstaged-edits"></a>
### `RESTAMP_UNSTAGED_EDITS`
+1 -1
View File
@@ -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',
+1 -1
View File
@@ -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)`);
+22
View File
@@ -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);
});
});