Git Team Collaboration Handbook
A field-tested reference for engineering teams — covering daily workflows, safe branching, pull requests, undoing mistakes, auditing history, and the rules that keep a shared repository healthy.
Golden Rules of Team Git
Before diving into commands, every developer on a shared repository must internalize these rules. They prevent the most common — and most painful — collaboration accidents.
maingit reset --hard and git push --force on shared branches destroy teammates' work. Use git revert instead.feat/, fix/, chore/, docs/. It makes branch lists readable at a glance.Module 1 — Tracking Changes & Core Commands
The Daily Workflow
Every change you make to a file moves through a lifecycle before it becomes permanent history. Understanding these three states is the foundation of using Git confidently.
You just fixed a bug in auth.js and added a unit test in auth.test.js. You want to commit only the fix now and the test separately. The staging area lets you do exactly that.
$ # 1. CHECK — see the state of all tracked and untracked files git status # Output: # On branch feat/fix-auth-token # Changes not staged for commit: # modified: src/auth.js # Untracked files: # src/auth.test.js
$ # 2. STAGE — add only the files you want in this commit git add src/auth.js # stage a single file git add src/ # stage an entire directory git add -p # interactive: stage individual hunks within files # Verify what is staged before committing git status # Changes to be committed: # modified: src/auth.js
$ # 3. COMMIT — save a permanent snapshot with a meaningful message git commit -m "fix(auth): validate token expiry before issuing refresh" # For multi-line messages (preferred for complex changes), omit -m # to open your editor: git commit
Writing Clear Commit Messages
A commit message is the single most important artifact you create when committing. Future-you, code reviewers, and automated changelogs all depend on it.
| Type | Use for | Example |
|---|---|---|
feat | New feature | feat(cart): add quantity selector to checkout |
fix | Bug fix | fix(api): handle null user on profile endpoint |
chore | Tooling, deps, config | chore: upgrade eslint to v9 |
docs | Documentation only | docs: add E2B sandbox setup to README |
refactor | Code restructure, no behavior change | refactor(db): extract query builder to helper |
test | Adding or updating tests | test(auth): cover token expiry edge cases |
perf | Performance improvement | perf(render): memoize expensive table sort |
revert | Reverting a previous commit | revert: revert "feat(cart): add quantity selector" |
Anatomy of a Great Commit Message
# Format: <type>(<scope>): <short imperative summary> ← subject line, max 72 chars # ← blank line separator # Body: explain the WHY and WHAT CHANGED, not the how. ← wrap at 72 chars # ← blank line # Footer: reference issues, breaking changes ← optional fix(auth): validate token expiry before issuing refresh Previously, the /auth/refresh endpoint would issue a new token even if the existing token had already expired more than 24 hours ago. This allowed stale sessions to persist indefinitely. The fix adds an explicit expiry check against the DB record before proceeding. If the token is too old, we return 401 and force re-authentication. Closes #412 BREAKING CHANGE: clients must handle the new 401 on /auth/refresh
"fix bug", "wip", or "changes" are useless when debugging a production incident at 2am. Write for the version of yourself six months from now who has zero context.Tracking Changes with git diff
Before you stage or commit, use git diff to see exactly what changed. This is your last chance to catch a stray console.log, a debug flag, or an accidental whitespace-only change.
$ # Show unstaged changes (working directory vs staging area) git diff # Example output: # diff --git a/src/auth.js b/src/auth.js # --- a/src/auth.js # +++ b/src/auth.js # - if (token.isValid()) { # + if (token.isValid() && !token.isExpired()) {
$ # Show staged changes (staging area vs last commit) — review before committing git diff --staged # alias: git diff --cached (older style, same result)
$ # Compare two branches — useful before opening a PR git diff main..feat/fix-auth-token # Compare a specific file between branches git diff main..feat/fix-auth-token -- src/auth.js # Show only the names of changed files (great for big diffs) git diff --name-only main..feat/fix-auth-token # Show a summary with change counts git diff --stat main..feat/fix-auth-token
git diff --staged to do a final review of exactly what's going in. This catches accidental inclusions of debug code, credentials, or unrelated changes that snuck into your staging area.Module 2 — Branching and Syncing
Branch Isolation — Never Work on main
Working directly on main means every work-in-progress commit immediately becomes "official" history. If your feature breaks tests or needs changes after review, you have no clean way to isolate it. Feature branches give you a safe sandbox.
Rule: Every piece of work — no matter how small — gets its own branch. Branch off the latest main, do your work, open a PR, get it merged. Never commit directly to main or develop.
$ # Modern syntax: git switch (replaces the confusing overloaded git checkout) # 1. Make sure you're on main and it's up to date git switch main git pull origin main # 2. Create and immediately switch to a new branch (-c = --create) git switch -c feat/user-profile-page # Branch naming conventions used by most teams: # feat/<ticket-or-description> — new feature # fix/<ticket-or-description> — bug fix # chore/<description> — dependency bump, config # docs/<description> — documentation only # hotfix/<description> — urgent production fix
$ # List all local branches (current branch has *) git branch # List all branches including remote-tracking ones git branch -a # Switch between existing branches git switch main git switch feat/user-profile-page # Delete a merged branch (safe — Git refuses if unmerged) git branch -d feat/user-profile-page # Force-delete an unmerged branch (use when you abandon work) git branch -D feat/user-profile-page # Delete the remote copy after your PR is merged git push origin --delete feat/user-profile-page
git restore: The modern way to discard unstaged changes to a file is git restore src/file.js. To unstage a file you've added: git restore --staged src/file.js. Both replace the error-prone overloaded git checkout -- file syntax.Staying Updated — fetch vs pull
When teammates push commits to main while you're working on your branch, your branch drifts further from the shared baseline. The longer you wait to sync, the nastier the eventual merge conflict. Sync regularly.
| Command | What it does | Safe? |
|---|---|---|
git fetch origin | Downloads all new commits and branch data from the remote — does not touch your working tree. Purely a download operation. | Always safe |
git pull origin main | Fetch + merge into current branch. Updates your working tree and history immediately. | Use carefully |
git pull --rebase origin main | Fetch + rebase your commits on top of the latest main. Produces cleaner linear history. | Preferred for feature branches |
You've been on feat/user-profile-page for 3 days. Two teammates have merged PRs into main that touch files near yours. You need to bring those changes into your branch before opening a PR, to resolve any conflicts locally rather than in the PR review.
$ # RECOMMENDED WORKFLOW: Rebase your feature branch onto latest main # Step 1: Fetch all remote changes (no working tree modifications) git fetch origin # Step 2: See what came in — compare your branch to remote main git log HEAD..origin/main --oneline # Step 3: Rebase your feature branch on top of the updated main # Your commits are replayed on top, as if you just branched off now git rebase origin/main # Step 4: If there are conflicts, Git pauses and tells you which files # Resolve each conflicted file, then: git add src/conflicted-file.js # mark as resolved git rebase --continue # continue the rebase # Step 5: Push your rebased branch (needs --force-with-lease if already pushed) git push --force-with-lease origin feat/user-profile-page
$ # ALTERNATIVE: Merge main into your branch (creates a merge commit) # Use this if you prefer preserving the merge topology in history git switch feat/user-profile-page git fetch origin git merge origin/main # Resolve any conflicts, then: git add . git commit # creates a merge commit: "Merge branch 'main' into feat/..." git push origin feat/user-profile-page
--force-with-lease after). Merge preserves topology but creates noisy merge commits. Most teams use rebase for feature branches and merge for the final PR.Module 3 — Merging and Pull Requests
Merging Locally
While PRs are the standard for team workflows, knowing how to merge locally is essential for hotfixes, solo projects, or understanding what the merge button on GitHub actually does.
$ # Full sequence to merge a feature branch into main locally # 1. Ensure your feature branch is up to date (see Module 2) git switch feat/user-profile-page git fetch origin git rebase origin/main # 2. Run your tests before merging — never merge broken code npm test # or: pytest, cargo test, go test ./... etc. # 3. Switch to main and pull the latest git switch main git pull origin main # 4. Merge — use --no-ff to always create a merge commit # (without --no-ff a fast-forward may produce no merge commit at all) git merge --no-ff feat/user-profile-page # 5. Push to remote git push origin main # 6. Clean up the local and remote branch git branch -d feat/user-profile-page git push origin --delete feat/user-profile-page
main branch with a rule that requires at least one approving review before merging.The Pull Request Workflow
A Pull Request is not just a code delivery mechanism — it's a conversation, a documentation artifact, and a quality gate. Every PR represents a unit of work that is reviewable, deployable, and revertable.
$ # Push your branch to the remote for the first time git push -u origin feat/user-profile-page # -u sets the upstream, so future `git push` works without specifying the remote # GitHub/GitLab prints the PR creation URL directly after push — click it # Or use the GitHub CLI: gh pr create --title "feat: add user profile page" --body-file .github/pull_request_template.md
Anatomy of a Good PR Description
## What this PR does Adds a user profile page at `/users/[id]` that displays the user's avatar, bio, join date, and recent activity feed. ## Why Part of the Q2 user engagement initiative (see ticket #388). Users currently have no public-facing profile — this is the MVP. ## Changes - `src/pages/users/[id].tsx` — new profile page component - `src/api/users.ts` — new `getUserActivity(userId)` endpoint - `src/components/ActivityFeed.tsx` — extracted from dashboard ## How to test 1. `npm run dev` 2. Visit `http://localhost:3000/users/1` 3. Verify avatar, bio, and last 5 activity items render ## Screenshots [attach before/after if it's a UI change] ## Checklist - [x] Tests added/updated - [x] No console.log left in code - [x] Responsive on mobile - [ ] Accessibility audit (follow-up ticket #392)
Code Review Best Practices
$ # After review feedback: make changes, add commits to the same branch # DO NOT close and re-open the PR — push to the same branch git add src/pages/users/[id].tsx git commit -m "fix(profile): handle missing avatar gracefully" git push origin feat/user-profile-page # The PR updates automatically on GitHub/GitLab
Module 4 — Undoing Mistakes Safely
git revert — The Safe Undo for Shared Branches
git revert is the only acceptable way to undo a commit that has already been pushed to a shared branch. It creates a brand-new commit that applies the inverse of the target commit — effectively cancelling its changes while preserving the full history.
Revert adds a commit — it never removes or rewrites existing history. Every developer who has already pulled the bad commit will receive the fix cleanly on their next pull. No force-push needed. No conflict with anyone's local copy.
A commit was merged to main two hours ago that accidentally deleted the user preferences table migration. CI is broken and other developers have already pulled the change. You need to undo it without disrupting anyone.
$ # Find the commit hash you want to undo git log --oneline -10 # Output: # a3f9d12 chore: update README badges # 8e2c4b1 feat(auth): add OAuth2 login ← the problematic commit # 7d1a8f3 fix(db): add missing index on users.email # Revert the problematic commit by its hash git revert 8e2c4b1 # Git opens your editor to write the revert commit message. # The default is fine, but add context: WHY are you reverting? # "Revert 'feat(auth): add OAuth2 login' — caused DB connection leak in prod (#441)"
$ # After saving the message, push the revert commit git push origin main # Everyone pulls this and the bad changes are undone cleanly. # Revert without opening an editor (uses auto-generated message) git revert 8e2c4b1 --no-edit # Revert a range of commits (reverts each commit individually, newest first) git revert 7d1a8f3..a3f9d12 # Revert a merge commit (requires -m to specify the mainline parent) git revert -m 1 a3f9d12 # -m 1 means "keep the first parent" (the branch you merged INTO)
git revert <revert-commit-hash> to undo the revert. The history stays intact and the intent is clear.git reset — Local-Only Undo
git reset rewrites the repository history by moving the branch pointer back to a previous commit. Because it destroys commits from the branch's perspective, it must only ever be used on commits that have not been pushed to a shared remote.
If you reset commits that teammates have already pulled, you will create a diverged history that forces everyone to resolve painful conflicts. Once a commit is on a shared branch, use git revert instead.
| Command | Branch pointer | Staging area | Working directory | Use when |
|---|---|---|---|---|
git reset --soft HEAD~1 |
Moved back ✓ | Changes kept staged | Unchanged | Undo last commit, keep work staged to re-commit |
git reset HEAD~1 (default = --mixed) |
Moved back ✓ | Changes unstaged | Unchanged | Undo last commit and unstage, keep file changes |
git reset --hard HEAD~1 |
Moved back ✓ | Cleared | Files reverted | Completely discard last commit and all its changes |
$ # Scenario: You committed to the wrong branch (not pushed yet) # 1. Undo the commit but keep changes staged (ready to re-commit) git reset --soft HEAD~1 # Switch to the correct branch and re-commit git switch feat/correct-branch git commit -m "fix: the commit that was on the wrong branch" # Scenario: Last commit message is wrong, not pushed yet git reset --soft HEAD~1 # unstage but keep changes staged git commit -m "fix(auth): correct commit message" # Scenario: Completely discard last N unpushed commits git reset --hard HEAD~3 # ⚠ permanently deletes last 3 commits' changes
git reset --hard is permanent. There is no undo prompt. Your changes are gone unless you have them stashed or in your editor's undo buffer. Double-check the commit hash with git log --oneline before running it. If you mess up, git reflog may help you recover lost commits for up to 90 days.Module 5 — The Danger Zone (Force Pushing)
Why git push --force is Destructive
git push --force overwrites the remote branch with your local version, regardless of what's on the remote. When done on a branch that other developers have pulled, it breaks their repositories in ways that can take hours to untangle.
Developer A and Developer B both pull main. Developer A rebases locally (which rewrites commits), then runs git push --force on main. Developer B's local main now has a completely different history from the remote — when B tries to push their work, Git will reject it. B will have to navigate a messy history divergence, potentially losing work or creating duplicate commits.
# Visual representation of what force push does to shared history: # BEFORE force push — everyone sees the same history: # A → B → C → D (remote/main and both devs' local) # Developer A rebases (rewrites history) then force-pushes: # A → B' → C' → D' (remote/main now rewritten) # Developer B still has: # A → B → C → D → E (B added commit E on top of old history) # Now B and remote have diverged — B cannot push normally. # Git says: "Updates were rejected because the remote contains work" # B has to figure out which commits are duplicates — painful and risky.
git push --force on main should be treated as a production incident. In most organizations, force push to the default branch is blocked entirely via branch protection rules on GitHub/GitLab. If you've done it by accident, immediately alert the team on Slack/Discord and help everyone recover.git push --force-with-lease — The Safer Alternative
Sometimes a force push is genuinely necessary — most commonly when you've rebased a feature branch that you already pushed to the remote. In this case, use --force-with-lease instead of --force.
--force-with-lease checks that the remote ref is where you last saw it before pushing. If someone else pushed commits to your branch while you were rebasing (e.g., a teammate pushed a fix), the command will fail with a clear error. Regular --force would have silently overwritten their work.
$ # Correct workflow after rebasing a feature branch that was already pushed # 1. You have pushed feat/my-feature before # 2. You then rebase it on top of latest main (rewrites commits) git rebase origin/main # 3. Now your local branch and the remote are diverged # Regular push will fail: "Updates were rejected (non-fast-forward)" # 4. Use --force-with-lease — will succeed ONLY if remote hasn't changed git push --force-with-lease origin feat/my-feature # If it fails with "stale info", someone pushed to your branch. # Fetch first, resolve, then retry: git fetch origin git rebase origin/feat/my-feature # incorporate their changes git push --force-with-lease origin feat/my-feature
| Command | Overwrites without check | Safe to use on shared branches | Use case |
|---|---|---|---|
git push | No — only fast-forwards | ✅ Yes | All normal pushes |
git push --force | ✅ Yes — always overwrites | 🚫 Never | Last resort on your own branch only |
git push --force-with-lease | Only if remote unchanged | ⚠️ Only on your feature branch | After rebasing a pushed branch |
--force.Module 6 — Auditing & Checking Logs
Viewing History Effectively
The default git log output is verbose. Learning a few flags transforms it into a powerful audit tool that reveals when features were added, when bugs were introduced, and how branches diverged.
$ # ── Clean graphical log — the most useful alias you'll ever set ────────── git log --oneline --graph --all --decorate # Example output: # * a3f9d12 (HEAD -> main, origin/main) chore: update README badges # * 8e2c4b1 Merge pull request #55 from feat/user-profile-page # |\ # | * f4c1e89 test(profile): add activity feed tests # | * 2b9a7d3 feat(profile): implement user activity feed # | * 1a8c5f2 feat(profile): add profile page scaffolding # |/ # * 7d1a8f3 fix(db): add missing index on users.email
$ # Set a permanent alias for this (add to ~/.gitconfig) git config --global alias.lg "log --oneline --graph --all --decorate" # Now you can just run: git lg
$ # ── Other useful log filters ────────────────────────────────────────────── # Show last 10 commits git log --oneline -10 # Show commits by a specific author git log --author="Alice" --oneline # Show commits in a date range git log --since="2025-01-01" --until="2025-03-31" --oneline # Show commits that touched a specific file git log --oneline --follow -- src/auth.js # Show commits whose message matches a pattern git log --grep="fix(auth)" --oneline # Show commits that added/removed a specific string in code git log -S "getUserActivity" --oneline # Show commits between two tags (useful for release notes) git log v1.3.0..v1.4.0 --oneline
$ # ── Inspect a specific commit ───────────────────────────────────────────── # Show the full diff of a commit git show 8e2c4b1 # Show only the files changed in that commit git show 8e2c4b1 --name-only # Show the state of a file at a specific commit git show 8e2c4b1:src/auth.js
$ # ── git reflog — your safety net ───────────────────────────────────────── # reflog records every movement of HEAD — even after reset, rebase, or branch deletes. # If you accidentally ran git reset --hard, this can save you. git reflog # Output: # a3f9d12 HEAD@{0}: commit: chore: update README badges # 8e2c4b1 HEAD@{1}: merge: Merge pull request #55 # f4c1e89 HEAD@{2}: reset: moving to HEAD~1 ← you can go back here! # Recover a lost commit using its reflog hash git switch -c recovery-branch f4c1e89
git revert on the shared branch.git blame — Finding Who Changed What
git blame annotates every line of a file with the commit hash, author, and date of the last change to that line. It's invaluable when you encounter unexpected behavior and need to understand the context of a line — and crucial for respectful code archaeology.
You're debugging a production bug. You find a suspicious if condition in src/billing/calculator.js that seems wrong. You need to know: who wrote this, when, and — crucially — which ticket or PR it was part of so you can understand the original intent.
$ # Blame a file — shows author and commit for every line git blame src/billing/calculator.js # Example output: # ^7d1a8f3 (Alice 2025-11-12 14:32:01 +0000 42) function applyDiscount(price, pct) { # 8e2c4b1a (Bob 2025-12-03 09:15:44 +0000 43) if (pct > 100) pct = 100; ← suspicious! # ^7d1a8f3 (Alice 2025-11-12 14:32:01 +0000 44) return price * (1 - pct / 100); # ^7d1a8f3 (Alice 2025-11-12 14:32:01 +0000 45) }
$ # Blame a specific line range (line 40 to 50) git blame -L 40,50 src/billing/calculator.js # Show the full commit hash (default is shortened) git blame -l src/billing/calculator.js # Ignore whitespace-only changes (prevents whitespace reformatting from # obscuring the true author of the logic) git blame -w src/billing/calculator.js # Blame at a specific commit in the past (not the current HEAD) git blame 8e2c4b1 src/billing/calculator.js
$ # Once you have the commit hash, dig into it for full context git show 8e2c4b1 # Shows: full commit message (which should reference the ticket), # author, date, and the complete diff of all files in that commit. # Find the PR that introduced this commit (GitHub-specific) gh pr list --search 8e2c4b1
git show before drawing conclusions.git bisect — Binary Search for Bugs
When you know a bug was introduced "somewhere between last week and today" but don't know which commit caused it, git bisect uses binary search to find the culprit commit in O(log N) steps.
$ # Start a bisect session git bisect start git bisect bad # current HEAD has the bug git bisect good v1.3.0 # this release was known good # Git checks out a commit halfway between good and bad. # Run your test / reproduce the bug, then mark it: git bisect bad # bug is present at this commit git bisect good # bug is NOT present at this commit # Repeat until Git identifies the first bad commit: # "8e2c4b1 is the first bad commit" # Always reset when done to return to your original HEAD git bisect reset
Command Quick Reference
Daily Workflow
git statusgit add -pgit diff --stagedgit commit -m "..."git restore src/file.jsgit restore --staged src/f.jsBranching & Syncing
git switch -c feat/namegit switch maingit fetch origingit rebase origin/maingit branch -d feat/namegit push origin --delete feat/nameUndoing Mistakes
git revert <hash>git reset --soft HEAD~1git reset --hard HEAD~1git refloggit push --force-with-leaseAuditing & History
git log --oneline --graph --allgit log -S "searchTerm"git show <hash>git blame -L 40,50 file.jsgit bisect start / bad / goodResources & Further Reading
- Official git-scm.com/doc — The official Git reference manual and Pro Git book (free online)
- Interactive learngitbranching.js.org — Visual, interactive Git branching exercises
- Conventional Commits conventionalcommits.org — The commit message specification used in this handbook
- GitHub GitHub PR Documentation — Official guide to Pull Requests, reviews, and branch protection
- Cheatsheet GitHub Git Cheat Sheet (PDF) — Quick reference card
-
CLI
GitHub CLI Manual —
gh pr create,gh pr review, and more - Best Practices Atlassian: Comparing Git Workflows — Feature Branch, Gitflow, Forking, Trunk-based compared