Skip to content

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.

Three models cover almost everything in use. Pick the simplest one your release process tolerates.

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.

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.

Terminal window
git switch main && git pull
git switch -c fix/parser-off-by-one
# work, commit
git push -u origin fix/parser-off-by-one
gh 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.

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 main several 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

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.

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 header
ended exactly on the chunk boundary, producing a spurious null in
the parsed value. Clamp the read to the remaining length instead of
assuming a following byte exists.
We considered buffering an extra byte instead, but that changes the
streaming 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.

A widely adopted convention that puts a machine-readable type prefix on the subject:

feat(parser): support multi-line headers
fix(api): retry on 503 responses
docs: clarify the timeout option
refactor(query): extract the builder
chore(deps): bump lodash to 4.17.21
test(parser): add boundary case

Format: <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.

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.

main moves while your branch is open. Two ways to catch up.

Terminal window
git fetch origin
git rebase origin/main
git push --force-with-lease

Linear history. Your commits appear as if written on top of the current main. Requires a force push because your commits are rewritten.

Terminal window
git fetch origin
git merge origin/main
git push

No 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.

Conflicts are not errors. They are Git correctly declining to guess between two valid edits.

A method that works:

  1. Read git status first. It lists exactly which files are unmerged and what command to run next. Do not start editing until you know the scope.

  2. 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
  3. 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.

  4. 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.

  5. git add each file as you finish it. git status then shrinks visibly, which keeps a big conflict tractable.

  6. Abort without shame if it is going badly.

    Terminal window
    git merge --abort
    git rebase --abort
    git cherry-pick --abort

    You 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.

  7. Enable rerere so 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:

Terminal window
git checkout --ours src/config.js # or --theirs
git add src/config.js
# then manually re-apply what you need

Nothing pushed yet. Move the commits to where they belong.

Terminal window
# Currently on main with 2 commits that belong on a feature branch
git switch -c feature/parser # create the branch here — it keeps the commits
git switch main
git reset --hard origin/main # rewind main to the remote's state
git switch feature/parser

For a single commit, cherry-pick is often simpler:

Terminal window
git log --oneline -1 # note the hash, e.g. 9c1f0a3
git reset --hard HEAD~1 # remove it from the wrong branch
git switch correct-branch
git cherry-pick 9c1f0a3

Committed but not pushed, and the commit is wrong

Section titled “Committed but not pushed, and the commit is wrong”
Terminal window
git commit --amend # fix the message
git add forgotten.js && git commit --amend --no-edit # add a file
git reset --soft HEAD~1 # un-commit, keep everything staged
git reset --hard HEAD~1 # un-commit and discard

Need 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.

Terminal window
git revert 9c1f0a3
git push

Do 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.

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.

Terminal window
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Stop tracking .env"

3. If it is only in the most recent, unpushed commit, amend it away:

Terminal window
git rm --cached .env
git commit --amend --no-edit

4. 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):

Terminal window
pip install git-filter-repo
git filter-repo --path .env --invert-paths # remove the file from all history
git filter-repo --replace-text secrets.txt # or redact specific strings

filter-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.

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:

Terminal window
git rm --cached huge-file.zip
echo "huge-file.zip" >> .gitignore
git commit --amend --no-edit

Already in history: rewrite with git filter-repo.

Terminal window
git filter-repo --path huge-file.zip --invert-paths
git filter-repo --strip-blobs-bigger-than 10M

For 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.

Terminal window
git lfs install
git lfs track "*.psd"
git lfs track "*.mp4"
git add .gitattributes # tracking rules live here and must be committed
git add design.psd
git commit -m "Add design source"

Check what is bloating a repository:

Terminal window
git count-objects -vH

Accidentally deleted a branch or reset too far

Section titled “Accidentally deleted a branch or reset too far”
Terminal window
git reflog
git switch -c recovered <hash>

Covered fully in history and recovery.

Never hand-merge package-lock.json, yarn.lock, poetry.lock, or similar. Regenerate them.

Terminal window
git checkout --theirs package-lock.json # or --ours; either is a starting point
npm install # regenerate from package.json
git add package-lock.json

Resolve package.json properly first, then let the tool produce a consistent lockfile.

Before pushing:

Terminal window
git status # nothing unintended staged or untracked
git diff --staged # read your own diff
git log --oneline -5 # messages make sense
git fetch && git log --oneline HEAD..origin/main # anything to catch up on

Before 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.

  • Prefer GitHub flow unless you genuinely maintain multiple released versions.
  • Atomic commits with imperative subjects and why-focused bodies make revert, bisect, and blame work as intended.
  • Small, single-concern, self-reviewed PRs get better reviews and merge faster.
  • Rebase onto main for private branches, merge main in for shared ones, and catch up often.
  • Conflicts are decisions, not failures — use zdiff3, enable rerere, and --abort freely.
  • 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.