mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-13 11:57:19 +01:00
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:
1 parent
9a02d4ca2a
commit
381f7e8215
6 files changed
+189
-15
No files matched your search
@@ -47,6 +47,25 @@ describe('pure pieces', () => {
|
||||
expect(checkChangelog(r.text, '1.2.5.0')).toEqual([]);
|
||||
expect(checkChangelog(r.text, '1.2.6.0').map(e => e.code)).toEqual(['CHANGELOG_TOP_MISMATCH']);
|
||||
expect(checkChangelog(r.text + '\n## [1.2.3.0] - 2026-10-02\n\n- dup\n', '1.2.5.0').map(e => e.code)).toEqual(['CHANGELOG_DUPLICATE_HEADING']);
|
||||
// A duplicate the base already carries is inherited history, not the PR's; one more copy still is.
|
||||
const dupBase = '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n\n## [1.2.3.0] - 2026-10-02\n\n- dup\n';
|
||||
const prText = '# Changelog\n\n## [1.2.5.0] - 2026-10-10\n\n- mine\n\n' + dupBase.slice('# Changelog\n\n'.length);
|
||||
expect(checkChangelog(prText, '1.2.5.0')).toHaveLength(1);
|
||||
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [dupBase])).toEqual([]);
|
||||
const third = checkChangelog(prText + '\n## [1.2.3.0] - 2026-10-03\n\n- third\n', '1.2.5.0', 'CHANGELOG.md', [dupBase]);
|
||||
expect(third.map(e => [e.code, e.path])).toEqual([['CHANGELOG_DUPLICATE_HEADING', 'CHANGELOG.md:15']]);
|
||||
expect(third[0]!.message).toBe('1.2.3.0 headed 3 times (the base carries it 2 times; first at line 7)');
|
||||
expect(checkChangelog(prText + '\n## [1.2.5.0] - 2026-10-11\n\n- again\n', '1.2.5.0', 'CHANGELOG.md', [dupBase]).map(e => e.message)).toEqual(['1.2.5.0 already headed at line 3']);
|
||||
// Several readings of the base (its tip and the fork point) grant the larger count; an [Unreleased]
|
||||
// placeholder the base keeps is inherited like any heading, and a second one is still the PR's.
|
||||
const collapsed = '# Changelog\n\n## [1.2.3.0] - 2026-10-01\n\n- base, merged copy\n';
|
||||
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [collapsed, dupBase])).toEqual([]);
|
||||
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [collapsed])).toHaveLength(1);
|
||||
expect(checkChangelog(prText, '1.2.5.0', 'CHANGELOG.md', [])).toHaveLength(1);
|
||||
const placeholders = '# Changelog\n\n## [Unreleased]\n\n## [Unreleased]\n\n## [1.2.3.0] - 2026-10-01\n\n- base\n';
|
||||
expect(checkChangelog(placeholders, null, 'CHANGELOG.md', [placeholders])).toEqual([]);
|
||||
expect(checkChangelog(placeholders, null)).toHaveLength(1);
|
||||
expect(checkChangelog(placeholders + '\n## [Unreleased]\n\n- mine\n', null, 'CHANGELOG.md', [placeholders]).map(e => e.message)).toEqual(['Unreleased headed 3 times (the base carries it 2 times; first at line 3)']);
|
||||
expect(reheadChangelog(r.text, { from: '1.2.5.0', to: '1.2.5.0', date: 'x' }).moved).toBe(false);
|
||||
expect(reheadChangelog('# Changelog\n\n## [1.2.3.0] - d\n\n- base\n', { from: '9.9.9.9', to: '1.2.5.0', date: 'x' }).error?.code).toBe('CHANGELOG_SECTION_MISSING');
|
||||
expect(normalizeChangelog(r.text)).toBe(normalizeChangelog(text.replace('[Unreleased]', '[1.2.9.0] - 2026-10-11')));
|
||||
@@ -188,6 +207,114 @@ describe('three PRs, reordered twice, zero manual edits', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHANGELOG duplicates and the base branch', () => {
|
||||
const dupHistory = (repo: FixtureRepo) => {
|
||||
const cl = readFile(repo, 'CHANGELOG.md');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl + '\n## [1.2.2.0] - 2026-09-02\n\n- older, headed a second time on main\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'history with a duplicate heading'); repo.git('push', '-q', 'origin', 'main');
|
||||
};
|
||||
|
||||
test('a duplicate heading the base already carries is not the PR\'s: the stamp proceeds and keeps both copies', () => {
|
||||
const repo = fixture();
|
||||
dupHistory(repo);
|
||||
expect(headings(repo)).toEqual(['1.2.3.0', '1.2.2.0', '1.2.2.0']);
|
||||
makePr(repo, 'pr-a');
|
||||
const r = restamp(repo, ['--next', '--pr', '1']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stages(r.out).changelog).toBe('done');
|
||||
expect(headings(repo)).toEqual(['1.2.4.0', '1.2.3.0', '1.2.2.0', '1.2.2.0']);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
|
||||
});
|
||||
|
||||
test('a duplicate heading the PR introduces is still CHANGELOG_DUPLICATE_HEADING with nothing written, base duplicates or not', () => {
|
||||
const repo = fixture();
|
||||
dupHistory(repo);
|
||||
makePr(repo, 'pr-a');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), readFile(repo, 'CHANGELOG.md') + '\n## [1.2.2.0] - 2026-09-03\n\n- a third copy, added by the PR\n');
|
||||
repo.git('commit', '-q', '-am', 'pr adds a duplicate');
|
||||
const r = restamp(repo, ['--next', '--pr', '1']);
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(r.err).toMatch(/CHANGELOG\.md:\d+: 1\.2\.2\.0 headed 3 times \(the base carries it 2 times; first at line \d+\); nothing written.*\(CHANGELOG_DUPLICATE_HEADING\)/);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n');
|
||||
expect(headings(repo)).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0', '1.2.2.0', '1.2.2.0']);
|
||||
|
||||
const plain = fixture();
|
||||
makePr(plain, 'pr-b');
|
||||
fs.writeFileSync(path.join(plain.work, 'CHANGELOG.md'), readFile(plain, 'CHANGELOG.md') + '\n## [1.2.2.0] - 2026-09-03\n\n- second copy, added by the PR\n');
|
||||
plain.git('commit', '-q', '-am', 'pr adds a duplicate');
|
||||
const p = restamp(plain, ['--next', '--pr', '1']);
|
||||
expect(p.status).toBe(EXIT.refused);
|
||||
expect(p.err).toMatch(/1\.2\.2\.0 already headed at line \d+; nothing written.*\(CHANGELOG_DUPLICATE_HEADING\)/);
|
||||
expect(readFile(plain, 'VERSION')).toBe('1.2.3.0\n');
|
||||
});
|
||||
|
||||
// Value: protects=a PR forked while main headed a version twice still stamps after main collapsed the copies into
|
||||
// one, because the allowance reads the fork point as well as the tip; fails_when=restamp reads only plan.baseSha;
|
||||
// why_new=the other rows never move main after makePr; seam=none; no_claim=nothing about a duplicate main gained
|
||||
// after the fork (the tip reading grants it, and the train merge dedups by heading).
|
||||
test('a duplicate main collapsed after the fork is still inherited: the merge-base reading keeps the stamp going', () => {
|
||||
const repo = fixture();
|
||||
dupHistory(repo);
|
||||
makePr(repo, 'pr-a');
|
||||
repo.git('checkout', '-q', 'main');
|
||||
const cl = readFile(repo, 'CHANGELOG.md').replace('\n## [1.2.2.0] - 2026-09-02\n\n- older, headed a second time on main\n', '');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl);
|
||||
repo.git('commit', '-q', '-am', 'collapse the duplicate'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'pr-a');
|
||||
expect(headings(repo)).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0', '1.2.2.0']);
|
||||
const r = restamp(repo, ['--next', '--pr', '1']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(headings(repo)).toEqual(['1.2.4.0', '1.2.3.0', '1.2.2.0', '1.2.2.0']);
|
||||
});
|
||||
|
||||
// Value: protects=a base with no CHANGELOG at all (the PR introduces the file) stamps, and the refusal for a
|
||||
// duplicate the new file carries says the base reading was unavailable; fails_when=the git show failure is
|
||||
// treated as an error, or the fallback to the self-contained rule is silent; why_new=every fixture seeds
|
||||
// CHANGELOG.md on main; seam=none; no_claim=other git show failures are not distinguished from an absent file.
|
||||
test('no CHANGELOG at the base: the PR\'s new file is checked against itself and the detail says so', () => {
|
||||
const repo = fixture();
|
||||
repo.git('rm', '-q', 'CHANGELOG.md'); repo.git('commit', '-q', '-m', 'no changelog yet'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'origin/main', '-b', 'pr-a');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- first entry\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'add changelog');
|
||||
const r = restamp(repo, ['--next', '--pr', '1']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toMatch(/^stage changelog: done \(Unreleased -> 1\.2\.4\.0; base CHANGELOG\.md unreadable at [0-9a-f]{12}:CHANGELOG\.md, so every duplicate heading counts\)$/m);
|
||||
expect(headings(repo)).toEqual(['1.2.4.0']);
|
||||
|
||||
const dup = fixture();
|
||||
dup.git('rm', '-q', 'CHANGELOG.md'); dup.git('commit', '-q', '-m', 'no changelog yet'); dup.git('push', '-q', 'origin', 'main');
|
||||
dup.git('checkout', '-q', 'origin/main', '-b', 'pr-b');
|
||||
fs.writeFileSync(path.join(dup.work, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- first entry\n\n## [0.9.0.0] - d\n\n- a\n\n## [0.9.0.0] - d\n\n- b\n');
|
||||
dup.git('add', '-A'); dup.git('commit', '-q', '-m', 'add changelog with a duplicate');
|
||||
const d = restamp(dup, ['--next', '--pr', '1']);
|
||||
expect(d.status).toBe(EXIT.refused);
|
||||
expect(d.err).toMatch(/0\.9\.0\.0 already headed at line \d+; base CHANGELOG\.md unreadable at [0-9a-f]{12}:CHANGELOG\.md, so every duplicate heading counts; nothing written.*\(CHANGELOG_DUPLICATE_HEADING\)/);
|
||||
expect(fs.existsSync(path.join(dup.work, 'VERSION')) && readFile(dup, 'VERSION')).toBe('1.2.3.0\n');
|
||||
});
|
||||
|
||||
// Value: protects=when only one base reading exists (main gained the CHANGELOG after the fork), inherited
|
||||
// headings are still counted from it and the detail names the reading that was used; fails_when=the note
|
||||
// claims every duplicate counts while the readable reading granted an allowance, or the unreadable reading
|
||||
// is treated as an error; why_new=the other rows read the same sha twice; seam=none; no_claim=nothing about
|
||||
// a file renamed between the two readings.
|
||||
test('one readable base reading: inherited headings count from it and the detail says so', () => {
|
||||
const repo = fixture();
|
||||
repo.git('rm', '-q', 'CHANGELOG.md'); repo.git('commit', '-q', '-m', 'no changelog yet'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'origin/main', '-b', 'pr-a');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- mine\n\n## [0.9.0.0] - d\n\n- a\n\n## [0.9.0.0] - d\n\n- b\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'pr adds the changelog, carrying history with a duplicate');
|
||||
repo.git('checkout', '-q', 'main');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), '# Changelog\n\n## [0.9.0.0] - d\n\n- a\n\n## [0.9.0.0] - d\n\n- b\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'main adds the changelog too'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('checkout', '-q', 'pr-a');
|
||||
const r = restamp(repo, ['--next', '--pr', '1']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toMatch(/^stage changelog: done \(Unreleased -> 1\.2\.4\.0; base CHANGELOG\.md unreadable at [0-9a-f]{12}:CHANGELOG\.md, inherited headings counted at [0-9a-f]{12} only\)$/m);
|
||||
expect(headings(repo)).toEqual(['1.2.4.0', '0.9.0.0', '0.9.0.0']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('journal and injected failures', () => {
|
||||
for (const stage of ['version', 'release_tool', 'changelog', 'stamp_paths']) {
|
||||
test(`a failure after the ${stage} stage leaves the branch untouched and the staging worktree removed`, () => {
|
||||
|
||||
Reference in new issue
Block a user