Git95 min total · 17 parts
Git Internals and Workflows: Branching, Merging, and Rebasing
Part 13 of 17 · ~4 min
Common Branching Workflows
Worth stepping back for a chapter: why does shiftplan look the way it does? It has a main, short-lived feature/ branches, and a release/2.2 branch still receiving cherry-picks. That is a specific set of choices, and it is not the only reasonable one.
The teaching version of this question is usually a list of three named models. The useful version is a single question: how do your users receive the software? Almost every difference between the models falls out of the answer.
shiftplan has been all three, in order.
Years one and two: trunk-based. Two founders, one deployed instance, no customers yet. Everybody committed to main several times a day, occasionally through a branch that lived a few hours. Unfinished work hid behind feature flags rather than behind branches. This is the fastest way to work and the least ceremonious, and its demands are real: without solid automated tests, main breaks constantly, and without feature flags you cannot commit anything you are not ready to show. Its great virtue is that integration happens continuously, so you never discover on Friday that two people spent the week building incompatible things.
Year three: GitHub Flow. Theo joined, and code review became something the team wanted rather than something it could do by leaning over. The model itself barely needs explaining: keep main in a permanently shippable state, put every change on its own short-lived branch, and let a pull request bring it back in once review and checks are green. That is the whole thing, and it is the right default for most web applications and most small teams. Your shift-swap branch is pure GitHub Flow — branch on Monday, review on Thursday, merge on Friday.
Now: GitHub Flow plus release branches. What changed is not team size. It is that customers self-host, so shiftplan ships numbered versions to machines it does not control, and some clinics are two versions behind and will stay there. "Whatever is on main today" stopped being a meaningful answer to "what is running in production," because there are forty different productions.
That is what a release branch is for. When 2.3 is ready, release/2.3 is cut from main and stabilised — only fixes land on it, while main carries on toward 2.4. Critical fixes get made on main and cherry-picked back, exactly as the payroll fix was. The branch lives as long as anybody is running that version.
Push that structure further and you land on Git Flow, which spells everything out: main and develop both stick around indefinitely, and feature/, release/, and hotfix/ each come with their own naming convention and their own rule for how they merge back in. main holds only released versions; develop is the integration branch; releases stabilise on their own branch before landing on both. It is genuinely well-suited to scheduled, versioned, installed software with multiple supported versions in the field — and it is genuinely too much machinery for a web app that deploys on merge, which is how it earned a reputation for being over-engineered. It is not over-engineered. It is engineered for a situation many teams are not in.
| Workflow | Best fit | Trade-off |
|---|---|---|
| Trunk-based | Continuous deployment, strong test and feature-flag culture | Demands real discipline; main breaking is everybody's problem immediately |
| GitHub Flow | Most web apps, small-to-medium teams, deploy-on-merge | Little structure for supporting several released versions at once |
| GitHub Flow + release branches | Versioned software with a small number of supported versions | Every fix needs a cherry-pick decision; branches must be retired deliberately |
| Git Flow | Scheduled releases, multiple supported versions, larger teams | Substantially more branches and ceremony to maintain correctly |
Two operational notes that matter more than the choice of model.
Branches must be deleted. A repository with two hundred stale branches is one where nobody can tell what is live. Once a branch is merged, its commits are on main — deleting the label deletes nothing.
git branch --merged main # branches fully contained in main: safe to delete
git branch -d feature/shift-swap # refuses if the branch is NOT merged
git branch -D feature/shift-swap # deletes regardless — the capital letter is the warning
git push origin --delete feature/shift-swap # remove it from the remote too
git fetch --prune # drop remote-tracking branches that no longer exist on origin
Stacked branches need --onto. If you branch feature/shift-swap off feature/swap-api rather than off main, and swap-api later lands squashed into main, a plain git rebase main will try to replay swap-api's original commits too — and conflict against its own squashed self. Tell Git exactly which range you mean:
git rebase --onto main feature/swap-api feature/shift-swap
# ^new base ^old base ^branch to move
Read it as: take the commits on feature/shift-swap that are not on feature/swap-api, and replay only those onto main. It is the cleanest tool in Git for moving a branch to a different parent, and it is worth learning the argument order rather than looking it up every time.