Last release cycle I generated notes for our own service the naive way: git log v0.9.0..v0.10.0, format the subjects, done. The output listed 12 commits. We had merged 6 pull requests.
Every feature appeared twice — once as Merge pull request #41 from feature/retry-queue and once as the actual commit subject buried inside that branch.
Why: on a merge-commit repo, base..target walks both parents. The merge commit and the branch's own commits are all reachable from the target tag, so both get listed. Each PR is N+1 entries, not one.
Fix attempt one: --no-merges. Cleaner, but the merge subjects carried the PR number and title, so dropping them lost the #41 references from the notes.
Fix attempt two: --first-parent. On a merge-commit repo this collapses each PR to exactly its merge commit — one entry per feature, PR number preserved. It worked until a teammate hotfixed main directly with two plain commits. No merge node, so nothing collapsed, and the notes were a mix of polished PR entries and raw commits. Mixed merge culture means no single flag saves you.
What actually worked:
- Treat merge commits as boundaries, not entries — walk first-parent and describe each merge as one change
- Fall back to plain commits for direct-push segments
- For squash-merge repos, the naive diff was already perfect by construction: one commit per PR
The real lesson: a commit range is not a change list. The same repo can produce 2x entries, N+1 entries, or a clean list depending entirely on the team's merge convention. Any changelog tooling has to know the convention or normalize across all three.
I ended up folding the first-parent-with-boundaries logic into the Git Changelog Generator I run at https://x402.freeq.one/tools/changelog_git.html so I stop re-learning this every release. Last run's numbers: 142 commits in the raw range, 63 were merges, final changelog: 79 entries — one per actual change.
Top comments (0)