Back to handbooks index

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.

Git 2.x+ git switch · git restore Team Safety First No Force Push on main March 2026

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.

🌿
1. Never commit directly to main
main is the shared truth. Every change goes through a feature branch and a Pull Request — no exceptions, even for "tiny" fixes.
🔄
2. Pull before you push
Always sync with the remote before pushing. Fetch and rebase (or merge) so your commits sit on top of the latest history.
3. Never rewrite shared history
git reset --hard and git push --force on shared branches destroy teammates' work. Use git revert instead.
📝
4. Write meaningful commit messages
A commit message is a letter to your future self and teammates. It should explain the why, not just the what.
🔍
5. Keep PRs small and focused
A PR that touches one logical unit of work is reviewed faster, merged cleaner, and easier to revert if it causes issues.
🏷️
6. Use consistent branch naming
Agree on a naming convention: 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.

Working Directory
→ git add →
Staging Area (Index)
→ git commit →
Local Repository
→ git push →
Remote (GitHub)
📅 Real-world scenario

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
💡
git add -p is underused. It lets you stage individual chunks of a file — so if you made two unrelated edits to the same file, you can split them into two separate, logical commits. This makes history far easier to read and bisect.

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.

TypeUse forExample
featNew featurefeat(cart): add quantity selector to checkout
fixBug fixfix(api): handle null user on profile endpoint
choreTooling, deps, configchore: upgrade eslint to v9
docsDocumentation onlydocs: add E2B sandbox setup to README
refactorCode restructure, no behavior changerefactor(db): extract query builder to helper
testAdding or updating teststest(auth): cover token expiry edge cases
perfPerformance improvementperf(render): memoize expensive table sort
revertReverting a previous commitrevert: 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
Vague messages hurt the whole team. Messages like "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
Workflow habit: Before every commit, run 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.

The Feature Branch Pattern One branch per task / ticket

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.

Isolated development Parallel work Clean PR reviews Easy rollback
$ # 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
💡
Restore files with 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.

CommandWhat it doesSafe?
git fetch originDownloads all new commits and branch data from the remote — does not touch your working tree. Purely a download operation.Always safe
git pull origin mainFetch + merge into current branch. Updates your working tree and history immediately.Use carefully
git pull --rebase origin mainFetch + rebase your commits on top of the latest main. Produces cleaner linear history.Preferred for feature branches
📅 Real-world scenario

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
Rebase vs Merge — team alignment matters. Pick one strategy per repo and stick to it. Rebase produces a clean linear history but rewrites commits (so you need --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
In a team, prefer PRs over local merges to main. Direct pushes to main bypass code review, CI checks, and the audit trail that PRs provide. Most teams protect the 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.

Branch off main
Develop & Commit
Push branch
Open PR
CI passes
Code Review
Approved & Merged
$ # 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

👁️
As a reviewer
Review the intent, not just syntax. Ask: Does this do what the PR says? Are there edge cases? Is this maintainable in 6 months? Be specific in comments — link to alternatives, not just problems.
✍️
As an author
Respond to every comment. Don't just push a fix silently — reply "Fixed in abc1234" or "Disagree because X, Y". Resolve threads only after the reviewer agrees. Never merge your own PRs without a review.
$ # 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.

Why revert is safe

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.

Safe on shared branches Preserves history No force-push needed Easily reversible
📅 Real-world scenario

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)
💡
Reverting a revert: If you revert a feature and later decide to bring it back properly, you can 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.

⛔ Only use git reset on local, unpushed commits

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.

Never on pushed commits Destroys history Safe for local work-in-progress
CommandBranch pointerStaging areaWorking directoryUse 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.

The Catastrophe Scenario

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.

How --force-with-lease protects you

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

Only on personal feature branches Will fail if remote was updated Still destructive if overwriting another dev's 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
CommandOverwrites without checkSafe to use on shared branchesUse case
git pushNo — only fast-forwards✅ YesAll normal pushes
git push --force✅ Yes — always overwrites🚫 NeverLast resort on your own branch only
git push --force-with-leaseOnly if remote unchanged⚠️ Only on your feature branchAfter rebasing a pushed branch
Protect your main branch on GitHub/GitLab. Go to Settings → Branches → Add rule → check "Require a pull request before merging" and "Block force pushes". This makes it technically impossible for anyone to accidentally force-push to main, even with --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 reflog expires after 90 days by default. It's stored locally and not synced to the remote — so it only helps you recover your own mistakes on your own machine. For shared recovery, use a 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.

📅 Real-world scenario

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 blame etiquette: Blame is a tool for understanding code, not assigning fault. The person who "owns" the last commit on a line may not have written the logic — they may have just reformatted it. Always read the full commit context with 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

Command
What it does
git status
Show staged, unstaged, and untracked file states
git add -p
Interactively stage individual hunks within files
git diff --staged
Review exactly what will go into the next commit
git commit -m "..."
Create a commit with a message
git restore src/file.js
Discard unstaged changes to a file
git restore --staged src/f.js
Unstage a file (keep working tree changes)

Branching & Syncing

Command
What it does
git switch -c feat/name
Create and switch to a new branch
git switch main
Switch to an existing branch
git fetch origin
Download remote changes without touching working tree
git rebase origin/main
Replay your commits on top of latest main
git branch -d feat/name
Delete a merged local branch
git push origin --delete feat/name
Delete a remote branch after merging

Undoing Mistakes

Command
What it does
git revert <hash>
Safe undo — creates a new commit reversing the target
git reset --soft HEAD~1
Undo last commit, keep changes staged (local only)
git reset --hard HEAD~1
Discard last commit and its changes permanently (local only)
git reflog
View all HEAD movements — recovery safety net
git push --force-with-lease
Safe force push — fails if remote was updated by someone else

Auditing & History

Command
What it does
git log --oneline --graph --all
Visual branch graph of all commits
git log -S "searchTerm"
Find commits that added/removed a specific string
git show <hash>
Show full diff and message for a commit
git blame -L 40,50 file.js
See who last changed each line in a range
git bisect start / bad / good
Binary search to find the commit that introduced a bug

Resources & Further Reading