Learning Objectives

By the end of this module, you will be able to:

  • Write effective pull request descriptions that streamline code review
  • Apply code review best practices as both an author and a reviewer
  • Maintain commit hygiene through atomic commits and meaningful messages
  • Manage long-lived feature branches without accumulating merge debt
  • Use .mailmap to unify contributor identities across a project's history
  • Annotate commits with git notes for post-hoc metadata
  • Configure CODEOWNERS files for automated review routing
  • Understand monorepo tooling options (Nx, Turborepo, Bazel)
  • Sign commits with GPG or SSH keys for verified identity
  • Set up branch protection rules and PR workflows for team collaboration

1. Commit Hygiene: The Foundation of Collaboration

Every collaboration workflow starts with how individual commits are written. Poor commit hygiene makes code review painful, bisecting impossible, and history useless.

Atomic Commits

An atomic commit is a single, self-contained unit of change. It does one thing, does it completely, and leaves the repository in a working state.

Bad — "kitchen sink" commit:
  abc1234  Add user search, fix login bug, update README, refactor database layer

Good — atomic commits:
  abc1234  feat: add user search endpoint
  def5678  fix: prevent redirect loop on login
  789abcd  docs: update API reference in README
  012efgh  refactor: extract database connection pooling

Rules for Atomic Commits

  1. Each commit should compile and pass tests. If someone checks out any commit in your history, the project should work.
  2. One logical change per commit. A bug fix is one commit. A new feature is one commit (or a series of small, incremental ones). Don't mix unrelated changes.
  3. Use git add -p for surgical staging. If you've made multiple changes in one file, stage them as separate commits:
# Stage hunks interactively
git add -p app.js
# Git shows each hunk and asks: Stage this hunk? [y,n,q,a,d,s,e,?]
# y = stage this hunk
# n = skip this hunk
# s = split this hunk into smaller pieces
# e = manually edit the hunk
  1. Avoid "WIP" commits on shared branches. If you need work-in-progress saves locally, squash them before merging.

Writing Good Commit Messages

A commit message has three parts — only the subject is required, but body and footer add value for non-trivial changes:

<type>(<scope>): <subject>        ← subject line (50 chars or less)
                                   ← blank line
<body>                             ← what and why (wrap at 72 chars)
                                   ← blank line
<footer>                           ← references, breaking changes

Subject line rules:

  • Use imperative mood: "Add search" not "Added search" or "Adds search"
  • Lowercase after the type prefix
  • No period at the end
  • 50 characters or fewer (hard limit: 72)

Body guidelines:

  • Explain what changed and why, not how (the diff shows how)
  • Wrap at 72 characters
  • Use bullet points for multiple items

Examples:

fix: prevent null pointer in user lookup

The getUserById method could return null when the user was
soft-deleted but the cache still held a reference. This caused
a NullPointerException downstream in the permissions check.

Added a null check after cache lookup and fall through to the
database query when the cached entry is stale.

Fixes #1234
feat(auth): add OAuth2 login with Google

- Add Google OAuth2 provider configuration
- Implement token exchange and refresh flow
- Store OAuth tokens encrypted in the session table
- Add "Sign in with Google" button to login page

Breaking change: AUTH_PROVIDER env var is now required.
Previously defaulted to "local".

Closes #567

Commit Message Anti-Patterns

Anti-PatternExampleWhy It's Bad
Meaningless messagefix stuffNo one knows what was fixed
Too vagueupdate codeWhich code? What update?
Describing the diffchange line 42 to return falseThe diff already shows this
Massive scoperefactor everythingImpossible to review or revert
Ticket number onlyJIRA-1234Requires external lookup to understand
Past tensefixed the bugConvention is imperative mood

2. Pull Request Best Practices

Writing Effective PR Descriptions

A good PR description answers three questions: What changed? Why? How do I test it?

## Summary
Add user search with fuzzy matching and result pagination.
 
## Motivation
Users with large teams reported difficulty finding specific team members.
The existing dropdown was limited to 20 entries with exact-match filtering.
See #456 for the original request.
 
## Changes
- Add `/api/users/search` endpoint with fuzzy matching (Levenshtein distance)
- Add `SearchBar` component with debounced input (300ms)
- Add pagination to search results (20 per page)
- Add search index to `users.name` column
 
## Testing
1. Navigate to Team Settings → Members
2. Type a partial name in the search bar
3. Verify results appear after typing stops (300ms debounce)
4. Verify pagination controls appear for > 20 results
5. Verify misspelled names still return fuzzy matches
 
## Screenshots
[Before/after screenshots for UI changes]
 
## Notes
- The search index migration may take ~2 minutes on production
- Fuzzy matching threshold is configurable via `SEARCH_FUZZ_THRESHOLD` env var

PR Size Guidelines

PR SizeLines ChangedReview TimeRecommendation
Small< 10015 minIdeal
Medium100–30030 minAcceptable
Large300–5001 hourSplit if possible
Too large> 5002+ hoursMust split

Research from Google and Microsoft shows that review quality drops sharply above 200-400 lines. Split large features into stacked PRs:

PR 1: Add database schema and migration        (50 lines)
PR 2: Add API endpoint with validation         (120 lines)
PR 3: Add frontend search component            (150 lines)
PR 4: Add integration tests                    (80 lines)

Stacked PRs

Stacked PRs are a series of small, dependent PRs that together implement a large feature:

main ◄── PR 1 (schema) ◄── PR 2 (API) ◄── PR 3 (UI) ◄── PR 4 (tests)
# Create stacked branches
git checkout main
git checkout -b feature/search-schema
# ... add schema ...
git push -u origin feature/search-schema
 
git checkout -b feature/search-api
# ... add API ...
git push -u origin feature/search-api
 
git checkout -b feature/search-ui
# ... add UI ...
git push -u origin feature/search-ui

When creating PRs, set each PR's base branch to the previous PR's branch (not main). After PR 1 merges into main, rebase PR 2 onto main.

As a PR Author

  1. Self-review before requesting review. Read your own diff on GitHub/GitLab as if you were the reviewer. You'll catch obvious issues.
  2. Keep the PR focused. Don't sneak in unrelated refactors or "while I'm here" fixes.
  3. Add context in comments. If a code section is tricky, add a PR comment explaining your reasoning.
  4. Respond to all review comments. Even if you disagree, acknowledge the feedback.
  5. Don't force-push during active review without telling reviewers — it invalidates their comments and makes it hard to see what changed.

As a PR Reviewer

  1. Review within 24 hours. Slow reviews block the team and encourage large, batched PRs.
  2. Start with the PR description. Understand the why before reading code.
  3. Check the big picture first. Is the approach right? Then drill into implementation details.
  4. Distinguish blocking vs. non-blocking feedback:
    • "Must fix: This SQL query is vulnerable to injection" (blocking)
    • "Nit: Consider renaming data to userRecords for clarity" (non-blocking)
  5. Approve with comments when appropriate. Not every suggestion needs to block the merge.
  6. Test locally for complex changes. Don't just read the diff — pull the branch and run it.
# Pull a PR for local testing
gh pr checkout 42
# or
git fetch origin pull/42/head:pr-42
git checkout pr-42

3. Managing Long-Lived Feature Branches

The Problem

Long-lived feature branches (weeks or months) diverge from main, accumulating merge debt:

Week 1:   main ── A ── B
                        ╲
                         feature ── C

Week 4:   main ── A ── B ── D ── E ── F ── G ── H ── I ── J
                        ╲
                         feature ── C ── K ── L ── M ── N
                         (diverged by 8 commits!)

When you finally merge, you face a massive conflict resolution with changes you don't understand because they were made weeks ago.

Strategy 1: Regular Rebase onto Main

Keep your branch up-to-date by rebasing onto main regularly (daily or at least twice a week):

git checkout feature/long-running
git fetch origin
git rebase origin/main
 
# If conflicts arise, resolve them while they're small and fresh
# Then force push your rebased branch
git push --force-with-lease origin feature/long-running

Strategy 2: Regular Merge from Main

If your team prefers merge commits over rebasing:

git checkout feature/long-running
git fetch origin
git merge origin/main

This creates merge commits in your feature branch but avoids force-pushing.

Strategy 3: Feature Flags

For features that take weeks, merge small pieces to main continuously behind a feature flag:

# Instead of keeping everything on a branch for 4 weeks:
if feature_flags.enabled("new_dashboard", user):
    render_new_dashboard()
else:
    render_legacy_dashboard()

Each piece is a small PR to main. The feature flag stays off until everything is ready.

Strategy 4: Break Into Incremental PRs

Decompose the large feature into independently mergeable pieces:

Instead of:  One massive PR after 4 weeks

Do:
  Week 1: PR — Add database tables (backwards-compatible)
  Week 2: PR — Add API endpoints (behind feature flag)
  Week 3: PR — Add UI components (behind feature flag)
  Week 4: PR — Enable feature flag, remove old code

4. .mailmap: Unifying Contributor Identities

The Problem

Contributors often commit from different machines, email addresses, or name formats:

git shortlog -sn
#   42  Jane Smith
#   15  jane smith
#    8  Jane S.
#    3  jsmith
#    1  Jane Smith (work laptop)

Five entries, one person.

The Solution: .mailmap

Create a .mailmap file in the repository root to map alternate identities to canonical ones:

# .mailmap
# Syntax: Canonical Name <canonical@email.com> Alternate Name <alternate@email.com>

# Map alternate email to canonical
Jane Smith <jane@company.com> <jane.smith@gmail.com>
Jane Smith <jane@company.com> <jsmith@old-company.com>

# Map alternate name (same email)
Jane Smith <jane@company.com> jane smith <jane@company.com>
Jane Smith <jane@company.com> Jane S. <jane@company.com>
Jane Smith <jane@company.com> jsmith <jane@company.com>

# Map both name and email
Jane Smith <jane@company.com> Jane Smith (work laptop) <jane-work@company.com>

Using .mailmap

# Commit .mailmap so the whole team benefits
git add .mailmap
git commit -m "chore: add mailmap for contributor identity mapping"
 
# Now shortlog shows unified identities
git shortlog -sn
#   69  Jane Smith
 
# git log also uses .mailmap
git log --format="%aN <%aE>" | sort -u
 
# Verify .mailmap is working
git check-mailmap "jane smith <jane.smith@gmail.com>"
# Jane Smith <jane@company.com>

Where .mailmap Applies

  • git shortlog
  • git log with %aN / %aE / %cN / %cE (normalized placeholders)
  • git blame (with --use-mailmap or if log.mailmap is true)
  • GitHub's contributor statistics (reads .mailmap from the repo)
# Enable mailmap globally for git log
git config --global log.mailmap true

5. git notes: Annotating Commits After the Fact

The Use Case

Sometimes you need to attach metadata to a commit after it's been created — code review results, CI build links, deployment records, or retrospective notes. Since commits are immutable, you can't change the message. git notes solves this.

How Notes Work

Notes are stored as separate objects linked to commits by their hash. They don't modify the commit, and they're stored in a separate ref (refs/notes/commits):

# Add a note to a commit
git notes add -m "Deployed to production on 2024-03-15" HEAD
 
# Add a note to a specific commit
git notes add -m "This commit introduced the memory leak — see #789" abc1234
 
# View notes
git log --show-notes
# commit abc1234
# Author: Jane Smith
# Date: Mon Mar 15 10:30:00 2024
#
#     feat: add caching layer
#
# Notes:
#     This commit introduced the memory leak — see #789
 
# Edit a note
git notes edit abc1234
 
# Append to an existing note
git notes append -m "Fix deployed in commit def5678" abc1234
 
# Remove a note
git notes remove abc1234

Notes Namespaces

You can organize notes into namespaces for different purposes:

# Add notes in different namespaces
git notes --ref=review add -m "Approved by @alice, 2 minor suggestions" HEAD
git notes --ref=deploy add -m "Deployed to staging 2024-03-15 14:30 UTC" HEAD
git notes --ref=ci add -m "Build #4567 passed. Coverage: 87%" HEAD
 
# View notes from a specific namespace
git log --show-notes=review
git log --show-notes=deploy
 
# View all notes
git log --show-notes="*"

Sharing Notes

Notes are local by default. To share them:

# Push notes to the remote
git push origin refs/notes/commits
 
# Fetch notes from the remote
git fetch origin refs/notes/commits:refs/notes/commits
 
# Configure automatic fetch of notes
git config --add remote.origin.fetch "+refs/notes/*:refs/notes/*"

Notes vs. Commit Messages vs. Tags

FeatureCommit MessageTagNote
When createdAt commit timeAny timeAny time
ModifiableNo (immutable)Annotated tags are immutableYes (editable, appendable)
Part of commit hashYesNoNo
Pushed by defaultYes--follow-tags or --tagsNo (explicit push required)
Best forDescribing the changeMarking releasesPost-hoc metadata

6. CODEOWNERS: Automated Review Routing

Purpose

CODEOWNERS automatically assigns reviewers to pull requests based on which files are changed. It ensures the right experts review the right code.

File Location

Place the file in one of these locations (checked in this order):

  • .github/CODEOWNERS (GitHub)
  • CODEOWNERS (root)
  • docs/CODEOWNERS

Syntax

# .github/CODEOWNERS
 
# Default owners for everything not covered below
*                           @org/core-team
 
# Frontend team
/src/components/            @org/frontend-team
/src/styles/                @org/frontend-team
*.tsx                       @org/frontend-team @senior-frontend-dev
*.css                       @org/frontend-team
 
# Backend team
/src/api/                   @org/backend-team
/src/services/              @org/backend-team
/src/models/                @org/backend-team
 
# Database changes require DBA review
/migrations/                @dba-lead @org/backend-team
 
# DevOps/Infrastructure
Dockerfile                  @org/devops
docker-compose*.yml         @org/devops
/.github/workflows/         @org/devops
/terraform/                 @org/devops @infra-lead
 
# Documentation
/docs/                      @org/docs-team
*.md                        @org/docs-team
 
# Security-sensitive files require security team
/src/auth/                  @org/security-team @security-lead
/src/crypto/                @org/security-team
*.pem                       @org/security-team
*.key                       @org/security-team
 
# Package management changes
package.json                @org/core-team @dependency-bot
package-lock.json           @org/core-team

How It Works

  1. A developer opens a PR that modifies files in /src/api/
  2. GitHub reads CODEOWNERS and assigns @org/backend-team as reviewers
  3. If branch protection requires CODEOWNERS approval, the PR can't merge until an owner approves

CODEOWNERS Best Practices

  • Don't over-assign. If every file requires 5 reviewers, reviews slow to a crawl.
  • Use teams, not individuals. Individuals go on vacation; teams don't.
  • Keep it maintained. Stale CODEOWNERS pointing to departed employees blocks PRs.
  • Match your team structure. CODEOWNERS should mirror how your team is actually organized.

7. Monorepo Tooling

Why Monorepo Tools Exist

When a monorepo contains multiple packages/services, running npm install && npm test across everything on every change is wasteful. Monorepo tools solve:

  • Task orchestration: Only build/test packages affected by a change
  • Dependency graph awareness: Understand which packages depend on which
  • Caching: Skip work that's already been done (locally and remotely)
  • Code generation: Scaffold new packages consistently

Nx

# Initialize Nx in an existing repo
npx nx init
 
# Run tests only for affected packages
npx nx affected --target=test
 
# Build a specific project and its dependencies
npx nx build my-app
 
# Visualize the dependency graph
npx nx graph

Nx features:

  • Language-agnostic (JavaScript, Java, Go, Python, etc.)
  • Remote caching (Nx Cloud) — share build caches across the team
  • Computation caching — if inputs haven't changed, skip the task
  • Distributed task execution across CI machines

Turborepo

# Initialize Turborepo
npx create-turbo@latest
 
# Run all build tasks (respecting dependency order)
npx turbo build
 
# Run tests for changed packages
npx turbo test --filter="...[HEAD~1]"

Turborepo features:

  • Focused on JavaScript/TypeScript ecosystems
  • Pipeline configuration in turbo.json
  • Remote caching (Vercel)
  • Simpler configuration than Nx
// turbo.json
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {}
  }
}

Bazel

# BUILD file
java_library(
    name = "my-lib",
    srcs = glob(["src/**/*.java"]),
    deps = ["//common:utils"],
)
 
java_test(
    name = "my-lib-test",
    srcs = glob(["test/**/*.java"]),
    deps = [":my-lib"],
)

Bazel features:

  • Google's build system, proven at enormous scale
  • Hermetic builds (reproducible regardless of environment)
  • Language-agnostic (Java, C++, Go, Python, JavaScript, etc.)
  • Remote execution — distribute builds across a cluster
  • Steeper learning curve than Nx or Turborepo

Comparison

FeatureNxTurborepoBazel
Primary languageAnyJS/TSAny
Learning curveMediumLowHigh
Remote cachingNx CloudVercelCustom / Remote Execution API
Affected detectionYesYesYes
Distributed executionYesLimitedYes (native)
Best forPolyglot monoreposJS/TS monoreposLarge-scale, multi-language

8. Signed Commits: Verified Identity

Why Sign Commits?

Git commit authorship is trivially spoofable:

# Anyone can pretend to be Linus Torvalds
git commit --author="Linus Torvalds <torvalds@linux-foundation.org>" -m "totally legit"

Signed commits cryptographically prove that the commit was made by the person who holds the private key. GitHub and GitLab display a "Verified" badge on signed commits.

Option 1: GPG Signing

# 1. Generate a GPG key (if you don't have one)
gpg --full-generate-key
# Choose: RSA and RSA, 4096 bits, your Git email
 
# 2. List your keys
gpg --list-secret-keys --keyid-format=long
# sec   rsa4096/ABC123DEF456 2024-01-01 [SC]
#       FINGERPRINT1234567890ABCDEF
# uid   Jane Smith <jane@company.com>
 
# 3. Configure Git to use your key
git config --global user.signingkey ABC123DEF456
git config --global commit.gpgsign true    # sign all commits automatically
git config --global tag.gpgsign true       # sign all tags automatically
 
# 4. Make a signed commit
git commit -S -m "feat: add verified feature"
 
# 5. Verify a signed commit
git log --show-signature -1
# gpg: Signature made ...
# gpg: Good signature from "Jane Smith <jane@company.com>"
 
# 6. Add your public key to GitHub
gpg --armor --export ABC123DEF456
# Copy the output and paste in GitHub → Settings → SSH and GPG keys → New GPG key

Option 2: SSH Signing (Simpler, Git 2.34+)

SSH signing reuses your existing SSH key — no GPG required:

# 1. Configure SSH signing
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
 
# 2. Make a signed commit
git commit -m "feat: add verified feature"
# Automatically signed with your SSH key
 
# 3. For local verification, create an allowed signers file
echo "jane@company.com $(cat ~/.ssh/id_ed25519.pub)" > ~/.config/git/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
 
# 4. Verify
git log --show-signature -1
# Good "git" signature for jane@company.com
 
# 5. Add your SSH signing key to GitHub
# Go to Settings → SSH and GPG keys → New SSH key
# Key type: "Signing Key"
# Paste your public key

Vigilant Mode (GitHub)

GitHub's vigilant mode flags all unsigned commits as "Unverified," making it visually obvious when a commit wasn't signed. Enable it in GitHub Settings → SSH and GPG keys → Vigilant mode.

Which Signing Method to Choose?

FactorGPGSSH
Setup complexityHigher (key generation, subkeys, expiration)Lower (reuse existing SSH key)
Key managementGPG keyringSSH agent
Platform supportUniversalGit 2.34+ required
EcosystemEstablished, widely supportedNewer, growing support
RecommendationExisting GPG users, compliance requirementsEveryone else

9. Branch Protection and Repository Settings

Branch Protection Rules

Branch protection prevents accidental or unauthorized changes to important branches:

# Using GitHub CLI to set up branch protection
gh api repos/{owner}/{repo}/branches/main/protection \
  --method PUT \
  --field required_status_checks='{"strict":true,"contexts":["ci/tests","ci/lint"]}' \
  --field enforce_admins=true \
  --field required_pull_request_reviews='{"required_approving_review_count":2}' \
  --field restrictions=null

Essential Protection Rules

RuleWhat It DoesRecommendation
Require PR reviewsNo direct pushes; changes must go through a PR1-2 approvals for most teams
Require status checksCI must pass before mergeEnable for tests, linting, security scans
Require up-to-date branchPR branch must be current with base branchEnable with "strict" mode
Require signed commitsOnly verified commits can be pushedEnable for security-sensitive projects
Require linear historyForce squash or rebase merge (no merge commits)Enable for clean history preference
Restrict force pushesPrevent --force on protected branchesAlways enable on main
Require CODEOWNERS reviewAt least one owner must approveEnable when using CODEOWNERS

Merge Methods

GitHub and GitLab offer three merge methods for PRs:

Merge commit (default):

main ── A ── B ──────── M
                       ╱
              C ── D ──
  • Preserves full branch history
  • Creates a merge commit
  • Best for: Git Flow, audit trail requirements

Squash merge:

main ── A ── B ── CD
  • Combines all branch commits into one
  • Clean, linear main history
  • Best for: GitHub Flow, small PRs

Rebase merge:

main ── A ── B ── C'── D'
  • Replays commits on top of main (no merge commit)
  • Linear history, preserves individual commits
  • Best for: Trunk-based development, detailed commit history

Repository Configuration Checklist

[ ] Branch protection on main (and develop, if using Git Flow)
[ ] Require at least 1 PR review
[ ] Require CI status checks to pass
[ ] Enable auto-delete of merged branches
[ ] Set default merge method (squash, merge, or rebase)
[ ] Add CODEOWNERS file
[ ] Add PR template (.github/pull_request_template.md)
[ ] Add issue templates (.github/ISSUE_TEMPLATE/)
[ ] Configure commit signing
[ ] Set up required labels or conventional PR titles

10. PR Templates and Issue Templates

PR Template

Create .github/pull_request_template.md:

## Summary
<!-- Brief description of what this PR does -->
 
## Motivation
<!-- Why is this change needed? Link to issue if applicable -->
 
## Changes
<!-- List of specific changes made -->
-
-
 
## Testing
<!-- How was this tested? Steps to reproduce/verify -->
1.
2.
 
## Checklist
- [ ] Tests added/updated
- [ ] Documentation updated (if applicable)
- [ ] No breaking changes (or documented in summary)
- [ ] Self-reviewed the diff

Issue Templates

Create .github/ISSUE_TEMPLATE/bug_report.md:

---
name: Bug Report
about: Report a bug to help us improve
labels: bug
---
 
## Description
<!-- Clear description of the bug -->
 
## Steps to Reproduce
1.
2.
3.
 
## Expected Behavior
<!-- What should happen -->
 
## Actual Behavior
<!-- What actually happens -->
 
## Environment
- OS:
- Browser/Runtime:
- Version:
 
## Screenshots
<!-- If applicable -->

Command Reference

CommandDescription
git add -pStage changes interactively, hunk by hunk
git commit -SCreate a GPG/SSH signed commit
git log --show-signatureShow commit signatures in log
git shortlog -snShow contributor commit counts
git shortlog -sn --no-mergesCommit counts excluding merges
git check-mailmap "<name> <email>"Test mailmap resolution
git notes add -m "<msg>" <commit>Add a note to a commit
git notes append -m "<msg>" <commit>Append to an existing note
git notes remove <commit>Remove a note from a commit
git notes --ref=<namespace> addAdd a note in a specific namespace
git log --show-notesShow notes in log output
git log --show-notes="*"Show notes from all namespaces
git push origin refs/notes/commitsPush notes to remote
gpg --list-secret-keys --keyid-format=longList GPG secret keys
gpg --armor --export <keyid>Export GPG public key (for GitHub)
git config commit.gpgsign trueEnable automatic commit signing
git config gpg.format sshUse SSH keys for signing
gh pr checkout <number>Check out a PR locally for review
gh pr create --title "<t>" --body "<b>"Create a pull request
gh pr review --approveApprove a pull request

Hands-On Lab: Team Collaboration Simulation

Setup

mkdir collab-lab && cd collab-lab
git init
echo "# Team Project" > README.md
git add . && git commit -m "initial commit"

Part 1: Atomic Commits with git add -p

Goal: Practice surgical staging to create atomic commits from mixed changes.

# 1. Create a file with multiple logical sections
cat > app.js << 'EOF'
// Authentication
function login(user, pass) {
    return authenticate(user, pass);
}
 
// Search
function search(query) {
    return database.find(query);
}
 
// Utilities
function formatDate(date) {
    return date.toISOString();
}
EOF
git add app.js && git commit -m "feat: add initial app structure"
 
# 2. Make multiple unrelated changes in the same file
cat > app.js << 'EOF'
// Authentication
function login(user, pass) {
    if (!user || !pass) throw new Error('Missing credentials');
    return authenticate(user, pass);
}
 
// Search
function search(query, options = {}) {
    const { limit = 20, offset = 0 } = options;
    return database.find(query).limit(limit).skip(offset);
}
 
// Utilities
function formatDate(date) {
    return date.toLocaleDateString('en-US');
}
EOF
 
# 3. Stage changes as separate atomic commits using git add -p
git add -p app.js
# When Git shows the first hunk (login validation):
#   → Press 'y' to stage it
# When Git shows the second hunk (search pagination):
#   → Press 'n' to skip it
# When Git shows the third hunk (date formatting):
#   → Press 'n' to skip it
 
git commit -m "fix: validate credentials before authentication"
 
# 4. Stage the next logical change
git add -p app.js
# Stage the search pagination hunk: 'y'
# Skip the date formatting hunk: 'n'
 
git commit -m "feat: add pagination to search results"
 
# 5. Stage the remaining change
git add app.js
git commit -m "fix: use localized date format"
 
# 6. Verify atomic commits
git log --oneline -3
# Three separate, focused commits

Checkpoint: git log --oneline -3 shows three separate commits, each addressing one logical change.

Part 2: PR Description and Templates

Goal: Create a PR template and practice writing a good PR description.

# 1. Create the GitHub directory structure
mkdir -p .github
 
# 2. Create a PR template
cat > .github/pull_request_template.md << 'EOF'
## Summary
<!-- Brief description of what this PR does -->
 
## Changes
<!-- List of specific changes made -->
-
 
## Testing
<!-- How was this tested? -->
1.
 
## Checklist
- [ ] Tests pass
- [ ] Self-reviewed
- [ ] Documentation updated (if applicable)
EOF
 
git add .github/ && git commit -m "chore: add PR template"
 
# 3. Create a feature branch with good commits
git checkout -b feature/add-validation
 
cat > validate.js << 'EOF'
function validateEmail(email) {
    const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return pattern.test(email);
}
 
function validatePassword(password) {
    return password.length >= 8
        && /[A-Z]/.test(password)
        && /[0-9]/.test(password);
}
 
module.exports = { validateEmail, validatePassword };
EOF
git add validate.js
git commit -m "feat: add email and password validation utilities"
 
cat > validate.test.js << 'EOF'
const { validateEmail, validatePassword } = require('./validate');
 
console.assert(validateEmail('user@example.com') === true);
console.assert(validateEmail('invalid') === false);
console.assert(validatePassword('Str0ngPass') === true);
console.assert(validatePassword('weak') === false);
console.log('All validation tests passed');
EOF
git add validate.test.js
git commit -m "test: add validation utility tests"
 
# 4. View the diff that would go into a PR
git log --oneline main..feature/add-validation
git diff --stat main..feature/add-validation

Checkpoint: Two focused commits on the feature branch. The PR template is in .github/pull_request_template.md.

Part 3: .mailmap for Contributor Mapping

Goal: Unify multiple contributor identities.

git checkout main
 
# 1. Simulate commits from different identities of the same person
git commit --allow-empty --author="Jane Smith <jane@company.com>" -m "feat: add user model"
git commit --allow-empty --author="jane smith <jane.smith@gmail.com>" -m "fix: user model validation"
git commit --allow-empty --author="J. Smith <jsmith@old-company.com>" -m "docs: add user model docs"
 
# 2. See the fragmented contributor list
git shortlog -sn --all
# Multiple entries for the same person
 
# 3. Create a .mailmap file
cat > .mailmap << 'EOF'
Jane Smith <jane@company.com> jane smith <jane.smith@gmail.com>
Jane Smith <jane@company.com> J. Smith <jsmith@old-company.com>
EOF
git add .mailmap && git commit -m "chore: add mailmap for contributor identity mapping"
 
# 4. Verify unified identities
git shortlog -sn --all
# Jane Smith should now have a single entry with all commits combined
 
# 5. Test the mapping
git check-mailmap "jane smith <jane.smith@gmail.com>"
# Should output: Jane Smith <jane@company.com>

Checkpoint: git shortlog -sn --all shows "Jane Smith" as a single contributor with all commits consolidated.

Part 4: git notes for Post-Hoc Annotation

Goal: Annotate existing commits with review and deployment metadata.

# 1. Find a commit to annotate
git log --oneline -5
# Pick a commit hash from the output
 
# 2. Add a review note
git notes add -m "Reviewed by @alice — approved with minor suggestions" HEAD~2
 
# 3. Add a deployment note in a separate namespace
git notes --ref=deploy add -m "Deployed to staging: 2024-03-15 14:30 UTC" HEAD~2
 
# 4. Add a CI note
git notes --ref=ci add -m "Build #1234 passed. Coverage: 91.2%" HEAD~2
 
# 5. View notes in the log
git log --show-notes -3
# The annotated commit shows the default note
 
git log --show-notes=deploy -3
# Shows deployment notes
 
git log --show-notes="*" -3
# Shows all notes from all namespaces
 
# 6. Append to a note
git notes append -m "Follow-up: Alice's suggestions addressed in commit HEAD" HEAD~2
git log --show-notes -3

Checkpoint: git log --show-notes="*" -3 shows notes from the default, deploy, and ci namespaces on the annotated commit.

Part 5: Commit Signing

Goal: Set up SSH signing and create verified commits.

# 1. Generate a signing key (if you don't have one)
ssh-keygen -t ed25519 -f ~/.ssh/git_signing_key -N "" -C "lab signing key"
 
# 2. Configure Git to use SSH signing
git config gpg.format ssh
git config user.signingkey ~/.ssh/git_signing_key.pub
git config commit.gpgsign true
 
# 3. Create an allowed signers file for local verification
mkdir -p ~/.config/git
echo "$(git config user.email) $(cat ~/.ssh/git_signing_key.pub)" > ~/.config/git/allowed_signers
git config gpg.ssh.allowedSignersFile ~/.config/git/allowed_signers
 
# 4. Make a signed commit
echo "signed content" > signed.txt
git add signed.txt
git commit -m "feat: add signed content"
 
# 5. Verify the signature
git log --show-signature -1
# Should show: Good "git" signature for your-email
 
# 6. Make an unsigned commit for comparison
git config commit.gpgsign false
echo "unsigned content" > unsigned.txt
git add unsigned.txt
git commit -m "feat: add unsigned content"
 
# 7. Compare
git log --show-signature -2
# First commit: no signature
# Second commit: Good signature
 
# 8. Re-enable signing
git config commit.gpgsign true

Checkpoint: git log --show-signature -2 shows one verified commit and one without a signature.

Part 6: CODEOWNERS Setup

Goal: Create a CODEOWNERS file that routes reviews based on file paths.

# 1. Create a project structure
mkdir -p src/{api,frontend,auth} docs
 
echo "const express = require('express');" > src/api/server.js
echo "import React from 'react';" > src/frontend/App.tsx
echo "function authenticate() {}" > src/auth/auth.js
echo "# API Docs" > docs/api.md
git add . && git commit -m "chore: add project structure"
 
# 2. Create CODEOWNERS file
mkdir -p .github
cat > .github/CODEOWNERS << 'EOF'
# Default owners
*                    @org/core-team
 
# Frontend
/src/frontend/       @org/frontend-team
*.tsx                @org/frontend-team
 
# Backend API
/src/api/            @org/backend-team
 
# Security-sensitive
/src/auth/           @org/security-team @security-lead
 
# Documentation
/docs/               @org/docs-team
*.md                 @org/docs-team
EOF
 
git add .github/CODEOWNERS
git commit -m "chore: add CODEOWNERS for automated review routing"
 
# 3. Verify the file
cat .github/CODEOWNERS

Checkpoint: The CODEOWNERS file correctly maps each directory to a team. On GitHub/GitLab, PRs touching these paths would automatically request reviews from the assigned teams.

Challenge: Full Repository Setup

Set up a production-ready repository from scratch with all collaboration best practices:

  1. Initialize a repo with a meaningful initial commit
  2. Add .github/pull_request_template.md with a thorough template
  3. Add .github/ISSUE_TEMPLATE/bug_report.md and feature_request.md
  4. Add .github/CODEOWNERS matching your project structure
  5. Add a .mailmap file
  6. Configure SSH commit signing
  7. Create a .gitignore appropriate for your tech stack
  8. Add a CONTRIBUTING.md documenting your branching strategy, commit message format, and PR process
  9. Create two feature branches with atomic commits and meaningful messages
  10. Simulate a PR review: check out one branch, add review notes with git notes, merge with --no-ff

Common Pitfalls

PitfallWhy It HappensHow to Avoid It
"Kitchen sink" commitsNot staging deliberatelyUse git add -p for atomic commits
Vague commit messagesRushing to commitFollow the type(scope): subject format
PRs with 1000+ changed linesNo decomposition of featuresSet a 300-line soft limit; use stacked PRs
Slow PR reviewsReviewers are busy, PRs are largeKeep PRs small; set a 24-hour review SLA
Force-pushing during active reviewReviewer comments get invalidatedCommunicate before force-pushing; push fixup commits instead
Stale CODEOWNERSTeam members leave, teams reorganizeReview CODEOWNERS quarterly
Unsigned commits acceptedSigning not enforcedEnable branch protection rule for signed commits
.mailmap not committedTreated as a local configCommit it — the whole team benefits
Notes not sharedNotes don't push by defaultConfigure remote.origin.fetch for refs/notes/*
Monorepo CI running everythingNo affected-change detectionUse Nx/Turborepo/Bazel for targeted task execution

Pro Tips

  1. Make git add -p your default workflow. Instead of git add ., review every hunk before staging. It catches accidental debug statements, forces you to think about commit boundaries, and produces cleaner history.

  2. Use PR templates with checkboxes. Checklists reduce the reviewer's burden and remind the author to self-review, update tests, and document changes before requesting review.

  3. Set up commit signing once, then forget about it. SSH signing with commit.gpgsign true adds verification to every commit with zero ongoing effort. It's one of the highest-value, lowest-effort security improvements.

  4. Treat CODEOWNERS as living documentation of team ownership. Review it during every team reorganization. Stale CODEOWNERS either blocks PRs (if required) or fails silently (if advisory).

  5. Use git notes for incident post-mortems. After resolving a production incident, annotate the offending commit with the post-mortem findings. Future developers investigating the same code area will find invaluable context.

  6. Measure your PR metrics. Track time-to-first-review, time-to-merge, PR size, and review rounds. These metrics reveal process bottlenecks better than any retrospective.


Quiz / Self-Assessment

1. What makes a commit "atomic," and why does it matter?

Show Answer

An atomic commit is a single, self-contained unit of change that:

  • Does exactly one logical thing
  • Leaves the repository in a working state (compiles, tests pass)
  • Can be understood, reviewed, reverted, or cherry-picked independently

Atomic commits matter because they make code review manageable (each commit is a focused change), git bisect effective (every commit is testable), and reverts clean (undo one feature without affecting others). They're the foundation of all collaboration workflows.

2. How does git add -p help create atomic commits?

Show Answer

git add -p (patch mode) shows each changed hunk in your working directory one at a time, letting you choose which to stage. You can:

  • y — stage this hunk
  • n — skip this hunk
  • s — split into smaller hunks
  • e — manually edit the hunk boundaries

This lets you separate unrelated changes in the same file into different commits. For example, a bug fix on line 10 and a feature addition on line 50 can be staged and committed separately.

3. What's the recommended maximum size for a pull request, and how do you handle features that are larger?

Show Answer

Research from Google and Microsoft suggests keeping PRs under 200-400 lines changed. Review quality drops significantly above this threshold.

For larger features, use stacked PRs — a series of small, dependent PRs that each add one piece of the feature. Each PR has its branch based on the previous PR's branch. Alternatively, use feature flags to merge small pieces directly to main with the feature hidden until complete.

4. What problem does .mailmap solve, and does it need to be committed?

Show Answer

.mailmap unifies contributor identities when the same person has committed under different names or email addresses. It maps alternate identities to a canonical name and email, so git shortlog, git log, and git blame show consistent attribution.

Yes, it should be committed. When committed to the repository, the entire team (and GitHub's contributor statistics) benefits from the unified identity mapping. It's not a local-only configuration.

5. How do git notes differ from commit messages, and when would you use notes?

Show Answer

Commit messages are written at commit time and are part of the commit's SHA-1 hash — they're immutable. Notes are separate objects attached to commits after the fact — they can be added, edited, and appended at any time.

Use notes when you need to add metadata after a commit exists:

  • Code review outcomes ("Approved by @alice")
  • Deployment records ("Deployed to prod 2024-03-15")
  • CI results ("Build #4567, coverage 91%")
  • Incident post-mortem annotations ("This commit caused issue #789")

Notes require explicit push/fetch (refs/notes/*) since they're not shared by default.

6. What's the difference between GPG signing and SSH signing for commits?

Show Answer

GPG signing uses GPG keys and the GPG keyring. It's the traditional method, widely supported, but requires generating and managing GPG keys separately.

SSH signing (Git 2.34+) reuses your existing SSH key for commit signing. It's simpler to set up since most developers already have SSH keys. Configure with git config gpg.format ssh and point user.signingkey to your SSH public key.

Both produce "Verified" badges on GitHub/GitLab. SSH signing is recommended for most developers due to its simplicity; GPG is preferred for compliance environments with existing PKI infrastructure.

7. How does CODEOWNERS work, and what happens when a PR modifies files owned by multiple teams?

Show Answer

CODEOWNERS maps file patterns to GitHub/GitLab users or teams. When a PR is opened, the platform reads CODEOWNERS and automatically requests reviews from the owners of the changed files.

When a PR modifies files owned by multiple teams, all matched teams are requested as reviewers. If branch protection requires CODEOWNERS approval, at least one member from each matched team must approve. This ensures cross-cutting changes get reviewed by all relevant experts.

8. What role do monorepo tools like Nx and Turborepo play in Git workflows?

Show Answer

Monorepo tools solve the performance and workflow challenges of having multiple packages/services in one repository:

  • Affected detection: Only build/test packages that changed (or depend on changes), not the entire repo
  • Task orchestration: Run tasks in the correct dependency order
  • Caching: Skip work that's already been done (both locally and via remote cache)
  • Dependency graph: Understand relationships between packages

Without these tools, every CI run on a monorepo would rebuild everything, making builds slow and wasteful. They complement Git's own performance features (sparse checkout, partial clone) by optimizing the build/test layer.

9. Why is it bad practice to force-push during active PR review?

Show Answer

Force-pushing rewrites the branch history, which:

  • Invalidates existing review comments — reviewers' inline comments may reference code that no longer exists at those line numbers
  • Makes it impossible to see incremental changes — reviewers can't distinguish "what changed since my last review" from the full diff
  • Loses the review conversation thread — previous review rounds become disconnected

Instead, push additional fixup commits during review, then squash when merging. If you must force-push, communicate with reviewers first so they know to re-review from scratch.

10. You're setting up a new team repository. List the collaboration essentials you'd configure on day one.

Show Answer

Day one setup:

  1. Branch protection on main: require PR reviews (1-2 approvals), require CI status checks, prevent force pushes, enable auto-delete of merged branches
  2. PR template (.github/pull_request_template.md): summary, changes, testing, checklist
  3. CODEOWNERS (.github/CODEOWNERS): map code areas to teams
  4. .gitignore: appropriate for your tech stack
  5. Commit signing: configure SSH signing with commit.gpgsign true
  6. Default merge method: choose squash, merge, or rebase and set it as the repository default
  7. Issue templates: bug report and feature request templates
  8. CI pipeline: linting, tests, security scans as required status checks
  9. .mailmap: start with current team identities
  10. CONTRIBUTING.md: document branching strategy, commit format, PR process