Teamwork with Git: Branching Strategies, Rulesets and Conventional Commits
Starting from a force push that wipes a teammate's commit, we compare GitHub flow, Git flow and trunk-based development; branch protection versus rulesets, server-side protection with receive.denyNonFastForwards, CODEOWNERS and Conventional Commits.
Version note: The commands in this post were tested with Git 2.54.
Guess First
Ada adds book search and pushes to main. Alan hasn't fetched since the morning; he adds tax to the cart on his machine and also tries to push to main:
$ git commit -am "Add tax to cart total"
[main 8588f45] Add tax to cart total
1 file changed, 2 insertions(+), 1 deletion(-)
$ git push
To ../bookstore.git
! [rejected] main -> main (fetch first)
error: failed to push some refs to '../bookstore.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
In a hurry, Alan types git push --force. What happens to the search commit Ada just pushed?
$ git push --force
To ../bookstore.git
+ 44764e7...8588f45 main -> main (forced update)
Ada sees the result on her next fetch:
$ git fetch
From ../bookstore
+ 44764e7...8588f45 main -> origin/main (forced update)
$ git log --oneline origin/main
8588f45 Add tax to cart total
7f7eeec Add cart total
61419c1 Add book list
5102cb8 Add README and .gitignore
44764e7 Add book search was removed from main on the remote. The commit still exists on Ada's machine, so it can be recovered, but Git threw a teammate's work off the shared branch without a single warning. Even a two-person team needs a shared rule and a protection that enforces it. This post covers both: branching strategies and the GitHub settings that protect main, plus CODEOWNERS and Conventional Commits.
GitHub Flow: One Long-Lived Branch
This is the model we applied step by step in the previous post. The only long-lived branch is main; everything else happens on a short-lived branch, goes through review in a pull request, gets merged and is deleted. main is considered deployable at any moment. What keeps the model this simple is that releases don't need a branch of their own: every merged PR becomes part of the next deploy.
Its strength is its simplicity. A newcomer learns the model in ten minutes, history stays readable, and CI watches a single branch. Its weak spot is when you have to support several versions at once: if you keep the 1.x and 2.x lines of an installed desktop app alive at the same time, a single main can't carry that on its own.
Git Flow: develop, release and hotfix
Git flow is a model that spread with Vincent Driessen's 2010 post. There are two long-lived branches: main holds only released versions, and develop is where the next release accumulates. Features are developed on feature/* branches created from develop. When it's time for a release, a release/* branch is created from develop, final fixes go there, then it's merged into both main and develop, and main is tagged. Urgent production bugs get a hotfix/* branch from main. Let's see what the graph looks like with just one feature and one release:
$ git switch -q -c develop
$ git switch -q -c feature/search
$ git add src/search.js && git commit -q -m "Add book search"
$ git switch -q develop && git merge -q --no-ff feature/search -m "Merge feature/search into develop"
$ git switch -q -c release/1.1.0
$ echo "1.1.0" > VERSION && git add VERSION && git commit -q -m "Bump version to 1.1.0"
$ git switch -q main && git merge -q --no-ff release/1.1.0 -m "Release 1.1.0"
$ git tag -a v1.1.0 -m "Version 1.1.0"
$ git switch -q develop && git merge -q --no-ff release/1.1.0 -m "Merge release/1.1.0 into develop"
$ git log --oneline --graph --all --decorate
* ab295b3 (HEAD -> develop) Merge release/1.1.0 into develop
|\
| | * d5612ed (tag: v1.1.0, main) Release 1.1.0
| | |\
| | |/
| |/|
| * | 8b07302 (release/1.1.0) Bump version to 1.1.0
|/ /
* | 394a5ce Merge feature/search into develop
|\ \
| |/
|/|
| * 46155cb (feature/search) Add book search
|/
* 7f7eeec Add cart total
* 61419c1 Add book list
* 5102cb8 Add README and .gitignore
Three merge commits and four branches for two real commits. That structure is exactly Git flow's strength: for products that ship planned releases, support several versions at once and have a freeze and test period before a release, it's clear who merges what where. It's also its weakness: it's easy to forget to merge releases and hotfixes into both places, and develop and main drift apart over time. Driessen himself, in a note he added to the top of his post in 2020, recommended a simpler model such as GitHub flow for continuously delivered web apps.
Trunk-Based Development
In trunk-based development everyone integrates their changes often, at least once a day, into a single main branch (the trunk, main for us). If a branch is created at all, it doesn't live longer than a day or two. Unfinished work can enter main too, but stays invisible to users; you do that with a feature flag:
// stays off until the discount feature is finished
const discountsEnabled = process.env.DISCOUNTS_ENABLED === "true";
function total(items, discount = 0) {
const sum = items.reduce((acc, item) => acc + item.price, 0);
return discountsEnabled ? Math.max(sum - discount, 0) : sum;
}
The gain is that integration never piles up: with no long-lived branches there are no big merges and no weekly conflict sessions either. The price is discipline. A fast, reliable test pipeline that catches every push is a must, and feature flags that aren't cleaned up stay in the code as dead branches. If you need releases, a release branch is cut from main and only fixes are cherry-picked onto it.
Which One When?
The right model depends on how the team delivers. If you build a continuously deployed web app or service, there are two options: GitHub flow or trunk-based. GitHub flow ties review to the PR and is easy to learn; trunk-based asks for more frequent integration and smaller changes, and in return expects strong CI and feature flag infrastructure. A GitHub flow team working with small PRs and fast reviews ends up very close to trunk-based in practice. If the product is installed, distributed through a store or has several versions supported at once, Git flow or a slimmed-down version of it (main plus one release branch per version) can make sense. Whichever you pick, write the rule down and enforce it with the protections below; an unwritten rule breaks at the first emergency.
Protecting main: Branch Protection and Rulesets
The way to prevent the accident from Guess First is a rule on the server side. Git itself has a simple equivalent: the receive.denyNonFastForwards and receive.denyDeletes settings on the bare repo. I replayed the same scenario with these settings on:
$ git -C bookstore.git config set receive.denyNonFastForwards true
$ git -C bookstore.git config set receive.denyDeletes true
$ git push --force
remote: error: denying non-fast-forward refs/heads/main (you should pull first)
To ../bookstore.git
! [remote rejected] main -> main (non-fast-forward)
error: failed to push some refs to '../bookstore.git'
$ git push origin --delete main
remote: error: denying ref deletion for refs/heads/main
To ../bookstore.git
! [remote rejected] main (deletion prohibited)
error: failed to push some refs to '../bookstore.git'
This time the rejection came from the server, not the client: [remote rejected]. Alan may have --force in his hands, but the rule lives on the server. On GitHub you have no access to server config; you do the same thing in the repo settings, and you can do much more: requiring a PR before merging, a certain number of approvals, status checks that must pass, and blocking force pushes and deletions.
There are two ways to do it. The old way is branch protection rules: they're tied to a branch or pattern, and only one protection rule applies to a branch at a time. The new way is rulesets: they arrived as a public beta on April 17, 2023 and became generally available on July 24, 2023. Here's how rulesets differ. Several rulesets can apply to a branch at once and they layer; where rules overlap, the most restrictive one wins. You can make a ruleset active or disabled without deleting it. Anyone with read access to the repo can view the rulesets, so the answer to "why was my push rejected" doesn't stay hidden in an admin setting. Rulesets can target not only branches but tags too. In a new repo, start with rulesets. The CI side that produces the status checks, GitHub Actions, will get its own series later.
CODEOWNERS
Protections say "approval required" but not whose approval. CODEOWNERS does that. It's a CODEOWNERS file in the repo root, inside docs/ or in the .github/ folder, and it records who is responsible for which files:
# .github/CODEOWNERS
# default owner: every file
* @ada
# Alan owns the cart code
/src/cart.js @alan
# documentation belongs to a team
/docs/ @bookstore/docs-team
The patterns resemble .gitignore, and the last matching line in the file wins; that's why the general rule goes at the top and specific rules below it. When a PR touches src/cart.js, Alan is added as a reviewer automatically. If you require code owner approval in branch protection or a ruleset, a PR touching that file can't be merged until Alan approves. As the team grows, this removes the question "who should review this".
Conventional Commits
Agreeing on a format for commit messages as a team makes history readable for humans and tools alike. The most common format is Conventional Commits: the message starts with a type, optionally followed by a scope in parentheses, then a colon and a short description. The bookstore team switched to this format later on:
$ git log --oneline
aee166d feat(books)!: store prices in cents
f94e27d docs: explain how to run the cart
c280b0c fix(cart): keep total from going below zero
bc647f2 feat(cart): add discount support
7f7eeec Add cart total
61419c1 Add book list
5102cb8 Add README and .gitignore
$ git log --oneline --grep="^feat"
aee166d feat(books)!: store prices in cents
bc647f2 feat(cart): add discount support
$ git log -1 --format=%B
feat(books)!: store prices in cents
BREAKING CHANGE: books now have priceInCents instead of price.
feat is a new feature and fix a bug fix; types like docs, refactor, test, ci and chore are common too. A ! right before the colon (after the type or scope) or a BREAKING CHANGE: line at the end of the message marks a change that breaks backward compatibility. The format maps directly to semantic versioning: fix means a patch, feat a minor and a breaking change a major version bump. As the --grep example shows, you can filter it even with plain Git; tools that generate changelogs and version numbers rely on it too. We'll see how to check the format automatically with a commit-msg hook in the hooks post.
Common Pitfalls
The first pitfall is picking the model from a blog post instead of from the team. Setting up Git flow for a three-person team that deploys several times a day means pushing every change through five branches. Look at how you deliver first, then pick the model to match.
The second is not applying protection to admins. In branch protection rules admins bypass the rules by default unless you turn on "Do not allow bypassing the above settings"; in rulesets, anyone you add to the bypass list can do the same. The door left open "because we need it in emergencies" is exactly the accident from Guess First. If you need an exception, define an explicit, recorded path for it.
The third is writing CODEOWNERS in reverse order. Because the last matching line wins, putting the * line at the bottom overrides all the specific rules and every PR goes only to the default owner.
The fourth is not thinking about Conventional Commits together with squash merges. If you use the squash method, the message that lands on main usually comes from the PR title; you have to apply the format not only to commits but to PR titles as well.
A Small Experiment
★☆☆ Set up a bare repo and two clones. Push from one, then push with --force from the other without fetching, and look at the (forced update) line in the first clone's fetch output. Then try the same with git push --force-with-lease; what's the difference?
★★☆ Turn on receive.denyNonFastForwards on the same bare repo and replay the scenario. Then work out how to get the rejected change pushed: fetch first, then merge or rebase.
★★★ Add a hotfix to the Git flow graph: create hotfix/1.1.1 from main, commit a fix, merge it into both main and develop with --no-ff and create the v1.1.1 tag. Compare the git log --oneline --graph --all --decorate output with a trunk-based history.
We've set up team rules and protections. In the next post we turn to a situation that's solitary but common: undoing a wrong change. We'll undo files with git restore and commits with the --soft, --mixed and --hard modes of git reset, and see one by one what each does to HEAD, the index and the working tree.