DEV Community

Cover image for One hotfix, three repos, four surprises: why I wrote a Git Flow doctor
Çağatay Uncu
Çağatay Uncu

Posted on AI-assisted

One hotfix, three repos, four surprises: why I wrote a Git Flow doctor

We still run classic Git Flow at work: main, develop, and short-lived
release/* and hotfix/* branches. Before you close the tab: yes, trunk-based
development is simpler, and for many teams it is the right call. But plenty of
teams ship versioned software to customers, keep a release line alive, and
live with Git Flow for good reasons.

What GitHub gives those teams is a pile of separate manual steps. Finishing
one hotfix means: merge to main, tag the merge commit, publish a GitHub
Release, merge back into develop (or into the open release branch, if there
is one), delete the branch. Five steps, and nothing tells you when one of them
was skipped.

Here is the hotfix that made me stop doing this by hand.

The hotfix

One fix, shipped in lockstep across three repositories (a backend and two
frontends). The branch was hotfix/2.0.0-hotfix.12. Four things went wrong,
and none of them was the fix itself.

1. "Latest tag" depended on my git config

Our tags look like 2.0.0-hotfix.12. Ask git for the newest tag:

$ git tag --merged origin/master --sort=-version:refname | head -1
2.0.0-hotfix.12

$ git -c versionsort.suffix=- tag --merged origin/master --sort=-version:refname | head -1
2.0.0
Enter fullscreen mode Exit fullscreen mode

Same repo, same command, different answer, depending on a config key most
people have never heard of. Anything built on "is this hotfix newer than the
latest tag?" gives a different answer on different machines.

2. The branch was cut from a stale base

hotfix.12 was opened before hotfix.11 was merged. hotfix.11 deleted a
comment block in a web.config; hotfix.12 added a rule right above that
comment. Result: a merge conflict on both legs of the finish, into
master and into develop. We only saw it because someone happened to run
git merge-tree during review.

3. A clean merge didn't mean working code

After the local merges, we built every repo before pushing. One detail from
the backend: Git Bash rewrites /t:Build into a file path, so MSBuild ran,
built nothing, and exited 0. A "green" verify step that proves nothing.

4. Someone else pushed in the middle

While the merges were being verified locally, those same local commits were
pushed from a GUI client on the same machine. The commits were identical, so
nothing broke. If they had differed, either unverified code would be on
master, or the three repos would be left half-released.

And in the background: four hotfix branches open at once, with nothing to say
which one to finish first.

What I built: gitdoctor

gitdoctor is one bash script
(bash 3.2+, no jq) plus a set of playbooks. The script is read-only: its
only mutation is git fetch. It checks the repo and tells you what is wrong
and how to fix it.

brew install cagatayuncu/tap/gitdoctor
cd your-repo
gitdoctor --format text
Enter fullscreen mode Exit fullscreen mode

It runs 51 checks: missing back-merges, tags that are not on main, release
tags without a GitHub Release, PRs opened against the wrong base, branches
main has moved past, stale branches, missing branch protection, and more.
Each finding comes with a fix and a recipe:

WARN  flow-branch-behind-main  hotfix/2.0.0-hotfix.12 is 6 commit(s) behind master — its finish would conflict (1 path(s); resolve on the branch first)
      fix: git switch hotfix/2.0.0-hotfix.12 && git merge origin/master
      recipe: references/fix-recipes.md#flow-branch-behind-main
Enter fullscreen mode Exit fullscreen mode

That one finding would have caught surprise #2 before anyone started merging.
The fix it suggests is the right one: merge main into the hotfix branch and
let the author resolve the conflict there, where they know the change.

Output is JSON by default, so agents, CI and scripts can use it. --format
text
, markdown and sarif are there for people, PR descriptions and GitHub
code scanning. gitdoctor --explain <check-id> prints the recipe in the
terminal.

Finishes you can resume

The part I care about most: a release or hotfix finish is a probe walk.

gitdoctor --probe finish-hotfix --branch hotfix/2.0.0-hotfix.12 --version 2.0.0-hotfix.12
Enter fullscreen mode Exit fullscreen mode

The probe reports, for each step, whether it is already done: merged to
main, tagged, tag pushed, back-merged, branch deleted, GitHub Release
created. It works that out from the repo itself (ancestry, then the GitHub PR,
then content equivalence for squash merges, then the tag). There is no state
file to go stale.

So when CI is red, the network drops or a session dies halfway, you run the
finish again and it continues from the first missing step. Half-finished
releases stop being a thing someone has to notice.

The four surprises, fixed

  • Version ordering is done in the script with strict SemVer precedence, never by git's sort. versionScheme: suffix-counter supports X.Y.Z-hotfix.N hotfixes: 2.0.0 < 2.0.0-hotfix.9 < 2.0.0-hotfix.12 < 2.0.1, and the next hotfix suggestion is 2.0.0-hotfix.13, skipping numbers already held by open branches.
  • Conflict forecast: before any merge starts, the probe runs git merge-tree for both legs and lists the paths that will conflict. multiple-hotfix-branches lists open hotfixes in version order, with the tag each was cut from and which one to finish first.
  • Verify commands in .gitflow.json run on the merged branch before the tag and the push. They are read from origin/main, never from the branch being finished (a branch shouldn't decide what runs on your machine). An expect glob checks that the build really produced something, for tools that exit 0 without doing any work.
  • Pushes from another tool are met safely: if origin already has exactly these commits the push is a no-op, and if origin moved elsewhere git rejects it. Never --force. And: finish from one tool, in one session.

For repos that release together there is a workspace mode (--workspace
.gitflow-workspace.json
): one view of every repo's state, a single "ready"
gate before anything is pushed, and a check that the same tag has the same
type and message everywhere.

Where the AI part comes in

The playbooks are written for coding agents (Claude Code and Cursor). You say
"finish the hotfix", the agent runs the probe, shows you the forecast, and
performs the merges and pushes, each one after you confirm. The script
decides what is true; the agent only acts on its JSON.

You don't need an agent, though. The doctor works on its own, in CI or as a
pre-push hook:

# .github/workflows/gitdoctor.yml (PR comment + critical findings gate)
- uses: cagatayuncu/gitdoctor@v0.4.0
Enter fullscreen mode Exit fullscreen mode

Try it

  • Homebrew: brew install cagatayuncu/tap/gitdoctor
  • GitHub Action: uses: cagatayuncu/gitdoctor@v0.4.0
  • Claude Code plugin: /plugin marketplace add cagatayuncu/claude-plugins, then /plugin install gitdoctor@cagatayuncu
  • Source (MIT): https://github.com/cagatayuncu/gitdoctor

It's opinionated about Git Flow, and I'd like to hear where it's wrong for
your setup. Which checks would you add? Issues and PRs are welcome.

Top comments (1)

Some comments have been hidden by the post's author - find out more