# Rebasing

> Rebase branches onto main, clean up commits with interactive rebase, and follow the golden rule so rewriting history never hurts your team.

Git & Collaboration roadmap · Everyday workflow · 19 min read · https://levelcode.org/roadmaps/git/rebase

Merging preserves history exactly as it happened, forks and all. Rebasing offers a different deal: it rewrites your branch so it looks as if you had started from the latest `main`, producing a straight, readable line. It is also the tool for polishing a messy series of commits before others review them. Used carelessly it causes real damage, so this lesson covers both how it works and the one rule that keeps it safe.

## What rebase actually does

Start with a feature branch that has fallen behind `main`:

```text
A ── B ── E          ← main
      \
       C ── D        ← feature/rate-limit ← HEAD
```

`git rebase main` performs three steps:

1. Find the commits on your branch that are not on `main` (`C` and `D`).
2. Move to the tip of `main` (`E`).
3. **Replay** each commit on top, one at a time, by reapplying its changes.

```text
A ── B ── E                ← main
           \
            C' ── D'       ← feature/rate-limit ← HEAD

(C and D still exist, but no branch points at them)
```

The replayed commits contain the same changes and messages, but they are **new commits** with new IDs. That has to be true: a commit's ID covers its parent, and `C'` has a different parent (`E`) from `C` (`B`). Remember this; everything about rebasing safely follows from it.

Compare with merging the same branches:

```text
Merge:   A ── B ── E ──────── M   ← main
               \             /
                C ───────── D

Rebase + fast-forward:
         A ── B ── E ── C' ── D'  ← main
```

Both end up with the same files. Merging records the true shape of the work; rebasing gives a linear story that is easier to read and search.

## Rebasing a feature branch onto main

Here is the real session. The branch has two commits and `main` has gained one:

```bash
git log --oneline --graph --all
```

```text
* 511b359 (HEAD -> feature/rate-limit) Wire limiter into router
* 4badb62 Add rate limiter
| * 78038d5 (main) Upgrade logger
|/  
* 734879a Initial commit
```

Rebase while on the feature branch, naming the branch you want to build on:

```bash
git rebase main
```

```text
Successfully rebased and updated refs/heads/feature/rate-limit.
```

```bash
git log --oneline --graph --all
```

```text
* fcef364 (HEAD -> feature/rate-limit) Wire limiter into router
* 9444c3e Add rate limiter
* 78038d5 (main) Upgrade logger
* 734879a Initial commit
```

Note the new IDs. `main` is now a direct ancestor of the feature branch, so integrating it is a fast-forward:

```bash
git switch main
git merge --ff-only feature/rate-limit
```

When `main` lives on a remote, rebase onto the remote-tracking branch after fetching: `git fetch` then `git rebase origin/main`. And `git pull --rebase`, which you met in the remotes lesson, is exactly "fetch, then rebase my local commits onto the upstream".

Replay that session below. Watch the ids under the commits: the rebased copies get new ones, and the originals fade out because no branch points at them any more. Then press **Reset**, run `git switch main` and `git merge feature/rate-limit` instead, and compare the two shapes.

> **Interactive: Rebase, then fast-forward.** Rebase finds the commits on feature/rate-limit that main lacks, replays them on top of main's tip as new commits, and moves the branch. main is then an ancestor of the branch, so --ff-only succeeds. Running git reset --hard ORIG_HEAD straight after the rebase brings the old commits back. Try it: https://levelcode.org/labs/git-graph

## Conflicts during a rebase

Because rebase replays commits one at a time, a conflict stops it on the commit that could not be applied:

```text
Auto-merging limiter.js
CONFLICT (add/add): Merge conflict in limiter.js
error: could not apply 9444c3e... Add rate limiter
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
```

You resolve it exactly as in a merge — edit, `git add` — but you finish with `git rebase --continue` instead of `git commit`. Git then applies the next commit, which may conflict again. Your options at each stop:

| Command | Effect |
| --- | --- |
| `git rebase --continue` | Commit the resolved changes and carry on with the next commit |
| `git rebase --skip` | Drop this commit entirely (useful if `main` already contains the same change) |
| `git rebase --abort` | Stop and return the branch to exactly where it was before the rebase |

> [!WARNING]
> "Ours" and "theirs" are **swapped** during a rebase. Rebase works by checking out `main` and replaying your commits onto it, so `HEAD` (ours) is the upstream side and the commit being replayed — *your* work — is theirs. The markers show it: `<<<<<<< HEAD` holds main's version and `>>>>>>> 9444c3e (Add rate limiter)` holds yours. Read the labels before using `--ours` or `--theirs`.

If a long branch conflicts at many commits, rebase more often, or squash the branch first so there is only one commit to replay.

## Interactive rebase: editing history

`git rebase -i` lets you rewrite a series of commits before sharing them: reorder, combine, reword or drop. Suppose your branch looks like this after a day of work:

```text
81fa1d0 WIP
905015a fix typo
fcef364 Wire limiter into router
9444c3e Add rate limiter
```

Reviewers do not need "fix typo" and "WIP". Start an interactive rebase onto `main` (or `HEAD~4`, "the last four commits"):

```bash
git rebase -i main
```

Git opens your editor with a **todo list**, oldest commit first (the opposite of `git log`):

```text
pick 9444c3e Add rate limiter
pick fcef364 Wire limiter into router
pick 905015a fix typo
pick 81fa1d0 WIP

# Rebase 78038d5..81fa1d0 onto 78038d5 (4 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
...
```

Edit the words at the start of each line, and reorder lines to reorder commits. The typo fix belongs to the first commit, so move it up and fold it in; the WIP belongs with the router change:

```text
pick 9444c3e Add rate limiter
fixup 905015a fix typo
pick fcef364 Wire limiter into router
fixup 81fa1d0 WIP
```

Save and close. Git replays the list top to bottom:

```text
Successfully rebased and updated refs/heads/feature/rate-limit.
```

```bash
git log --oneline main..
```

```text
c7a91e2 Wire limiter into router
6d2e0f4 Add rate limiter
```

The commands you will use most:

| Command | What it does |
| --- | --- |
| `pick` | Keep the commit as is |
| `reword` | Keep it, but stop to edit the message |
| `edit` | Stop after applying it so you can amend it (or split it into several commits) |
| `squash` | Merge into the previous commit and combine both messages |
| `fixup` | Merge into the previous commit and discard this commit's message |
| `drop` | Remove the commit (deleting the line does the same) |

Reordering can cause conflicts when commits depend on each other; resolve them as above, or `--abort` and try another plan.

### Fixup commits and autosquash

You can plan this cleanup while you work. When you notice a mistake in an earlier commit, record the fix as a fixup aimed at it:

```bash
git add limiter.js
git commit --fixup=6d2e0f4
git log --oneline -3
```

```text
828fc55 fixup! Add rate limiter
c7a91e2 Wire limiter into router
6d2e0f4 Add rate limiter
```

Later, `git rebase -i --autosquash main` pre-arranges the todo list for you, moving each `fixup!` commit under its target and marking it `fixup`. Set `git config --global rebase.autoSquash true` to make that the default for interactive rebases.

## The golden rule and force pushing

> [!IMPORTANT]
> **Never rebase commits that other people have based work on.** Rebasing replaces commits with new ones. If a teammate has already pulled the old ones, their history and yours now disagree, and the next pull duplicates or resurrects commits in confusing ways. Rebase your local work freely; leave shared branches like `main` alone.

In practice, rebasing **your own** feature branch after you pushed it — to tidy it up or to catch up with `main` during review — is common and fine, as long as nobody else is committing to that branch. But the remote still has the old commits, so a normal push is rejected as a non-fast-forward. You must force it, and you should do so carefully:

```bash
git push --force-with-lease
```

```text
To github.com:ada/api.git
 + be57890...0ee49ee feature/rate-limit -> feature/rate-limit (forced update)
```

Plain `--force` overwrites the remote branch no matter what. `--force-with-lease` only overwrites it if it still points where your `origin/feature/rate-limit` says it does. If a colleague pushed in the meantime, the push is refused:

```text
 ! [rejected]        feature/rate-limit -> feature/rate-limit (stale info)
```

One subtlety: a `git fetch` updates `origin/feature/rate-limit`, which renews the "lease" even if you never looked at the new commits. Adding `--force-if-includes` closes that gap by also checking that the remote tip is reachable from your local branch's reflog, meaning you actually had it locally at some point (your pre-rebase tip counts); it refuses with `(remote ref updated since checkout)` otherwise. A good habit is `git push --force-with-lease --force-if-includes`, perhaps behind an alias.

## Recovering and choosing

A rebase that went wrong is easy to undo while you are in it (`git rebase --abort`). After it finishes, the old tip is still available: Git stores it in `ORIG_HEAD`, and the reflog shows it on the line just below `rebase (start)`.

```bash
git reset --hard ORIG_HEAD     # straight after the rebase
git reflog                     # otherwise, find the old tip and reset to it
```

When to use which:

| Prefer rebase when | Prefer merge when |
| --- | --- |
| Updating your own branch with the latest `main` | Integrating a finished branch others have used |
| Cleaning up commits before review | The branch is shared and others may have pulled it |
| You want a linear, bisectable history | You want history to show exactly how work came together |

## Practice

1. Create a repository with one commit on `main`. Branch `feature/tags` and make three commits: `Add tag model`, `oops`, and `Add tag API`, where `oops` fixes a mistake in the tag model file.
2. On `main`, add a commit that touches a different file.
3. Rebase `feature/tags` onto `main`, then use interactive rebase to fold `oops` into `Add tag model`.
4. Confirm the branch now has exactly two commits after `main`, then fast-forward `main` to it.

**Solution**

```bash
git init rebase-lab && cd rebase-lab
echo "readme" > README.md && git add . && git commit -m "Initial commit"

git switch -c feature/tags
echo "class Tag {}" > tag.js && git add tag.js && git commit -m "Add tag model"
echo "export class Tag {}" > tag.js && git commit -am "oops"
echo "GET /tags" > api.txt && git add api.txt && git commit -m "Add tag API"

git switch main
echo "MIT" > LICENSE && git add LICENSE && git commit -m "Add licence"

git switch feature/tags
git rebase main
git rebase -i main
```

In the editor, change the second line from `pick` to `fixup`:

```text
pick 1a2b3c4 Add tag model
fixup 5d6e7f8 oops
pick 9a8b7c6 Add tag API
```

```bash
git log --oneline main..      # two commits: Add tag API, Add tag model
git switch main
git merge --ff-only feature/tags
git log --oneline             # linear: Add tag API, Add tag model, Add licence, Initial commit
```

`oops` came directly after its target, so no reordering was needed. Your IDs will differ, and all three branch commits got new IDs during the first rebase.

## Key takeaways

- Rebase replays your commits on a new base, creating new commits with new IDs.
- Resolve rebase conflicts commit by commit, then `--continue`, `--skip` or `--abort`; ours and theirs are swapped.
- `git rebase -i` lets you pick, reword, edit, squash, fixup, reorder and drop commits; `--fixup` plus `--autosquash` automates cleanup.
- Golden rule: never rebase commits others have built on.
- After rebasing your own pushed branch, use `git push --force-with-lease` (ideally with `--force-if-includes`), never plain `--force`.
- `ORIG_HEAD` and the reflog let you undo a finished rebase.

## Quiz

1. Why do commits get new IDs when you rebase them onto main?

   A. Rebase deliberately randomises IDs to avoid collisions
   B. Rebase changes the author on each commit
   C. Each replayed commit has a different parent, and the parent ID is part of what is hashed
   D. Git only reuses IDs for merge commits

   **Answer: C.** A commit's ID covers its tree, parents and metadata. Replaying onto a new base gives every commit a new parent, so every ID changes even if the changes and messages are identical.

2. During git rebase main on your feature branch, a conflict appears. Which side does --ours refer to?

   A. The upstream (main) side, because rebase replays your commits onto it
   B. Your feature branch's commit being replayed
   C. The merge base
   D. Whichever version is newer

   **Answer: A.** Rebase checks out the new base and applies your commits on top, so HEAD (ours) is main plus already-replayed commits, and the commit being applied (yours) is theirs. It is the reverse of a merge.

3. In an interactive rebase todo list, what does changing pick to fixup do?

   A. Stops so you can amend the commit
   B. Deletes the commit and its changes
   C. Moves the commit to the end of the branch
   D. Melds the commit into the one above it and discards its message

   **Answer: D.** fixup combines the commit's changes into the previous line's commit and keeps only that previous message. squash does the same but lets you combine both messages; edit stops for amending; drop removes it.

4. You rebased your own feature branch, which you had already pushed. What is the right way to update the remote?

   A. git push --force
   B. git push --force-with-lease, which refuses if the remote branch has moved since you last fetched
   C. git pull, then git push
   D. Delete the remote and push again

   **Answer: B.** The remote has the old commits, so a normal push is rejected. --force-with-lease overwrites only if nobody pushed in the meantime. git pull would merge the old commits back in and duplicate your work.
