Best Practices and Anti-Patterns
Topic 70

Git Best Practices

Best Practices

The Git discipline that pays off is the one a stranger feels a year later: history that reads like a record of decisions, branches that mean something, and a clear rule for when to rewrite versus when to preserve. None of it is exotic — it is a handful of habits applied consistently, which is exactly why teams that skip them end up with a log full of "WIP" and a main no one trusts.

This page consolidates the commit, branch, and rebase rules established earlier into the working discipline you actually apply per pull request. The throughline is that history is a product you ship to future readers — including yourself with a regression open at 2 a.m. and git bisect running.

Commit Granularity and Messages

Aim for one logical change per commit: a commit that adds a feature, fixes a bug, or performs a refactor, but not all three at once. The payoff is that the commit becomes a unit you can revert, cherry-pick, or read in isolation. A commit that touches twelve unrelated things can only be reverted as a blunt instrument.

Write an imperative-mood subject under about fifty characters — "Add retry to upload client", not "added retries and fixed some stuff" — and use the body to explain why, not what. The diff already shows what changed; what it cannot show is the reasoning, the rejected alternative, or the issue that forced your hand.

Branch Hygiene

Cut short-lived feature branches off current main, and delete them once they merge. A branch is a tiny pointer, so the cost is never in creating one — it is in letting two hundred of them rot until no one can tell which represent live work and which were abandoned six months ago.

The longer a branch lives, the further it drifts from main, and the more the eventual integration turns into a conflict marathon. Keep branches scoped to a single piece of work and merge them while they are still close to the trunk.

Rebase vs Merge Discipline

The rule that prevents most rebase disasters is about ownership, not preference. Rebase your local, unpushed work to stay current with main and keep your branch linear before review. Use merge (or squash-merge) to integrate finished work. Never rebase a branch others have already pulled — you are rewriting commits they have based work on, and their next pull will re-duplicate the old history.

Keeping History Bisectable

The reason per-commit quality is not just aesthetics: git bisect does a binary search through history to pin a regression to a single commit, but it only works if every commit on main actually builds and passes tests. A commit that is broken in the middle of a series poisons the search — bisect lands on it and you learn nothing. Treat "each commit is independently green" as the contract that keeps debugging cheap.

.gitignore and What Never Belongs in Git

Build output, node_modules, secrets, and large binaries do not belong in version control. Once committed, they are baked into history permanently — the repo carries their weight on every clone forever, and a leaked secret is compromised the moment it is pushed. Maintain a .gitignore that keeps generated and vendored files out from the start, and route large assets to Git LFS or external object storage rather than the object database.

Atomic, Reviewable Diffs

Separate mechanical changes from behavioral ones. A two-thousand-line auto-format mixed with a real logic change is unreviewable: the one line that matters is buried in noise no reviewer will read line by line. Land the rename or the formatting pass as its own commit, then the behavior change as another, so the diff a reviewer opens contains only what they need to reason about.

Rebase vs Merge

Rebase — replays your commits onto a new base, producing a linear, rewritten history that reads cleanly. Safe and ideal on local, unpushed branches; destructive on shared branches because it rewrites commits others may already hold.

Merge — records a merge commit that preserves the true topology of when work diverged and rejoined. Non-destructive and the correct way to integrate shared work, at the cost of a less linear log.

Common Mistakes
  • Writing "WIP" and "fix" commit messages that say nothing — six months later no one, including you, can tell what changed or why without re-reading the diff.
  • Rebasing a branch others have already pulled — their next pull re-applies the old commits, creating duplicates and conflicts that look like sabotage.
  • Committing generated files or node_modules — the repo bloats permanently, and history cannot easily forget bytes already pushed.
  • Bundling a 2000-line auto-format with a real logic change in one commit — the actual bug hides in noise no reviewer reads, and it ships.
  • Letting a feature branch drift for weeks — the eventual merge against a moved main becomes a multi-hour conflict resolution no one budgeted for.
Best Practices
  • Write imperative-mood subjects under about fifty characters and use the commit body to explain why.
  • Rebase only local, unpushed branches to stay current; merge to integrate shared work.
  • Keep feature branches short-lived, cut from current main, and delete them after merge.
  • Ensure every commit on main builds and passes tests so git bisect stays usable.
  • Commit refactors and behavior changes separately so each diff is reviewable on its own.
  • Maintain a .gitignore for build output and secrets, and route large binaries to Git LFS or object storage.
Comparable toolsNo direct equivalent — synthesis chapter these are Git-native disciplinesGitLab same commit and branch conventions, squash-on-merge optionsGerrit enforces one-commit-per-change review natively

Knowledge Check

When is rebasing safe versus destructive?

  • Safe on local, unpushed branches; destructive on branches others have already pulled, because it rewrites shared commits
  • Always safe under every circumstance, because a rebase only reorders the commits and never actually deletes any of the original ones
  • Safe only on the long-lived main branch and reliably destructive on every shorter-lived feature branch
  • Destructive only in the cases where the rebase actually produces merge conflicts that have to be resolved by hand

Why does keeping every commit on main green matter for debugging?

  • git bisect binary-searches history to pin a regression to one commit, which only works if each commit builds and passes tests
  • Commits whose tests all passed end up compressing markedly better inside the repository pack file, shrinking clone size over time
  • CI inspects past outcomes and refuses to run on a branch that still has any red commit anywhere in its history
  • It earns you the right to skip writing tests for the commits you add later in the same branch

What is the cost of a long-lived feature branch?

  • It drifts further from main over time, turning the eventual merge into a large conflict resolution
  • Git bills additional disk storage for every single day that the branch continues to exist on the remote
  • It permanently locks every file that it touches against edits coming from any other concurrent branch
  • It can no longer be rebased at all once it has been alive for more than one week

What belongs in .gitignore, and why?

  • Build output, node_modules, secrets, and large binaries — once committed they bloat history forever or are compromised on push
  • Only temporary editor swap files; the build output, vendored dependencies, and everything else should all stay tracked
  • Any source files you have written but are not yet ready to put up for review on a pull request, kept out of the index until the feature is polished enough to share with the team
  • Nothing at all — a genuinely well-run repository deliberately tracks every single file for completeness

You got correct