Back to Resources

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
4 linescompact
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"]
Rendered from Mermaid source with the native ZeroLabs diagram container.

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 LayerMinimum VersionProduction RecommendationPurpose in Stack
Git binary2.39.02.50+ (Apple Git or git-scm)rebase -i, log --graph
perl5.325.34+GIT_SEQUENCE_EDITOR todo rewrite
GitHub CLIgh 2.40.0gh 2.98+repo create --private --push
Auth sessiongh api user returns a loginToken already in gh authPrivate create, no browser
Prompt lockGIT_TERMINAL_PROMPT=0 plus GH_PROMPT_DISABLED=1Same, plus GH_PAGER=cat, GIT_EDITOR=trueFail missing credentials, never open vim
Scratch path$HOME/rebase-cli-scratch--private onlyIsolate 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 verbWhat Git doesOpens a message editor?This recipe
pickKeep the commitNoLine 1 of HEAD~3
squashFold into previous, keep both messagesYes. GIT_EDITOR=trueLines 2 and 3
fixupFold into previous, drop this messageNoTable only
rewordKeep the patch, edit the subjectYesTable only
editApply, then stopThen --continueAbort and continue drills
dropOmit the commitNoTable 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.

  1. Confirm Git, perl, GitHub CLI, and an authenticated login.

    Terminalbash
    export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --versionperl -vgh --versiongh api user --jq .login

    Expected: Git 2.39+ (we measured git version 2.50.1 (Apple Git-155)), perl 5.32+ (we measured v5.34.1), a gh version line, and a login. If gh api user fails, stop. Missing binaries: HOMEBREW_NO_AUTO_UPDATE=1 brew install git gh or sudo apt-get update -y && sudo apt-get install -y git gh perl.

  2. Create the private scratch repository on main and push it. Push main now. Do not push feature until after the squash.

    Terminalbash
    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-pushed

    Expected: one main commit, matching 40-character SHAs, then main-pushed. If create waits on Visibility, you omitted --private.

  3. Create unpublished feature with three commits. Do not push it. Three commits is the minimum for HEAD~3. Leave them off origin.

    Terminalbash
    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-unpublished

    Expected: on feature, 3 commits not in main, ## feature with no [origin/feature], then feature-unpublished. If status shows origin/feature, you pushed too early. Stop.

  4. Squash the last two commits with git rebase -i HEAD~3 and prove a shorter linear log. GIT_SEQUENCE_EDITOR rewrites later pick lines to squash. GIT_EDITOR=true keeps Git's combined message. EDITOR=true is the fallback.

    Terminalbash
    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-ok

    Expected: Rebasing (2/3), Rebasing (3/3), Successfully rebased and updated refs/heads/feature, fatal: Needed a single revision (exit 128), two * lines, then squash-ok. We measured 83.0 milliseconds. On CONFLICT, git rebase --abort and stop. If vim opens, GIT_SEQUENCE_EDITOR was unset.

  5. Stop an edit rebase with --abort, then finish a second edit with --continue. edit stops with exit 0 and still writes .git/rebase-merge. --abort restores the tip. --continue finishes. Check that directory, not the exit code.

    Terminalbash
    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-ok

    Expected: Stopped at ... Add feat.txt line a, then abort-ok, then continue-ok. We measured 47.2 milliseconds for --abort and 45.1 milliseconds for --continue. If rebase-merge is missing after -i, you used HEAD instead of HEAD~1.

  6. Push unpublished feature without --force. This feature was never on origin, so the first push creates a new ref.

    Terminalbash
    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-pushed

    Expected: * [new branch] feature -> feature, matching SHAs, then feature-pushed. On non-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.

Terminalbash
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):

text
VERIFY_OKc0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe

When we ran this probe at ZeroShot Studio, it printed VERIFY_OK in under 20 milliseconds. Cleanup (--yes required):

Terminalbash
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 -i hangs in vim: Export GIT_SEQUENCE_EDITOR='perl -pi -e "s/^pick/squash/ if \$. > 1"' and GIT_EDITOR=true before -i. EDITOR=true is the fallback.
  • Squash opens a second editor: GitHub shows the combined-message file after squash. GIT_EDITOR=true accepts it. Sequence editor alone is not enough.
  • CONFLICT during replay: git rebase --abort restores the pre-rebase tip. Do not --skip. Conflict repair is a later recipe.
  • Exit 0 during edit: git rebase -i HEAD~1 with edit exits 0 and still writes .git/rebase-merge. Then --abort or --continue.
  • --force onto a shared branch: GitHub documents git push origin main --force-with-lease. Do not run it. If push prints non-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.

Share