Skip to main content

Harshal V. LADHE

Advanced Git: Rebasing, Internals, and Real-World Workflows

Rebase confidently, understand internals, and recover from anywhere.
Published at:
Last updated:
Estimated reading time:12 min read
Series:Git: From Basics to Advanced (2/2)

Introduction

This is Part 2 of a two-part series. Part 1 covered the everyday loop — staging, committing, branching, merging, undoing mistakes, and working with a remote. If any of those feel shaky, that post is the place to start; everything here builds directly on that mental model of working directory → staging area → repository, and branches as cheap, movable pointers.

This post covers the tools you reach for once a repo gets real: rewriting history safely before sharing it, applying individual commits with precision, finding the exact commit that broke something, and understanding what Git is actually doing under the hood — which turns "undo commands feel scary" into "I know exactly how to get this back."

By the end, you'll be comfortable with:

  • Rebasing — including interactive rebase, and when not to reach for it
  • cherry-pick and bisect for surgical history operations
  • Git's internal object model — what a commit, branch, and tag actually are
  • Submodules, worktrees, hooks, and productivity config
  • Recovering "lost" work with git reflog
  • Real branching workflows — Git Flow, GitHub Flow, trunk-based development

Rebasing

Rebasing replays your branch's commits on top of a different base commit, producing a cleaner, linear history than a merge commit would.

git switch feature/login
git rebase main              # replay feature/login's commits on top of main's latest commit
git rebase --continue        # after resolving a conflict mid-rebase
git rebase --skip            # skip the commit currently causing a conflict
git rebase --abort           # bail out entirely, back to the pre-rebase state
git rebase --onto main old-base feature   # replay only commits since old-base, onto main
Diagram of git rebase main on feature: before, feature branches off main at commit B with its own commits D and E; after, those commits are replayed as new commits D' and E' on top of main's latest commit C, rather than moved or merged in place.mainfeatureABCDEgit rebase mainreplays D, E as new commits D', E'mainfeatureABCD'E'original commitreplayed commit

Interactive Rebase

The single most powerful history-editing tool in Git — lets you reorder, edit, combine, or drop commits before they're shared.

git rebase -i HEAD~5     # interactively edit the last 5 commits
git rebase -i main       # interactively edit everything since diverging from main

This opens an editor listing your commits, oldest first, each with an action:

pick   a1b2c3d Add login form
squash e4f5g6h Fix typo in login form
reword h7i8j9k Add validation
fixup  k1l2m3n Remove console.log
drop   n4o5p6q WIP: experiment (not needed)

--autosquash automates the common "small fix for an earlier commit" case:

git commit --fixup=abc1234       # creates a commit clearly marked as a fixup for abc1234
git rebase -i --autosquash main  # automatically reorders and marks it "fixup" for you

Cherry-Picking

Applies the changes from a specific commit onto your current branch — useful for pulling in one fix without merging an entire branch.

git cherry-pick abc1234              # apply one commit onto the current branch
git cherry-pick abc1234 def5678      # apply several commits, in the order given
git cherry-pick abc1234^..def5678    # apply a range of commits (exclusive of abc1234 itself)
git cherry-pick -n abc1234           # apply the changes, but don't commit — stage them for review first
git cherry-pick -x abc1234           # append "(cherry picked from commit abc1234)" to the message
git cherry-pick --continue           # after resolving a conflict
git cherry-pick --abort              # bail out entirely

Finding the Commit That Introduced a Bug: git bisect

Binary-searches through history to find the exact commit that introduced a bug.

git bisect start
git bisect bad                 # the current commit is broken
git bisect good v1.2.0         # this earlier tag/commit was known-good

# Git checks out a commit halfway between — test it, then tell it the result:
git bisect good                # this commit is fine, search the newer half
git bisect bad                 # this commit is broken, search the older half
# ...repeat until Git reports the exact first-bad commit

git bisect reset               # done — return to your original branch
Diagram of git bisect narrowing 8 commits down to the first bad one over 3 rounds: testing commit 4 (bad) narrows the range to 1-4, testing commit 3 (good) narrows it to 3-4, and since those are adjacent, commit 4 is confirmed as the first bad commit.1234test5678commit 4 is bad — search narrows to 1–4123test45678commit 3 is good — search narrows to 3–412345678commit 4 is the first bad commitgoodtestingbad

Rewriting History Safely

Beyond interactive rebase, a few more tools exist specifically for cleaning up history before sharing it.

git rebase -i --root                # interactively rewrite EVERY commit, from the very first one
git commit --squash=abc1234         # like --fixup, but keeps this commit's message for review during squash
git filter-repo --path secrets.env --invert-paths   # (separate tool) permanently remove a file from all history

Submodules & Subtrees

Two different ways to embed one Git repository inside another.

Submodules

A submodule is a pointer to a specific commit of another repository — the two histories stay entirely separate.

git submodule add https://github.com/user/lib.git libs/lib
git submodule init                  # after cloning a repo that already has submodules
git submodule update                # check out the commit each submodule is pinned to
git submodule update --init --recursive   # do both, recursively, in one step
git submodule foreach 'git pull origin main'   # run a command inside every submodule

Subtrees

A subtree copies another repository's history directly into yours, as a subdirectory — one combined history, no separate pointer to manage.

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

Worktrees

Lets you check out multiple branches at once, each in its own directory, all backed by the same .git history — no need to stash or commit WIP just to switch context.

git worktree add ../hotfix hotfix/urgent-bug   # new working directory, on an existing branch
git worktree add -b feature/new ../new-feature  # new working directory AND new branch, together
git worktree list                              # see every linked worktree
git worktree remove ../hotfix                  # done with it — clean up

Git Internals: Objects, Refs, and the .git Directory

Part 1 introduced commits as snapshots and branches as pointers, at the level you need to use Git day-to-day. Here's what that actually looks like on disk.

Git's entire history is a content-addressable key-value store of four object types, all identified by the SHA-1 (or, on newer repos, SHA-256) hash of their contents.

ObjectContains
blobA single file's raw content (no filename, no metadata)
treeA directory listing — names + modes, pointing at blobs and other trees
commitA pointer to one tree (the snapshot), one or more parent commits, author/committer, and a message
tagAn annotated tag's metadata, pointing at a commit

Here's how those objects actually link together for a small project:

Diagram of a Git commit pointing at its root tree, which points at a blob for index.js, a blob for styles.css, and a nested tree for src/, which itself points at a blob for utils.js.committreeblob (index.js)blob (styles.css)tree (src/)blob (utils.js)committreeblob

Plumbing Commands

The commands you've used so far ("porcelain") are built on lower-level "plumbing" commands that operate on these objects directly:

git cat-file -p HEAD          # pretty-print any object's raw content, by hash or ref
git cat-file -t abc1234       # print an object's type (blob / tree / commit / tag)
git hash-object src/index.js  # compute the SHA a file's content WOULD get, without storing it
git rev-parse HEAD            # resolve any ref/shorthand to its full SHA
git ls-tree HEAD              # list a commit's top-level tree entries
git update-ref refs/heads/main abc1234  # move a branch pointer directly (what `git reset` uses internally)

The .git Directory, Briefly

.git/
├── HEAD              # currently "ref: refs/heads/main" — a pointer to a pointer
├── config            # this repo's local config (git config --local)
├── index             # the staging area, in binary form
├── objects/          # every blob/tree/commit/tag, content-addressed by hash
├── refs/
   ├── heads/        # one file per local branch, containing a commit SHA
   └── tags/         # one file per tag
└── logs/             # the reflog — see below

.gitattributes

Part 1 covered .gitignore for excluding untracked files. .gitattributes is its sibling — controlling per-path behavior for files that are tracked: line endings, diff/merge strategy, and what counts as generated code.

* text=auto              # normalize line endings for text files, leave binaries alone
*.png binary             # never diff, never normalize — treat as opaque binary
package-lock.json -diff  # tracked normally, but excluded from diffs (generated file)
*.min.js linguist-generated  # hint to GitHub's diff UI: this is generated, collapse it by default

Hooks

Scripts Git runs automatically at specific points — stored in .git/hooks/, and (unlike everything else in .git) not cloned or versioned along with the repo by default.

.git/hooks/pre-commit    # runs before a commit is created — great for linting/formatting
.git/hooks/commit-msg    # runs after the message is written — enforce a message format
.git/hooks/pre-push      # runs before a push leaves your machine — run the test suite

A minimal pre-commit hook (make it executable with chmod +x):

#!/bin/sh
npm run lint || {
  echo "Lint failed — commit aborted."
  exit 1
}

Aliases & Productivity Config

Aliases turn long, frequent commands into muscle memory.

git config --global alias.co switch
git config --global alias.st status -sb
git config --global alias.lg "log --oneline --graph --all"
git config --global alias.amend "commit --amend --no-edit"
git config --global alias.undo "reset --soft HEAD~1"
git config --global alias.unstage "restore --staged"

These end up in ~/.gitconfig and can also be edited directly:

[alias]
  co = switch
  st = status -sb
  lg = log --oneline --graph --all

Recovering From Disaster: git reflog

reflog is a local, private log of every place HEAD and each branch have pointed — it's what makes Git's "undo" commands feel so fearless, because almost nothing is actually gone until Git's garbage collector eventually runs.

git reflog                      # every place HEAD has pointed, most recent first
a1b2c3d HEAD@{0}: commit: Add validation
e4f5g6h HEAD@{1}: reset: moving to HEAD~1
h7i8j9k HEAD@{2}: commit: Fix typo
k1l2m3n HEAD@{3}: checkout: moving from main to feature/login

Recovering a commit that a reset --hard (or a bad rebase, or an accidental branch deletion) seemed to erase:

git reflog                          # find the SHA from just before the mistake
git branch recovered-work a1b2c3d   # create a new branch pointing at it — nothing is lost
# or, to put your CURRENT branch back exactly where it was:
git reset --hard a1b2c3d

Common Workflows

The commands above compose into a handful of well-known branching strategies. None is "correct" — the right one depends on release cadence and team size.

WorkflowShapeBest fit
GitHub Flowmain is always deployable; every change is a short-lived feature branch + PR, merged straight back into mainContinuous deployment, web apps
Git FlowLong-lived main + develop, with feature/*, release/*, and hotfix/* branchesScheduled releases, versioned software
Trunk-Based DevelopmentEveryone commits small, frequent changes directly to main (or very short-lived branches), often behind feature flagsLarge teams, high commit velocity, strong CI

GitHub Flow

Diagram of GitHub Flow: a short-lived feature branch forks off main, gets its own commits, and merges back into main via a single merge commit.mainfeatureABEFCDbranch, commit, open a PR, merge back into maincommitmerge commit

Git Flow

Diagram of Git Flow: main and develop are two long-lived lines. A feature branch forks off develop and merges back into develop. A release branch forks off develop and merges into both develop and main. A hotfix branch forks off main and merges into both main and develop.maindevelopfeaturereleasehotfixrelease and hotfix merge into both main and developcommitmerge commit

Trunk-Based Development

Diagram of Trunk-Based Development: commits land directly on a single main line, one after another, with no long-lived branches.maincommits land directly on main, multiple times a day

Performance: Shallow, Sparse & Partial Clones

Once a repository gets large — long history, huge tree, or both — a few flags keep everyday work fast.

git clone --depth 1 <url>                 # shallow: only the latest commit's history
git clone --filter=blob:none <url>        # partial: full commit history, file content fetched on demand
git sparse-checkout init --cone           # sparse: only materialize specific directories on disk
git sparse-checkout set src/ docs/
git gc                                    # compact loose objects into packfiles, prune unreachable ones
git maintenance start                     # schedule background gc/prefetch automatically

Command Cheat Sheet

A quick-reference pass over this post's topics.

TaskCommand
Rebase onto maingit rebase main
Interactive rebasegit rebase -i HEAD~n
Autosquash a fixupgit commit --fixup=<sha> + git rebase -i --autosquash main
Apply one commit elsewheregit cherry-pick <sha>
Find a bug's origingit bisect start
Remove a file from all historygit filter-repo --path <file> --invert-paths
Add a submodulegit submodule add <url> <path>
Work on two branches at oncegit worktree add <path> <branch>
Inspect any objectgit cat-file -p <sha>
Recover "lost" workgit reflog
Shallow / partial clonegit clone --depth 1 / --filter=blob:none

Best Practices

Never force-push to a shared branch without warning. If a rewrite is truly necessary, use --force-with-lease (it fails safely if the remote has commits you haven't seen yet, instead of silently discarding them), and tell whoever else has that branch checked out.

git push --force-with-lease

Reach for git reflog before you panic. Almost nothing committed is truly lost immediately — reflog, fsck --lost-found, and the object store itself usually have your back.

Prefer git filter-repo over git filter-branch. It's the tool Git's own docs point to now for history-wide rewrites — faster, and much harder to get subtly wrong.

Keep hooks fast, or people will bypass them. A pre-commit hook that takes 30 seconds trains everyone to reach for --no-verify, which quietly defeats the whole point. Keep it fast, or move the slow checks to CI instead.

Squash before you share, not after. Interactive rebase to clean up a branch's commits is cheap and safe before anyone else has pulled it. Once it's shared, you're stuck merging instead — so tidy up early.

Common Pitfalls

"I need to undo a merge I already pushed"

git revert -m 1 <merge-commit-sha>   # -m 1 keeps the mainline parent, undoing the merged-in side

"My interactive rebase hit a conflict and I'm not sure what state I'm in"

git status               # tells you exactly which commit is being replayed and what's conflicted
git rebase --abort        # when in doubt, bail out entirely — you're back to before the rebase started

"I force-pushed and think I overwrote someone's commits"

# On THEIR machine, if they still have the old commits checked out or in their own reflog:
git reflog                          # find the SHA of the commit that got overwritten
git push origin <that-sha>:main     # push it back up, then reconcile properly

Going forward, this is exactly the scenario --force-with-lease exists to prevent.

Frequently Asked Questions (FAQ)

What's the difference between git merge and git rebase?

Both integrate one branch's changes into another, but merge preserves history exactly as it happened (including a new merge commit), while rebase rewrites your branch's commits to look as if they were built on top of the latest target branch — producing a linear history at the cost of changing commit SHAs.


How do I completely undo a git reset --hard?

Run git reflog, find the SHA from immediately before the reset, then git reset --hard <that-sha>. This works as long as garbage collection hasn't run since — which, by default, takes weeks.


What's the actual difference between SHA-1 and SHA-256 repositories?

Git has supported SHA-256 object hashing since 2.29 as an opt-in alternative to the default SHA-1, mainly for stronger collision resistance. In practice almost every repository you'll touch — including this one — still uses SHA-1; SHA-256 support is there, but the tooling ecosystem (hosting providers, CI, other tools) hasn't fully caught up yet.

Conclusion

Git's surface area is large, but nearly all of it comes back to the same handful of ideas from Part 1: commits are immutable snapshots, and branches are cheap, movable pointers into that snapshot history. Everything in this post is really just that model applied more forcefully — rebasing is replaying pointers onto a new base, reflog works because old pointer positions don't vanish immediately, and a "commit" is, underneath it all, just a tree object with a parent and a message.

Key Takeaways:

  • merge preserves history as it happened; rebase rewrites it for linearity — never rebase commits others have already pulled
  • Interactive rebase (rebase -i) is the single most useful tool for turning messy WIP commits into a clean, reviewable history before sharing it
  • git reflog is your safety net — almost nothing is unrecoverable until garbage collection actually runs
  • Prefer --force-with-lease over --force when a rewrite genuinely must be pushed
  • A commit is a tree + parent pointer(s) + metadata; a branch is a file containing a SHA — once that clicks, most "advanced" Git stops feeling like magic
  • Pick a workflow (GitHub Flow, Git Flow, trunk-based) that matches your release cadence, and stay consistent about it as a team

Between this post and Part 1, that's Git end to end — from your first git init to rewriting history with confidence. Keep both bookmarked as a reference, and when a command's exact flags slip your mind, git help <command> is always one step closer than a search engine.

This half is the one that turns Git from "a tool I follow instructions for" into something you can actually reason about — I hope the internals section in particular makes the rest of it click. Thanks for sticking with the series through to the end. 🚀

Changelog

  • Added GitHub Flow, Git Flow, and trunk-based development workflow diagrams
  • Added a rebase before/after diagram, a git bisect binary search diagram, and a Git object graph diagram
  • Initial publication
This post is licensed under CC BY 4.0 by the author.

Share this post

  • Git Basics: A Beginner's Guide to Version Control

    Master Git's core workflow, from commit to merge.
    A beginner-friendly guide to Git covering setup, the staging area, everyday commands like add/commit/diff/log, .gitignore, branching, merging, undoing mistakes, stashing, remotes, and tags.
    Published at:
  • Getting Started with CSS Grid: A Beginner's Guide to 2D Layouts

    Lay the foundation, shape the grid.
    A beginner-friendly introduction to CSS Grid that covers core concepts and guides you through building your first 2D layouts with real-world examples.
    Published at:
  • Mastering CSS Grid: Grid Areas, Item Alignment, and Spanning

    Structure with clarity, align with precision.
    Take your CSS Grid skills to the next level by mastering semantic layouts with grid areas, named lines, item alignment, spanning, subgrid, and accessible ordering.
    Published at: