Interactive rebase, cherry-pick, bisect, worktrees, reflog rescue, and the branching strategies that actually work. Git commands I use every day that most developers don't know exist.
Most developers learn five Git commands and stop there. add, commit, push, pull, merge. Maybe checkout and branch if they're feeling adventurous. That gets you through the first year. Then your branch has 47 commits with messages like "fix" and "wip" and "please work," you accidentally reset something you shouldn't have, and you spend 40 minutes on Stack Overflow trying to undo a merge gone wrong.
I've been using Git for years. Not casually — heavily. Multiple branches, multiple repos, multiple collaborators, all day, every day. What follows are the commands and workflows I actually use. Not the ones that look good in a tutorial. The ones that save me real time, every single week.
Your branch has twelve commits. Half of them are "fix typo." One says "undo previous commit." Another says "actually fix it this time." You're about to open a PR. Nobody needs to see that history.
Interactive rebase is how you rewrite history on your branch before sharing it. It lets you squash commits together, reword messages, reorder them, or drop them entirely.
git rebase -i HEAD~5This opens an editor showing your last 5 commits, oldest first:
pick a1b2c3d Add user authentication endpoint
pick d4e5f6g Fix typo in auth middleware
pick h7i8j9k Add rate limiting
pick l0m1n2o Fix rate limit bug
pick p3q4r5s Update auth testsEach line starts with a command. Change pick to one of these:
squash (or s) — Merge this commit into the one above it, combine the messagesfixup (or f) — Same as squash, but discard this commit's messagereword (or r) — Keep the commit but change its messagedrop (or d) — Delete this commit entirelyedit (or e) — Pause the rebase at this commit so you can amend itHere's what I actually do. That messy history above becomes:
pick a1b2c3d Add user authentication endpoint
fixup d4e5f6g Fix typo in auth middleware
pick h7i8j9k Add rate limiting
fixup l0m1n2o Fix rate limit bug
pick p3q4r5s Update auth testsSave and close. Now you have three clean commits instead of five. The typo fix gets folded into the auth commit. The rate limit bug fix gets folded into the rate limit commit. Your PR reviewer sees a clean, logical progression.
You can literally rearrange the lines. If the test commit should come before the rate limiting commit, just move the line:
pick a1b2c3d Add user authentication endpoint
pick p3q4r5s Update auth tests
pick h7i8j9k Add rate limitingGit will replay your commits in this new order. If there are conflicts, it'll pause and let you resolve them.
If you know a commit is a fix for a previous one, mark it at commit time:
git commit --fixup=a1b2c3dThis creates a commit with the message fixup! Add user authentication endpoint. Then when you rebase:
git rebase -i --autosquash HEAD~5Git automatically reorders the fixup commits right below their targets and marks them as fixup. You just save and close. No manual editing.
I use this constantly. It's the fastest way to iterate on a branch while keeping the final history clean.
Never rebase commits that have been pushed to a shared branch. If other people have based work on those commits, rewriting history will cause real problems. Rebase your own feature branches before merging. Never rebase main.
If you've already pushed your feature branch and need to rebase:
git push --force-with-leaseThe --force-with-lease flag is safer than --force. It refuses to push if someone else has pushed to the same branch since your last fetch. It won't prevent all problems, but it catches the most common one.
Cherry-pick takes a specific commit from one branch and applies it to another. Not a merge. Not a rebase. Just one commit, cleanly applied.
The most common scenario: I fixed a bug on my feature branch, but the fix also needs to go to main or a release branch right now. I don't want to merge my entire feature branch. I want just that one fix.
# Find the commit hash of the fix
git log --oneline feature/user-auth
# a1b2c3d Fix null pointer in session validation
# Switch to main and cherry-pick it
git checkout main
git cherry-pick a1b2c3dDone. The fix is on main as a new commit with the same changes.
Sometimes you want to apply the changes but not commit them yet. Maybe you want to combine several cherry-picks into one commit, or modify the changes slightly:
git cherry-pick --no-commit a1b2c3dThe changes are staged but not committed. You can modify them, add more changes, then commit when ready.
Need multiple consecutive commits? Use range syntax:
git cherry-pick a1b2c3d..f6g7h8iThis cherry-picks everything after a1b2c3d up to and including f6g7h8i. Note that a1b2c3d itself is excluded. If you want to include it:
git cherry-pick a1b2c3d^..f6g7h8iCherry-pick conflicts work like merge conflicts. When one happens:
# Git will tell you there's a conflict
# Fix the conflicting files, then:
git add .
git cherry-pick --continueOr if you change your mind:
git cherry-pick --abortOne thing to watch: cherry-picked commits create new commit hashes. If you later merge the original branch, Git is usually smart enough to handle the duplication. But if you cherry-pick aggressively between branches that will eventually merge, you might see unexpected conflicts. Use it surgically, not as a merge strategy.
Something broke. You know it worked two weeks ago. There have been 200 commits since then. Which one broke it?
You could check each commit manually. Or you could use git bisect, which uses binary search to find the exact breaking commit in log2(n) steps. For 200 commits, that's about 7-8 checks instead of 200.
# Start bisecting
git bisect start
# Mark the current commit as bad (the bug exists here)
git bisect bad
# Mark a known good commit (the bug didn't exist here)
git bisect good v2.1.0Git checks out a commit halfway between good and bad. Test it. Then:
# If the bug exists at this commit:
git bisect bad
# If the bug doesn't exist at this commit:
git bisect goodGit narrows the range by half each time. After 7-8 steps, it tells you:
a1b2c3d4e5f6g7h is the first bad commit
commit a1b2c3d4e5f6g7h
Author: Some Developer <dev@example.com>
Date: Tue Feb 18 14:23:01 2026 +0300
Refactor session handling to use async middlewareNow you know exactly which commit introduced the bug. When you're done:
git bisect resetThis takes you back to where you started.
If you have a test that can detect the bug, you can automate the entire thing:
git bisect start
git bisect bad HEAD
git bisect good v2.1.0
git bisect run npm test -- --grep "session validation"Git will automatically check out commits, run the test, and mark them as good or bad based on the exit code. Zero means good, non-zero means bad. Walk away, come back, and it tells you the exact commit.
You can use any script:
git bisect run ./test-regression.shWhere test-regression.sh is:
#!/bin/bash
npm run build 2>/dev/null || exit 125 # 125 means "skip this commit"
npm test -- --grep "session" || exit 1 # 1 means "bad"
exit 0 # 0 means "good"Exit code 125 is special — it tells bisect to skip that commit (useful if a commit doesn't compile). This is one of those features that seems niche until you need it, and then it saves you an entire afternoon.
Here's what it looks like in practice:
$ git bisect start
$ git bisect bad HEAD
$ git bisect good abc1234
Bisecting: 97 revisions left to test after this (roughly 7 steps)
[def5678...] Commit message here
$ npm test -- --grep "login flow"
# Tests fail
$ git bisect bad
Bisecting: 48 revisions left to test after this (roughly 6 steps)
[ghi9012...] Another commit message
$ npm test -- --grep "login flow"
# Tests pass
$ git bisect good
Bisecting: 24 revisions left to test after this (roughly 5 steps)
...
# After ~7 iterations:
abc1234def5678ghi is the first bad commitSeven steps to find a needle in a haystack of 200 commits.
This is the most underused Git feature. I'm convinced most developers don't know it exists.
The problem: you're deep in a feature branch. Files are changed everywhere. Then someone says "can you look at this production bug real quick?" You have three options:
Or option 4: worktrees.
A worktree is a second (or third, or fourth) working directory linked to the same repository. Each worktree has its own checked-out branch, its own working files, its own index. But they share the same .git data, so you're not duplicating the entire repo.
# You're on feature/user-auth, deep in work
# Need to fix a bug on main:
git worktree add ../hotfix-session mainThis creates a new directory ../hotfix-session with main checked out. Your current directory stays exactly as it was. Nothing stashed, nothing committed, nothing disrupted.
cd ../hotfix-session
# Fix the bug
git add .
git commit -m "Fix null pointer in session validation"
git push origin main
cd ../my-project
# Continue working on your feature as if nothing happenedgit worktree add ../hotfix-nav -b hotfix/nav-crash mainThis creates the worktree AND creates a new branch hotfix/nav-crash based on main.
# List all worktrees
git worktree list
# /home/dev/my-project abc1234 [feature/user-auth]
# /home/dev/hotfix-session def5678 [main]
# Remove a worktree when done
git worktree remove ../hotfix-session
# If the directory was already deleted:
git worktree pruneStashing is fine for quick context switches. But worktrees are better for anything that takes more than five minutes:
I typically keep two or three worktrees active: my main feature branch, a main worktree for quick checks, and sometimes a review worktree where I check out someone else's PR.
You can't have the same branch checked out in two worktrees. That's by design — it prevents you from making conflicting changes to the same branch in two places. If you try, Git will refuse.
You did a hard reset and lost commits. You deleted a branch. You rebased and something went horribly wrong. You think your work is gone.
It's not. Git almost never actually deletes anything. The reflog is your safety net.
Every time HEAD moves — every commit, checkout, rebase, reset, merge — Git records it in the reflog. It's a log of everywhere your HEAD has been, in order.
git reflogOutput:
a1b2c3d (HEAD -> main) HEAD@{0}: reset: moving to HEAD~3
f4e5d6c HEAD@{1}: commit: Add payment processing
b7a8c9d HEAD@{2}: commit: Update user dashboard
e0f1g2h HEAD@{3}: commit: Fix auth token refresh
i3j4k5l HEAD@{4}: checkout: moving from feature/payments to mainEvery entry has an index (HEAD@{0}, HEAD@{1}, etc.) and a description of what happened.
You accidentally ran git reset --hard HEAD~3 and lost three commits. They're right there in the reflog:
# See what you lost
git reflog
# The commit before the reset is HEAD@{1}
git reset --hard f4e5d6cAll three commits are back. Crisis averted.
You deleted a branch that had unmerged work:
git branch -D feature/experimental
# Oh no, that had two weeks of workThe commits still exist. Find them:
git reflog | grep "feature/experimental"
# Or just look through the reflog for the last commit on that branch
# Found it. Recreate the branch at that commit:
git branch feature/experimental a1b2c3dThe branch is back, with all its commits.
You rebased and everything went wrong. Conflicts everywhere, wrong commits, chaos:
# The reflog shows where you were before the rebase
git reflog
# a1b2c3d HEAD@{0}: rebase (finish): ...
# ...
# f4e5d6c HEAD@{5}: rebase (start): checkout main
# b7a8c9d HEAD@{6}: commit: Your last good commit
# Go back to before the rebase
git reset --hard b7a8c9dYou're back to exactly where you were before the rebase started. As if it never happened.
By default, Git keeps reflog entries for 30 days (90 days for reachable commits). After that, they can be garbage collected. So you have a month to realize you made a mistake. In practice, this is more than enough.
You can check the expiry:
git config gc.reflogExpire
# default: 90.days.ago (for reachable)
git config gc.reflogExpireUnreachable
# default: 30.days.ago (for unreachable)If you're paranoid, increase it:
git config --global gc.reflogExpireUnreachable "180.days.ago"Before any destructive operation — hard reset, force push, branch deletion — I run git log --oneline -10 first. I mentally note the current HEAD. It takes two seconds and has saved me more than once from a panic I didn't need to have.
git stash#Most people use stash like this:
git stash
# do something
git stash popThat works, but it's the equivalent of throwing everything into a box labeled "stuff." When you have three stashes, you have no idea which is which.
git stash push -m "WIP: user auth form validation"Now when you list stashes:
git stash list
# stash@{0}: On feature/auth: WIP: user auth form validation
# stash@{1}: On main: Quick fix attempt for nav bug
# stash@{2}: On feature/payments: Experiment with Stripe webhooksYou can see exactly what each stash contains.
By default, git stash only stashes tracked files. New files you haven't added yet are left behind:
# Stash everything, including new files
git stash push --include-untracked -m "WIP: new auth components"
# Or even include ignored files (rarely needed)
git stash push --all -m "Full workspace snapshot"I use --include-untracked almost every time. Leaving new files behind when you switch branches causes confusion.
This is the one most people don't know about. You can stash specific files:
# Stash only specific files
git stash push -m "Just the auth changes" src/auth/ src/middleware.tsOr use patch mode to stash specific hunks within files:
git stash push --patch -m "Partial: only the validation logic"Git will go through each change interactively and ask if you want to stash it. y for yes, n for no, s to split the hunk into smaller pieces.
# Pop: apply and remove from stash list
git stash pop stash@{2}
# Apply: apply but keep in stash list
git stash apply stash@{2}I use apply when I'm not sure if the stash will apply cleanly. If there's a conflict, the stash is preserved. With pop, if there's a conflict, the stash stays in the list anyway (many people don't know this), but I prefer the explicit intent of apply.
# See what files changed in a stash
git stash show stash@{0}
# See the full diff
git stash show -p stash@{0}If your stash has grown into something more substantial:
git stash branch feature/auth-validation stash@{0}This creates a new branch from the commit where you originally stashed, applies the stash, and drops it. Clean.
There are three mainstream branching strategies. Each has a context where it shines and contexts where it causes pain.
The classic. main, develop, feature/*, release/*, hotfix/*. Created by Vincent Driessen in 2010.
# Feature branch
git checkout -b feature/user-auth develop
# ... work ...
git checkout develop
git merge --no-ff feature/user-auth
# Release branch
git checkout -b release/2.1.0 develop
# ... final fixes ...
git checkout main
git merge --no-ff release/2.1.0
git tag -a v2.1.0 -m "Release 2.1.0"
git checkout develop
git merge --no-ff release/2.1.0
# Hotfix
git checkout -b hotfix/session-fix main
# ... fix ...
git checkout main
git merge --no-ff hotfix/session-fix
git checkout develop
git merge --no-ff hotfix/session-fixWhen it works: Mobile apps, desktop software, anything with named versioned releases and multiple versions supported simultaneously. If you ship v2.1 and v3.0 and need to patch both, Gitflow handles that.
When it doesn't: Web applications with continuous deployment. If you deploy to production 5 times a day, the ceremony of release branches and develop branches is pure overhead. Most web teams that adopt Gitflow end up with a develop branch that's perpetually broken and release branches that nobody understands.
Simple. You have main. You create feature branches. You open PRs. You merge to main. You deploy main.
git checkout -b feature/user-auth main
# ... work ...
git push origin feature/user-auth
# Open PR, get reviewed, merge
# main is always deployableWhen it works: Small to medium teams shipping web apps. Continuous deployment. If main is always deployed, this is all you need. It's what this site uses.
When it doesn't: When you need to maintain multiple release versions, or when you have a long QA cycle before deployment. GitHub Flow assumes main goes to production quickly.
Everyone commits to main (the "trunk") directly or via very short-lived branches (less than a day). No long-running feature branches.
# Short-lived branch (merged same day)
git checkout -b fix/auth-token main
# ... small, focused change ...
git push origin fix/auth-token
# PR reviewed and merged within hours, not daysWhen it works: High-performing teams with good CI/CD, comprehensive test suites, and feature flags. Google, Meta, and most large tech companies use trunk-based development. It forces small, incremental changes and eliminates merge hell.
When it doesn't: Teams without good test coverage or CI. If merging to trunk means deploying untested code, you'll break production constantly. You also need feature flags for anything that takes more than a day to build:
# Feature flag in code
if (featureFlags.isEnabled('new-checkout-flow')) {
renderNewCheckout();
} else {
renderLegacyCheckout();
}For most web development teams: start with GitHub Flow. It's simple, it works, and it doesn't require tooling beyond what GitHub/GitLab already provides.
If your team grows past 15-20 engineers and you're deploying multiple times a day, look at trunk-based development with feature flags. The investment in feature flag infrastructure pays for itself in reduced merge conflicts and faster iteration.
If you're shipping versioned software (mobile apps, CLI tools, libraries): Gitflow or a simplified version of it. You actually need those release branches.
Don't pick a strategy because a blog post said it's the best. Pick the one that matches how you actually ship.
Git hooks are scripts that run automatically at specific points in the Git workflow. They're local to your machine (not pushed to the remote), which means you need a way to share them with your team.
pre-commit — Runs before every commit. Use it for linting and formatting:
#!/bin/bash
# .git/hooks/pre-commit
# Run ESLint on staged files only
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|tsx)$')
if [ -n "$STAGED_FILES" ]; then
echo "Running ESLint on staged files..."
npx eslint $STAGED_FILES --quiet
if [ $? -ne 0 ]; then
echo "ESLint failed. Fix errors before committing."
exit 1
fi
fi
# Run Prettier on staged files
if [ -n "$STAGED_FILES" ]; then
echo "Running Prettier..."
npx prettier --check $STAGED_FILES
if [ $? -ne 0 ]; then
echo "Prettier check failed. Run 'npx prettier --write' first."
exit 1
fi
ficommit-msg — Validates the commit message format. Perfect for enforcing conventional commits:
#!/bin/bash
# .git/hooks/commit-msg
COMMIT_MSG=$(cat "$1")
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .{1,72}"
if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
echo "Invalid commit message format."
echo "Expected: type(scope): description"
echo "Example: feat(auth): add session refresh endpoint"
echo ""
echo "Valid types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert"
exit 1
fipre-push — Runs before pushing. Use it for tests:
#!/bin/bash
# .git/hooks/pre-push
echo "Running tests before push..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed. Push aborted."
exit 1
fiNative hooks live in .git/hooks/. The problem: the .git directory isn't tracked by Git, so you can't share hooks through the repo. Everyone has to set them up manually.
Husky solves this. It stores hook configurations in the repo and sets up .git/hooks automatically on npm install:
npx husky initThis creates a .husky/ directory. Add hooks as files:
# .husky/pre-commit
npx lint-stagedCombined with lint-staged, you get fast, targeted pre-commit checks:
{
"lint-staged": {
"*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.css": ["prettier --write"],
"*.json": ["prettier --write"]
}
}This runs ESLint and Prettier only on the files you're actually committing. Not the entire codebase. Fast.
Sometimes you need to commit without hooks running. Emergency hotfixes, work-in-progress commits on your own branch:
git commit --no-verify -m "WIP: debugging production issue"Use this sparingly. If you find yourself skipping hooks regularly, your hooks are probably too slow or too strict.
My .gitconfig has evolved over years. These are the aliases that survived — the ones I actually use daily, not the ones I added because they looked clever.
The default git log is verbose. This gives you a clean, colorful, graph-based view:
git config --global alias.lg "log --oneline --graph --all --decorate"Usage:
git lg
# * a1b2c3d (HEAD -> main) Fix session validation
# | * d4e5f6g (feature/payments) Add Stripe integration
# | * h7i8j9k Update payment models
# |/
# * l0m1n2o Merge PR #42
# * p3q4r5s Add user dashboardI run this 20 times a day. It's the fastest way to understand the state of your repository.
Keep the changes, just undo the commit:
git config --global alias.undo "reset HEAD~1 --mixed"Usage:
git undo
# Commit is gone, but all changes are still in your working directoryI use this when I commit too early, forget a file, or want to restructure the changes.
git config --global alias.unstage "reset HEAD --"Usage:
git unstage src/auth/session.ts
# File is unstaged but changes are preservedBecause you'll forget what you set up:
git config --global alias.aliases "config --get-regexp ^alias\\."# Show what you're about to commit
git config --global alias.staged "diff --staged"
# Short status
git config --global alias.st "status -sb"
# Amend without changing the message
git config --global alias.amend "commit --amend --no-edit"
# Show the last commit
git config --global alias.last "log -1 HEAD --stat"
# Pull with rebase instead of merge
git config --global alias.up "pull --rebase --autostash"
# Delete branches that have been merged to main
git config --global alias.cleanup "!git branch --merged main | grep -v '^[ *]*main$' | xargs git branch -d"The up alias is particularly good. pull --rebase keeps your history linear instead of creating merge commits for every pull. --autostash automatically stashes and restores your changes if you have dirty files. It's what pull should have been by default.
The cleanup alias deletes local branches that have been merged to main. After a while, you accumulate dozens of stale branches. Run this weekly.
These aren't aliases or advanced features. They're just commands I run constantly that many developers don't seem to know about.
git log --oneline --graph --allThis shows every branch, every merge, the entire topology of your repo in a compact view. It's the first thing I run when I pull changes. It answers "what's happening in this repo right now?"
git diff --stagedThis shows what's about to be committed. Not what's changed in your working directory — what's actually staged. I always run this before committing. Always. It catches accidental inclusions, debug statements, console.logs that shouldn't be there.
# See unstaged changes
git diff
# See staged changes
git diff --staged
# See both
git diff HEADgit show a1b2c3dShows the full diff of a single commit. Useful when reviewing history, understanding what a commit actually changed.
# Show just the files that changed
git show --stat a1b2c3d
# Show a specific file at a specific commit
git show a1b2c3d:src/auth/session.tsThat last one is incredibly useful. You can view any file at any point in history without checking out that commit.
git blame -L 42,60 src/auth/session.tsShows who last modified lines 42-60. More useful than blaming the entire file, which is usually overwhelming.
# Show the commit message too, not just the hash
git blame -L 42,60 --show-name src/auth/session.ts
# Ignore whitespace changes (very useful)
git blame -w -L 42,60 src/auth/session.ts
# Show the commit before the blamed one (dig deeper)
git log --follow -p -- src/auth/session.tsThe -w flag is important. Without it, blame will attribute lines to whoever last reformatted the file, which is rarely the person you're looking for.
# Find when a function was added or removed
git log -S "validateSession" --oneline
# Find when a regex pattern appeared
git log -G "session.*timeout" --oneline-S (the "pickaxe") finds commits where the number of occurrences of a string changed. -G finds commits where the diff matches a regex. Both are powerful for archeology — figuring out when something was introduced or removed.
# What changed between two branches
git diff main..feature/auth
# What changed on this branch since it diverged from main
git diff main...feature/auth
# List just the changed files
git diff main...feature/auth --name-only
# Stat view (files + insertions/deletions)
git diff main...feature/auth --statTwo dots vs three dots matters. Two dots shows the difference between the tips of both branches. Three dots shows what changed on the right side since it diverged from the left side. Three dots is usually what you want when reviewing a feature branch.
# See what would be deleted (dry run)
git clean -n
# Delete untracked files
git clean -f
# Delete untracked files and directories
git clean -fd
# Delete untracked and ignored files (nuclear option)
git clean -fdxAlways run with -n first. git clean -fdx will delete your node_modules, .env, build artifacts — everything not tracked by Git. Useful for a truly fresh start, but destructive.
# Get the main branch version of a file without switching branches
git restore --source main -- src/config/database.tsOr from a specific commit:
git restore --source a1b2c3d -- src/config/database.tsThis is cleaner than the old git checkout main -- path/to/file syntax, and it doesn't affect HEAD.
Here's what a typical day looks like with these tools:
# Morning: check what's happening
git lg
git fetch --all
# Start a feature
git checkout -b feature/session-refresh main
# Work, commit incrementally
git add -p # Stage specific hunks, not entire files
git commit -m "Add token refresh endpoint"
git commit -m "Add refresh token rotation"
git commit -m "Fix: handle expired refresh tokens"
git commit -m "Add integration tests"
# Interrupted: need to fix a production bug
git worktree add ../hotfix main
cd ../hotfix
# ... fix, commit, push, PR merged ...
cd ../my-project
git worktree remove ../hotfix
# Back to feature work
git commit -m "Fix edge case in token validation"
# Ready for PR: clean up history
git rebase -i main
# Squash the fix commits, reword for clarity
# Push and open PR
git push -u origin feature/session-refresh
# Something broke in staging? Find out which commit:
git bisect start
git bisect bad HEAD
git bisect good main
git bisect run npm test
# Oops, I hard-reset the wrong thing
git reflog
git reset --hard HEAD@{2}Each of these commands takes seconds. Together, they save hours. Not hypothetical hours — real hours, every week, that I'd otherwise spend untangling Git messes, manually searching for bugs, or losing work to context switches.
Git is a tool that rewards depth. The basics get you through the day. But the commands in this post are what separate "I use Git" from "Git actually makes me faster." Learn them incrementally. Pick one new technique this week and use it until it's muscle memory. Then pick another.
Your future self, staring at a production bug at 11 PM, will thank you for knowing git bisect.