From 8dab33149a976d2155dbe87addfc56d4d852742e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 25 Aug 2026 19:28:08 +0000 Subject: [PATCH] fix(pr): omit commits already released on a diverged branch --- scripts/update-changelog.ts | 31 ++++++++++++++++++++++++++++++- test/update-changelog.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/scripts/update-changelog.ts b/scripts/update-changelog.ts index 288e5d0..c7415f3 100644 --- a/scripts/update-changelog.ts +++ b/scripts/update-changelog.ts @@ -222,6 +222,33 @@ function parseCommit (raw: string): Commit | null { } } +/** + * Drop commits whose subject already appears on the other side of a + * diverged range. The previous release tag is not always an ancestor of + * HEAD (a `4.x` maintenance line tagged after `main` moved on to `5.0`), + * so `tag..HEAD` includes everything since the branches diverged, most of + * which shipped already as cherry-picks. Subjects are matched exactly: + * a cherry-pick keeps its subject but not its hash or patch id. + */ +export function dropAlreadyReleased (commits: Commit[], releasedSubjects: Set): Commit[] { + if (releasedSubjects.size === 0) return commits + return commits.filter(c => !releasedSubjects.has(c.message)) +} + +/** Subjects of commits reachable from `ref` but not from HEAD. */ +function subjectsOnlyOn (ref: string): Set { + try { + const stdout = execFileSync( + 'git', + ['log', `HEAD..${ref}`, '--pretty=format:%s'], + { encoding: 'utf8', maxBuffer: MAX_BUFFER }, + ) + return new Set(stdout.split('\n').map(s => s.trim()).filter(Boolean)) + } catch { + return new Set() + } +} + function getCommitsSince (tag: Tag | null): Commit[] { const range = tag ? `${tag.ref}..HEAD` : 'HEAD' const stdout = execFileSync( @@ -229,12 +256,14 @@ function getCommitsSince (tag: Tag | null): Commit[] { ['log', range, `--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%s%x1f%b%x1e`], { encoding: 'utf8', maxBuffer: MAX_BUFFER }, ) - return stdout + const commits = stdout .split('\x1e') .map(s => s.replace(/^\n/, '')) .filter(Boolean) .map(parseCommit) .filter((c): c is Commit => c !== null) + + return tag ? dropAlreadyReleased(commits, subjectsOnlyOn(tag.ref)) : commits } export function determineBump (commits: Commit[]): BumpLevel { diff --git a/test/update-changelog.test.ts b/test/update-changelog.test.ts index 21ba228..931d358 100644 --- a/test/update-changelog.test.ts +++ b/test/update-changelog.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { buildBumpFileSet, buildIndependentBody, determineBump, formatChangelog, buildIndependentBumpFileSet, computeIndependentPlan, extractPreamble, truncateBody, incVersion, latestLockstepTag, latestTagForPackage, releaseBranchDrift, isSupersededReleaseBranch, type Commit } from '../scripts/update-changelog.ts' +import { buildBumpFileSet, buildIndependentBody, determineBump, formatChangelog, buildIndependentBumpFileSet, computeIndependentPlan, extractPreamble, truncateBody, dropAlreadyReleased, incVersion, latestLockstepTag, latestTagForPackage, releaseBranchDrift, isSupersededReleaseBranch, type Commit } from '../scripts/update-changelog.ts' import { resolveWorkspaces } from '../scripts/_workspaces.ts' let tmp: string @@ -515,6 +515,30 @@ describe('extractPreamble', () => { }) }) +describe('dropAlreadyReleased', () => { + const commit = (message: string): Commit => ({ + hash: message, + shortHash: message.slice(0, 7), + message, + type: 'fix', + scope: '', + description: message, + isBreaking: false, + author: { name: 'a', email: 'a@b.c' }, + references: [], + }) + + it('omits commits whose subject shipped on the diverged branch', () => { + const commits = [commit('fix: a (#1)'), commit('fix: b (#2)')] + expect(dropAlreadyReleased(commits, new Set(['fix: a (#1)'])).map(c => c.message)).toEqual(['fix: b (#2)']) + }) + + it('is a no-op without diverged subjects', () => { + const commits = [commit('fix: a')] + expect(dropAlreadyReleased(commits, new Set())).toBe(commits) + }) +}) + describe('truncateBody', () => { it('leaves bodies within the limit untouched', () => { expect(truncateBody('short body')).toBe('short body') -- 2.51.2