mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
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:
1 parent
87c9ab27d7
commit
5a9b99fcaf
5 files changed
+46
-14
No files matched your search
+19
-11
@@ -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`);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user