Home Programming Git and GitHub Best Practices for Professional Developers

Git and GitHub Best Practices for Professional Developers

In 2017, a developer at a major financial institution accidentally force-pushed to the main branch on a Friday afternoon. The push overwrote three weeks of work from a team of twelve engineers. There were no branch protection rules. No required reviews. No backup strategy beyond “we’ll just be careful.” The team spent the entire weekend reconstructing commits from local copies scattered across developer machines, Slack messages containing code snippets, and sheer memory. The estimated cost — factoring in overtime, delayed releases, and lost client confidence — exceeded $300,000.

This wasn’t an isolated incident. A 2023 survey by GitLab found that 40% of developers have experienced significant code loss or merge conflicts that took more than a full day to resolve. Stack Overflow’s developer survey consistently shows that while over 95% of professional developers use Git, the vast majority rely on fewer than ten commands. They know git add, git commit, git push, and git pull. When something goes wrong — and it inevitably does — they panic, copy their working directory to the desktop (“just in case”), and start Googling.

Here’s the uncomfortable truth: most developers use about 10% of Git’s capabilities. They treat it as a glorified save button rather than the powerful distributed version control system it actually is. And in the age of collaborative, fast-moving software development — where teams ship dozens of times per day through automated pipelines — that knowledge gap isn’t just inconvenient. It’s dangerous.

This guide is designed to close that gap. We’ll cover everything from branching strategies used by teams at Google, Meta, and Stripe, to commit conventions that make your project history actually useful, to advanced techniques like interactive rebase and bisect that can save you hours of debugging. Whether you’re a junior developer looking to level up or a senior engineer who wants to formalize what you already know, this is the comprehensive reference you’ve been looking for.

Why Git Mastery Matters More Than You Think

Git is the most widely used version control system in the world. As of 2025, GitHub alone hosts over 400 million repositories and has more than 100 million developers. GitLab and Bitbucket add tens of millions more. Every Fortune 500 company uses Git in some form. It’s not a tool you can afford to use casually.

But Git mastery isn’t just about knowing commands. It’s about understanding workflows — the patterns and conventions that allow teams of five, fifty, or five thousand developers to work on the same codebase without stepping on each other’s toes. A developer who understands Git deeply can:

  • Resolve merge conflicts in minutes instead of hours, because they understand what Git is actually tracking
  • Navigate project history to find when and why a bug was introduced, using tools like git bisect and git log
  • Recover from mistakes — accidental commits, bad merges, even deleted branches — using git reflog
  • Collaborate effectively through well-structured pull requests and meaningful commit messages
  • Automate quality checks using Git hooks that run before code ever reaches the remote repository

The difference between a developer who “uses Git” and one who “understands Git” becomes especially apparent during incidents. When production is down and you need to identify which commit caused the regression, revert it cleanly, and deploy a fix — all within minutes — your Git proficiency directly impacts your team’s mean time to recovery (MTTR).

Key Takeaway: Git proficiency is a force multiplier. The time you invest in learning Git deeply pays dividends every single day — in faster debugging, smoother collaboration, and fewer catastrophic mistakes.

Building the Right Mental Model

Before we dive into specific practices, let’s establish a mental model that will make everything else easier to understand.

Git is fundamentally a directed acyclic graph (DAG) of snapshots. Every commit is a complete snapshot of your project at a point in time, linked to its parent commit(s). Branches are just movable pointers to commits. Tags are fixed pointers. The HEAD is a pointer to whatever branch or commit you’re currently working on.

When you internalize this model, Git stops being mysterious. A merge creates a new commit with two parents. A rebase replays commits on top of a new base. A cherry-pick copies a single commit to a new location. These aren’t magic — they’re graph operations.

Understanding this graph model is especially important when you’re working with the same repository across Docker-based development environments where multiple containers might interact with the same codebase, or when your CI/CD pipeline needs to make decisions based on what changed between commits.

Branching Strategies That Scale

Choosing the right branching strategy is one of the most impactful decisions a team can make. The wrong strategy creates bottlenecks, increases merge conflicts, and slows down delivery. The right one makes collaboration feel effortless.

There are three dominant branching strategies in professional software development, each optimized for different team sizes and release cadences.

Git Branching Strategy Comparison Git Flow main develop feature release hotfix Best for: Scheduled releases GitHub Flow main feature-a feature-b PR PR Best for: Continuous deployment Trunk-Based main (trunk) <1 day <1 day <1 day Best for: High-velocity teams

Git Flow

Introduced by Vincent Driessen in 2010, Git Flow uses two long-lived branches — main (production) and develop (integration) — along with short-lived feature, release, and hotfix branches. It’s the most structured of the three strategies.

The workflow looks like this:

  1. Developers create feature branches from develop
  2. Completed features merge back into develop
  3. When enough features accumulate, a release branch is cut from develop
  4. The release branch gets final testing and bug fixes
  5. The release merges into both main (tagged with a version) and back into develop
  6. Hotfix branches are created from main for critical production bugs, then merged into both main and develop

When to use Git Flow: Teams with scheduled releases (e.g., mobile apps with App Store review cycles), products that need to maintain multiple versions simultaneously, or organizations with strict release management processes.

When to avoid it: If you deploy continuously (multiple times per day), Git Flow adds unnecessary ceremony. The release branch process becomes a bottleneck when you want to ship fast.

GitHub Flow

GitHub Flow is radically simpler. There’s one long-lived branch: main. Everything else is a feature branch.

  1. Create a branch from main
  2. Make commits on that branch
  3. Open a pull request
  4. Discuss and review the code
  5. Merge to main and deploy

That’s it. No develop branch, no release branches, no hotfix branches. The simplicity is the point. Every merge to main triggers a deployment, which means main must always be deployable.

When to use GitHub Flow: Web applications with continuous deployment, SaaS products, open source projects, and any team that deploys frequently and wants minimal process overhead.

Trunk-Based Development

Trunk-Based Development (TBD) takes simplicity even further. Developers commit directly to the trunk (main) or use extremely short-lived feature branches that last no more than a day or two. This is the strategy used by Google, where thousands of engineers commit to a single monorepo.

The key enablers for trunk-based development are:

  • Feature flags: Incomplete features are hidden behind toggles so they can be in the codebase without being user-visible
  • Comprehensive automated testing: Since there’s no release branch for manual QA, automated tests must be thorough
  • Small, incremental changes: Large features are broken into small, independently deployable pieces

When to use TBD: High-velocity teams with strong CI/CD pipelines, experienced developers who can work in small increments, and organizations that prioritize deployment speed over release ceremony.

Aspect Git Flow GitHub Flow Trunk-Based
Long-lived branches main + develop main only main only
Feature branch lifespan Days to weeks Hours to days Hours (max 1-2 days)
Release process Release branches Merge to main = deploy Continuous from trunk
Complexity High Low Low
Best for Scheduled releases Continuous deployment High-velocity teams
Team size Medium to large Any size Senior/experienced teams

 

Tip: If your team is just starting to formalize its Git workflow, start with GitHub Flow. It’s simple enough that everyone can learn it quickly, yet flexible enough to scale. You can always migrate to trunk-based development as your CI/CD maturity grows.

Commit Conventions That Tell a Story

Your commit history is a narrative of your project’s evolution. A well-maintained history lets any developer understand what changed, why it changed, and when it changed — without having to read every line of code. A poorly maintained history is noise.

Compare these two commit histories from real projects:

# Bad history — tells you nothing
fix stuff
updates
WIP
more changes
asdfasdf
final fix (for real this time)
oops

# Good history — tells a story
feat(auth): add JWT refresh token rotation
fix(api): handle race condition in concurrent order processing
docs(readme): add deployment instructions for AWS
refactor(db): extract connection pooling into shared module
test(auth): add integration tests for OAuth2 flow

The difference is night and day. Let’s look at how to achieve the second style consistently.

The Conventional Commits Specification

Conventional Commits is a lightweight convention for commit messages that provides structure without being burdensome. The format is:

<type>(<scope>): <description>

[optional body]

[optional footer(s)]

The type describes the category of change:

Type Purpose Example
feat New feature feat(cart): add quantity selector to checkout
fix Bug fix fix(auth): prevent session hijacking on token refresh
docs Documentation only docs(api): update rate limiting section
style Formatting, no code change style: apply prettier to all JS files
refactor Code change that’s not a fix or feature refactor(db): simplify query builder interface
perf Performance improvement perf(search): add index for full-text queries
test Adding or fixing tests test(payments): add edge cases for currency conversion
chore Maintenance tasks chore(deps): upgrade React from 18.2 to 18.3
ci CI/CD configuration changes ci: add Node.js 20 to test matrix

 

The scope (optional but recommended) identifies the module, component, or area of the codebase affected. The description is a short, imperative statement of what the commit does — “add,” not “added” or “adds.”

The Art of Atomic Commits

An atomic commit is a commit that contains exactly one logical change. Not two. Not half of one. Exactly one.

This is harder than it sounds. Developers naturally work on multiple things simultaneously. You start fixing a bug and notice a typo in a comment. You refactor a function and realize you should also update the tests. Before you know it, your working directory has changes spanning five files and three unrelated concerns.

The discipline of atomic commits means using git add -p (patch mode) to stage only the hunks related to one change, committing, then staging and committing the next change. This approach is fundamental to clean code principles — your commit history should be as well-organized as your code itself.

# Stage specific parts of a file interactively
git add -p src/auth/login.py

# Git will show each "hunk" (changed section) and ask:
# Stage this hunk [y,n,q,a,d,s,e,?]?
# y = yes, n = no, s = split into smaller hunks, e = edit manually

# After staging the relevant hunks, commit
git commit -m "fix(auth): validate email format before database lookup"

# Now stage and commit the next logical change
git add -p src/auth/login.py
git commit -m "refactor(auth): extract validation logic into separate module"

Why does this matter? Because six months from now, when you need to git revert a specific change or git cherry-pick a fix to a release branch, atomic commits let you do so cleanly. If one commit contains a bug fix and an unrelated refactor, reverting the buggy part means also reverting the good refactor.

Caution: Never commit work-in-progress (WIP) to shared branches. If you need to save your work before switching context, use git stash or commit to a personal branch with a WIP prefix. Clean up before opening a pull request.

Writing Commit Messages That Your Future Self Will Thank You For

The commit description answers “what.” The commit body answers “why.” Here’s a template for non-trivial commits:

fix(api): return 429 status when rate limit is exceeded

Previously, the API returned a generic 500 error when a client
exceeded the rate limit. This made it impossible for clients to
distinguish between server errors and rate limiting, leading to
incorrect retry behavior.

Now returns 429 Too Many Requests with a Retry-After header,
conforming to RFC 6585. Clients can use this header to implement
proper exponential backoff.

Fixes #1234
See also: https://datatracker.ietf.org/doc/html/rfc6585

Notice the structure: imperative subject line (under 72 characters), a blank line, then the body explaining the before state, the after state, and why the change was needed. This pattern — called the “50/72 rule” — is a widely adopted convention because most Git tools wrap text at these boundaries.

Pull Request Best Practices

Pull requests (PRs) are where individual work becomes team work. A great PR makes reviewers’ lives easy. A terrible PR — a 3,000-line monstrosity with the description “some updates” — makes everyone miserable and usually results in a rubber-stamp approval, which defeats the entire purpose of code review.

Pull Request Lifecycle Create Branch from main Write Code atomic commits Open PR description + context CI Checks lint, test, build Code Review discuss + iterate Changes requested Approved LGTM Merge to main Deploy Key Principle: Keep PRs under 400 lines of code changes. Smaller PRs get reviewed faster and more thoroughly.

The Golden Rule: Keep PRs Small

Research from Google’s engineering practices shows a clear correlation: the larger the PR, the less effective the review. Reviewers’ attention degrades sharply after about 200-400 lines of changes. A 2,000-line PR almost guarantees that subtle bugs will slip through because no human can maintain focused attention across that much code.

The ideal PR is:

  • Under 400 lines of changed code (not counting generated files, lock files, or test fixtures)
  • Focused on a single concern — one feature, one bug fix, or one refactor
  • Self-contained — it doesn’t leave the codebase in a broken state if nothing else merges after it

If your feature requires 2,000 lines of code, break it into a stack of 4-5 smaller PRs that build on each other. Many teams use tools like Graphite, ghstack, or GitHub’s own branch protection rules to manage stacked PRs.

Writing PR Descriptions That Accelerate Reviews

A great PR description follows a template that answers three questions: What did you change? Why did you change it? How can the reviewer verify it?

## What

Add rate limiting to the public API endpoints using a
token bucket algorithm. Limits are configurable per
endpoint and per API key tier.

## Why

We've been experiencing abuse from scrapers hitting our
search endpoint at 1000+ requests/minute, degrading
performance for legitimate users. This was flagged in
incident INC-2847.

## How to Test

1. Run `make test-integration` to execute the new rate
   limiting tests
2. For manual testing:
   - Start the server: `docker compose up`
   - Hit the endpoint rapidly: `for i in {1..100}; do
     curl -s -o /dev/null -w "%{http_code}\n"
     http://localhost:8000/api/search; done`
   - Verify you get 429 responses after exceeding the limit

## Screenshots

[Before/after screenshots if applicable]

## Checklist

- [x] Tests pass locally
- [x] Documentation updated
- [x] No breaking API changes
- [x] Rate limit headers added per RFC 6585

This kind of description turns a 30-minute review into a 10-minute one. The reviewer doesn’t need to guess why the change exists or how to test it — it’s all right there.

PR Etiquette That Builds Team Trust

Pull requests are as much about human interaction as they are about code. Here are the unwritten rules that make PR culture healthy:

For authors:

  • Respond to all review comments, even if just to say “Done” or “Good point, fixed”
  • Don’t take review feedback personally — the reviewer is critiquing code, not you
  • If you disagree with feedback, explain your reasoning rather than ignoring the comment
  • Self-review your PR before requesting reviews — you’ll catch obvious issues yourself
  • Add inline comments to complex sections to proactively explain your reasoning

For reviewers:

  • Review within 24 hours — blocking someone’s PR for days is disrespectful of their time
  • Distinguish between blocking concerns and nits: prefix optional suggestions with “nit:” or “optional:”
  • Explain why something should change, not just what should change
  • Approve with comments when appropriate — not every suggestion needs to block the merge
  • Acknowledge good work — “Nice approach here” goes a long way
Tip: Configure your GitHub repository with branch protection rules that require at least one approving review, passing CI checks, and up-to-date branches before merging. This prevents accidental merges of broken code and ensures the review process is followed consistently.

Code Review Workflow and Standards

Code review is one of the highest-leverage activities in software engineering. Google’s data shows that code review catches approximately 15% of bugs before they reach production. But the benefits extend far beyond bug detection:

  • Knowledge sharing: Reviews spread awareness of the codebase across the team, reducing bus factor
  • Mentoring: Senior developers can guide juniors through real-world code decisions
  • Consistency: Reviews enforce coding standards and architectural patterns across the team
  • Documentation: The PR discussion thread becomes a record of why decisions were made

What to Look for in a Code Review

A thorough code review examines multiple dimensions:

Correctness: Does the code do what it claims? Are edge cases handled? Are there off-by-one errors, null pointer risks, or race conditions?

Design: Is this the right approach? Could it be simpler? Does it follow existing patterns in the codebase? Will it scale?

Readability: Can another developer understand this code six months from now? Are variable names descriptive? Is the logic clear or unnecessarily clever?

Testing: Are there tests? Do they cover the important cases? Are they testing behavior (good) or implementation details (fragile)?

Security: Is user input validated? Are there SQL injection or XSS vulnerabilities? Are secrets hardcoded? This is especially critical when building REST APIs with frameworks like FastAPI, where input validation must be rigorous.

Performance: Are there N+1 queries? Unbounded loops? Memory leaks? Large allocations in hot paths?

Automating the Tedious Parts

Human reviewers should focus on design, logic, and architecture — not formatting, style, or obvious errors. Automate everything that can be automated:

# .github/workflows/code-quality.yml
name: Code Quality
on: [pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run linter
        run: npx eslint . --format=json --output-file=lint-results.json

  format-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check formatting
        run: npx prettier --check "src/**/*.{ts,tsx,json}"

  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: TypeScript type check
        run: npx tsc --noEmit

When linting, formatting, and type-checking are handled by CI, reviewers can skip “you’re missing a semicolon” comments and focus on what actually matters.

GitHub Actions and CI/CD Integration

GitHub Actions has become the de facto CI/CD platform for projects hosted on GitHub. It integrates seamlessly with pull requests, branch protection rules, and the wider GitHub ecosystem. Understanding how to leverage Actions effectively is a core professional skill.

Anatomy of a GitHub Actions Workflow

A workflow is defined in a YAML file under .github/workflows/. Here’s a production-ready example for a Python project — the kind you might use when building a FastAPI application:

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  contents: read
  pull-requests: write

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12", "3.13"]

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
          restore-keys: ${{ runner.os }}-pip-

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install -r requirements-dev.txt

      - name: Run linting
        run: |
          ruff check .
          ruff format --check .

      - name: Run tests with coverage
        run: |
          pytest --cov=src --cov-report=xml --cov-report=term-missing
        env:
          DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb

      - name: Upload coverage
        if: matrix.python-version == '3.12'
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run security scan
        uses: pyupio/safety-action@v1
      - name: Check for secrets
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

  deploy:
    needs: [test, security]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        run: echo "Deploy step here"
        env:
          DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

This workflow demonstrates several best practices: matrix testing across Python versions, service containers for database tests, dependency caching for faster builds, security scanning as a separate job, and conditional deployment that only runs on main branch pushes after all checks pass.

Protecting the Main Branch

Branch protection rules are the guardrails that prevent accidents. At minimum, configure these for your main branch:

# Configure via GitHub UI: Settings > Branches > Branch protection rules
# Or via GitHub CLI:
gh api repos/{owner}/{repo}/branches/main/protection -X PUT \
  -f "required_status_checks[strict]=true" \
  -f "required_status_checks[contexts][]=test" \
  -f "required_status_checks[contexts][]=security" \
  -f "required_pull_request_reviews[required_approving_review_count]=1" \
  -f "required_pull_request_reviews[dismiss_stale_reviews]=true" \
  -f "enforce_admins=true" \
  -f "restrictions=null"

These rules ensure that:

  • No one can push directly to main (all changes go through PRs)
  • At least one team member must approve the PR
  • All CI checks must pass before merging
  • Stale approvals are dismissed when new commits are pushed (preventing approval bypass)
  • Even repository admins must follow the rules

Git Hooks for Quality Enforcement

Git hooks are scripts that run automatically at specific points in the Git workflow. They’re your first line of defense — catching issues on the developer’s machine before code even reaches the remote repository.

Git Hooks in the CI/CD Pipeline Local Machine Remote / CI Server Write Code git add . pre-commit Lint code Format check git commit pre-push Run tests Type check git push GitHub receives push CI Pipeline Full test suite Security scan Build Docker image Artifacts Deploy Production fail: fix & retry fail: fix & retry Git Hooks (local) CI Checks (remote) Deployment

Essential Git Hooks

The two most useful client-side hooks are pre-commit and pre-push.

Pre-commit runs before every commit. Use it for fast checks — linting, formatting, and static analysis. If the hook fails, the commit is rejected.

Pre-push runs before every push to a remote. Use it for slower checks — running the test suite, type checking, or security scanning. This is your last gate before code leaves your machine.

#!/bin/sh
# .git/hooks/pre-commit

echo "Running pre-commit checks..."

# Check for formatting issues
if ! npx prettier --check "src/**/*.{ts,tsx,json}" 2>/dev/null; then
    echo "ERROR: Formatting issues found. Run 'npx prettier --write .' to fix."
    exit 1
fi

# Run linter
if ! npx eslint src/ --quiet; then
    echo "ERROR: Linting errors found. Fix them before committing."
    exit 1
fi

# Check for console.log statements
if git diff --cached --name-only | xargs grep -l 'console\.log' 2>/dev/null; then
    echo "WARNING: Found console.log statements in staged files."
    echo "Remove them or use a proper logger before committing."
    exit 1
fi

# Check for secrets (basic check)
if git diff --cached | grep -iE '(api_key|secret|password|token)\s*=' | grep -v '#' | grep -v '//'; then
    echo "ERROR: Possible secrets detected in staged changes!"
    exit 1
fi

echo "All pre-commit checks passed."

Using Husky and lint-staged for JavaScript/TypeScript Projects

Managing Git hooks manually is tedious. Husky automates hook installation, and lint-staged runs tools only on staged files (not the entire project), making hooks fast even in large codebases.

# Install Husky and lint-staged
npm install --save-dev husky lint-staged

# Initialize Husky
npx husky init

# Create pre-commit hook
echo "npx lint-staged" > .husky/pre-commit

Configure lint-staged in package.json:

{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md}": [
      "prettier --write"
    ],
    "*.py": [
      "ruff check --fix",
      "ruff format"
    ]
  }
}

For Python projects, the equivalent tool is pre-commit (confusingly named the same as the Git hook). It supports hooks for any language and manages tool versions automatically:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
        args: ['--maxkb=500']
      - id: detect-private-key
Key Takeaway: Git hooks shift quality enforcement left — catching issues on the developer’s machine rather than in CI. This creates a faster feedback loop and reduces wasted CI minutes. Combine local hooks for fast checks with CI for comprehensive checks.

Advanced Git Techniques

The techniques in this section separate competent Git users from Git power users. These commands can save you hours of debugging and make complex code history operations feel routine.

Interactive Rebase: Rewriting History (Carefully)

Interactive rebase (git rebase -i) lets you rewrite commit history before sharing it. This is incredibly powerful for cleaning up a messy development history into a clean, logical sequence of commits before opening a PR.

# Rebase the last 5 commits interactively
git rebase -i HEAD~5

# Your editor will show something like:
pick a1b2c3d feat(auth): add login endpoint
pick d4e5f6g WIP: working on validation
pick h7i8j9k fix typo
pick l0m1n2o add input validation
pick p3q4r5s feat(auth): add password reset flow

# Change to:
pick a1b2c3d feat(auth): add login endpoint
fixup d4e5f6g WIP: working on validation    # merge into previous, discard message
fixup h7i8j9k fix typo                      # merge into previous, discard message
squash l0m1n2o add input validation          # merge into previous, edit message
pick p3q4r5s feat(auth): add password reset flow

# Result: 3 messy commits become part of the first commit
# with a clean, combined message

The commands you can use in interactive rebase:

Command What It Does
pick Keep the commit as-is
reword Keep changes but edit the commit message
squash Merge into the previous commit, combine messages
fixup Merge into previous commit, discard this commit’s message
edit Pause rebase to amend the commit (add/remove files, split it)
drop Delete the commit entirely

 

Caution: Never rebase commits that have been pushed to a shared branch. Rebasing rewrites commit hashes, which means anyone else who has pulled those commits will have conflicts. The golden rule: rebase local commits before pushing; never rebase shared history.

Git Bisect: Finding Bugs with Binary Search

git bisect uses binary search to find which commit introduced a bug. Instead of checking every commit one by one, it narrows down the culprit in logarithmic time — checking 10 commits to search through 1,000.

# Start bisecting
git bisect start

# Mark the current commit as bad (has the bug)
git bisect bad

# Mark a known good commit (before the bug existed)
git bisect good v2.1.0

# Git checks out a commit halfway between good and bad
# Test it, then tell Git:
git bisect good  # if this commit doesn't have the bug
# or
git bisect bad   # if this commit has the bug

# Git narrows the range and checks out the next commit to test
# Repeat until Git identifies the exact commit

# When done:
git bisect reset

# Pro tip: Automate bisect with a test script
git bisect start HEAD v2.1.0
git bisect run python -m pytest tests/test_auth.py::test_login -x

The automated version (git bisect run) is especially powerful. Give it a script that exits with code 0 for “good” and non-zero for “bad,” and it will find the offending commit without any manual intervention. This is an invaluable technique when tracking down regressions in complex systems — whether you’re dealing with Python or Rust codebases alike.

Cherry-Pick: Surgical Commit Transplanting

git cherry-pick copies a specific commit from one branch to another. It’s essential for backporting fixes to release branches or selectively applying changes.

# Apply a specific commit to the current branch
git cherry-pick a1b2c3d

# Cherry-pick without committing (stage the changes instead)
git cherry-pick --no-commit a1b2c3d

# Cherry-pick a range of commits
git cherry-pick a1b2c3d..f4e5d6c

# If there are conflicts during cherry-pick:
# Fix the conflicts, then:
git cherry-pick --continue
# Or abort:
git cherry-pick --abort

A common use case: you’ve fixed a critical bug on main, but you also need that fix on a release branch. Instead of merging all of main into the release branch (which would include unfinished features), you cherry-pick just the fix commit.

Reflog: The Git Safety Net

The reflog (reference log) is Git’s undo history. It records every time HEAD moves — commits, merges, rebases, resets, checkouts. Even when you think you’ve lost commits (through a bad rebase or a hard reset), the reflog usually has them.

# View the reflog
git reflog

# Output looks like:
# a1b2c3d HEAD@{0}: commit: feat(api): add rate limiting
# d4e5f6g HEAD@{1}: rebase: finishing
# h7i8j9k HEAD@{2}: rebase: starting
# l0m1n2o HEAD@{3}: commit: fix(db): close connection on error
# p3q4r5s HEAD@{4}: checkout: moving from feature-x to main

# Recover a commit lost during rebase
git checkout -b recovery-branch HEAD@{3}

# Or reset to a previous state
git reset --hard HEAD@{4}

Think of the reflog as a time machine. It’s the reason that in Git, it’s almost impossible to truly lose work — the data is still there; you just need to know how to find it. Reflog entries are kept for 90 days by default, giving you a generous window for recovery.

Tip: If you ever accidentally delete a branch or reset to the wrong commit, don’t panic. Run git reflog, find the commit hash you need, and create a new branch pointing to it: git checkout -b rescue HEAD@{n}.

Git Worktree: Multiple Working Directories

Need to work on a hotfix while your feature branch has uncommitted changes? Instead of stashing (which can get messy), use git worktree to create a separate working directory for the same repository:

# Create a new worktree for a hotfix
git worktree add ../hotfix-branch hotfix/critical-bug

# Work in the new directory
cd ../hotfix-branch
# Make changes, commit, push

# When done, remove the worktree
git worktree remove ../hotfix-branch

# List all worktrees
git worktree list

Each worktree is a fully functional checkout with its own staging area and working directory. You can have as many as you need, all sharing the same repository history and objects. This is especially useful for developers who frequently context-switch between tasks.

Security: Protecting Your Repository

Security in Git goes beyond just writing secure code — it means ensuring that your repository itself doesn’t become a vulnerability vector. A single committed secret can compromise your entire infrastructure.

A Comprehensive .gitignore

Your .gitignore file is your first line of defense against accidentally committing sensitive files. Start with a comprehensive template and customize it for your stack:

# Environment and secrets
.env
.env.*
!.env.example
*.pem
*.key
*.p12
credentials.json
service-account.json

# Dependencies
node_modules/
vendor/
__pycache__/
*.pyc
.venv/
venv/

# Build output
dist/
build/
*.egg-info/
target/

# IDE files
.idea/
.vscode/settings.json
*.swp
*.swo
.DS_Store

# Logs and databases
*.log
*.sqlite3
*.db

# Test and coverage
coverage/
.coverage
htmlcov/
.pytest_cache/
.nyc_output/

If you’re containerizing your application with Docker for production deployments, make sure your .dockerignore mirrors your .gitignore to avoid baking secrets into Docker images.

Secrets Scanning

Even with a good .gitignore, developers sometimes commit secrets accidentally. GitGuardian’s 2024 State of Secrets Sprawl report found that over 12 million new secrets were detected in public GitHub commits in a single year.

Set up multiple layers of protection:

Pre-commit hook: Use tools like detect-secrets or trufflehog to scan changes before they’re committed.

GitHub’s built-in secret scanning: Available for public repositories (free) and private repositories (GitHub Advanced Security). It scans for known secret patterns from over 200 service providers.

CI pipeline scanning: Add a secrets scan to your CI workflow as a safety net.

# Install detect-secrets
pip install detect-secrets

# Create a baseline of existing secrets (to handle legacy code)
detect-secrets scan > .secrets.baseline

# Scan for new secrets
detect-secrets scan --baseline .secrets.baseline

# Add to pre-commit config
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.4.0
    hooks:
      - id: detect-secrets
        args: ['--baseline', '.secrets.baseline']
Caution: If you accidentally commit a secret, simply removing it in a new commit is not enough. The secret remains in Git history forever. You must: (1) immediately rotate the compromised credential, (2) use git filter-repo or BFG Repo-Cleaner to purge the secret from history, and (3) force-push the cleaned history. GitHub also provides a guide for removing sensitive data.

Signed Commits: Verifying Identity

Git commits have an author field, but there’s nothing stopping someone from setting it to any name or email. Signed commits use GPG or SSH keys to cryptographically verify that a commit really came from who it claims to be from.

# Option 1: Sign with SSH key (simpler, recommended since Git 2.34)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true

# Option 2: Sign with GPG key (traditional approach)
# First, generate a GPG key:
gpg --full-generate-key

# Get your key ID:
gpg --list-secret-keys --keyid-format=long

# Configure Git to use it:
git config --global user.signingkey YOUR_KEY_ID
git config --global commit.gpgsign true

# Verify a signed commit
git log --show-signature

# On GitHub, signed commits show a "Verified" badge

Many organizations now require signed commits as a security policy. GitHub, GitLab, and Bitbucket all display verification badges on signed commits, giving the team confidence that commits haven’t been tampered with.

Monorepo vs Polyrepo

As your organization grows, you’ll face a fundamental architectural decision: should you keep all your code in a single repository (monorepo) or split it across multiple repositories (polyrepo)?

The Monorepo Approach

Google, Meta, Microsoft, and Twitter/X all use monorepos — single repositories containing multiple projects, services, and libraries. Google’s monorepo is legendary: over 2 billion lines of code, 86 terabytes, with 25,000 developers committing changes daily.

Advantages:

  • Atomic cross-project changes: Refactor a shared library and update all consumers in a single commit
  • Code sharing: Easy to extract common code into shared packages
  • Unified tooling: One CI/CD pipeline, one set of linting rules, one testing framework
  • Simplified dependency management: No version matrix across repos

Challenges:

  • Scale: Git slows down significantly with very large repositories (hundreds of GB). You need tools like VFS for Git, sparse checkouts, or git clone --filter
  • CI complexity: Need smart CI that only tests what changed, not the entire repo
  • Access control: Harder to restrict access to specific directories (GitHub has CODEOWNERS; GitLab has more granular permissions)

Popular monorepo tooling includes Nx (JavaScript/TypeScript), Bazel (multi-language, used by Google), Turborepo (JavaScript), and Pants (Python). These tools understand the dependency graph of your monorepo and can determine which projects are affected by a change, running only the necessary tests and builds.

The Polyrepo Approach

Most organizations use polyrepos — separate repositories for each service, library, or application. This is the default pattern on GitHub and maps naturally to microservices architectures where each service lives in its own Docker container.

Advantages:

  • Clear ownership: Each repo has a defined team, README, and set of maintainers
  • Independent deployment: Each service can be built, tested, and deployed independently
  • Access control: Simple and granular — each repo has its own permissions
  • Git performance: Never an issue; repos stay small

Challenges:

  • Cross-repo changes: Updating a shared library requires PRs to every consuming repo
  • Version hell: Service A depends on library v1.2, Service B depends on v1.5, and they’re incompatible
  • Inconsistent tooling: Each repo might use different linters, test frameworks, or CI configurations
  • Discovery: Hard for new developers to find relevant code across dozens of repos
Factor Monorepo Polyrepo
Cross-project refactoring Easy — single commit Hard — multiple PRs
Git performance Degrades at scale Always fast
Access control Complex (CODEOWNERS) Simple per-repo
CI/CD Needs smart build tools Standard per-repo
Code sharing Direct imports Via package registries
Team independence Less — shared rules More — full autonomy
Best for Tightly coupled services Independent microservices

 

Key Takeaway: There is no universally “right” answer. Many successful organizations use a hybrid approach: a monorepo for closely related services and shared libraries, with separate repos for truly independent applications. Choose based on your team’s size, coupling between projects, and tooling maturity.

Frequently Asked Questions

Should I use merge or rebase to integrate changes from the main branch?

It depends on your team’s preference and the context. Merge preserves the exact history of how development happened — you can see when branches diverged and reconnected. Rebase creates a linear history that’s easier to read and bisect. A common best practice is to rebase your feature branch onto main before merging (to stay up to date and resolve conflicts early), then use a merge commit to integrate the feature into main. This gives you the best of both worlds: a clean branch history with an explicit record of when the feature was integrated. Many teams enforce this with GitHub’s “Require linear history” or “Squash and merge” options.

How do I undo the last commit without losing changes?

Use git reset --soft HEAD~1. This moves HEAD back one commit but keeps all the changes from that commit staged and ready to be recommitted. If you also want to unstage the changes (keep them as working directory modifications), use git reset --mixed HEAD~1 (or simply git reset HEAD~1 since mixed is the default). If you’ve already pushed the commit, use git revert HEAD instead — this creates a new commit that undoes the changes, preserving shared history.

What’s the difference between git fetch and git pull?

git fetch downloads new data from the remote repository (new commits, branches, tags) but doesn’t change your working directory or current branch. It updates your remote-tracking branches (like origin/main) so you can see what’s changed. git pull is essentially git fetch followed by git merge (or git rebase if configured). Using git fetch first gives you the opportunity to inspect changes before integrating them, which is safer. Many experienced developers prefer git fetch + git merge (or rebase) over git pull for this reason.

How should I handle large binary files in Git?

Git is designed for text files. Large binary files (images, videos, compiled assets, ML models) bloat the repository because Git stores every version. Use Git LFS (Large File Storage) to handle binaries. Git LFS replaces large files with text pointers in the repository while storing the actual file content on a separate server. Set it up with git lfs install and git lfs track "*.psd". GitHub provides 1 GB of free LFS storage per repository, with additional storage available for purchase.

How many approvals should be required for a pull request?

For most teams, one approval is the sweet spot. It ensures that at least one other person has reviewed the code without creating a bottleneck. For critical paths (security-sensitive code, database migrations, infrastructure changes), consider requiring two approvals. Use GitHub’s CODEOWNERS file to automatically assign reviewers based on which files are changed. Avoid requiring more than two approvals — it creates delays without proportionally increasing quality. If you have concerns about a specific change, escalate through conversation rather than adding more required reviewers.

Related Reading

Conclusion

Git mastery is not about memorizing obscure commands. It’s about understanding the mental model — the DAG of snapshots, the pointers, the graph operations — and then building on that foundation with disciplined practices that make your team more productive, your codebase more maintainable, and your deployments more reliable.

Let’s recap the most impactful practices covered in this guide:

Choose your branching strategy deliberately. GitHub Flow gives you simplicity and speed. Git Flow gives you structure and release management. Trunk-Based Development gives you velocity at the cost of requiring more discipline and mature CI/CD. Pick the one that matches your team’s reality, not the one that sounds most impressive.

Write atomic commits with meaningful messages. Your commit history is a communication tool. Use Conventional Commits to add structure. Use git add -p to keep commits focused. Write messages that explain why, not just what.

Keep pull requests small and well-described. Under 400 lines. One logical change per PR. Include context, testing instructions, and screenshots. Your reviewers will thank you with faster, more thorough reviews.

Automate quality enforcement. Use pre-commit hooks for fast local checks. Use GitHub Actions for comprehensive CI. Use branch protection rules to prevent accidents. The best teams make it harder to do the wrong thing than the right thing.

Learn the advanced tools. Interactive rebase for cleaning up history. Bisect for finding bugs efficiently. Reflog for recovering from mistakes. These aren’t esoteric tricks — they’re everyday tools for professional developers.

Take security seriously. Use a comprehensive .gitignore. Scan for secrets in pre-commit hooks and CI. Sign your commits. Remember that Git history is permanent — a committed secret is a compromised secret, even if you remove it in the next commit.

The investment in learning these practices pays compound returns. Every clean commit, every well-structured PR, every automated check — they accumulate into a codebase that’s a joy to work with instead of a minefield to navigate. And in an industry where your ability to ship reliable software quickly is a core competitive advantage, that matters more than any framework or language choice you’ll ever make.

Start with one change this week. Maybe it’s adopting Conventional Commits. Maybe it’s adding a pre-commit hook. Maybe it’s configuring branch protection rules on your main repository. Small, consistent improvements compound over time — and that’s true for your Git practices just as much as it is for your long-term investment strategy.

References

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *