← Back to blog Khalil Drissi

Git workflow and branching best practices

Listen to article
0:00

I have worked on teams that treated Git like a sacred ritual and teams that treated it like a junk drawer. Neither extreme ships software well. After years of cleaning up messy histories and untangling merge disasters, I have landed on a set of habits that keep things calm. None of them are clever. That is the point.

Pick one branching model and stop arguing about it

The model matters less than the agreement. For most product teams I use trunk-based development with short-lived feature branches. You branch off main, you do your work in a day or two, you merge back, you delete the branch. The longer a branch lives, the more it drifts, and the more painful the eventual merge becomes. Long-lived release branches have their place in software that ships on a fixed cadence to customers who cannot update on demand, but for a web app deploying several times a day they are pure overhead.

What I actively avoid is the elaborate GitFlow setup with develop, release, hotfix, and feature branches all interleaving. I have watched it confuse new hires for weeks. If your deployment is continuous, your branching should be too.

Write commits that explain the why

A commit message is a note to whoever is reading the history at 2am during an incident, and that person might be you. The diff already shows what changed. The message needs to capture why. I keep the subject line under about fifty characters, written in the imperative, and I use the body to explain reasoning when the change is not obvious.

fix: prevent double charge on retry

The payment client retried on a 504 even though the
charge had already gone through on the gateway side.
We now key the request with an idempotency token so
the gateway dedupes it. Closes #482.

Atomic commits are the other half of this. One logical change per commit. When a commit does three unrelated things, you can never cleanly revert one of them, and bisecting becomes useless. If you find yourself writing “and” in a subject line, that is two commits.

Rebase your own work, merge shared work

This is the rule that saves the most pain. Before I open a pull request, I rebase my branch onto the latest main so my changes sit on top of current reality and review is a clean read. But once a branch is shared or a PR is open and others have looked at it, I stop rebasing and merge instead, because rewriting published history forces everyone else to recover their local state.

Keep main always releasable

The single most valuable property of a repository is that main always works. If main is green, you can cut a release at any moment, and a broken deploy can be fixed by reverting one merge. I enforce this with required status checks: tests and linting have to pass before a merge button even appears. This connects directly to how I run reviews, which I wrote about in code review best practices. A clean history makes reviews faster, and good reviews keep the history clean. They feed each other.

Make reverting boring

When something breaks in production, the fastest safe action is usually to revert, not to debug live. Squash-merging each PR into a single commit on main makes this trivial: one PR is one commit, and reverting it removes the whole feature cleanly. I like squash merges for exactly this reason on application code, though for libraries where individual commit history carries real value I keep the full history.

Tag your releases so you can always answer “what was running last Tuesday.” A lightweight tag costs nothing and turns a vague question into a one-line answer.

Some habits that pay off quietly

Git rewards discipline more than knowledge. You do not need to memorize the plumbing commands. You need a small set of agreements that everyone actually follows. The same thinking shows up when I design data stores, which I covered in database schema best practices, where a few firm conventions early save enormous cleanup later.

Comments
Leave a comment