Advanced Git Operations#

These are the commands that separate someone who uses Git from someone who understands it. Each one solves a specific problem that basic Git workflows cannot handle.

Interactive Rebase#

Interactive rebase rewrites commit history. Use it to clean up a branch before merging.

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

This opens an editor with your commits listed oldest-first:

pick a1b2c3d feat: add user export endpoint
pick e4f5g6h WIP: export formatting
pick i7j8k9l fix typo in export
pick m0n1o2p feat: add CSV download button

Change the commands to reshape history:

pick a1b2c3d feat: add user export endpoint
squash e4f5g6h WIP: export formatting
fixup i7j8k9l fix typo in export
pick m0n1o2p feat: add CSV download button
  • squash: merge into previous commit, combine commit messages.
  • fixup: merge into previous commit, discard the commit message.
  • reword: keep the commit, edit its message.
  • drop: delete the commit entirely.
  • edit: pause rebase at this commit so you can amend it.

Reorder lines to reorder commits. Delete a line to drop that commit.

# If rebase goes wrong, abort and return to original state
git rebase --abort

# If a conflict occurs during rebase, resolve it then:
git add resolved-file.go
git rebase --continue

Rule: never rebase commits that have been pushed to a shared branch. Rebase rewrites commit hashes, which breaks anyone who has pulled those commits.

Cherry-Pick#

Apply a specific commit from one branch to another without merging the entire branch.

# Apply a single commit
git cherry-pick abc1234

# Apply a range of commits (exclusive start, inclusive end)
git cherry-pick abc1234..def5678

# Apply a range inclusive of both endpoints
git cherry-pick abc1234^..def5678

# Cherry-pick without committing (stage changes only)
git cherry-pick --no-commit abc1234

Common use case: a bugfix lands on main and you need it on release/2.3:

git checkout release/2.3
git cherry-pick abc1234
git push

If there is a conflict, resolve it the same way as a merge conflict, then git cherry-pick --continue.

Bisect for Finding Bugs#

Binary search through commits to find which one introduced a bug.

# Start bisect
git bisect start

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

# Mark a known good commit (bug did not exist here)
git bisect good v2.1.0

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

# If the bug exists at this commit
git bisect bad

# If the bug does not exist
git bisect good

Repeat until Git identifies the exact commit. For automated bisect:

# Run a test script at each step -- exit 0 means good, non-zero means bad
git bisect start HEAD v2.1.0
git bisect run ./test-login.sh

When done:

git bisect reset

Reflog for Recovery#

The reflog records every position HEAD has been at. It is your undo history for Git itself.

# Show reflog
git reflog

# Output looks like:
# abc1234 HEAD@{0}: commit: feat: add export
# def5678 HEAD@{1}: rebase: checkout main
# ghi9012 HEAD@{2}: commit: WIP changes

Recover from a bad rebase:

# Find the commit before the rebase started
git reflog
# Look for the entry before the rebase entries

# Reset to that point
git reset --hard HEAD@{5}

Recover a deleted branch:

# Find the last commit on the deleted branch
git reflog | grep "checkout: moving from deleted-branch"

# Create a new branch at that commit
git branch recovered-branch abc1234

The reflog is local and expires (default 90 days). It does not exist on remote repositories.

Stash#

Temporarily shelve changes without committing them.

# Stash all tracked modified files
git stash

# Stash with a description
git stash push -m "WIP: half-finished auth refactor"

# Stash including untracked files
git stash push --include-untracked -m "WIP with new files"

# List stashes
git stash list

# Apply the most recent stash (keep it in stash list)
git stash apply

# Apply and remove from stash list
git stash pop

# Apply a specific stash
git stash apply stash@{2}

# Drop a specific stash
git stash drop stash@{1}

# Clear all stashes
git stash clear

Use stash when you need to switch branches but have uncommitted work. Prefer frequent small commits over heavy stash usage – stashes are easy to forget and lose track of.

Worktrees#

Check out multiple branches simultaneously in separate directories. No more stashing or committing WIP to switch branches.

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

# Work in that directory independently
cd ../myproject-hotfix
# Make changes, commit, push

# List worktrees
git worktree list

# Remove when done
git worktree remove ../myproject-hotfix

Worktrees share the same .git repository, so commits in one are visible from the other. This is ideal for reviewing PRs while keeping your current work intact.

Submodules vs Subtrees#

Submodules pin an exact commit of an external repository:

git submodule add https://github.com/org/shared-lib.git libs/shared
git submodule update --init --recursive

Submodules are pointers. The parent repo records a specific commit hash. Updating requires an explicit step. They are notorious for confusing developers who forget to git submodule update after pulling.

Subtrees copy the external repository’s content directly into your tree:

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

Subtrees are simpler for consumers – cloning the repo gets everything with no extra steps. The tradeoff is a larger repository and more complex update workflow.

Prefer subtrees unless you need to push changes back to the external repo or the external repo is very large. For most cases, a package manager (Go modules, npm, pip) is better than either approach.

Git Archaeology#

Find when code was introduced, changed, or deleted.

# Who last modified each line
git blame src/auth/login.go

# Search for when a string was added or removed
git log -S "DatabaseConnection" --oneline

# Search with regex
git log -G "func.*Export" --oneline

# Follow a file through renames
git log --follow --oneline -- src/auth/login.go

# Show diff for a specific function over time
git log -p -L :handleLogin:src/auth/login.go

# Find commits that touched a specific line range
git log -L 50,75:src/auth/login.go

The -S flag (pickaxe) finds commits where the number of occurrences of a string changed. -G finds commits where the diff matches a regex. Use -S to find when something was added or deleted, -G to find when it was modified.

Repository Cleanup#

Over time, repositories accumulate large files, dead objects, and bloat.

# Garbage collection (compresses objects, removes unreachable commits)
git gc --aggressive

# Remove unreachable objects older than 2 weeks
git prune

# Find large files in repository history
git rev-list --objects --all | \
  git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | \
  awk '/^blob/ {print $3, $4}' | sort -rn | head -20

To permanently remove large files from history, use git-filter-repo (not the deprecated filter-branch):

# Install
pip install git-filter-repo

# Remove a file from all history
git filter-repo --path secrets.env --invert-paths

# Remove files larger than 50MB from history
git filter-repo --strip-blobs-bigger-than 50M

After filter-repo, all commit hashes change. Every collaborator must re-clone. Use this only when absolutely necessary – a 500MB repo with an accidentally committed binary is the typical case.