Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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

# Merge creates a merge commit -- history shows the branches and where they joined
git checkout feature
git merge main

# Rebase replays feature commits on top of main -- history looks linear,
# as if you started your feature from the latest main all along
git checkout feature
git rebase main
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

# Rebase last 3 commits
git rebase -i HEAD~3

# Rebase onto main
git rebase -i main
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

# Clean up commits before PR
git rebase -i HEAD~5

# In editor:
pick abc123 Add feature
squash def456 Fix typo
squash 789abc Fix another typo
reword 123def Update docs

# Result: 2 clean commits instead of 5

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.
# Apply commit abc123 to current branch -- creates a NEW commit with the same diff
git cherry-pick abc123

# Cherry-pick multiple commits (applied in order, left to right)
git cherry-pick abc123 def456

# Cherry-pick without auto-committing -- useful when you want to combine
# changes from multiple cherry-picks into a single commit
git cherry-pick -n abc123
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 all tracked, modified files (untracked files are NOT included by default)
git stash

# Stash with a descriptive message -- critical when you have multiple stashes
git stash save "WIP: working on payment validation logic"

# List stashes
git stash list

# Apply latest stash
git stash apply

# Apply and remove stash
git stash pop

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

# Drop stash
git stash drop stash@{0}

# Clear all stashes
git stash clear

Stash Options

# Stash including untracked files
git stash -u

# Stash including ignored files
git stash -a

# Create branch from stash
git stash branch new-branch stash@{0}

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.
# View reflog -- shows every HEAD movement with timestamps
git reflog

# Reflog for specific branch
git reflog show feature-branch

# Recover lost commits: find the hash in reflog, then create a branch pointing to it
git reflog
# Find commit hash
git checkout abc123
git branch recovered-branch
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
# Oops, deleted branch!
git branch -D important-feature

# Find it in reflog
git reflog
# See: abc123 HEAD@{5}: commit: important work

# Recover
git checkout abc123
git branch important-feature

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?”
# Start bisect -- enter binary search mode
git bisect start

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

# Mark a known good commit (the bug did not exist here)
git bisect good abc123

# Git automatically checks out the commit halfway between good and bad.
# Test it manually, then tell Git what you found:
git bisect good  # bug is NOT present in this commit
# or
git bisect bad   # bug IS present in this commit

# Repeat -- each step cuts the remaining commits in half.
# After ~7 steps for 100 commits, Git tells you the exact first bad commit.

# End bisect and return to your original branch
git bisect reset

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.
# Fully automated: bisect runs "npm test" at each step.
# If the test exits 0, the commit is good; non-zero means bad.
# This can find the offending commit in minutes, unattended.
git bisect start HEAD abc123
git bisect run npm test
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.
# Create a worktree: checks out feature-branch in a sibling directory.
# No need to stash or commit -- your current branch stays exactly as is.
git worktree add ../feature-2 feature-branch

# List all worktrees (shows paths and checked-out branches)
git worktree list

# Remove worktree when done (deletes the directory too)
git worktree remove ../feature-2
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.
# View any object's content by its hash
git cat-file -p abc123

# View the object's type (blob, tree, commit, tag)
git cat-file -t abc123

# View the tree (directory listing) of the latest commit
git cat-file -p HEAD^{tree}
# Output: mode, type, hash, and filename for each entry

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.
# .git/refs/heads/   -- local branches (each file = one branch)
# .git/refs/tags/    -- tags
# .git/refs/remotes/ -- remote-tracking branches

# A branch is just a file containing a commit hash
cat .git/refs/heads/main
# Output: a1b2c3d4e5f6789012345678901234567890abcd

# HEAD tells Git which branch you are on (or which commit, if detached)
cat .git/HEAD
# Output: ref: refs/heads/main

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.
# View the index -- shows file mode, blob hash, stage number, and filename
git ls-files --stage
# 100644 8b137891... 0    README.md
# 100644 a5c19667... 0    package.json

# Stage number 0 = normal. During merge conflicts:
# Stage 1 = common ancestor, Stage 2 = ours, Stage 3 = theirs
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

# Keep feature branch updated
git checkout feature
git fetch origin
git rebase origin/main

# Resolve conflicts if any
git add .
git rebase --continue

# Force push (safe)
git push --force-with-lease origin feature

Fixup Commits

# Make changes
git add .
git commit --fixup abc123

# Auto-squash during rebase
git rebase -i --autosquash main

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.
# Hooks live here (NOT tracked by Git -- this is the sharing problem)
ls .git/hooks/

# Common hooks and when they fire:
# pre-commit    -- before commit executes (lint, format, fast tests)
# commit-msg    -- after you write the message (enforce format like "JIRA-123: ...")
# pre-push      -- before push sends data (run integration tests, block pushes to main)
# post-merge    -- after merge completes (auto-run "npm install" when package.json changes)
# prepare-commit-msg -- before editor opens (auto-populate ticket number from branch name)
Example pre-commit hook:
#!/bin/sh
# .git/hooks/pre-commit
# Runs BEFORE every git commit. Non-zero exit = commit aborted.

# Run linter on staged files only (fast feedback)
npm run lint
if [ $? -ne 0 ]; then
  echo "Linting failed! Fix the issues above and try again."
  exit 1  # Abort the commit
fi

# Run fast unit tests (skip slow integration tests here)
npm run test:unit -- --bail
if [ $? -ne 0 ]; then
  echo "Unit tests failed! Commit aborted."
  exit 1
fi
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.
# Basic shortcuts -- save keystrokes on the commands you use most
git config --global alias.co checkout
git config --global alias.br branch
git config --global alias.ci commit
git config --global alias.st status

# Complex aliases -- encode team knowledge into short commands
# This one-liner shows a beautiful, compact branch graph
git config --global alias.lg "log --oneline --graph --all --decorate"

# "Undo the last staging" -- safer than remembering the full reset syntax
git config --global alias.unstage "reset HEAD --"

# Show what you committed today (useful for standup prep)
git config --global alias.today "log --since='midnight' --oneline --author='$(git config user.email)'"

# Show the diff of what is staged (the most common "wait, what am I about to commit?" check)
git config --global alias.staged "diff --cached"

# Use them exactly like built-in commands
git co main
git lg
git today
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)

git reset --soft HEAD~1

Undo Last Commit (Discard Changes)

git reset --hard HEAD~1

Recover After Hard Reset

git reflog
git reset --hard abc123

Fix Commit on Wrong Branch

# On wrong-branch
git reset --hard HEAD~1

# Switch to correct branch
git checkout correct-branch
git cherry-pick abc123

Remove File from All History

# Use filter-branch (old way)
git filter-branch --tree-filter 'rm -f passwords.txt' HEAD

# Use filter-repo (recommended)
git filter-repo --path passwords.txt --invert-paths

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 →