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~5Or rebase everything since the branch diverged from main:
git rebase -i mainGit 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 testsChange 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 testsThis 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 mainCherry-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 abc1234Cherry-pick a range of commits (inclusive on both ends with ..):
git cherry-pick abc1234^..def5678Cherry-pick without committing, so you can modify the changes first:
git cherry-pick --no-commit abc1234Common use case: a hotfix lands on main and needs to be backported to a release branch.
git checkout release/2.3
git cherry-pick abc1234If there is a conflict, resolve it, then:
git add .
git cherry-pick --continueTo abort a cherry-pick that went sideways:
git cherry-pick --abortBisect 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 bugGit 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 bugRepeat 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.shThe 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.shWhen done, reset to your original state:
git bisect resetReflog 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 reflogOutput looks like:
abc1234 HEAD@{0}: reset: moving to HEAD~3
def5678 HEAD@{1}: commit: Add feature X
ghi9012 HEAD@{2}: commit: Update configRecover 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 abc1234Recover 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-fixThis 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 listRemove a worktree when done:
git worktree remove ../project-hotfixWorktrees 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 --recursiveUpdate 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 --squashPull updates from upstream:
git subtree pull --prefix=libs/shared https://github.com/org/shared-lib.git main --squashPush changes back upstream:
git subtree push --prefix=libs/shared https://github.com/org/shared-lib.git mainWhen 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
fiCommit-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
fiPre-push hook – runs before pushing. Use it for running tests:
#!/bin/bash
# .git/hooks/pre-push
set -e
make testFor 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-formatInstall 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 initDefine 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-cryptAdd a collaborator by GPG key:
git-crypt add-gpg-user ABCD1234Or export a symmetric key for CI systems:
git-crypt export-key /path/to/keyfileUnlock a cloned repository:
git-crypt unlock
# or with a keyfile:
git-crypt unlock /path/to/keyfileCheck encryption status:
git-crypt statusImportant 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.