fix(restamp): CHANGELOG_DUPLICATE_HEADING refuses only duplicates the PR introduces

checkChangelog takes the base branch's CHANGELOG: a heading the base already
carries N times may appear N times on the PR (inherited released history), and
only a further copy is a duplicate. gstack-restamp passes origin/<base>'s
CHANGELOG at the plan's base sha, so a base like gbrain's master, which heads
one version twice, can be stamped; a duplicate the PR adds is still refused
with nothing written.

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-26 05:45:17 +00:00
1 parent 9a02d4ca2a
commit 381f7e8215
6 files changed
+189 -15

No files matched your search

+30 -8
View File
@@ -2,8 +2,9 @@
* changelog-check — CHANGELOG re-heading and checks for gstack-restamp (C2)
* and the tree receipt (C3). A restamp moves the PR's section (`[Unreleased]`
* or the version currently stamped on the branch) to the top under the new
* version, above its predecessor's entry. Duplicate version headings and a
* top entry that is not VERSION are errors. The tree receipt uses
* version, above its predecessor's entry. Duplicate version headings beyond
* those the base branch already carries, and a top entry that is not VERSION,
* are errors. The tree receipt uses
* `normalizeChangelog` to accept a heading move as a stamp, never a change.
*/
import type { ResultCodeName } from './result-codes';
@@ -44,15 +45,36 @@ export function parseChangelog(text: string): ParsedChangelog {
return { header, sections };
}
/** Duplicate headings, and (when `expectedTop` is given) the top entry equals VERSION. */
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md'): ChangelogError[] {
/**
* Duplicate headings, and (when `expectedTop` is given) the top entry equals
* VERSION. `base` holds readings of the base branch's CHANGELOG (the base tip,
* and the commit the PR's own lines are counted from): a heading it already carries N
* times may appear N times here (history the PR inherited and may not
* rewrite); only an occurrence beyond that count is a duplicate the PR
* introduced. The count is per heading, not per section body: an inherited
* copy the PR edits or replaces is caught by the train's released-history
* checks (`ownEntryAboveMain`, `changelogDamage`), not here.
*/
export function checkChangelog(text: string, expectedTop: string | null, file = 'CHANGELOG.md', base: string[] = []): ChangelogError[] {
const errors: ChangelogError[] = [];
const parsed = parseChangelog(text);
const seen = new Map<string, number>();
const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
const inherited = new Map<string, number>();
for (const reading of base) {
const counts = new Map<string, number>();
for (const s of parseChangelog(reading).sections) counts.set(key(s), (counts.get(key(s)) ?? 0) + 1);
for (const [k, n] of counts) inherited.set(k, Math.max(inherited.get(k) ?? 0, n));
}
const seen = new Map<string, number[]>();
for (const s of parsed.sections) {
const key = s.unreleased ? 'Unreleased' : s.version!;
if (seen.has(key)) errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${key} already headed at line ${seen.get(key)! + 1}` });
else seen.set(key, s.start);
const k = key(s);
const starts = seen.get(k) ?? [];
const allowed = Math.max(1, inherited.get(k) ?? 0);
if (starts.length >= allowed) {
const where = allowed > 1 ? `headed ${starts.length + 1} times (the base carries it ${allowed} times; first at line ${starts[0]! + 1})` : `already headed at line ${starts[0]! + 1}`;
errors.push({ code: 'CHANGELOG_DUPLICATE_HEADING', path: `${file}:${s.start + 1}`, message: `${k} ${where}` });
}
seen.set(k, [...starts, s.start]);
}
if (expectedTop) {
const top = parsed.sections[0];
+13 -3
View File
@@ -380,9 +380,19 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string
const r = reheadChangelog(fs.readFileSync(clPath, 'utf8'), { from, to: plan.newVersion, date, file: clRel! });
if (r.error) { cl.status = 'failed'; cl.detail = r.error.message; throw new Refusal(r.error.code, `${clRel}: ${r.error.message}; nothing written to the branch`); }
if (r.moved) fs.writeFileSync(clPath, r.text);
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!);
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`;
// Inherited duplicates are allowed at the larger count of two base readings: the base tip and
// `since`, the commit the branch's own lines are counted from (its fork point, or the predecessor's
// head in a gate-ahead stamp), so neither a duplicate main collapsed after the fork nor one it
// gained since refuses the PR. A reading the base cannot show (the PR adds the file) is named.
const readings = [...new Set([plan.baseSha, since])].map(sha => ({ sha, cl: git(o.repoRoot, ['show', `${sha}:${clRel}`]) }));
const readable = readings.filter(x => x.cl.ok);
const unreadable = readings.filter(x => !x.cl.ok).map(x => `${x.sha.slice(0, 12)}:${clRel}`);
const baseNote = unreadable.length === 0 ? '' : readable.length === 0
? `; base ${clRel} unreadable at ${unreadable.join(', ')}, so every duplicate heading counts`
: `; base ${clRel} unreadable at ${unreadable.join(', ')}, inherited headings counted at ${readable.map(x => x.sha.slice(0, 12)).join(', ')} only`;
const check = checkChangelog(fs.readFileSync(clPath, 'utf8'), plan.newVersion, clRel!, readable.map(x => x.cl.out + '\n'));
if (check.length) { cl.status = 'failed'; cl.detail = check[0]!.message + baseNote; throw new Refusal(check[0]!.code, `${check[0]!.path}: ${check[0]!.message}${baseNote}; nothing written to the branch`); }
cl.status = 'done'; cl.detail = (r.moved ? `${r.from} -> ${r.to}` : `already headed ${plan.newVersion}`) + baseNote;
} else { cl.status = 'skipped'; cl.detail = clRel ? `${clRel} absent` : 'changelog: null'; }
injected('changelog');
+2 -2
View File
@@ -226,8 +226,8 @@ export const RESULT_CODES = {
},
CHANGELOG_DUPLICATE_HEADING: {
anchor: 'changelog-duplicate-heading',
summary: 'two CHANGELOG sections carry the same version heading',
fix: 'merge the duplicate sections into one entry under the new version',
summary: 'two CHANGELOG sections carry the same version heading, beyond any duplicate the base branch already carries',
fix: 'merge the duplicate sections the PR introduced into one entry under the new version',
},
CHANGELOG_TOP_MISMATCH: {
anchor: 'changelog-top-mismatch',