DevFox Labs
HomeLearningToolsAboutContact
intermediate35 minby DevFox

Rewriting History with Rebase

What rebase actually does to your commits, merge versus rebase as a real trade-off, the ours/theirs inversion, rebase --onto, and force-pushing with --force-with-lease.

  • Git

On this page

  • What a rebase actually does
  • Merge or rebase, argued honestly
  • Rebasing a feature branch onto main
  • The rebase you will use every day
  • When a rebase hits a conflict
  • Why "ours" and "theirs" are backwards
  • Moving a branch anywhere with --onto
  • The golden rule, and what it protects
  • Publishing a rebased branch safely
  • Getting yourself back out
  • A cheat sheet to keep
  • Where to go next
DevFox Labs

Structured lessons and courses for developers who care about craft.

Platform

HomeLearningAll lessonsAboutContact

Legal

Terms of ServicePrivacy PolicyCookie Policy

© 2026 DevFox Labs. All rights reserved.

    Your feature branch has been open for three days, and main has moved on without you. Merging works, but it leaves a knot in the history: a merge commit that says nothing, and your five careful commits shuffled among everyone else's by date. There is another way to catch up.

    Rebase replays your commits on top of the latest main, as if you had started this morning. By the end of this lesson you can rebase a branch onto an updated base with confidence, name the cases where you must not, and get back out when it goes wrong.

    What a rebase actually does

    A rebase answers one question: what would my work look like if I had started it from a different commit? Git answers it in the most literal way available — by doing the work again.

    Start from a branch that split off two commits ago, with main one commit ahead — the same divergence you met in the merge lesson, with the same merge base of B:

    text
    A---B---C---G          main
         \
          D---E            feature

    Rebasing feature onto main runs five steps. Git lists the commits reachable from feature but not from main — D and E, exactly what git log main..feature prints. It saves each as a patch. It checks out main's tip, G. It applies each patch in order, making a brand-new commit for each. It moves the feature pointer to the last of them.

    text
    A---B---C---G          main
                 \
                  D'--E'   feature

    D' carries the same change, message, and author as D, and is still a different commit: its parent is G instead of B, so its hash differs, and so does every hash after it. Think of re-recording a guitar part over a new backing track — same notes, new take, new tape.

    New commit, same change

    Rebase does not move commits. It copies them and abandons the originals. D and E still sit in your repository, unreachable from any branch, until garbage collection clears them — which is why the recovery below works.

    Merge or rebase, argued honestly

    The two commands solve one problem and disagree about what history is for:

    text
    merge                          rebase
    
    A---B---C---G---M    main      A---B---C---G        main
         \         /                            \
          D-------E      feature                 D'--E' feature

    Merge

    Records what actually happened.

    Hashes survive, the branch point stays visible, nothing is rewritten — so it is always safe, including on branches other people have pulled.

    The cost is a braided history: git log becomes a wall of merge commits and git bisect has more shapes to walk.

    Rebase

    Produces a straight line.

    Every commit has one parent, git log reads like a changelog, and reverting a feature means reverting a contiguous run rather than untangling a merge.

    The cost is that it is a fiction — those commits were never tested on top of G — and the rewrite makes the branch unsafe to share.

    One problem, two answers, and they disagree about what history is for

    A rule you can defend in review: rebase your own unpublished branch to keep it current, and merge it into main so the integration point stays recorded. Reasonable teams disagree; some rebase everything, some ban it outside local work. Learn your team's convention before you change anyone's history.

    Rebasing a feature branch onto main

    Update main first, since you are about to replay onto whatever it points at:

    bash
    git switch main
    git pull
    git switch feature
    git rebase main

    git rebase main means "replay the current branch onto main." The two-argument form saves you the switch — Git checks out the named branch for you:

    bash
    git rebase main feature      # same as the four commands above

    Afterwards git log --graph --oneline main feature shows one straight line, with new hashes.

    The rebase you will use every day

    Long before you rebase a branch on purpose, you meet rebase through git pull. The default pull fetches and merges, so when you and a colleague both commit to main you get a merge commit reading "Merge branch 'main' of github.com/…" for no reason but timing. Multiply by a team and history fills with noise.

    bash
    git pull --rebase

    This fetches, then replays your local commits on top of the updated remote branch: no merge commit appears. Those commits were unpublished, so rewriting them costs nothing.

    Set it once and forget it

    Run git config --global pull.rebase true to make that the default, and git config --global rebase.autoStash true so Git stashes a dirty working tree before any rebase and restores it after. Together they kill two daily annoyances: pointless merge commits, and being told to stash before you can pull.

    When a rebase hits a conflict

    A merge conflict arrives all at once. A rebase conflict arrives one commit at a time, because Git applies your patches in order and stops the moment one does not fit. Resolving is the work you already know — open the file, deal with the markers, stage the result. Only what you type next differs:

    bash
    git status                   # shows which commit is replaying
    # edit the conflicted files
    git add <file>
    git rebase --continue        # commit this one, replay the next

    Two escapes. git rebase --skip discards the commit being replayed — for when its change is already on the new base, not merely inconvenient. git rebase --abort stops everything and restores the branch exactly as it was: the right move whenever you feel lost.

    If a long rebase makes you resolve the same clash over and over, Git can memorise resolutions for you with rerere.enabled — a feature worth a lesson of its own, later.

    Why "ours" and "theirs" are backwards

    This is the most confusing thing about rebase, and it catches experienced people. ours always means "whatever HEAD is" — and a rebase checks out the new base before replaying anything.

    During a merge

    ours is your branch.

    HEAD is where you were standing when you ran git merge, so the labels match everybody's intuition.

    During a rebase

    ours is the branch you are replaying onto.

    Git checked out main before replaying, so HEAD is main's tip and your commits are the incoming patches.

    Reaching for --ours to mean "my work" silently throws your change away, and the rebase finishes with no error at all.

    The rule never changes. Which branch is HEAD does.
    bash
    git restore --ours <file>    # take main's version
    git restore --theirs <file>  # take your replayed commit's version

    Older guides spell these git checkout --ours; git restore is the modern form, and it only ever touches files.

    Read the labels, not the names

    With the diff3 or zdiff3 conflict style from the merge lesson, the markers name the actual commits. Read those rather than working out which of ours and theirs applies today.

    Moving a branch anywhere with --onto

    Sooner or later you branch off the wrong thing. You start payments-ui from payments-api because you needed its endpoints, then the API branch is rejected in review and your UI work has to sit on main instead:

    text
    A---B---C                     main
         \
          P1--P2                  payments-api
               \
                U1--U2--U3        payments-ui

    git rebase main payments-ui will not do it: everything on payments-ui that is not on main includes P1 and P2, so the API work comes along. You need to name the commits and the destination separately. That is the three-argument form:

    bash
    git rebase --onto main payments-api payments-ui

    Three slots: newbase is where commits land, upstream is the cut line (anything already in it is excluded), branch is what gets replayed and moved. Take the commits in payments-ui that are not in payments-api, and put them on main.

    text
    A---B---C                     main
         \   \
          \   U1'--U2'--U3'       payments-ui
           P1--P2                 payments-api

    payments-api has not moved, and your UI branch now depends on nothing but main. Once you see the three slots the simple form stops looking special: git rebase main feature is exactly git rebase --onto main main feature, cut line and destination happening to be the same branch.

    Carrying a stack of branches along

    If you keep dependent branches stacked on one another, rebasing the bottom one strands the branches above it on the abandoned commits. git rebase --update-refs moves every branch pointer inside the replayed range to its new commit, and rebase.updateRefs makes that the default.

    The golden rule, and what it protects

    Stated precisely: do not rebase commits that other people have based work on. Not "never rebase pushed commits" — plenty of teams rebase their own pushed feature branches all day. The line is whether someone else's work sits on top of yours.

    Here is what breaks. Your colleague holds D and E. You rebase into D' and E' and force-push. They pull, and Git sees four unrelated commits, because a hash is the only identity a commit has. Their pull merges the two lines, so every change now appears twice and they hit conflicts between a commit and its own copy — and if they push that, it is everyone's problem.

    Rewriting shared history is a team event

    Rebasing history others have built on cannot be fixed by you alone — every affected person has to reset their branch by hand. If a branch has collaborators, either merge instead or agree the rewrite first and hand them the exact recovery command. On a protected branch like main, do not rebase at all.

    Publishing a rebased branch safely

    Rebase a branch you have already pushed and your history and the remote's have diverged, so a normal git push is rejected. You need a force push, and which one matters:

    bash
    git push --force-with-lease

    Plain --force says "make the remote equal mine, whatever is there". If a teammate pushed to your branch ten minutes ago, that commit is gone silently. --force-with-lease sends the value your origin/feature tracking ref holds, and the server refuses the push unless the branch still points there.

    Make it your default reflex

    --force-with-lease is strictly better than --force: identical result when nothing changed, a rejection instead of data loss when something did. Its one weak spot is a background fetch (an IDE polling the remote) quietly refreshing your tracking ref. --force-if-includes closes that hole by checking the fetched commits are in your history; modern Git enables it whenever --force-with-lease is used with no explicit value.

    Getting yourself back out

    While a rebase is in progress — conflict markers on screen, a detached prompt — one command undoes all of it:

    bash
    git rebase --abort

    Branch, working tree, and index return to their exact state before you started.

    After a rebase finishes, the escape hatch is that the originals still exist. Git records where HEAD was before the rebase:

    bash
    git reset --hard ORIG_HEAD

    If another operation has overwritten ORIG_HEAD, find the old tip yourself. git reflog lists every position HEAD has held, newest first; the entry just before rebase (start) is your pre-rebase branch:

    bash
    git reflog                        # find the hash you want
    git reset --hard <hash>

    git reset --hard discards uncommitted work in the working tree and index without asking, so commit or stash first. The reflog is a serious safety net and gets a full lesson of its own later.

    A cheat sheet to keep

    bash
    git rebase main                    # replay this branch onto main
    git rebase main feature            # same, without switching first
    git rebase --onto main old new     # replay new's commits, not old's
    git rebase --continue              # commit the fix, replay the next
    git rebase --skip                  # drop the commit being replayed
    git rebase --abort                 # cancel, restore the branch
    git rebase --update-refs           # carry stacked branches along
    git pull --rebase                  # fetch, then replay, no merge
    git restore --ours <file>          # rebase: the new base's copy
    git restore --theirs <file>        # rebase: your own commit's copy
    git push --force-with-lease        # publish a rebased branch safely
    git reset --hard ORIG_HEAD         # undo a rebase that finished
    git reflog                         # find any commit you have lost
    git config --global pull.rebase true       # rebase on every pull
    git config --global rebase.autoStash true  # stash around rebases
    git config --global rebase.updateRefs true # move stacked branches

    Where to go next

    You can now bring a branch up to date without a merge commit, move one off the wrong base, and undo either. That is rebase as a navigation tool: same commits, new location.

    The next lesson turns it into an editing tool. Interactive rebase, git rebase -i, stops at each commit so you can reorder, combine, split, and reword — turning a trail of "wip", "fix typo", and "actually fix typo" into three commits a reviewer can read. Every mechanic here still holds.

    Before then, spend twenty minutes in a throwaway repository. Diverge two branches, rebase one onto the other, force a conflict and resolve it, abort halfway through, then recover a finished rebase with the reflog. Rebase stops being frightening the moment you have undone one on purpose.