# Git Rebase

> Use when squashing fixup commits into earlier commits, cleaning up a feature branch with interactive rebase, recovering a dropped or failed autosquash, inserting a reformatting commit before code changes, moving file changes between commits, splitting one working-tree edit across several historical commits, or diagnosing autosquash conflicts.

- Skill: `lisa-tarbo/git-rebase` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lisa-tarbo/git-rebase`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lisa-tarbo/git-rebase/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: lisa-tarbo (https://skillmd.com/u/lisa-tarbo)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lisa-tarbo/git-rebase

---


# git-rebase

## Overview

The standard fixup workflow is: create a `fixup!` commit in the branch, then `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>`. Manually editing the todo file or writing a custom sequence editor script is almost never necessary and introduces failure modes.

**Core principle — reconstruct, don't merge.** Every pattern below for rearranging commit contents rests on one idea: rather than replaying patches through three-way merge (which conflicts the moment surrounding context has shifted), take each file's *complete, correct state* — or a clean per-target slice of it — and commit that directly. No merge means no conflicts. When a procedure says "why this works," this is why.

## Safety

**Before any rebase**, note current HEAD:
```bash
git log --oneline -1  # copy this SHA
```

**Recovery after a bad rebase:**
```bash
git reflog              # find the pre-rebase HEAD@{N}
git reset --hard HEAD@{N}
```

**Never rebase while parallel agents have staged changes.** Staged changes are shared working-tree state. If another agent commits while you're mid-rebase, the commits become entangled. Finish or abort all rebase operations before handing off to parallel agents.

## Before You Start: Audit Per-File Targets

**One fixup commit can squash into exactly one target commit.** If your working-tree change to a file needs to land in multiple historical commits, you need multiple fixup commits — each containing only the slice that belongs in its target.

This is the single most common cause of mid-rebase conflicts. Catch it upfront:

```bash
# For each file you've modified, see which branch commits already touched it
git status --short | awk '{print $2}' | while read f; do
  echo "=== $f ==="
  git log <base>..HEAD --oneline -- "$f"
done
```

If a file appears in only one commit: a single fixup is fine.
If a file appears in N commits: you'll need to split the diff across N fixups. See [Splitting One Working-Tree Change Across Multiple Fixup Targets](#splitting-one-working-tree-change-across-multiple-fixup-targets) below.

## Core Workflow

**1. Create the fixup commit**

```bash
# Stage your changes, then:
git commit -m "fixup! <exact subject of target commit>"
```

The message after `fixup! ` must match the target commit's subject verbatim. `git commit --fixup <sha>` generates this automatically.

**2. Verify the fixup is in the rebased range**

```bash
git log <base>..HEAD --oneline | grep "fixup!"
```

If it's not listed, the autosquash will silently have nothing to squash. See "Dropped fixup recovery" below.

**3. Autosquash**

```bash
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>
```

`GIT_SEQUENCE_EDITOR=true` accepts the autosquash-generated todo without opening an editor. Git arranges the `fixup` line correctly on its own. No custom script needed.

**4. Verify the result**

```bash
git log --oneline -10          # find the new SHA (rebasing rewrites SHAs)
git show <new-sha> --stat      # confirm expected files are in the right commit
```

"Rebase succeeded" ≠ "rebase did what I intended." Always inspect the commit.

**On long branches with many fixups, prefer incremental autosquash.** Commit one fixup, autosquash, verify, then create the next. Batching seven fixups and running one autosquash means conflicts surface in arbitrary mid-rebase order with no cheap way to course-correct — if fixup #1 turns out to span two commits, you discover it three commits into the rebase instead of before starting.

## Dropped Fixup Recovery

If a fixup commit was dropped from the branch by a previous rebase:

**Don't** try to manually insert the old SHA into the todo file. Instead, re-create the commit from scratch:

```bash
# Find the dropped commit
git reflog | grep "fixup!"

# Inspect it
git show <dropped-sha>

# Re-apply its changes to the working tree
git checkout <dropped-sha> -- <file>    # for file changes
# or apply the diff manually

# Create a new fixup commit
git add <file>
git commit -m "fixup! <target subject>"

# Now autosquash normally
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>
```

## Custom GIT_SEQUENCE_EDITOR Scripts

**Avoid them.** `--autosquash` handles standard `fixup!` cases without any script. Custom scripts are only needed when you want non-standard rearrangements.

If you must write one, it **must be two-pass**:

```python
#!/usr/bin/env python3
import sys, re

todo_path = sys.argv[1]
with open(todo_path) as f:
    lines = f.readlines()

# PASS 1: build map of fixup targets → fixup lines, collect non-fixup lines
non_fixup = []
fixups = {}  # target_subject → [fixup_line, ...]
for line in lines:
    m = re.match(r'^pick (\S+) fixup! (.+)\n', line)
    if m:
        target = m.group(2)
        fixups.setdefault(target, []).append('fixup ' + m.group(1) + ' fixup! ' + target + '\n')
    else:
        non_fixup.append(line)

# PASS 2: insert fixup lines after their targets
result = []
for line in non_fixup:
    result.append(line)
    stripped = line.strip()
    if stripped and not stripped.startswith('#'):
        subject = stripped.split(None, 2)[2] if len(stripped.split(None, 2)) == 3 else ''
        for fixup_line in fixups.pop(subject, []):
            result.append(fixup_line)

# Append any unmatched fixups rather than silently dropping them
for lines_list in fixups.values():
    result.extend(lines_list)

with open(todo_path, 'w') as f:
    f.writelines(result)
```

**The single-pass trap:** Processing the todo top-to-bottom fails when the target commit appears *before* the `fixup!` line (i.e., always — the target is older). A single-pass script will check `if fixup:` for the target line when no fixup has been seen yet, store the fixup, and never emit it.

## Choosing a Reconstruction Pattern

The next three sections rearrange commit contents. They all apply the "reconstruct, don't merge" principle above; pick by what you start from:

| You have… | …and want to | Pattern |
|-----------|--------------|---------|
| A branch mixing formatting and logic changes | A pure reformatting commit first, then code-only commits | **Replay-and-reformat** |
| Existing commits whose files belong in *different* commits | Recombine file states across those commits | **Checkout-and-reconstruct** (Moving File Changes) |
| *One* uncommitted edit | Slice it across *multiple* historical commits | **Per-slice fixups** (Splitting) |
| One edit that is a mechanical, idempotent transform (rename, formatter) | Auto-slice it across the commits it touches | **`git rebase --exec`** shortcut |

## Inserting a Reformatting Commit Before Code Changes

**Replay-and-reformat.** When a branch mixes formatting changes (e.g., from `ruff format`, `black`) with logic changes, split them into a pure reformatting commit followed by code-only commits. Do **not** cherry-pick or rebase the code commits onto a reformatted base — every hunk conflicts because the surrounding context changed (quotes, line wrapping, indentation), producing dozens of unresolvable conflicts. Reconstruct instead:

```bash
# 1. Note current HEAD for safety
git log --oneline -1

# 2. Create a branch at the commit just before code changes
git checkout -b temp-branch <last-pre-code-commit>

# 3. Create the reformatting commit
<formatter> <files>
git add <files>
git commit -m "Reformat with <tool>"

# 4. Replay each code-change commit by checking out its file state
#    from the original branch, reformatting, and committing
for sha in <code-commit-1> <code-commit-2> ...; do
    git checkout "$sha" -- <files>
    <formatter> <files>
    git add <files>
    msg=$(git log -1 --format="%B" "$sha")
    git diff --cached --quiet || git commit -m "$msg"
done

# 5. Verify final content matches original (reformatted)
git show <original-HEAD>:<file> > /tmp/orig
<formatter> /tmp/orig
diff /tmp/orig <file>  # should be empty

# 6. Update the original branch
git branch -f <original-branch> HEAD
git checkout <original-branch>
git branch -D temp-branch
```

**Why this works:** each commit's complete file state comes from the original branch (where it was correct) and is reformatted — reconstruct, don't merge. The formatter being idempotent is what makes it safe to run unconditionally in the loop: re-running it on already-formatted code is a no-op.

## Moving File Changes Between Commits

**Checkout-and-reconstruct.** When a commit contains changes to files that belong in different commits (e.g., a production code fix committed together with test updates that belong in the next commit), reconstruct the history by checking out specific file states from the old commits.

**1. Check out the commit before the one to split**

```bash
git checkout <parent-of-commit-to-split>
```

**2. Rebuild the first commit with only the files it should contain**

```bash
git checkout <original-commit> -- path/to/file_A
git commit -m "First commit message"
```

**3. Rebuild the next commit by combining leftover files with its own files**

```bash
git checkout <original-commit> -- path/to/file_B       # leftover from split
git checkout <next-commit> -- path/to/file_C file_D    # files from next commit
git commit -m "Second commit message"
```

`git checkout <sha> -- <file>` grabs a file's exact state from any commit and stages it — the mechanism behind reconstruct-don't-merge, here recombining file changes across commits without patches or interactive rebase.

**4. Cherry-pick remaining commits**

```bash
git cherry-pick <remaining-commit-1> <remaining-commit-2> ...
```

**5. Point the branch at the new history and verify**

```bash
git branch -f <branch> HEAD
git checkout <branch>
git diff <branch>_backup..<branch> --stat   # should be empty
```

## Splitting One Working-Tree Change Across Multiple Fixup Targets

**Per-slice fixups.** Unlike the section above (which recombines existing commits), this covers the common case where you have **one uncommitted edit to a file** that must land in **multiple historical commits** — one fixup per target, each carrying only its slice.

Example: you've edited `output.py` to (a) add a `HASH_LENGTH` constant (belongs in the commit that introduced hashing), (b) harden the filename sanitizer (belongs in the sanitization commit), and (c) replace the tmp-file write with `O_NOFOLLOW`/`O_EXCL` (belongs in the atomic-write commit).

```bash
# 1. Save the final state and reset the file to HEAD
cp src/output.py /tmp/output.final
git checkout HEAD -- src/output.py

# 2. Apply ONLY the slice for target A (the hashing commit).
#    Hand-edit src/output.py to introduce HASH_LENGTH and its usages.
$EDITOR src/output.py
git add src/output.py
git commit -m "fixup! <subject of hashing commit>"

# 3. Apply slice B (the sanitizer commit). Re-edit to add the
#    control-char / bidi / NAME_MAX cap changes.
$EDITOR src/output.py
git add src/output.py
git commit -m "fixup! <subject of sanitization commit>"

# 4. Apply slice C (the atomic-write commit). Re-edit to add
#    O_NOFOLLOW / O_EXCL / unique tmp suffix.
$EDITOR src/output.py
git add src/output.py
git commit -m "fixup! <subject of atomic-write commit>"

# 5. Verify the cumulative result matches the saved final state
diff /tmp/output.final src/output.py  # should be empty

# 6. Autosquash
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>
```

**Why this works:** each fixup carries only the slice valid against its target's state, so the rebase applies each on top of a file that already has everything before it — reconstruct, don't merge, at the per-slice level.

**Common pitfall:** committing the entire current file state under a fixup-A message just because A is the file's earliest commit. The fixup then carries content (symbols, imports, functions) that doesn't exist at A and will conflict, often noisily, when the rebase replays it.

### Shortcut: mechanical transformations via `git rebase --exec`

When the slice is a **mechanical, idempotent transformation** (`sed` rename, `ruff format`, codemod), you don't need to hand-edit per-slice — `git rebase --exec` does the slicing automatically:

```bash
git rebase --exec '<transformation>; if ! git diff --quiet <file>; then git add <file> && git commit --amend --no-edit; fi' <base>
```

After each `pick`, the exec runs the transformation, and `--amend` folds any resulting change into the just-picked commit. Because at each pick the only `<file>` content "new" relative to the previous pick is what *that* commit contributed, the transformation naturally slices itself across the chain. Commits that don't touch `<file>` (or touch it without producing a diff after the transformation) skip the amend silently.

**Concrete example** — folding a global rename `<old_name>` → `<new_name>` into the three commits that introduced those references:

```bash
git rebase --exec 'sed -i "s/<old_name>/<new_name>/g" <file>; if ! git diff --quiet <file>; then git add <file> && git commit --amend --no-edit; fi' <earliest-introducing-commit>^
```

After the rebase, each of the three commits' diff shows `<new_name>` as if it had been written that way originally. No follow-up "fix: rename …" commit is needed; no hand-editing per slice.

**Use this when:** the transformation is monotonic and you want it folded into whichever commits introduced the affected lines. Typical fits: global identifier renames, formatter runs after the fact, codemods.

**Don't use this when:**
- The right slice differs from the mechanical one (e.g. some occurrences should be renamed, others kept — the transformation would over-apply).
- The transformation is not idempotent — re-running it should be a no-op, otherwise the amend will re-trigger on already-transformed commits and noise up history.
- The transformation depends on context outside the touched file (e.g. needs imports that this commit hasn't introduced yet) — that's a per-slice judgment call, not a mechanical apply.

**Downstream chain caveat:** rewriting commits in this branch changes their SHAs, so any branch stacked on top must be re-rebased afterward (`git rebase --onto <new-tip> <old-tip> <downstream-branch>`). Snapshot the old tips into tags (`git tag old/<branch> <sha>`) before the rewrite so the upstreams remain referenceable.

## Diagnosing Autosquash Conflicts

When autosquash stops with a conflict, the conflict marker almost always reveals which mistake you made. Read the `>>>>>>>` block — the "theirs" side — and compare it against the file state at the current `pick`:

- **Your fixup adds a symbol (constant, function) that doesn't exist in the surrounding file yet, or references an import (`os`, `Path`, etc.) the file doesn't have yet.** The fixup is targeting a commit earlier than where that symbol or import is naturally introduced. *Fix:* keep HEAD, then carve the offending lines into a separate fixup against the later commit.
- **Your fixup deletes or wholesale-replaces a function that a later branch commit also modifies.** The later commit will conflict against an empty or changed target when its turn comes. *Fix:* accept HEAD when the later commit conflicts; it shrinks to whatever changes still apply, or empties out.
- **Your fixup touches lines that another fixup against a later commit also touches.** Two fixups are racing for the same lines. *Fix:* order matters — the later-targeted fixup should be a no-op against the already-modified lines, or its slice needs to be redrafted.
- **Conflict markers appear in a file you didn't intend to touch.** A merge-recursive 3-way pulled in collateral changes. *Fix:* resolve to HEAD, verify with `git diff HEAD -- <file>` after, and run `git rebase --skip` only if the resulting commit is truly empty.

**General rule:** if you can't immediately explain *which target commit each conflicting line belongs to*, abort with `git rebase --abort`, re-audit per-file targets, and split the offending fixup.

## Common Mistakes

| Mistake                                                          | Fix                                                                                                              |
|------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------|
| Writing a custom sequence editor instead of using `--autosquash` | Use `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <base>`                                                 |
| Inserting a reflog SHA manually into the todo file               | Re-create the commit in the branch, then autosquash                                                              |
| Not verifying the result                                         | Always run `git show <sha> --stat` after rebasing                                                                |
| Single-pass sequence editor script                               | Two passes: collect first, emit second                                                                           |
| Assuming "rebase succeeded" means it did the right thing         | Verify with `git show`                                                                                           |
| Cherry-picking code commits onto a reformatted base              | Use the replay-and-reformat pattern (see above) — cherry-pick produces dozens of unsolvable formatting conflicts |
| Using interactive rebase (`-i`) to split/rearrange commits       | Claude Code can't use `-i`; use the checkout-and-reconstruct pattern instead                                     |
| Lumping all of a file's edits into one fixup when the file appears in multiple branch commits | Audit per-file targets up front; split the diff into one fixup per target — see "Splitting One Working-Tree Change Across Multiple Fixup Targets" |
| Batching many fixups before a single autosquash on a long branch | Commit one fixup, autosquash, verify, repeat — conflicts surface at the source instead of mid-rebase             |
| Treating an autosquash conflict as a merge problem to resolve in place | The conflict is usually telling you a fixup spans the wrong number of target commits; abort, split, retry          |

