</>CodeWithKarani

Git for Two: The Branching Model a Small Team Actually Needs

Karani GeoffreyKarani Geoffrey8 min read

Nobody on a two-person team needs Git Flow

I have watched two-person teams adopt Git Flow - develop, release branches, hotfix branches, the whole poster diagram - and then spend more time reconciling branches than writing features. Git Flow was designed around 2010 for versioned desktop software shipped on a quarterly cadence, where several releases live in the wild at once and need patching independently. If you deploy a web app to one server, and there is exactly one version of your code that matters, that model is costume jewellery. It looks professional and it does nothing.

Here is the thesis I will defend for the rest of this article: for a team of two, your branching model should be the smallest thing that stops you from clobbering each other, and nothing more. That is a short-lived branch off main, a pull request, and a merge inside of a day. Every extra branch type you add is a request for better tests or a better conversation, wearing a Git costume.

The industry has largely settled here too. GitHub Flow (branch, PR, merge to main, deploy) and trunk-based development (very short-lived branches, or direct commits to main behind flags) are the two live options. The difference between them for a team of two is mostly how long your branch survives. Both assume main is deployable at all times.

The model, in five rules

  1. main is always deployable. If main is broken, that is a P1, not a ticket.
  2. Branches live less than 24 hours. If a branch is older than a day, it is not a branch, it is a fork with optimism.
  3. Every change goes through a PR, even when the other person is sitting next to you. The PR is the audit log, not the ceremony.
  4. Merge to main squashes to one commit with a message that would make sense to you in eighteen months.
  5. Nothing gets reverted with force-push. You revert forward.

That is it. No develop branch. No release branches. Tags mark releases if you need them.

The setup you do once

Most of the pain people blame on Git is actually missing configuration. Do this on both machines, today:

# Pulls rebase instead of creating merge bubbles on your local branch
git config --global pull.rebase true

# Delete local refs to branches that were deleted on the remote
git config --global fetch.prune true

# Remember how you resolved a conflict, and replay it automatically next time
git config --global rerere.enabled true

# Ignore whitespace-only changes when assigning blame
git config --global blame.ignoreRevsFile .git-blame-ignore-revs

# Sensible default: new branches track the remote of the same name
git config --global push.default simple
git config --global push.autoSetupRemote true

The one people sleep on is rerere (reuse recorded resolution). If you rebase a branch more than once, Git will replay your earlier conflict resolutions instead of asking again. On a two-person team where you rebase daily, that alone pays for the article.

Then protect main. Do it from the terminal so it is reproducible:

gh api --method PUT \
  repos/OWNER/REPO/branches/main/protection \
  -f "required_status_checks[strict]=true" \
  -f "required_status_checks[contexts][]=test" \
  -F "enforce_admins=false" \
  -F "required_pull_request_reviews[required_approving_review_count]=1" \
  -F "restrictions=null" \
  -F "allow_force_pushes=false" \
  -F "allow_deletions=false"

Note enforce_admins=false. On a two-person team you will occasionally need to bypass the rule at 11pm when the payment webhook is down and your teammate is asleep. Pretending otherwise leads to people disabling protection permanently. Leave the escape hatch, and treat using it as a thing you mention the next morning.

The daily loop

This is the entire workflow, and it is six commands:

# Start from a fresh main
git switch main
git pull

# Branch. Name it so your teammate knows what it is without asking.
git switch -c geoff/mpesa-callback-retry

# ... work, commit in small pieces ...
git add -p
git commit -m "Retry M-Pesa callback verification on 5xx"

# Before you open the PR, catch up with main
git fetch origin
git rebase origin/main

# Push and open the PR from the terminal
git push -u origin HEAD
gh pr create --fill --base main

Use git switch and git restore rather than git checkout. Checkout is overloaded: it changes branches and it destroys uncommitted file changes, with nearly identical syntax. Git split those responsibilities in 2.23 precisely because that overloading kept eating people's work. Two commands, two jobs, far fewer accidents.

And use git add -p. It forces you to look at every hunk before it becomes permanent. I catch a stray debug print or a hardcoded token maybe once a fortnight this way.

Rebase your branch, squash into main

The rebase-versus-merge argument gets religious. Here is the position that works for small teams, and the reason:

  • Rebase your own unpushed or solely-yours branch onto main. It keeps your PR diff honest. A reviewer should see only your changes, not a merge commit soup of your teammate's work.
  • Never rebase a branch someone else has pulled. On a team of two you know exactly who that is, so just ask.
  • Squash-merge into main. One commit per unit of work means git bisect and git revert operate on meaningful units. The intermediate "wip", "fix typo", "actually fix typo" commits are noise the moment the PR is merged.

If you stack work (branch B built on branch A while A is in review), Git has a real answer now:

# Rebases the current branch AND moves any other branch refs
# that pointed at the rewritten commits
git rebase --update-refs origin/main

Before --update-refs landed in Git 2.38, rebasing a stack meant manually resetting each dependent branch and getting it wrong. Now it just works. If you stack often, set git config --global rebase.updateRefs true and forget the flag.

The two conflicts that will actually bite you

Feature code rarely conflicts on a two-person team, because you are working in different files. Two things conflict constantly:

1. Lockfiles

Both of you add a dependency, both lockfiles regenerate, Git shows you 4,000 conflicting lines. Do not hand-merge a lockfile. Ever. Take one side and regenerate:

git checkout --theirs package-lock.json
npm install
git add package-lock.json
git rebase --continue

You can also declare intent in .gitattributes so nobody is tempted to open the file in a merge tool:

# .gitattributes
package-lock.json  merge=ours  linguist-generated=true
poetry.lock        merge=ours  linguist-generated=true
*.min.js           -diff       linguist-generated=true

2. Sequentially numbered migrations

You write 0042_add_payouts.sql, your teammate writes 0042_add_refunds.sql, and now production has two different histories depending on merge order. Stop using sequence numbers. Use timestamps (20260723T1412_add_payouts.sql), which almost never collide, and make your migration runner order by filename. This is a five-minute change that removes a recurring weekly argument.

Releases and rollbacks

You do not need release branches. You need tags and the ability to go backwards:

# Tag what you shipped, so "which code is on prod" is answerable
git tag -a v2026.07.23 -m "Payout retries, invoice PDF fix"
git push origin v2026.07.23

# What changed since the last release?
git log --oneline --no-merges v2026.07.16..v2026.07.23

# Something is broken and you do not know what
git bisect start
git bisect bad HEAD
git bisect good v2026.07.16
# Git checks out midpoints; you mark each one
git bisect good   # or: git bisect bad
git bisect reset

And when you need to undo something already on main:

# Correct: creates a new commit that undoes the change
git revert 8f3c1a2

# Wrong on a shared branch: rewrites history other people have
git reset --hard HEAD~1 && git push --force

The second one is how two-person teams lose a day. Your teammate pulls, gets a divergence, "fixes" it with a merge, and now the reverted commit is back in main without anyone noticing. Revert forward, always.

The part that is not about Git

Two-person code review has a specific failure mode: it degrades into rubber-stamping within about three weeks. Both of you are busy, you trust each other, and "LGTM" costs nothing. Then a security bug ships and nobody actually read the diff.

What has worked for me:

  • Cap PR size at roughly 400 lines of real change. Beyond that, review quality collapses and everybody knows it. If your change is bigger, split it into a mechanical PR (rename, move files) and a behavioural PR (the actual logic). Mechanical PRs review in ninety seconds.
  • The PR description answers one question: what breaks if this is wrong? Not "what does this do" - the diff says that.
  • Author leaves the first comments. Point at the risky part yourself. It changes review from a search problem into a verification problem.
  • Anything touching money, auth, or migrations gets read line by line, out loud if you are in the same room. Everything else can be a skim.

A branching model cannot make two people communicate. It can only make it obvious when they have not.

What I would tell my past self

The single highest-leverage change is not the branching diagram. It is reducing the time between "I wrote this line" and "this line is on main". Every long-lived branch is a bet that the world will not change underneath you, and you lose that bet in proportion to how long you hold it.

So: branch in the morning, merge before you close the laptop. Protect main. Turn on rerere. Squash on merge. Revert forward. Put timestamps on your migrations. That is a complete Git strategy for a two-person team, and you can implement all of it in an afternoon.

#git#workflow#small-teams#code-review#engineering-craft
Keep reading

Related articles