git log main..develop reported ten commits that had not reached main, even though the content of all ten was already on main byte for byte. GitHub kept offering Compare & pull request, and every merge after that raised conflicts on files nobody had touched twice.
The repository belongs to a client, and I manage its git. The cause is recorded in the branch convention file for that repository, in a section dated 31 August 2026 whose heading reads more like an admission than a rule: the --no-ff the file specifies had been ignored for six releases, and it cost real time. Throughout that span, every merge into main used git merge --squash. Production content was never wrong. The only damage was to ancestry, the parent links that tell git which commits main descends from.
Two branches, two deploy targets
The branch split has been in force since 5 May 2026. Before that, staging and production both auto-deployed from develop, and at around 16:00 that day a recovery push of five commits fired both deploys at once and exhausted the CPU on the app's hosting platform. According to the reasoning of the DevOps contact on the client team, who wires up the hosting side, simultaneous staging and production deploys from one branch doubled the instantaneous CPU load and contributed to that day's health-check failure, when the app could not load. By the same reasoning, splitting the branches staggers deploy timing. Staging redeploys when develop is pushed, while production redeploys later, only when main is updated. At 18:23 the same day the contact confirmed that the production branch had been switched to main, and that merging into main would trigger an automatic deploy.
Since then the convention has been simple. develop auto-deploys to staging and carries day-to-day work. main auto-deploys to production and is only updated through a merge from develop when a release is ready, so nothing is committed to main directly. Commits are pushed to two remotes, client and origin, the second being a personal mirror. The release flow to production is written as five lines:
git checkout main
git merge develop --no-ff -m "merge: <release description>"
git push client main
git push origin main
git checkout developThe --no-ff on the second line is there because it keeps an explicit merge commit in the history of main, which makes it easy to tell later when a given release went live. A merge commit like that has two parents, the previous tip of main and the tip of develop being merged. That second parent is what tells git the develop commits are now part of main.
Checking the convention against a history that was already wrong
Using --squash for those six releases was not a judgement call. The note in the same file calls it a failure to read. The convention was checked, but not by opening the file. What got run was this command:
git log --format="%H %p" -3 main%H prints the commit hash and %p prints its parent hashes, so the command shows how many parents the three top commits on main have. A merge commit made with --no-ff shows two parent hashes. A squash commit shows one, exactly like an ordinary commit:
# the shape of the output, simplified with placeholders
<hash> <parent> one parent: an ordinary commit, squash results included
<hash> <parent-main> <parent-develop> two parents: a merge commit from --no-ffThe three commits it returned were already squashes from the same run of work. The history I used as a reference was the output of the very mistake I was repeating. The note calls it circular evidence, when the convention file spells out the answer in plain words.
One parent, so develop never counts as merged
git merge --squash develop prepares the working tree and index as if a real merge had happened, but records none of the merge information. The commit that follows is an ordinary commit with a single parent, the previous tip of main. My note puts the consequence in one sentence: a squash records one parent, so git no longer saw develop as merged.
The notation main..develop means commits reachable from develop but not from main. Without a second parent, the develop commits made since the last real merge never become ancestors of main, no matter how many releases have already carried their content. That is how git log main..develop ended up reporting ten outstanding commits whose content was already on main byte for byte. On GitHub, the same problem showed up as a Compare & pull request offer that kept coming back.
The third symptom lived in the merges. Every merge after that raised conflicts on files nobody had touched twice. In general terms, a three-way merge is computed from the merge base, the last point the two branches share, and without a second parent that point never moves forward. The content a squash brought into main is recorded as other commits with no link to the develop commits, so git compares develop with copies of its own content, measured from an ever older common point. I resolved those conflicts by taking develop wholesale, four times, while treating recurring conflicts as normal for this repository. They were self-inflicted.
The repair only records the missing parent
What had not broken decided the shape of the repair. The tree of main matched the tree of develop exactly throughout, so production was never wrong and nothing was lost. Only the ancestry needed fixing. Still, the --squash mistake had already reached both remotes, and undoing it took an ancestry repair.
The recipe in my notes, for when the trees already match, is git merge -s ours develop. It leaves the tree of main untouched and records only the missing second parent. The ours strategy always keeps the tree of the current branch and ignores every change from the other branch, so it is only safe when the two trees are already identical. If any difference remains, this strategy throws it away without a conflict and without a warning. Do not confuse it with the -X ours option either, which only favours your own side in the conflicting hunks and still merges everything else.
The same note prescribes the verification: the tree hash before and after must be identical, and main..develop must go to 0. As commands it looks roughly like this. This is my simplified version, not a transcript from that repository:
# simplified, only for the case where main and develop already have the same tree
git rev-parse 'main^{tree}' 'develop^{tree}' # these two hashes must be identical
git checkout main
git merge -s ours develop
git rev-parse 'main^{tree}' # must equal the main hash above
git rev-list --count main..develop # must be 0
git checkout developThe repair does not rewrite history. It adds one merge commit on top of main, so no commit that already exists on a remote has to be replaced.
One thing has to be stated as it is. My notes hold the recipe and the verification steps, but not their results. There is no tree hash from before or after, no main..develop count after the repair, and no hash for the repair commit. What I can vouch for here is that the ancestry repair was run and what should have been checked afterwards, not the numbers that check produced.
The fifth line, skipped twice
The next section of the same file, also dated 31 August, records three more violations of what is written above it, all on that same day. They happened in the same working session, after the --squash correction had already been written into the file. The first two items share the same shape. The first is three commits made directly on main. They were caught before pushing, moved onto develop with git cherry-pick, and then main was reset and merged properly. The second is the same thing again two commits later, with the same fix and the same cause.
The cause was identical both times. The release flow ends with git checkout develop, but only the first four lines were run, and a merge leaves you standing on main. The convention file states this in capital letters: the fifth line is part of the procedure, not tidying up after the release. It is what stops the next piece of work from starting on the wrong branch, and skipping it is how commits land on main.
The operational rule I set afterwards is to run git branch --show-current before the first git add of any piece of work. Not before the commit, but before the first file is staged.
A written procedure is for running, not remembering
The general rule I took from that day: when a note documents a procedure, run the procedure it names, and do not re-derive it from the state of the repository. Repository state can be the output of an earlier mistake, and that is exactly what happened here. The three squashes at the top of main were that kind of output.
Every failure above came from working from memory of the procedure rather than reading it. Reading the procedure took ten seconds. The three recoveries took far longer, and one of them, the squash, had reached both remotes and needed an ancestry repair to undo.