Interactive Rebase#

Interactive rebase rewrites commit history before merging a feature branch. It turns a messy series of “WIP”, “fix typo”, and “actually fix it” commits into a clean, reviewable sequence.

Start an interactive rebase covering the last 5 commits:

git rebase -i HEAD~5

Or rebase everything since the branch diverged from main:

git rebase -i main

Git opens your editor with a list of commits. Each line starts with an action keyword:

pick a1b2c3d Add user authentication endpoint
pick d4e5f6g Fix missing import
pick h7i8j9k WIP: token refresh
pick l0m1n2o Finish token refresh logic
pick p3q4r5s Fix tests

Change the keywords to reshape history:

  • pick – keep the commit as-is.
  • squash (or s) – merge this commit into the previous one, combining messages.
  • fixup (or f) – like squash but discard this commit’s message.
  • reword (or r) – keep the changes, edit the commit message.
  • edit (or e) – pause at this commit to amend it.
  • drop (or d) – delete the commit entirely.

A common cleanup pattern squashes fix-up commits into the features they belong to:

pick a1b2c3d Add user authentication endpoint
fixup d4e5f6g Fix missing import
pick h7i8j9k WIP: token refresh
fixup l0m1n2o Finish token refresh logic
fixup p3q4r5s Fix tests

This produces two clean commits: one for authentication, one for token refresh.

Autosquash shortcut: If you name fix-up commits with a fixup! prefix matching the original commit message, git rebase -i --autosquash automatically reorders and marks them:

git commit -m "fixup! Add user authentication endpoint"
# later:
git rebase -i --autosquash main

Cherry-Pick#

Cherry-pick copies a specific commit from one branch to another without merging the entire branch. It creates a new commit with the same changes but a different hash.

git cherry-pick abc1234

Cherry-pick a range of commits (inclusive on both ends with ..):

git cherry-pick abc1234^..def5678

Cherry-pick without committing, so you can modify the changes first:

git cherry-pick --no-commit abc1234

Common use case: a hotfix lands on main and needs to be backported to a release branch.

git checkout release/2.3
git cherry-pick abc1234

If there is a conflict, resolve it, then:

git add .
git cherry-pick --continue

To abort a cherry-pick that went sideways:

git cherry-pick --abort

Bisect for Bug Finding#

git bisect performs a binary search through commit history to find the exact commit that introduced a bug. Instead of checking every commit linearly, it halves the search space each step.

Start a bisect session:

git bisect start
git bisect bad          # current commit has the bug
git bisect good v2.1.0  # this tag did NOT have the bug

Git checks out a commit halfway between good and bad. Test it, then mark:

git bisect good  # this commit is fine
# or
git bisect bad   # this commit has the bug

Repeat until git identifies the exact commit. Typically takes log2(n) steps – for 1000 commits, about 10 checks.

Automated bisect with a test script is even more powerful:

git bisect start HEAD v2.1.0
git bisect run ./test-for-bug.sh

The script must exit 0 for good commits and non-zero for bad. Git runs it automatically at each step and reports the guilty commit. A simple test script:

#!/bin/bash
make build && ./run-specific-test.sh

When done, reset to your original state:

git bisect reset

Reflog Recovery#

The reflog records every position HEAD has pointed to, even after destructive operations. If you accidentally reset, rebase, or delete a branch, the commits still exist in the reflog for at least 30 days (configurable via gc.reflogExpire).

View the reflog:

git reflog

Output looks like:

abc1234 HEAD@{0}: reset: moving to HEAD~3
def5678 HEAD@{1}: commit: Add feature X
ghi9012 HEAD@{2}: commit: Update config

Recover from an accidental git reset --hard:

git reflog
# Find the commit before the reset
git reset --hard HEAD@{1}

Recover a deleted branch:

git reflog
# Find the last commit on the deleted branch
git branch recovered-branch abc1234

Recover from a bad rebase:

git reflog
# Find the state before the rebase started (look for "rebase (start)")
git reset --hard HEAD@{5}

Worktrees#

Worktrees let you check out multiple branches simultaneously in separate directories. Instead of stashing work-in-progress to review a PR on another branch, you open a second worktree.

Create a worktree for a branch:

git worktree add ../project-hotfix hotfix/critical-fix

This creates a new directory ../project-hotfix with the hotfix/critical-fix branch checked out. Both directories share the same .git storage – no extra clone needed.

List active worktrees:

git worktree list

Remove a worktree when done:

git worktree remove ../project-hotfix

Worktrees are especially useful for agents and CI systems that need to operate on multiple branches without the overhead of full clones.

Subtrees vs Submodules#

Both mechanisms embed one Git repository inside another. They solve the same problem differently.

Submodules store a pointer (commit hash) to a specific commit in an external repository. The external repo is cloned into a subdirectory.

git submodule add https://github.com/org/shared-lib.git libs/shared
git commit -m "Add shared-lib submodule"

After cloning a repo with submodules:

git submodule update --init --recursive

Update a submodule to its latest:

cd libs/shared
git pull origin main
cd ../..
git add libs/shared
git commit -m "Update shared-lib to latest"

Subtrees merge the external repository’s history directly into your repo. No separate clone, no .gitmodules file, no special commands for consumers.

git subtree add --prefix=libs/shared https://github.com/org/shared-lib.git main --squash

Pull updates from upstream:

git subtree pull --prefix=libs/shared https://github.com/org/shared-lib.git main --squash

Push changes back upstream:

git subtree push --prefix=libs/shared https://github.com/org/shared-lib.git main

When to use which: Submodules work well when the dependency is large and you want to control exactly which version you pin to. Subtrees work better when the dependency is small, changes infrequently, and you want consumers to git clone without extra steps. For monorepos, subtrees are generally simpler. For large shared libraries with their own release cycles, submodules provide clearer version control.

Git Hooks#

Hooks are scripts that Git runs automatically at specific points. They live in .git/hooks/ (local, not committed) or can be managed via tools like pre-commit, husky, or lefthook.

Pre-commit hook – runs before each commit. Use it for linting and formatting:

#!/bin/bash
# .git/hooks/pre-commit
set -e

# Run linter on staged files only
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')
if [ -n "$STAGED_FILES" ]; then
    ruff check $STAGED_FILES
    ruff format --check $STAGED_FILES
fi

Commit-msg hook – validates or modifies the commit message:

#!/bin/bash
# .git/hooks/commit-msg
# Enforce conventional commits format
if ! grep -qE '^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}' "$1"; then
    echo "ERROR: Commit message must follow conventional commits format"
    echo "Example: feat(auth): add token refresh endpoint"
    exit 1
fi

Pre-push hook – runs before pushing. Use it for running tests:

#!/bin/bash
# .git/hooks/pre-push
set -e
make test

For team-wide hooks, use a framework that reads configuration from a committed file:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.0
    hooks:
      - id: ruff
      - id: ruff-format

Install with pre-commit install. The hooks are now consistent across all contributors.

git-crypt for Secrets#

git-crypt provides transparent encryption for files in a Git repository. Files are encrypted on push and decrypted on pull, using GPG keys or a symmetric key.

Initialize git-crypt in a repository:

git-crypt init

Define which files to encrypt via .gitattributes:

secrets/** filter=git-crypt diff=git-crypt
*.secret filter=git-crypt diff=git-crypt
config/production.env filter=git-crypt diff=git-crypt

Add a collaborator by GPG key:

git-crypt add-gpg-user ABCD1234

Or export a symmetric key for CI systems:

git-crypt export-key /path/to/keyfile

Unlock a cloned repository:

git-crypt unlock
# or with a keyfile:
git-crypt unlock /path/to/keyfile

Check encryption status:

git-crypt status

Important limitations: git-crypt encrypts file contents but not filenames. Encrypted files show up as binary blobs in diffs, so you lose git diff for those files. For anything beyond simple configuration secrets, a dedicated secrets manager (Vault, AWS Secrets Manager) is a better choice. git-crypt works well for .env files that need to live in the repo for operational convenience.