Git Changelog Generator Pitfalls: Merge Commits Hid the Truth

A git changelog generator lost merge-introduced commits due to a linear assumption, revealing git history is a DAG, not a list.
The author built a git changelog generator by diffing two commits. The first version walked `git log A..B`, which under the standard PR workflow showed only merge commits and not the merged feature commits, causing release notes to lose substance. The fix uses `git log --first-parent` for a branch-level summary, then `git log ^parent1 parent2` per merge to expand real commits and deduplicate. The article notes `A..B` includes reverted commits and isn't the actual release. Core lesson: git history is a DAG; linear-assumption tools silently lose information.
A seemingly simple requirement that exposed the essence of git
The author built a git changelog generator that outputs structured release notes by diffing two commits. The first implementation simply walked git log A..B, treating each commit as a point on a straight line—clean, deterministic, but wrong.
The problem showed up in repositories using the standard PR workflow: when running git log A..B against the main branch, merge commits were visible, but the individual feature commits introduced by those merges were not. The generated changelog would only say "Merge pull request #42," leaving readers with no idea what was actually released.

The insidiousness of this bug: the tool itself ran fine and the output format was correct, but information was silently swallowed. The problem only surfaced when someone reading the release notes asked, "What exactly changed in v2.3?"
The fix isn't a bigger diff
The author's approach to the fix is instructive—the problem wasn't that the diff range was too small, but that the wrong object was being traversed. The solution has two layers:
- Use
git log --first-parent A..Bto get a branch-level summary, treating each merge commit as a structural event - For each merge commit, use
git log ^parent1 parent2to enumerate the commits that merge actually introduced
The final output first presents the merge events, then expands the real commits beneath them, deduplicating against what's already in the baseline. This way readers see both the structure and the substance.
Why A..B lies
The original article points out an easily overlooked semantic trap: git log A..B means "reachable from B but not from A." On the graph, this set includes commits on branches that were merged and later reverted—they are "reachable," but that doesn't mean they were "released."
If what you want is "what was actually delivered," you need: reachable from B, minus reachable from A, intersected with first-parent edges, plus the side-branch commits pulled in by each merge. The author states plainly that this isn't solvable with a one-liner—it's a small graph traversal.
--first-parent is a traversal option for git log that only follows the first parent of each merge commit. In the standard GitHub/GitLab PR workflow, the first parent typically corresponds to the target branch (e.g., main), and the second parent corresponds to the merged feature branch. Therefore git log --first-parent naturally skips all side-branch commits and treats only the merge commits themselves as milestones, giving a "branch-level" linear summary. This is good for answering "what major events happened on the mainline," but it is not a complete release manifest—the specific commits that were merged in are all collapsed away.
In contrast, git log ^parent1 parent2 is one form of Rev range syntax: ^parent1 excludes all commits reachable from the first parent, and parent2 denotes the set reachable from the second parent. Subtracting the two yields "commits that exist only on the feature branch and haven't yet entered the mainline." Doing this enumeration once per merge commit surfaces all the real commits pulled in by the merge. This is precisely the core mechanism of the original article's fix.
The deeper lesson: history is a DAG
Git history is a directed acyclic graph, not a list. Any tool that assumes a linear structure is lying to its users, and that lie is invisible until you read the release notes. The graph is the source of truth; --first-parent is just one view. Walking both and reconciling them is the honest answer.
The universal value of this case: many tools built around git (changelogs, impact analysis, version comparison) are prone to falling into the linear-assumption trap. They appear to work but actually lose information. The author's approach insists on determinism—no LLM was used to "rewrite" history, just letting the graph do what a graph should do.
In git's underlying model, each commit is a record containing a tree object (representing the directory snapshot at that time) and zero or more parent commits. A commit with no parents is a root commit; one with two or more parents is a merge commit. "Reachable" refers to the set of all commits that can be reached by starting from some reference and continuously walking back along parent chains. A..B is the set difference between "reachable from B" and "reachable from A."
The reason "reverted commits are still included" occurs is that a revert merely adds a new commit with a reverse patch; the original commit still exists in the history graph and is still reachable from B. Reachable ≠ in effect, and ≠ released. Understanding this makes it clear why A..B alone cannot answer "what was actually delivered": it describes the topological relationships of the graph, not release semantics.
Implications for tool developers
If you're building any tool that depends on git history, there are two checkpoints worth confirming upfront: whether the default A..B semantics match your definition of "release"; and whether merge commits are treated as noise to be ignored or as structural signals in your output. These two decisions directly affect the quality of end users' understanding of "what changed."
One-line Summary
A git changelog generator lost merge-introduced commits due to a linear assumption, revealing that git history is a DAG, not a list.
Paragraph Summary
The author built a git changelog generator based on diffing two commits. The first version simply walked git log A..B, which under the standard PR workflow showed only merge commits and not the merged feature commits, causing release notes to lose substantive content. The fix uses git log --first-parent to get a branch-level summary, then for each merge commit uses git log ^parent1 parent2 to expand the real commits and deduplicate. The article further points out that A..B means "reachable from B but not from A," which includes reverted commits and doesn't equal actual release content. The core lesson: git history is a DAG, and any tool with a linear assumption silently loses information—the graph is the source of truth.
Related articles

Building an AI Agent with Claude Code from Scratch: From Workspace to Cloud Autonomy
No coding required: build an AI Agent in four steps with the Claude Code desktop app—from workspace setup and Agent Designer to Skills, Memory, and cloud autonomy.

No Python Required: How Cursor AI Coding Is Reshaping Automated Testing and Test Development
Test engineers can do automated testing without learning Python? This article examines how Cursor and other AI coding tools empower functional testing, automation scripts, test platform development, and performance tuning, compares the cost and access restrictions of mainstream domestic and foreign tools, and offers capability transformation advice for testers.

Nearly 5,000 Affected! AI Coding Plugin Found with Backdoor, Officials Urgently Remove It
Claude Code's VS Code extension version 2.1.313 was maliciously tampered with, implanting a backdoor for remote login and persistent control, potentially affecting up to 5,000 users. Officials have removed the malicious version; this article details the attack method and cleanup steps.