Skip to main content

Git Advanced Topics

Take your Git skills to the next level with rebase, cherry-pick, stash, and understanding Git internals.

Git Rebase

Rebase rewrites history by replaying your commits on top of another branch. Think of it like cutting your branch off the tree, moving to a higher point on the trunk, and reattaching it there. The commits look the same, but they now sit on a different foundation — and critically, they get new hashes.

Rebase vs Merge

When to use:
  • Rebase: Clean linear history, feature branches that only you work on, cleaning up before a PR
  • Merge: Preserving complete history, shared branches, when you want to know “when” branches diverged and joined
Never rebase public branches! It rewrites history and causes problems for collaborators.

Interactive Rebase

Interactive commands:
  • pick - Use commit as-is
  • reword - Change commit message
  • edit - Amend commit
  • squash - Combine with previous commit
  • fixup - Like squash but discard message
  • drop - Remove commit

Practical Example


Cherry-Pick

Cherry-pick lets you pluck a specific commit from one branch and apply it to another — like picking a single cherry from a tree without taking the whole branch. The original commit stays where it is; Git creates a new commit with the same changes but a different hash.
Use cases:
  • Hotfix to multiple branches: Fix a bug on main, cherry-pick it to release/v2.1
  • Port feature to different version: Backport a feature from v3 to v2
  • Recover specific changes: Pull one good commit from an otherwise broken branch
Common mistake: Over-relying on cherry-pick creates duplicate commits across branches. If you later merge those branches, Git may struggle with the duplicated changes. Use cherry-pick for targeted hotfixes, not as a substitute for proper merge/rebase workflows.

Git Stash

Stash is your “hold that thought” command. It temporarily shelves your uncommitted changes so you can switch context — handle an urgent bug, review a PR, or just try something else — and come back to exactly where you left off. Think of it like putting a bookmark in a book and lending it to someone; you pick it back up right where you stopped.

Stash Options


Git Reflog

Reflog (reference log) records every movement of HEAD — every commit, checkout, rebase, reset, and merge. It is your ultimate safety net, the “undo history” that most people do not realize Git maintains. Even when a commit seems lost (after a bad rebase or accidental branch deletion), the reflog still has it for 90 days by default. Think of it as Git’s flight recorder — it logs where HEAD has been, even when the branches that pointed there are gone.
Common scenarios:
  • Undo bad rebase: Find the pre-rebase commit in reflog, reset to it
  • Recover deleted branch: The commits still exist; find them in reflog and recreate the branch
  • Find lost commits: After a hard reset or force push, reflog is your last resort

Git Bisect

Bisect performs a binary search through your commit history to find exactly which commit introduced a bug. Instead of manually checking 100 commits, bisect narrows it down in about 7 steps (log2(100)). It is like the number-guessing game: “Is the bug in this half or that half?”

Automated Bisect

For the ultimate debugging workflow, give bisect a test script. It will run the script at each step and automatically mark commits as good or bad.
Production scenario: A regression was reported on Friday but nobody knows when it started. You know the release from two weeks ago was fine. With 200 commits in between, manually checking each one would take days. git bisect run ./test-regression.sh finds the culprit in under 8 steps — usually less than 5 minutes.

Git Worktree

Worktrees let you check out multiple branches simultaneously in separate directories — without needing multiple clones. It is like having two desks side by side, each with a different project open. You can work on one, glance at the other, and neither interferes.
Use cases:
  • Review a PR while continuing work on your current feature
  • Run two versions side by side for comparison testing
  • Build a release branch while keeping your dev branch open
  • Avoid constant stash/checkout/stash-pop cycles when context-switching frequently

Git Internals

Understanding how Git works under the hood transforms Git from a magic incantation into a predictable tool. At its core, Git is a content-addressable filesystem — a fancy way of saying “a key-value store where the key is a hash of the content.” Think of it like a library where books are shelved by fingerprint rather than by title. Change one sentence and the fingerprint changes, so it goes on a different shelf.

Objects

Git stores everything as one of four object types:
  1. Blob: Raw file contents (no filename, no permissions — just bytes). Two files with identical content share one blob.
  2. Tree: A directory listing — maps filenames and permissions to blobs (files) and other trees (subdirectories).
  3. Commit: A snapshot in time — points to a tree (the project state), parent commit(s), author, committer, and message.
  4. Tag: An annotated bookmark — points to a commit with a name, tagger info, and message. Lightweight tags are just refs.

References

Refs are human-readable pointers to commit hashes. Without them, you would have to remember 40-character SHA-1 strings. A branch is literally a 41-byte file containing a commit hash.

The Index (Staging Area)

The index (.git/index) is a binary file that sits between your working directory and the repository. It tracks which file versions are staged for the next commit.
Production gotcha: Understanding the index is key to debugging merge conflicts. When a conflict occurs, the index holds all three versions of the conflicted file (stages 1, 2, 3). You can view each version individually with git show :1:file.txt (ancestor), git show :2:file.txt (ours), git show :3:file.txt (theirs). This is invaluable when the conflict markers in the file are confusing.

Advanced Workflows

Rebase Workflow

Fixup Commits


Git Hooks

Git hooks are scripts that fire automatically at specific points in the Git workflow — think of them as event listeners for your repository. If a hook exits with a non-zero code, the Git action is aborted. This makes them powerful quality gates that catch mistakes before they enter history.
Example pre-commit hook:
The sharing problem: Hooks in .git/hooks/ are NOT committed to version control, so teammates never get them. Use Husky (Node projects) or pre-commit (Python framework) to store hooks in committed files that everyone gets automatically on npm install or pip install. See the Hooks and Automation chapter for a full walkthrough.
Production gotcha: Keep pre-commit hooks under 10 seconds. If they are slow (full test suite, type-checking the entire project), developers will add --no-verify to every commit and the hooks become theater. Move heavier checks to pre-push hooks or CI. Hooks are a convenience; CI is the enforcement.

Git Aliases

Aliases let you create shortcuts for commands you type dozens of times per day. This is not just convenience — it removes friction that slows you down and makes complex commands discoverable by your team.
Practical tip: For shell aliases (not Git aliases), add these to your .bashrc or .zshrc for even faster workflows: alias gs='git status', alias gp='git pull --rebase', alias gco='git checkout'. The goal is to remove every unnecessary keystroke from the commands you run 50+ times per day.

Troubleshooting

Undo Last Commit (Keep Changes)

Undo Last Commit (Discard Changes)

Recover After Hard Reset

Fix Commit on Wrong Branch

Remove File from All History


Best Practices

Keep feature branches updated with main via rebase for clean history. Run git fetch origin && git rebase origin/main regularly. If the branch has been open for more than a day without rebasing, you are accumulating merge conflict risk.
Only rebase branches that have not been pushed to a shared remote, or branches where you are the sole contributor. Rebasing rewrites commit hashes, which means anyone who has already pulled the old commits will have diverged history. The fix is messy (--force-with-lease) and error-prone. When in doubt, merge.
Stash instead of committing half-done work with “WIP” messages. Always use descriptive messages: git stash save "WIP: payment validation halfway done". Without messages, git stash list becomes a pile of mystery entries nobody dares touch.
Reflog is your safety net — almost nothing is truly lost in Git. After a bad rebase, accidental branch deletion, or overzealous reset --hard, reflog still has the commits for 90 days. Run git reflog, find the hash you need, and git checkout -b recovery-branch <hash>. Practice this before you need it in a crisis.
If you must force push (after a rebase on a personal feature branch), use git push --force-with-lease instead of --force. It refuses to push if someone else has pushed to the branch since your last fetch — preventing you from silently overwriting a teammate’s work.

Key Takeaways

  • Rebase: Clean linear history, but never on public branches
  • Cherry-pick: Apply specific commits across branches
  • Stash: Temporary storage for uncommitted work
  • Reflog: Safety net for recovering lost work
  • Bisect: Binary search for bug-introducing commits
  • Internals: Understanding objects makes Git less magical

Interview Deep-Dive

Strong Answer:
  • First, do not panic. Git almost never deletes data. My commits still exist in my local repository — they are just no longer reachable from any branch pointer. The reflog is my safety net.
  • I run git reflog on my local machine to find the SHA of my last commit before the force push. The reflog records every HEAD movement, and my commits will show up there even though no branch points to them anymore.
  • Once I have the SHA (say abc123), I create a recovery branch: git branch recovery abc123. Now my three commits are reachable again.
  • To integrate, I have options depending on the situation. If my teammate’s commits and mine are independent, I rebase my recovery branch onto the current remote: git checkout recovery && git rebase origin/feature. If there are conflicts, I resolve them during rebase. If the commits overlap, I might cherry-pick specific commits from recovery instead.
  • After recovery, I push with --force-with-lease (not --force). The --force-with-lease flag checks that the remote ref has not changed since I last fetched — it prevents me from accidentally overwriting someone else’s work the same way mine was overwritten.
  • Prevention: the team should protect shared branches in the Git hosting platform (GitHub branch protection rules, GitLab protected branches) to reject force pushes. For feature branches with multiple contributors, require --force-with-lease and communicate before force-pushing.
Follow-up: What is the difference between —force and —force-with-lease, and when would you use each?--force unconditionally overwrites the remote ref. If someone pushed between your last fetch and your push, their work is gone. --force-with-lease checks that the remote ref matches what you last fetched. If someone else pushed in the meantime, the push is rejected and you must fetch first. I use --force-with-lease in all cases where a force push is necessary (e.g., after rebase). I use bare --force only for personal branches that I am certain no one else is working on, and even then, --force-with-lease is strictly better. Some teams alias push --force to push --force-with-lease globally.
Strong Answer:
  • I start by identifying a “good” commit (the last known working release tag, e.g., v2.3.0) and a “bad” commit (the current HEAD where the bug exists). Then I run git bisect start, git bisect bad HEAD, and git bisect good v2.3.0.
  • Git checks out the commit halfway between good and bad. I test whether the bug exists at this commit. If it does, I run git bisect bad. If not, git bisect good. Each step halves the remaining commits. For 300 commits, this takes about 9 steps (log2(300) is roughly 8.2).
  • For maximum efficiency, I write a test script that can detect the bug automatically: git bisect run ./test-regression.sh. The script exits 0 for “good” and non-zero for “bad.” Git runs it at each step automatically, finding the culprit in minutes without any manual intervention.
  • Once bisect identifies the first bad commit, I examine it with git show <sha>. I then run git bisect reset to return to my original branch. The identified commit usually makes the root cause obvious — it narrows the investigation from “somewhere in 300 commits” to “this specific 20-line diff.”
  • In practice, the hardest part is writing the automated test. If the bug requires a running database or external service, I might need to use a Docker-based test environment. If the bug is visual (UI regression), I might have to test manually at each step, but 9 manual tests is still far better than 300.
Follow-up: What if the bug is intermittent and not every test run catches it?If the test is flaky, git bisect run will give incorrect results. I handle this by modifying the test script to run the test N times (e.g., 5) and report “bad” only if any run fails. The script exits with code 125 for “skip” if the commit cannot be tested (e.g., it does not compile). Code 125 tells bisect to skip that commit and try an adjacent one. For truly non-deterministic bugs, I increase the test repetitions at the cost of longer bisect time — 5 runs x 9 steps is still only 45 test executions, which is manageable.
Strong Answer:
  • Think of the reflog as Git’s flight recorder. Every time HEAD moves — every commit, checkout, rebase, merge, reset, pull, cherry-pick — Git records the before-and-after in the reflog. It is a chronological log of “where was HEAD 5 minutes ago, 1 hour ago, yesterday.” Even if you delete a branch, reset to an earlier commit, or mess up a rebase, the reflog remembers the commit SHAs from before the operation.
  • The reflog is local to your machine. It is not pushed or shared. By default, entries expire after 90 days for reachable commits and 30 days for unreachable ones.
  • Real scenario: a developer was rebasing a 2-week-old feature branch onto main and hit a cascade of merge conflicts. They panicked, tried to abort but accidentally ran git reset --hard to a wrong commit. Their branch now pointed to a commit from 2 weeks ago, and their last 2 weeks of work was not on any branch.
  • Recovery: git reflog showed the exact commit SHA from before the rebase started (the entry read something like HEAD@{7}: checkout: moving from main to feature-x). They ran git branch recovery HEAD@{7}, confirmed the recovery branch had all their work, and deleted the mangled feature branch. Total recovery time: under 2 minutes.
  • The lesson: git reflog is why I tell junior developers “in Git, nothing is truly lost for 90 days.” The only operations that can cause permanent data loss are git reflog expire (which nobody should run) and git gc --prune=now (which aggressively garbage-collects unreachable objects).
Follow-up: A commit is in the reflog but git gc runs and removes it. How would you prevent this?If you know a specific commit is important and should not be garbage collected, the simplest fix is to point a branch or tag at it: git branch keep-this abc123. As long as a ref points to a commit, git gc will never remove it. For systemic protection, you can increase the reflog expiry time: git config gc.reflogExpireUnreachable 180 extends the window from 30 to 180 days for unreachable commits. But the best practice is to always create a branch for any commit you want to preserve — refs are cheap and explicit.

Congratulations! You’ve completed the Git Crash Course. You now have the skills to use Git professionally, from basics to advanced workflows. Next: Linux Crash Course →