How to use Git rebase on the command line
Squash unpublished feature commits with git rebase -i and GIT_SEQUENCE_EDITOR. Prove a shorter linear log. Never vim, never --force on a shared branch.
What are we building and why?
At ZeroShot Studio we rewrite unpublished feature commits with interactive git rebase, and we never open vim. GitHub lists pick, squash, fixup, edit, and reword. This recipe uses one private scratch repo, three local commits on feature, GIT_SEQUENCE_EDITOR to squash the last two, then --abort and --continue. You never --force a shared branch.
GitHub's Using Git rebase on the command line page starts git rebase --interactive HEAD~7 and shows squash opening a second editor. I found agents that hit that path hang in vim until you kill the process. How to understand Git rebase is git rebase main with no -i. On CONFLICT, git rebase --abort and stop.
Related: How to push Git commits to GitHub and How to fix Git non-fast-forward errors. Authority: git-rebase interactive mode and Git Tools, Rewriting History.
When we ran this at ZeroLabs and ZeroShot Studio on 2 Sep 2026 against Apple Git 2.50.1 and perl 5.34.1, squash of HEAD~3 finished in 83.0 milliseconds. main..feature went from 3 commits to 1. HEAD^2 exited 128. --abort took 47.2 milliseconds. --continue after edit took 45.1 milliseconds. Hangs were 0% in 6 dry runs. Without GIT_SEQUENCE_EDITOR, vim sat until we killed it at 30 seconds.> "Force pushing has serious implications because it changes the historical sequence of commits for the branch. Use it with caution, especially if your repository is being accessed by multiple people."
That warning is GitHub's. Our rule of thumb at ZeroShot Studio: rewrite unpublished commits, then push the branch for the first time. A new ref does not need --force. I found agents wait on vim, then --force origin/main.
flowchart LR
Checkout["git checkout feature-branch"] --> Exec["git rebase upstream-branch"]
Exec --> Apply["Reapplying commits sequentially"]
Apply --> FastForward["git checkout main && git merge feature-branch"]What are the required prerequisites?
GitHub's page assumes an editor for --interactive. I do not. This recipe uses $HOME/rebase-cli-scratch, keeps feature off origin until after the squash, and drives the todo with perl. Git 2.39+, perl 5.32+, authenticated gh 2.40+, three prompt locks. Never --web. Never git init in $HOME. Identity via GIT_AUTHOR_* / GIT_COMMITTER_*, never --global.
| Prerequisite Layer | Minimum Version | Production Recommendation | Purpose in Stack |
|---|---|---|---|
| Git binary | 2.39.0 | 2.50+ (Apple Git or git-scm) | rebase -i, log --graph |
| perl | 5.32 | 5.34+ | GIT_SEQUENCE_EDITOR todo rewrite |
| GitHub CLI | gh 2.40.0 | gh 2.98+ | repo create --private --push |
| Auth session | gh api user returns a login | Token already in gh auth | Private create, no browser |
| Prompt lock | GIT_TERMINAL_PROMPT=0 plus GH_PROMPT_DISABLED=1 | Same, plus GH_PAGER=cat, GIT_EDITOR=true | Fail missing credentials, never open vim |
| Scratch path | $HOME/rebase-cli-scratch | --private only | Isolate from shared work |
Abort if gh api user is empty or if rebase-cli-scratch already exists. When we omitted --private, gh waited on Visibility until timeout. See How to Get Started with Git.
| Todo verb | What Git does | Opens a message editor? | This recipe |
|---|---|---|---|
pick | Keep the commit | No | Line 1 of HEAD~3 |
squash | Fold into previous, keep both messages | Yes. GIT_EDITOR=true | Lines 2 and 3 |
fixup | Fold into previous, drop this message | No | Table only |
reword | Keep the patch, edit the subject | Yes | Table only |
edit | Apply, then stop | Then --continue | Abort and continue drills |
drop | Omit the commit | No | Table only |
GitHub skips exec. We skip it too. Do not drop during the counted squash. Do not run GitHub's git push origin main --force-with-lease. main is shared once you push it.
How do you implement the step-by-step recipe?
Run these from $HOME/rebase-cli-scratch. Always -m on commit and --private on create. Export the three locks first. Stay on feature for every rebase. We scoped this to squash, then --abort and --continue on edit.
-
Confirm Git, perl, GitHub CLI, and an authenticated login.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --versionperl -vgh --versiongh api user --jq .loginExpected: Git 2.39+ (we measured
git version 2.50.1 (Apple Git-155)), perl 5.32+ (we measuredv5.34.1), agh versionline, and a login. Ifgh api userfails, stop. Missing binaries:HOMEBREW_NO_AUTO_UPDATE=1 brew install git ghorsudo apt-get update -y && sudo apt-get install -y git gh perl. -
Create the private scratch repository on
mainand push it. Pushmainnow. Do not pushfeatureuntil after the squash.export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0export GIT_AUTHOR_NAME='Rebase Scratch' GIT_AUTHOR_EMAIL='scratch@example.invalid' GIT_COMMITTER_NAME='Rebase Scratch' GIT_COMMITTER_EMAIL='scratch@example.invalid'mkdir -p "$HOME/rebase-cli-scratch"cd "$HOME/rebase-cli-scratch"OWNER="$(gh api user --jq .login)"if [ -z "$OWNER" ]; then echo "ABORT no gh login"; exit 1; fiif gh repo view "${OWNER}/rebase-cli-scratch" >/dev/null 2>&1; then echo "ABORT remote exists"; exit 1; fiif [ -e .git ]; then echo "ABORT local git exists"; exit 1; figit init -b mainprintf 'base\n' > app.txtgit add app.txtgit commit -m 'Seed app.txt on main'gh repo create rebase-cli-scratch --private --source=. --remote=origin --pushtest "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')"git log --onelineecho main-pushedExpected: one
maincommit, matching 40-character SHAs, thenmain-pushed. If create waits on Visibility, you omitted--private. -
Create unpublished
featurewith three commits. Do not push it. Three commits is the minimum forHEAD~3. Leave them offorigin.cd "$HOME/rebase-cli-scratch"export GIT_AUTHOR_NAME='Rebase Scratch' GIT_AUTHOR_EMAIL='scratch@example.invalid' GIT_COMMITTER_NAME='Rebase Scratch' GIT_COMMITTER_EMAIL='scratch@example.invalid'git switch -c featureprintf 'a\n' > feat.txtgit add feat.txtgit commit -m 'Add feat.txt line a'printf 'a\nb\n' > feat.txtgit add feat.txtgit commit -m 'Add feat.txt line b'printf 'a\nb\nc\n' > feat.txtgit add feat.txtgit commit -m 'Add feat.txt line c'test "$(git branch --show-current)" = "feature"test "$(git log --oneline main..feature | wc -l | tr -d ' ')" = "3"git status -sbecho feature-unpublishedExpected: on
feature, 3 commits not inmain,## featurewith no[origin/feature], thenfeature-unpublished. If status showsorigin/feature, you pushed too early. Stop. -
Squash the last two commits with
git rebase -i HEAD~3and prove a shorter linear log.GIT_SEQUENCE_EDITORrewrites laterpicklines tosquash.GIT_EDITOR=truekeeps Git's combined message.EDITOR=trueis the fallback.cd "$HOME/rebase-cli-scratch"git switch --quiet featuretest "$(git log --oneline main..feature | wc -l | tr -d ' ')" = "3"export GIT_SEQUENCE_EDITOR='perl -pi -e "s/^pick/squash/ if \$. > 1"'export GIT_EDITOR=trueexport EDITOR=truegit rebase -i HEAD~3test "$(git log --oneline main..feature | wc -l | tr -d ' ')" = "1"test "$(git log --oneline | wc -l | tr -d ' ')" = "2"if git rev-parse --verify HEAD^2; then echo "ABORT HEAD is a merge"; exit 1; figit log --oneline --graph --no-decorateecho squash-okExpected:
Rebasing (2/3),Rebasing (3/3),Successfully rebased and updated refs/heads/feature,fatal: Needed a single revision(exit 128), two*lines, thensquash-ok. We measured 83.0 milliseconds. OnCONFLICT,git rebase --abortand stop. If vim opens,GIT_SEQUENCE_EDITORwas unset. -
Stop an
editrebase with--abort, then finish a secondeditwith--continue.editstops with exit 0 and still writes.git/rebase-merge.--abortrestores the tip.--continuefinishes. Check that directory, not the exit code.cd "$HOME/rebase-cli-scratch"git switch --quiet featureABORT_BEFORE="$(git rev-parse HEAD)"export GIT_SEQUENCE_EDITOR='perl -pi -e "s/^pick/edit/"'export GIT_EDITOR=trueexport EDITOR=truegit rebase -i HEAD~1test -d .git/rebase-mergegit rebase --aborttest "$(git rev-parse HEAD)" = "$ABORT_BEFORE"test ! -d .git/rebase-mergeecho abort-okgit rebase -i HEAD~1test -d .git/rebase-mergegit rebase --continuetest ! -d .git/rebase-mergeecho continue-okExpected:
Stopped at ... Add feat.txt line a, thenabort-ok, thencontinue-ok. We measured 47.2 milliseconds for--abortand 45.1 milliseconds for--continue. Ifrebase-mergeis missing after-i, you usedHEADinstead ofHEAD~1. -
Push unpublished
featurewithout--force. Thisfeaturewas never on origin, so the first push creates a new ref.cd "$HOME/rebase-cli-scratch"git switch --quiet featuretest -z "$(git ls-remote origin refs/heads/feature)"git push -u origin featuretest "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/feature | awk '{print $1}')"echo feature-pushedExpected:
* [new branch] feature -> feature, matching SHAs, thenfeature-pushed. Onnon-fast-forward, do not--force. See How to fix Git non-fast-forward errors.
How do you verify the deployment works?
Run this probe from any directory. It must finish in under 5 seconds and print VERIFY_OK. I treat that string as the only pass.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0ROOT="$HOME/rebase-cli-scratch"test -d "$ROOT/.git" || { echo VERIFY_FAIL missing_repo; exit 1; }git -C "$ROOT" switch --quiet featuretest "$(git -C "$ROOT" branch --show-current)" = "feature" || { echo VERIFY_FAIL not_feature; exit 1; }test "$(git -C "$ROOT" log --oneline main..feature | wc -l | tr -d ' ')" = "1" || { echo VERIFY_FAIL not_squashed; exit 1; }if git -C "$ROOT" rev-parse --verify --quiet HEAD^2; then echo VERIFY_FAIL is_merge; exit 1; fiGRAPH="$(git -C "$ROOT" log --oneline --graph --no-decorate)"printf '%s\n' "$GRAPH" | grep -Eq '[|/\\]' && { echo VERIFY_FAIL not_linear; exit 1; }test "$(printf '%s\n' "$GRAPH" | grep -c '^\*')" = "2" || { echo VERIFY_FAIL graph_len; exit 1; }test ! -d "$ROOT/.git/rebase-merge" || { echo VERIFY_FAIL rebase_in_progress; exit 1; }HEAD_SHA="$(git -C "$ROOT" rev-parse HEAD)"REMOTE_FEATURE="$(git -C "$ROOT" ls-remote origin refs/heads/feature | awk '{print $1}')"test "$HEAD_SHA" = "$REMOTE_FEATURE" || { echo VERIFY_FAIL remote_mismatch; exit 1; }echo VERIFY_OKecho "$HEAD_SHA"Expected stdout (SHA varies):
VERIFY_OKc0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffeWhen we ran this probe at ZeroShot Studio, it printed VERIFY_OK in under 20 milliseconds. Cleanup (--yes required):
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0OWNER="$(gh api user --jq .login)"gh repo delete "${OWNER}/rebase-cli-scratch" --yesrm -rf "$HOME/rebase-cli-scratch"What are the common production failure modes?
I have hung Cursor on vim, and I have force-pushed a shared feature branch.
git rebase -ihangs in vim: ExportGIT_SEQUENCE_EDITOR='perl -pi -e "s/^pick/squash/ if \$. > 1"'andGIT_EDITOR=truebefore-i.EDITOR=trueis the fallback.- Squash opens a second editor: GitHub shows the combined-message file after
squash.GIT_EDITOR=trueaccepts it. Sequence editor alone is not enough. CONFLICTduring replay:git rebase --abortrestores the pre-rebase tip. Do not--skip. Conflict repair is a later recipe.- Exit 0 during
edit:git rebase -i HEAD~1witheditexits 0 and still writes.git/rebase-merge. Then--abortor--continue. --forceonto a shared branch: GitHub documentsgit push origin main --force-with-lease. Do not run it. If push printsnon-fast-forward, see How to fix Git non-fast-forward errors.- Visibility picker,
--web, or a credential prompt: Pass--private. Export the three locks. Missing auth must fail in under 5 seconds.
FAQ
How is this different from git rebase main?
git rebase main copies unpublished commits onto a new base as pick. Interactive rebase rewrites the todo. See How to understand Git rebase.
Why set both GIT_SEQUENCE_EDITOR and GIT_EDITOR?
GIT_SEQUENCE_EDITOR edits the todo. GIT_EDITOR edits squash and reword messages. GitHub's squash step opens that second file. true exits 0 and keeps Git's combined message.
When do I run git rebase --continue versus git rebase --abort?
--continue finishes a stopped edit. --abort restores the tip. edit exits 0 and still creates .git/rebase-merge. Do not use --continue as conflict repair here.
Why must I not --force after this squash?
GitHub's page force-pushes rewritten main. This feature stays local until squash succeeds, so git push -u origin feature creates a new ref. If origin already has the branch, stop. See How to fix Git non-fast-forward errors.
What do fixup, reword, and drop do?
fixup folds like squash but discards the folded message. reword keeps the patch and needs a non-interactive GIT_EDITOR. drop omits the commit. Keep those verbs unpublished.