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 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"]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 Layer | Minimum Version | Production Recommendation | Purpose in Stack |
|---|---|---|---|
| Git binary | 2.39.0 | 2.50+ (Apple Git or git-scm) | init -b, switch, rebase, log --graph, rev-parse |
| GitHub CLI | gh 2.40.0 | gh 2.98+ | repo create --private --source --remote --push |
| Auth session | gh api user returns a login | Token already in gh auth | Create the private repo without a browser |
| Prompt lock | GIT_TERMINAL_PROMPT=0 plus GH_PROMPT_DISABLED=1 | Same, plus GH_PAGER=cat, never --web | Fail missing credentials in under 5 seconds |
| Scratch path | $HOME/rebase-about-scratch | Same path, --private only | Isolate 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 shape | Graph | HEAD^2 | When it is safe | This recipe |
|---|---|---|---|---|
Non-interactive git rebase main on unpublished feature | Linear stars | Must fail (exit 128) | Local commits nobody else has | Required |
git merge main into feature | Diamond, two parents | Succeeds | Shared branches you must not rewrite | Contrast only |
git rebase --interactive / -i | Todo rewrite | Usually fails | Unpublished only, later item | Do not run |
Rebase then --force onto a shared branch | Rewritten published SHAs | Fails | Never | Forbidden |
git pull --rebase on a tracking branch | Linear if it succeeds | Fails | Only if you own the unpublished tip | Out 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.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0-
Confirm Git, GitHub CLI, and an authenticated login.
export GH_PROMPT_DISABLED=1 GH_PAGER=cat GIT_TERMINAL_PROMPT=0git --versiongh --versiongh api user --jq .loginExpected: Git 2.39+ (we measured
git version 2.50.1 (Apple Git-155)), agh versionline, and a login. Missing binaries:HOMEBREW_NO_AUTO_UPDATE=1 brew install git ghorsudo apt-get update -y && sudo apt-get install -y git gh. Ifgh api userfails, stop. -
Create the private scratch repository on
mainand push it. Two commits sofeaturehas a real merge-base.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-pushedExpected: two
maincommits, matching 40-character SHAs, thenmain-pushed. If create waits on Visibility, you omitted--private. -
Create unpublished
featurewith two commits. Do not push it. Leaving these commits offoriginis what makes the rewrite safe.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-unpublishedExpected: on
feature, 2 commits not inmain, emptyfeature..main,## featurewith no[origin/feature], thenfeature-unpublished. If status showsorigin/feature, you pushed too early. Stop. -
Advance
mainso the histories diverge, then pushmain. Feature stays local.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 divergedExpected: a split graph, 1 commit on
mainnot infeature, 2 onfeaturenot inmain, thendiverged.git ls-remote origin refs/heads/featureis empty. -
Rebase
featureontomainwith non-interactivegit rebase main. Git copies the two feature commits onto the newmaintip and writes new SHAs.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-okExpected:
Rebasing (1/2),Rebasing (2/2),Successfully rebased and updated refs/heads/feature, a straight column of stars, thenrebase-ok. We measured 47.6 milliseconds. If Git stops withCONFLICT, rungit rebase --abortand stop. -
Prove HEAD is not a merge, then push unpublished
featurewithout--force. A merge commit hasHEAD^2. After this rebase it must not.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-pushedExpected: five
*lines and no|,1parent,fatal: Needed a single revision(exit 128), thenfeature-pushed. The first push offeatureis a new ref. If push printsnon-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.
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):
VERIFY_OKc0ffee0c0ffee0c0ffee0c0ffee0c0ffee0c0ffeWhen 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):
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 -ihangs in an editor: GitHub's samples use--interactive OTHER-BRANCH-NAMEandHEAD~7. Usegit rebase mainonly. Never setGIT_SEQUENCE_EDITOR.- Rebase after
featurewas already pushed:git pushprints! [rejected] (non-fast-forward). Do not--forceon a shared branch. Fetch and merge. See How to fix Git non-fast-forward errors. CONFLICTduring replay:git rebase --abortrestores the pre-rebase tip. Do not--skip. Conflict repair is a later item.- Rebasing while
HEADismain: That rewrites publishedmainif you then push. Alwaysgit switch featurethengit 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.