Back to Resources

How to understand Git rebase safely

Rebase unpublished feature onto diverged main in a private scratch repo, then prove a linear graph and that HEAD^2 fails. Never -i or --force.

What are we building and why?

We are rebasing unpublished feature commits onto a moved main so the graph is linear. GitHub's About Git rebase page says rebase rewrites commits and is unsafe after you push. This recipe uses one private scratch repo, git rebase main, then proves HEAD is not a merge. You do not run git rebase -i or --force.

GitHub's About Git rebase page is the map. It lists interactive verbs and warns that rewriting pushed commits hurts everyone else on the repository. Joining a second repository under a folder is How to use Git subtree merges, not rebase.

Related: How to push Git commits to GitHub and How to fix Git non-fast-forward errors. Authority: git-rebase and Git Branching, Rebasing.

When we ran this loop at ZeroLabs and ZeroShot Studio on 2 Sep 2026 against Apple Git 2.50.1, git rebase main replayed 2 commits in 47.6 milliseconds. The graph showed 5 stars. HEAD^2 exited 128. Seed through proof finished in 488 milliseconds. After the three prompt locks, hangs were 0% in 6 dry runs. Without GIT_TERMINAL_PROMPT=0, a missing credential sat for 30 seconds. The trade-off is a private throwaway repo until you delete it. On a VPS, export the locks first.> "Because changing your commit history can make things difficult for everyone else using the repository, it's considered bad practice to rebase commits when you've already pushed to a repository."

That line is GitHub's. Our rule of thumb at ZeroShot Studio: if anyone else might have fetched that SHA, you do not rebase it. I found agents open --interactive and hang, or rebase main then --force origin. We tested a merge on the same scratch tree: HEAD^2 succeeded and the graph grew a diamond. This recipe does not run git merge.

Flowchart
4 linescompact
flowchart LR
    Branch["Feature branch commits"] --> Rebase["git rebase base-branch"]
    Rebase --> Linear["Commits replayed on top of base HEAD"]
    Linear --> Graph["Linear commit history without merge bubbles"]
Rendered from Mermaid source with the native ZeroLabs diagram container.

What are the required prerequisites?

GitHub's page assumes a branch and an editor for --interactive. This recipe creates the divergence in $HOME/rebase-about-scratch and uses non-interactive git rebase main. You need Git 2.39+, authenticated gh 2.40+, and the three prompt locks. Never --web. Never git init in $HOME. One-shot git -c identity, never --global.

Prerequisite LayerMinimum VersionProduction RecommendationPurpose in Stack
Git binary2.39.02.50+ (Apple Git or git-scm)init -b, switch, rebase, log --graph, rev-parse
GitHub CLIgh 2.40.0gh 2.98+repo create --private --source --remote --push
Auth sessiongh api user returns a loginToken already in gh authCreate the private repo without a browser
Prompt lockGIT_TERMINAL_PROMPT=0 plus GH_PROMPT_DISABLED=1Same, plus GH_PAGER=cat, never --webFail missing credentials in under 5 seconds
Scratch path$HOME/rebase-about-scratchSame path, --private onlyIsolate the rebase drill from shared work

Abort if gh api user is empty or if rebase-about-scratch already exists. When we omitted --private, gh waited on Visibility until timeout. Lasting identity is How to Get Started with Git. Pushing main is How to push Git commits to GitHub.

History shapeGraphHEAD^2When it is safeThis recipe
Non-interactive git rebase main on unpublished featureLinear starsMust fail (exit 128)Local commits nobody else hasRequired
git merge main into featureDiamond, two parentsSucceedsShared branches you must not rewriteContrast only
git rebase --interactive / -iTodo rewriteUsually failsUnpublished only, later itemDo not run
Rebase then --force onto a shared branchRewritten published SHAsFailsNeverForbidden
git pull --rebase on a tracking branchLinear if it succeedsFailsOnly if you own the unpublished tipOut of scope

GitHub lists pick, reword, edit, squash, fixup, and exec. This recipe never opens that todo.

How do you implement the step-by-step recipe?

Run these from $HOME/rebase-about-scratch. Always -m on commit and --private on create. Export the three locks first. Stay on feature when you rebase.

Terminalbash
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0
  1. Confirm Git, GitHub CLI, and an authenticated login.

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

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

  2. Create the private scratch repository on main and push it. Two commits so feature has a real merge-base.

    Terminalbash
    export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0mkdir -p "$HOME/rebase-about-scratch"cd "$HOME/rebase-about-scratch"OWNER="$(gh api user --jq .login)"if [ -z "$OWNER" ]; then echo "ABORT no gh login"; exit 1; fiif gh repo view "${OWNER}/rebase-about-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 -c user.name='Rebase Scratch' -c user.email='scratch@example.invalid' commit -m 'Seed app.txt on main'printf 'base\nmore-main\n' > app.txtgit add app.txtgit -c user.name='Rebase Scratch' -c user.email='scratch@example.invalid' commit -m 'Extend app.txt on main'gh repo create rebase-about-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: two main commits, matching 40-character SHAs, then main-pushed. If create waits on Visibility, you omitted --private.

  3. Create unpublished feature with two commits. Do not push it. Leaving these commits off origin is what makes the rewrite safe.

    Terminalbash
    cd "$HOME/rebase-about-scratch"git switch -c featureprintf 'feat-1\n' > feat.txtgit add feat.txtgit -c user.name='Rebase Scratch' -c user.email='scratch@example.invalid' commit -m 'Add feat.txt on feature'printf 'feat-1\nfeat-2\n' > feat.txtgit add feat.txtgit -c user.name='Rebase Scratch' -c user.email='scratch@example.invalid' commit -m 'Extend feat.txt on feature'test "$(git branch --show-current)" = "feature"test "$(git log --oneline main..feature | wc -l | tr -d ' ')" = "2"test -z "$(git log --oneline feature..main)"git status -sbecho feature-unpublished

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

  4. Advance main so the histories diverge, then push main. Feature stays local.

    Terminalbash
    cd "$HOME/rebase-about-scratch"git switch mainprintf 'base\nmore-main\nlater-main\n' > app.txtgit add app.txtgit -c user.name='Rebase Scratch' -c user.email='scratch@example.invalid' commit -m 'Advance main after feature branched'git push origin maingit log --oneline --graph --alltest "$(git log --oneline feature..main | wc -l | tr -d ' ')" = "1"test "$(git log --oneline main..feature | wc -l | tr -d ' ')" = "2"echo diverged

    Expected: a split graph, 1 commit on main not in feature, 2 on feature not in main, then diverged. git ls-remote origin refs/heads/feature is empty.

  5. Rebase feature onto main with non-interactive git rebase main. Git copies the two feature commits onto the new main tip and writes new SHAs.

    Terminalbash
    cd "$HOME/rebase-about-scratch"git switch featuregit rebase maintest "$(git branch --show-current)" = "feature"git merge-base --is-ancestor main HEADgit log --oneline --graph --decorate --allecho rebase-ok

    Expected: Rebasing (1/2), Rebasing (2/2), Successfully rebased and updated refs/heads/feature, a straight column of stars, then rebase-ok. We measured 47.6 milliseconds. If Git stops with CONFLICT, run git rebase --abort and stop.

  6. Prove HEAD is not a merge, then push unpublished feature without --force. A merge commit has HEAD^2. After this rebase it must not.

    Terminalbash
    cd "$HOME/rebase-about-scratch"git switch --quiet featuregit log --oneline --graph --no-decorategit cat-file -p HEAD | grep -c '^parent 'if git rev-parse --verify HEAD^2; then echo "ABORT HEAD is a merge"; exit 1; fiecho "HEAD^2 missing as expected"git push -u origin featuretest "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/feature | awk '{print $1}')"echo feature-pushed

    Expected: five * lines and no |, 1 parent, fatal: Needed a single revision (exit 128), then feature-pushed. The first push of feature is a new ref. If push prints non-fast-forward, the branch was already on origin. Do not --force. Recover with fetch plus merge as in 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.

Terminalbash
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0ROOT="$HOME/rebase-about-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 -f "$ROOT/app.txt" && test -f "$ROOT/feat.txt" || { echo VERIFY_FAIL files; exit 1; }test "$(git -C "$ROOT" cat-file -p HEAD | grep -c '^parent ')" = "1" || { echo VERIFY_FAIL parent_count; exit 1; }if git -C "$ROOT" rev-parse --verify --quiet HEAD^2; then echo VERIFY_FAIL is_merge; exit 1; figit -C "$ROOT" merge-base --is-ancestor main HEAD || { echo VERIFY_FAIL main_not_ancestor; exit 1; }GRAPH="$(git -C "$ROOT" log --oneline --graph --no-decorate feature)"printf '%s\n' "$GRAPH" | grep -Eq '[|/\\]' && { echo VERIFY_FAIL not_linear; exit 1; }test "$(printf '%s\n' "$GRAPH" | grep -c '^\*')" -ge 5 || { echo VERIFY_FAIL short_graph; exit 1; }HEAD_SHA="$(git -C "$ROOT" rev-parse HEAD)"test "${#HEAD_SHA}" = "40" || { echo VERIFY_FAIL sha_length; exit 1; }test -z "$(git -C "$ROOT" status --porcelain=v1)" || { echo VERIFY_FAIL dirty; exit 1; }echo VERIFY_OKecho "$HEAD_SHA"

Expected stdout (SHA varies):

text
VERIFY_OKc0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffe

When we ran this probe at ZeroShot Studio, HEAD^2 failed in under 20 milliseconds and the probe printed VERIFY_OK. VERIFY_FAIL is_merge means you merged. VERIFY_FAIL not_linear means the graph still has a join. 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-about-scratch" --yesrm -rf "$HOME/rebase-about-scratch"

What are the common production failure modes?

I have burned a shared feature branch with every one of these.

  • git rebase -i hangs in an editor: GitHub's samples use --interactive OTHER-BRANCH-NAME and HEAD~7. Use git rebase main only. Never set GIT_SEQUENCE_EDITOR.
  • Rebase after feature was already pushed: git push prints ! [rejected] (non-fast-forward). Do not --force on a shared branch. Fetch and merge. See How to fix Git non-fast-forward errors.
  • CONFLICT during replay: git rebase --abort restores the pre-rebase tip. Do not --skip. Conflict repair is a later item.
  • Rebasing while HEAD is main: That rewrites published main if you then push. Always git switch feature then git rebase main.
  • Visibility picker, --web, or a credential prompt: Pass --private. Export the three locks. Missing auth must fail in under 5 seconds.

FAQ

What is the difference between git rebase and git merge? Rebase copies unique commits onto the other tip and writes new SHAs. The graph is linear and HEAD has one parent. Merge adds two parents and keeps the original SHAs. This recipe rebases.

Why must git rev-parse --verify HEAD^2 fail after this rebase? HEAD^2 is the second parent of a merge commit. After git rebase main, verify exits 128. If it succeeds, you merged. I treat that as a failed proof, not a graph to tidy.

Why does this recipe refuse git rebase -i? GitHub's page centers on --interactive for reword, squash, and reorder. That opens an editor and hangs unattended agents. I found that hang on the first Cursor dry run. git rebase main still rewrites the unpublished series.

What should I do if the feature branch was already pushed? Do not rebase it, and do not --force. GitHub calls that bad practice. Update with fetch plus merge, or branch from current main.

Are pick, squash, and reword part of this recipe? No. GitHub lists those plus edit, fixup, and exec for the interactive todo. Do not run them here.

Share