beginner 3 min answer

A team keeps `main` for the current release and `develop` for the next one, and every hotfix is cherry-picked into both. Last quarter two fixes that shipped in `main` were missing from the next release, and a customer re-reported a bug that had been closed six weeks earlier. What failed, and which design decision allowed it?

branchingcherry-pickpatch-idrelease-branchmerge-forward
Show the full answer Hide the answer

The trigger

Someone cherry-picked a hotfix into main, shipped it, and did not repeat the pick on develop. Nothing failed at the time. Both branches built, both test suites passed, and the fix was live in production on one branch and absent from the other for six weeks.

Why Git cannot tell you

A cherry-pick creates a new commit with a new hash and a different parent, so the two copies of the fix share no ancestry. Every reachability tool therefore answers the wrong question: git branch --contains <sha> reports that develop does not contain the fix whether the fix was picked or not, because the commit on develop is a different commit.

Git's answer is patch-id comparison. git cherry main develop and git log --cherry-pick --right-only main...develop hash each commit's diff with line numbers and whitespace normalised, then treat equal hashes as the same change. That turns "is the fix there" into a list of candidates, not a proof, because a pick that was adapted during conflict resolution has a different diff and so a different patch-id. The errors run one way: it over-reports missing changes and does not under-report them, which is the right direction for a safety check and the reason nobody trusts it as a gate until they understand that.

Why detection lagged

The failure mode is absence, and absence emits no signal. There is no failing build, no error rate, no alert. Detection latency equals the release interval plus the time for a customer to hit the bug again and care enough to report it — six weeks here, and it could have been a year for a rarer path.

The structural fix

  1. Make the flow one-directional. Fix on the oldest branch that needs it, then merge forward into the newer line rather than picking into both. A merge records ancestry, so --contains becomes meaningful again and the question is answerable by tooling instead of by memory.
  2. Gate the release on the candidate list. Run the patch-id comparison in the release pipeline and fail if any candidate is unreviewed. Reviewing a short list of false positives costs minutes; losing a fix costs a quarter of reputation.
  3. Ship a regression test with every hotfix. A fix that carries a test fails loudly on the branch that lacks it, which converts silent absence into a red build.
  4. Count the lines. Each supported line adds a forward merge per fix, so four lines cost three merges per hotfix. That arithmetic, not preference, is the argument for deleting develop.

When not to delete the second branch

When a branch has customers on it. Supported major versions of installed software genuinely need a maintenance branch per version, and fixing forward on trunk then picking backwards is the right shape there — the pick is unavoidable because the code has diverged, so the patch-id gate and the regression test are what make it safe. What has no customers is a second branch for the same line of development. develop and main describe the same product at two points in time, which a tag already does, and the cost of the duplicate is the class of failure above.