diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75d66ae1f..e31bc15f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,13 @@
## [Unreleased]
-**Three /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit.
+**Four /ship frictions the unattended lanes hit (GSTA-102).** Each fix is one commit.
### Fixed
- **`gstack-gate-diff ` in a clone with no local `` branch.** Before, `gstack-gate-diff main` in a checkout that cloned only its feature branch printed `GATE_ERROR=no_base ref=main fix=git fetch origin main`, and that fix did not help: the tool only tried the local branch, and in a single-branch clone a bare `git fetch origin main` updates FETCH_HEAD alone. Now `lib/gate-diff/git.ts` resolves the base to its remote-tracking branch (`origin/main`, origin first, then the other remotes) when no local branch exists, says so once on stderr, and the merge-base is the same commit. When the remote-tracking branch is missing too, the fix line fetches into it explicitly: `git fetch origin +main:refs/remotes/origin/main` (with `--unshallow` on a shallow clone), which works in every clone shape. `test/gate-diff-cli.test.ts` adds a single-branch clone fixture with no local `main` and no `origin/main`: it asserts the error, runs the printed fix verbatim, and expects the next run to list the fixture's RH-1 hunk; two more clones cover a second remote that carries the branch when origin does not, and an upstream-only clone of a master-default repository whose default base (no argument) now comes from the same remote list as the fix line. The refspec is forced (`+main:refs/remotes/origin/main`) so the line also repairs a tracking ref that exists but shares no history with HEAD.
- **`gstack-post` no longer flags the repository's own release versions as public IPs.** A four-part version is byte-for-byte a dotted quad, so "(1.91.191.0)" in a PR body raised `pii.ip_public` (MEDIUM) and stopped an unattended docs audit that cannot confirm a MEDIUM finding. The engine's version exemption only reads the declaration right before the span (`"version": "`, `## [`), so prose that merely names a release got no help. Now `lib/gstack-post.ts` reads the root `VERSION` value and every version in a root `CHANGELOG.md` release heading (`## [1.2.3.4]`, `## v1.2.3.4`, through the same `parseChangelog` gstack-restamp uses) and passes those exact strings to the scan's allowlist. Verdict: this narrows one detector by the repo's own release set, and nothing else. The allowlist is exact-string and context-free, so a released quad is exempt wherever it appears in the text, `host: 1.91.191.0` included; the set is the repo's own releases and nothing wider. `8.8.8.8` and `203.0.113.7` in prose, a quad the repo never released (`1.128.1.0`), and the same text outside a repository or in a repo whose VERSION is three-part all still need confirmation; `test/gstack-post.test.ts` carries those controls beside the four benign bodies (`(1.91.191.0)`, `v1.91.226.0`, a bare `1.91.191.0`, `release 1.91.190.0`) against a synthetic CHANGELOG in which `1.91.192.0` is not a release. The same post path also stops reading a ship receipt's all-digit tree hash (`tree=704992822996`) and an 11-digit GitHub Actions run id in prose or link text (`run 19368712345`, `[run 19368712345](…/actions/runs/19368712345)`) as `pii.phone.e164`: only the exact tokens in those positions are allowlisted, and `+14155551234`, a bare 11-digit number and a 10-digit number after `run` still need confirmation (GSTA-94 hit this on #3424).
- **`gstack-evidence` records the test count of gstack's shard runner and of the remote pregate lane.** `bun run test:free` / `test:ubicloud` run quiet shards that forward bun's `Ran N tests` line without its ` N pass` counts, so the bun parser read every full-suite run as `tests_ran: 0` and /ship's Step 16 saw a zero-run suite (GSTA-94 on #3424); the remote lane prints `PREGATE_REMOTE: tree=… tests=9660 failed=0` (a lane row under `lanes:` in the pregate table) and `requires-remote: … cleared by remote-gate tree=… tests=9660`, which no parser knew, so it was `tests_ran: unknown` and the ship receipt and the Review Readiness Dashboard never carried the number. Now a bun terminal line with no counts block above it is not a bun summary (bun always prints ` 0 pass` / ` 0 fail`, even for an all-skip run, which still grades ZERO-RUN), the shard runner's per-shard `[test:free] PASS — N tests` epilogues are summed for the last run in the output (a files-from run's `FILES-FROM RESULT: … tests=N` is the same sum; a run with a `FAIL —` or `FLAKY-PASS —` epilogue and no such total has lost a shard's count and is recorded as unknown, not a partial sum), and when the shard runner spoke its verdict stands for the whole tail (the tail is the stdout tail then the stderr tail, so a line's position cannot order bun's stderr counts block against the runner's stdout epilogues; a focused `bun test` run before the suite in the same command no longer records its count, or ZERO-RUN, for the 10k-test run that followed), and the remote lane's two lines are parsed as whole lines like every other runner pattern (a prefixed or quoted echo does not count; the later line wins when both appear); a paid-only selection's `tests=0` is ZERO-RUN, not a pass. Honest limit: the shard runner and the remote gate print only bun's terminal totals, which include skipped tests, so for those two the number is tests ran with skips included, not executed (the shard runner refuses a shard whose bun summary counts fewer files than planned, but a shard whose files all loaded and executed nothing still passes with that total). The exit code is still never changed by the parse. `test/evidence.test.ts` runs the wrapper over `test/fixtures/evidence/pregate-remote-failed.out`, the stdout of a real `gstack-pregate --stage tests --remote` run captured under gstack-evidence on 2026-10-26 (9658 tests, 1 failed, recorded as `tests_ran=9658` with `exit=1`, never FRESH), over a cleared lane's table (`tests_ran=9660`, FRESH), over the receipt line alone, over the two lines with differing counts in both orders, and over `test/fixtures/evidence/shard-runner-files-from.out`, the remote gate's own shard-runner stream for this branch (275 files, 16 shards, 10138 tests: the epilogue sum equals the `FILES-FROM RESULT` total, with and without that final line, and a forwarded `Ran N tests` line on stderr no longer zeroes it).
+- **`gstack-restamp` refuses only the CHANGELOG duplicates the PR introduces.** `CHANGELOG_DUPLICATE_HEADING` compared the branch's headings with themselves, so a base branch whose released history already heads one version twice (gbrain's `master` carries two `## [0.38.0.0]` sections, and its own check requires every one of them to stay) could never be stamped. The check now takes the base branch's CHANGELOG, read at `origin/` and at the commit the branch's own lines are counted from (its fork point, or the predecessor's head in a gate-ahead stamp), the larger count per heading winning: a heading the base carries N times may appear N times on the PR, and only a further copy is refused, with a message that says how many the base has. When a reading is unavailable (the PR adds the CHANGELOG itself) the stage detail and the refusal name it. A duplicate the PR adds, on a clean base or beside an inherited one, is still refused with nothing written. Tests: a fixture whose `main` heads `1.2.2.0` twice stamps and keeps both copies, and still does after `main` collapsed the copies behind the PR's back; a PR that adds a third copy, one that adds the first duplicate on a clean base, and one whose new CHANGELOG on a base without one heads a version twice, are refused. `docs/troubleshooting.md` says which duplicates count.
## [1.91.226.0] - 2026-10-26
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 4597984fd..31b7a94c0 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2619,8 +2619,22 @@ read the receipt again.
**Meaning.** Two sections share one version heading, usually a stamp that
re-headed `[Unreleased]` beside an entry that already carried the version.
+In `gstack-restamp` only duplicates the PR introduces count: the headings are
+compared against the base branch's CHANGELOG as read at `origin/` and at
+the commit the branch's own lines are counted from (its fork point, or the
+predecessor's head in a gate-ahead stamp); the larger count per heading wins,
+so a version the base already heads twice (released history the PR inherited
+and must keep) passes as long as the PR adds no further copy of it. When a
+reading is unavailable (the PR adds the CHANGELOG itself, or the file moved),
+the stage detail and the refusal name it: with no reading left every duplicate
+counts, otherwise inherited headings are counted at the readable commit only. Two paths keep the strict rule: the section being re-headed itself
+(`[Unreleased]`, or the branch's earlier stamp) may exist only once, and
+`gstack-version-bump write --changelog` (the claim-at-open bump) compares the
+file with itself.
-**Fix.** Merge them into one entry under the new version.
+**Fix.** Merge the duplicate sections the PR introduced into one entry under
+the new version. A duplicate that is on the base branch itself is not this
+PR's to fix in a restamp; leave it and the stamp proceeds.
### `CHANGELOG_TOP_MISMATCH`
diff --git a/lib/changelog-check.ts b/lib/changelog-check.ts
index ae2d81333..72f1ee2a7 100644
--- a/lib/changelog-check.ts
+++ b/lib/changelog-check.ts
@@ -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();
+ const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!);
+ const inherited = new Map();
+ for (const reading of base) {
+ const counts = new Map();
+ 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();
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];
diff --git a/lib/restamp.ts b/lib/restamp.ts
index 8980d5d27..2a588aabc 100644
--- a/lib/restamp.ts
+++ b/lib/restamp.ts
@@ -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');
diff --git a/lib/result-codes.ts b/lib/result-codes.ts
index d1e09b56f..b1226dc44 100644
--- a/lib/result-codes.ts
+++ b/lib/result-codes.ts
@@ -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',
diff --git a/test/gstack-restamp-queue.test.ts b/test/gstack-restamp-queue.test.ts
index dcac80407..646231635 100644
--- a/test/gstack-restamp-queue.test.ts
+++ b/test/gstack-restamp-queue.test.ts
@@ -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`, () => {