We hit this in the middle of shipping. A branch was ready, tests were green, and the merge into main stopped on a conflict — not in code, in CHANGELOG.md. Two branches had both added a line near the top, under the same ## [Unreleased] heading, and git couldn't decide whose line came first. We resolved it by hand, again, and then asked the question we should have asked months earlier: why are we maintaining this file at all?
Why a CHANGELOG keeps conflicting
The conflict isn't bad luck — it's structural. A hand-written changelog grows by everyone appending to the same place: the top of the file, under [Unreleased]. So every branch edits the same handful of lines. Two branches touch them, and the merge has no way to know the intended order. You get conflict markers on a file that has nothing to do with what you actually changed.
It gets worse the more parallel work you have. We run several coding agents at once, each on its own branch, and the changelog was the most collision-prone file we had — the one most likely to clash between any two of them. The code merged cleanly; the prose about the code did not.
Nobody was reading it
The part that made the decision easy: we went looking for everything that read CHANGELOG.md. We couldn't find anything that did — not the deploy, not CI, not the app. It was a document we hand-wrote, hand-merged, and hand-resolved conflicts on — purely as ceremony. We were paying a real, recurring cost (every merge) for something with no consumer.
That's the trap with a hand-maintained changelog. It feels responsible. But if you already write decent commit messages, you're describing every change twice — once for git, once for the file — and only the second copy ever conflicts.
Your commits already say it
So we deleted the workflow. The source of truth for "what shipped" is now the git history, and a small script turns it into a changelog on demand. We already use Conventional Commits — feat:, fix:, refactor:, chore: — so the grouping is free:
# group commits since the last release into Added / Fixed / Changed
git log --no-merges --format='%s%n%b' <last-release>..HEAD
feat becomes Added, fix becomes Fixed, everything else Changed. The commit body carries the detail, so the generated entries read about as richly as the ones we used to write by hand — because the effort just moved into the commit message, where it belongs.
The generated file lands in a gitignored scratch directory, exactly like the board views we generate from our issue tracker. That's the whole trick: the changelog is a view, not a record. It's regenerated, never committed on a feature branch, so there is nothing for two branches to fight over. A view can't conflict.
What we kept, and the one catch
We didn't remove it entirely. The committed CHANGELOG.md still exists as the historical, per-release record — it just gets regenerated at release time instead of hand-edited on every branch. And as a safeguard, a single line in .gitattributes tells git to union-merge it if it's ever touched on two branches at once:
CHANGELOG.md merge=union
Union-merge means "keep both sides' lines" instead of raising a conflict. For an append-only list that's almost always what you want.
The honest catch: a generated changelog is only as good as your commit messages. If your subjects are fix stuff and wip, your changelog will say fix stuff and wip. Switching to generation forced us to actually write the one-line summary at commit time — which is a better habit anyway, and now it's the only place we write it.
If your changelog conflicts on every merge, that file is trying to tell you something: it's a second copy of information git already has. Let git keep it.