Collaboration and Best Practices
Git does not enforce a workflow. Everything on this page is convention — but conventions are what make a shared repository readable a year later, and they are what a code review is actually evaluating.
Branching strategies
Section titled “Branching strategies”Three models cover almost everything in use. Pick the simplest one your release process tolerates.
Trunk-based development
Section titled “Trunk-based development”One long-lived branch (main). Branches live hours to a day or two and merge back immediately.
Incomplete features are hidden behind feature flags rather than kept on a branch.
main ──●──●──●──●──●──●──●──► \ / \ / ●──● ● (short-lived, hours)- Good for: continuous deployment, high-velocity teams, strong CI.
- Requires: fast, reliable tests; small changes; feature flags for anything half-done.
- Payoff: conflicts stay tiny because nothing diverges for long.
GitHub flow
Section titled “GitHub flow”The default for most teams and virtually all open source. main is always deployable. Every change
gets a branch, a pull request, review, CI, then a merge.
git switch main && git pullgit switch -c fix/parser-off-by-one# work, commitgit push -u origin fix/parser-off-by-onegh pr create --fill# review, CI, merge, delete branch- Good for: nearly everyone. Simple, well-tooled, easy to explain.
- Branch lifetime: days, not weeks.
- Effectively trunk-based with a mandatory review gate.
Git flow
Section titled “Git flow”Two permanent branches (main for released code, develop for integration) plus feature/*,
release/*, and hotfix/* branches with defined merge paths.
main ──●─────────────●──────────●── (tagged releases only) \ / /release ●─────────● / \ /develop ──●───●──●──●──●──●──●──●── \ / \ /feature ●──● ●──●- Good for: versioned software with multiple supported releases — installed desktop apps, libraries, firmware.
- Cost: a lot of ceremony and long-lived divergence, which means big conflicts.
- Its own author has publicly noted it is overkill for continuously-delivered web software. If you
deploy from
mainseveral times a day, do not use it.
| Trunk-based | GitHub flow | Git flow | |
|---|---|---|---|
| Long-lived branches | 1 | 1 | 2+ |
| Branch lifetime | Hours | Days | Weeks |
| Review gate | Optional / pairing | Pull request | Pull request |
| Best fit | Continuous deployment | Almost everything | Versioned releases |
Good commits
Section titled “Good commits”Atomic
Section titled “Atomic”One commit, one logical change. Not one file, not one day of work — one coherent unit that could be described in a single sentence and reverted on its own.
Signs you should split a commit:
- The message needs “and”.
- It mixes a refactor with a behaviour change. (These are the worst kind — reviewers cannot tell which moved lines also changed meaning.)
- It bundles unrelated formatting churn with real work.
Use git add -p to split as you go, and
rebase -i to split retroactively.
Atomic commits pay off concretely: git revert on a single commit does the right thing, git bisect
lands on a small comprehensible diff, and git log -p <file> reads as a narrative.
The message
Section titled “The message”Fix off-by-one when the header ends at a buffer boundary
The parser read one byte past the end of the buffer when a headerended exactly on the chunk boundary, producing a spurious null inthe parsed value. Clamp the read to the remaining length instead ofassuming a following byte exists.
We considered buffering an extra byte instead, but that changes thestreaming contract for callers.
Fixes #142- Imperative subject, under ~50 characters, no trailing period. It completes the sentence “Applied, this commit will…”.
- Blank line, then a body wrapped at ~72 columns.
- Why, not what. The diff shows what. It cannot show the bug report, the alternative you rejected, or the constraint you were working around.
- Reference issues (
Fixes #142,Refs #98). GitHub closes the issue when the commit lands on the default branch.
Bad subjects, and what they cost you: fix, update, changes, wip, asdf. In six months, git log --oneline is a list of these and history is useless.
Conventional commits
Section titled “Conventional commits”A widely adopted convention that puts a machine-readable type prefix on the subject:
feat(parser): support multi-line headersfix(api): retry on 503 responsesdocs: clarify the timeout optionrefactor(query): extract the builderchore(deps): bump lodash to 4.17.21test(parser): add boundary caseFormat: <type>(<optional scope>): <description>. A breaking change is marked with ! after the
type (feat!:) or a BREAKING CHANGE: footer.
The payoff is automation: tools can derive the next semantic version and generate a changelog from the log. The cost is a small amount of ritual on every commit. Worth it for published libraries and anything with a changelog; optional otherwise. Adopt it as a team or not at all — a half-applied convention is worse than none.
Pull request hygiene
Section titled “Pull request hygiene”A pull request is a request for someone’s attention. Optimise for the reviewer.
Keep them small. Review quality collapses as diffs grow; a 1000-line PR gets “LGTM” and a 50-line PR gets real scrutiny. If a change is unavoidably large, split it into a stack: refactor first (no behaviour change), then the feature.
One concern per PR. Do not bundle a dependency bump with a bug fix. Anything unrelated you noticed gets its own PR or its own issue.
Write the description for someone with no context. What problem, what approach, what you considered and rejected, how to test it, anything the diff cannot show. Screenshots for UI. Link the issue.
Self-review before requesting review. Open your own PR on GitHub and read the diff top to bottom.
You will find debug logging, a commented-out block, a stray console.log, a file you never meant to
add. This costs two minutes and saves a review round trip.
Make CI green before asking. A reviewer should never be the one to discover the tests fail.
Leave your own comments on lines that need explanation — “this looks odd because the upstream API returns a string here”. Pre-empting questions is faster than answering them.
As a reviewer: distinguish blocking issues from preferences (say which), review promptly since PRs rot, and approve when it is better than what is there rather than when it is perfect.
Keeping a branch current
Section titled “Keeping a branch current”main moves while your branch is open. Two ways to catch up.
Rebase onto main
Section titled “Rebase onto main”git fetch origingit rebase origin/maingit push --force-with-leaseLinear history. Your commits appear as if written on top of the current main. Requires a force push
because your commits are rewritten.
Merge main into your branch
Section titled “Merge main into your branch”git fetch origingit merge origin/maingit pushNo force push, no rewriting. Costs a merge commit in the branch, and the PR diff can get noisy with “Merge branch ‘main’ into feature” commits.
| Rebase from main | Merge main in | |
|---|---|---|
| Force push needed | Yes | No |
| History | Linear | Merge commits inside the branch |
| Conflicts | Possibly once per commit | Once, total |
| Safe if others share the branch | No | Yes |
| Review diff | Clean | Cluttered |
Rule: rebase if the branch is yours alone; merge if anyone else has pulled it. If your team squash-merges PRs, it barely matters — the branch’s internal shape disappears on merge.
Resolving conflicts calmly
Section titled “Resolving conflicts calmly”Conflicts are not errors. They are Git correctly declining to guess between two valid edits.
A method that works:
-
Read
git statusfirst. It lists exactly which files are unmerged and what command to run next. Do not start editing until you know the scope. -
Turn on the three-way conflict style once, permanently. Seeing the common ancestor turns most conflicts from a guess into a decision.
Terminal window git config --global merge.conflictStyle zdiff3 -
Take one file at a time. For each conflict, ask what each side was trying to achieve, not which text looks right. The answer is often “both”, and the resolution is neither side verbatim.
-
Check for logical conflicts. Git merges text. If they renamed a function and you added a call to the old name in a different file, the merge succeeds and the build breaks. Compile and run the tests after every resolution.
-
git addeach file as you finish it.git statusthen shrinks visibly, which keeps a big conflict tractable. -
Abort without shame if it is going badly.
Terminal window git merge --abortgit rebase --abortgit cherry-pick --abortYou are returned exactly to the pre-conflict state. Then try a different approach — merge instead of rebase, or catch up in stages by rebasing onto an intermediate commit.
-
Enable
rerereso you never resolve the same conflict twice:Terminal window git config --global rerere.enabled true
For a hopeless conflict, take one side wholesale and re-apply the other change by hand:
git checkout --ours src/config.js # or --theirsgit add src/config.js# then manually re-apply what you needCommon mistakes and their fixes
Section titled “Common mistakes and their fixes”Committed to the wrong branch
Section titled “Committed to the wrong branch”Nothing pushed yet. Move the commits to where they belong.
# Currently on main with 2 commits that belong on a feature branchgit switch -c feature/parser # create the branch here — it keeps the commitsgit switch maingit reset --hard origin/main # rewind main to the remote's stategit switch feature/parserFor a single commit, cherry-pick is often simpler:
git log --oneline -1 # note the hash, e.g. 9c1f0a3git reset --hard HEAD~1 # remove it from the wrong branchgit switch correct-branchgit cherry-pick 9c1f0a3Committed but not pushed, and the commit is wrong
Section titled “Committed but not pushed, and the commit is wrong”git commit --amend # fix the messagegit add forgotten.js && git commit --amend --no-edit # add a filegit reset --soft HEAD~1 # un-commit, keep everything stagedgit reset --hard HEAD~1 # un-commit and discardNeed to undo a commit that is already pushed
Section titled “Need to undo a commit that is already pushed”Use revert. It adds an inverse commit, so everyone else’s history stays valid.
git revert 9c1f0a3git pushDo not reset --hard and force-push a shared branch. Everyone who pulled the commit now has a
history the server no longer has, and their next pull re-introduces it.
If it must be truly erased (a secret, a legal issue), that is history rewriting — coordinate with everyone, and see below.
Committed a secret
Section titled “Committed a secret”Assume it is compromised the moment it exists in a commit.
1. Rotate the credential first. Revoke the key, token, or password and issue a new one. Do this before any Git work. If the commit was pushed to a public repository, it was scraped within minutes — removing it from history does not un-leak it.
2. Stop tracking the file and ignore it.
git rm --cached .envecho ".env" >> .gitignoregit commit -m "Stop tracking .env"3. If it is only in the most recent, unpushed commit, amend it away:
git rm --cached .envgit commit --amend --no-edit4. If it is deeper in history, you must rewrite. git filter-repo is the current recommended
tool (git filter-branch is deprecated and dangerously slow):
pip install git-filter-repo
git filter-repo --path .env --invert-paths # remove the file from all historygit filter-repo --replace-text secrets.txt # or redact specific stringsfilter-repo intentionally removes the origin remote afterwards, so re-add it and force-push all
branches and tags. Every collaborator must re-clone — their old clones still contain the secret.
Prevention: never commit secret files, keep .env in .gitignore from day one, commit a
.env.example with placeholder values, and enable secret scanning + push protection on GitHub.
Committed a large file
Section titled “Committed a large file”Git stores every version of every file forever. A 200 MB binary bloats the repository permanently, and it slows every clone for everyone from then on. GitHub rejects individual files over 100 MB outright.
Not yet pushed:
git rm --cached huge-file.zipecho "huge-file.zip" >> .gitignoregit commit --amend --no-editAlready in history: rewrite with git filter-repo.
git filter-repo --path huge-file.zip --invert-pathsgit filter-repo --strip-blobs-bigger-than 10MFor files that legitimately belong in the repo (design assets, models, test fixtures), use Git LFS. It stores a small text pointer in Git and the real bytes on a separate server.
git lfs installgit lfs track "*.psd"git lfs track "*.mp4"git add .gitattributes # tracking rules live here and must be committedgit add design.psdgit commit -m "Add design source"Check what is bloating a repository:
git count-objects -vHAccidentally deleted a branch or reset too far
Section titled “Accidentally deleted a branch or reset too far”git refloggit switch -c recovered <hash>Covered fully in history and recovery.
Merge conflict in a lockfile
Section titled “Merge conflict in a lockfile”Never hand-merge package-lock.json, yarn.lock, poetry.lock, or similar. Regenerate them.
git checkout --theirs package-lock.json # or --ours; either is a starting pointnpm install # regenerate from package.jsongit add package-lock.jsonResolve package.json properly first, then let the tool produce a consistent lockfile.
A short checklist
Section titled “A short checklist”Before pushing:
git status # nothing unintended staged or untrackedgit diff --staged # read your own diffgit log --oneline -5 # messages make sensegit fetch && git log --oneline HEAD..origin/main # anything to catch up onBefore opening a PR: branch is current with main, CI passes locally, commits are cleaned up, the
description explains why, and you have read your own diff on GitHub.
Key points
Section titled “Key points”- Prefer GitHub flow unless you genuinely maintain multiple released versions.
- Atomic commits with imperative subjects and why-focused bodies make
revert,bisect, andblamework as intended. - Small, single-concern, self-reviewed PRs get better reviews and merge faster.
- Rebase onto
mainfor private branches, mergemainin for shared ones, and catch up often. - Conflicts are decisions, not failures — use
zdiff3, enablererere, and--abortfreely. - A leaked secret is compromised on commit: rotate first, rewrite second.
- Keep binaries out of Git, or put them behind Git LFS before they land.