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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-16 23:16:25 +00:00
1 parent 4185aa8bd1
commit 43c75a6bb6
2 files changed
+107 -2

No files matched your search

+12 -2
View File
@@ -118,15 +118,25 @@ export function normalizeChangelog(text: string): string {
/**
* Gate-ahead merge of two CHANGELOGs that conflict textually but not
* semantically: the predecessor's header and sections, with the branch's own
* sections (those the predecessor lacks, by heading) placed first. Both sides
* carrying `[Unreleased]` is a real conflict and returns null.
* sections (those the predecessor lacks, by heading) placed first. When the
* branch's top section carries the same version heading as a predecessor
* section with a different body (both took the same tentative stamp), the
* branch's entry is kept under `[Unreleased]` so the restamp re-heads it.
* Both sides carrying `[Unreleased]` is a real conflict and returns null.
*/
export function mergeChangelogs(ours: string, theirs: string): string | null {
const a = parseChangelog(ours);
const b = parseChangelog(theirs);
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
const entryText = (s: ChangelogSection) => s.lines.slice(1).join('\n').replace(/\n+$/, '');
const top = a.sections[0];
if (top && !top.unreleased && b.sections.some(s => key(s) === key(top) && entryText(s) !== entryText(top))) {
const heading = /^##\s+\[/.test(top.heading) ? '## [Unreleased]' : '## Unreleased';
a.sections[0] = { ...top, unreleased: true, version: null, heading, lines: [heading, ...top.lines.slice(1)] };
}
const theirKeys = new Set(b.sections.map(key));
const ownOnly = a.sections.filter(s => !theirKeys.has(key(s)));
if (a.sections.filter(s => s.unreleased).length > 1) return null;
if (a.sections.some(s => s.unreleased) && b.sections.some(s => s.unreleased)) return null;
const header = [...b.header];
while (header.length && header[header.length - 1] === '') header.pop();
+95
View File
@@ -0,0 +1,95 @@
/**
* GBRA-102 diagnosis (field list 2, W1, premise P-i): a lane gating ahead of a
* predecessor whose stamped CHANGELOG entry already sits above the base's top
* entry. The refusal the gbrain coordinator anticipated ("restamp may refuse
* due to #6754's entry above master") belongs to gbrain's own
* `bun run release:restamp` (scripts/release-restamp.ts capture(): "this
* branch has 2 CHANGELOG entries above master's"), which merges into the lane
* branch itself. gstack-restamp --after merges in a throwaway worktree and
* resolves the CHANGELOG with lib/changelog-check mergeChangelogs, so that
* shape stamps cleanly here. The fixture found a different defect instead:
* when both PRs carried the same tentative stamp (gstack's own queue
* convention), the merge dropped the branch's entry and re-headed the
* predecessor's. Real subprocesses on fixture repos, each with a timeout.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { EXIT } from '../lib/headless-artifacts';
import { mergeChangelogs } from '../lib/changelog-check';
import { cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const headings = (text: string) => text.split('\n').filter(l => l.startsWith('## '));
/** Predecessor #1 stamped at base + PATCH (its entry above the base's), then the lane branch in the given shape. */
function queueOfTwo(shape: 'unreleased' | 'tentative' | 'none'): FixtureRepo {
const repo = makeFixtureRepo();
repos.push(repo);
makePr(repo, 'pr-a');
expect(restamp(repo, ['--next', '--pr', '1']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'stamp a');
makePr(repo, 'pr-b', { changelog: shape !== 'none' });
if (shape === 'tentative') {
expect(restamp(repo, ['--next', '--pr', '2']).status).toBe(EXIT.ok);
repo.git('commit', '-qam', 'tentative stamp b');
}
return repo;
}
function gatedChangelog(repo: FixtureRepo): { text: string; tree: string } {
const rec = JSON.parse(readFile(repo, '.gstack/tmp/gate-ahead.json'));
return { text: repo.git('show', `${rec.gated_head}:CHANGELOG.md`), tree: rec.gated_tree };
}
describe('GBRA-102: gate-ahead over a predecessor entry above the base', () => {
test('the GBRA-102 shape (predecessor stamped, lane entry under [Unreleased]) stamps N+1 above the predecessor; no refusal', () => {
const repo = queueOfTwo('unreleased');
const r = restamp(repo, ['--after', '1', '--predecessor-ref', 'pr-a', '--pr', '2']);
expect(r.status).toBe(EXIT.ok);
expect(r.out).toContain('stage changelog: done (Unreleased -> 1.2.5.0)');
const { text } = gatedChangelog(repo);
expect(headings(text).map(h => /\[([^\]]+)\]/.exec(h)![1])).toEqual(['1.2.5.0', '1.2.4.0', '1.2.3.0', '1.2.2.0']);
expect(text).toMatch(/## \[1\.2\.5\.0\][^\n]*\n\n- pr-b change\n\n## \[1\.2\.4\.0\][^\n]*\n\n- pr-a change/);
}, 120_000);
test('both PRs carried the same tentative stamp: the lane keeps its own entry and gates the same tree as the [Unreleased] shape', () => {
const reference = queueOfTwo('unreleased');
expect(restamp(reference, ['--after', '1', '--predecessor-ref', 'pr-a', '--pr', '2']).status).toBe(EXIT.ok);
const repo = queueOfTwo('tentative');
expect(headings(readFile(repo, 'CHANGELOG.md'))[0]).toMatch(/^## \[1\.2\.4\.0\]/);
const r = restamp(repo, ['--after', '1', '--predecessor-ref', 'pr-a', '--pr', '2']);
expect(r.status).toBe(EXIT.ok);
const { text, tree } = gatedChangelog(repo);
expect(text).toContain('- pr-b change');
expect(text).toContain('- pr-a change');
expect(text).toMatch(/## \[1\.2\.5\.0\][^\n]*\n\n- pr-b change\n\n## \[1\.2\.4\.0\][^\n]*\n\n- pr-a change/);
expect(tree).toBe(gatedChangelog(reference).tree);
}, 120_000);
test('a lane with no entry of its own refuses with CHANGELOG_SECTION_MISSING and writes nothing', () => {
const repo = queueOfTwo('none');
const head = repo.git('rev-parse', 'HEAD');
const r = restamp(repo, ['--after', '1', '--predecessor-ref', 'pr-a', '--pr', '2']);
expect(r.status).toBe(EXIT.refused);
expect(r.err).toMatch(/no \[Unreleased\] section; nothing written.*\(CHANGELOG_SECTION_MISSING\)/);
expect(repo.git('rev-parse', 'HEAD')).toBe(head);
expect(repo.git('status', '--porcelain')).toBe('');
}, 120_000);
});
describe('mergeChangelogs: a version heading both sides took', () => {
const base = '## [1.2.3.0] - d\n\n- base\n';
test('the branch top entry under the predecessor\'s heading becomes [Unreleased], placed first', () => {
expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine\n\n${base}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs\n\n${base}`))
.toBe(`# C\n\n## [Unreleased]\n\n- mine\n\n## [1.2.4.0] - e\n\n- theirs\n\n${base}`);
});
test('an identical section on both sides is shared history, never the branch entry', () => {
expect(mergeChangelogs(`# C\n\n## [Unreleased]\n\n- mine\n\n${base}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs\n\n${base}`))
.toBe(`# C\n\n## [Unreleased]\n\n- mine\n\n## [1.2.4.0] - e\n\n- theirs\n\n${base}`);
});
test('a taken heading plus an [Unreleased] block on the branch, or on the predecessor, is a real conflict', () => {
expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine\n\n## [Unreleased]\n\n- more\n\n${base}`, `# C\n\n## [1.2.4.0] - e\n\n- theirs\n\n${base}`)).toBeNull();
expect(mergeChangelogs(`# C\n\n## [1.2.4.0] - d\n\n- mine\n\n${base}`, `# C\n\n## [Unreleased]\n\n- x\n\n## [1.2.4.0] - e\n\n- theirs\n\n${base}`)).toBeNull();
});
});