On this page
This morning git pull --rebase printed a success line and deleted the
commit you pushed yesterday. No conflict, no error, no prompt. The
working tree is clean, the branch says up to date, and the commit is on
no branch anywhere: not yours, not the remote’s.
Nothing in that story fails, which is what makes it dangerous. Reproducing it takes two clones and four commits. Watch it happen, get the commit back, then meet the rule that decided your pushed work was disposable. Git 2.51, stock configuration, throughout.
Two clones, one branch
Your teammate started the branch search and pushed two commits; you
cloned and joined. Your contribution is a ranking fix:
$ git log --oneline
97d747a Index file paths
5d48fd5 Add search command
$ git add ranking.js
$ git commit -m "Rank exact matches first"
[search 5a818d0] Rank exact matches first
1 file changed, 1 insertion(+)
create mode 100644 ranking.js
$ git push
To …/origin.git
97d747a..5a818d0 search -> search
5a818d0 is on the remote now; anyone who fetches gets it. The push also did one other thing, quietly, in your own clone. It comes back when we get to the why.
The force push you never saw
Your teammate’s clone hasn’t fetched since before your push, so their branch still ends at 97d747a, and they’ve just committed a filter:
$ git add filters.js
$ git commit -m "Match on file type"
[search d5c109a] Match on file type
1 file changed, 1 insertion(+)
create mode 100644 filters.js
$ git push
To …/origin.git
! [rejected] search -> search (fetch first)
error: failed to push some refs to '…/origin.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
The work the remote contains that they do not have is your commit. The refusal is Git protecting it, and the hint says how to take it in. They read the refusal as friction:
$ git push --force
To …/origin.git
+ 5a818d0...d5c109a search -> search (forced update)
The last post was about
this moment from the pusher’s side, and about the flags that would have
refused it. Today the interest is on your side of the wire, because
nothing so far has touched your clone. The remote lost its copy of
5a818d0; yours is intact, checked out, one git log away. The deletion
that sticks is going to be yours.
The pull
$ git pull --rebase
From …/origin
+ 5a818d0...d5c109a search -> origin/search (forced update)
Successfully rebased and updated refs/heads/search.
$ git log --oneline
d5c109a Match on file type
97d747a Index file paths
5d48fd5 Add search command
$ git status
On branch search
Your branch is up to date with 'origin/search'.
nothing to commit, working tree clean
Your commit is gone, and every signal reads as routine. The fetch did
disclose what it saw: one (forced update) line, the same dialect the
last post decoded. The rebase then proceeded as if that line settled
things. “Up to date” here means agreeing with a remote that was
force-pushed a minute ago.
The rescue
Reason later; rescue the commit first. The branch’s reflog (the local diary of
the positions search has held in your clone) still lists it:
$ git reflog search
d5c109a search@{0}: pull --rebase (finish): refs/heads/search onto d5c109ab3f4881e4f58b8de14947c0cb23a202ee
5a818d0 search@{1}: commit: Rank exact matches first
97d747a search@{2}: clone: from …/origin.git
search@{1} is the branch as it stood before the pull rewrote it. If
your tree isn’t clean, git stash --include-untracked first —
--hard throws away uncommitted changes. Then move back:
$ git reset --hard search@{1}
HEAD is now at 5a818d0 Rank exact matches first
$ git log --oneline
5a818d0 Rank exact matches first
97d747a Index file paths
5d48fd5 Add search command
$ git status -sb
## search...origin/search [ahead 1, behind 1]
If you’d already committed new work on top of the pulled branch
before noticing, reset would take it down with the pull’s result; in
that case git cherry-pick 5a818d0 brings the lost commit onto the
current state instead. A branch repaired that way already sits on
top of origin/search, so there is nothing left to integrate: skip
the reset below, git push, and git stash pop if you stashed
at the start.
Ahead 1, behind 1 is the divergence the pull was supposed to resolve, now out in the open: you have a commit missing from the remote, the remote has one you haven’t integrated. If you took the reset path, that gets resolved in Integrating it properly, below. First, the reason.
Why the rebase threw it away
A rebase pull is a fetch, then a rebase of your branch onto the updated
origin/search. The rebase’s first job is drawing a boundary: which
commits are yours to replay onto the new tip, and which belonged to the
old upstream, where replaying would only resurrect discarded history.
Git has two ways to draw that boundary, and they disagree about you:
$ git merge-base origin/search search
97d747a8a37a3b4cf5d3032ff2504761c898646e
$ git merge-base --fork-point origin/search search
5a818d0272d94df7a2b447d349402105f8df8c0a
Plain merge-base answers from the commit graph: the two histories
part ways after 97d747a, so everything past it on your branch is yours
to replay — one commit, 5a818d0. That is the boundary an explicit
git rebase origin/search would use.
--fork-point is what git pull uses, and it consults something the
graph doesn’t have: the reflog of your remote-tracking ref.
origin/search is your clone’s private record of the remote branch,
and it keeps a diary of its own:
$ git reflog origin/search
d5c109a refs/remotes/origin/search@{0}: pull --rebase: forced-update
5a818d0 refs/remotes/origin/search@{1}: update by push
update by push. Your push didn’t just send the commit; it recorded,
in your own clone, that the upstream branch has stood at 5a818d0. The
fork-point rule treats every position in that diary as upstream
history — and the upstream has since moved off 5a818d0, so by its
reading, the upstream considered your commit and threw it away. The
boundary lands on the commit itself, the replay set is empty, and the
rebase does exactly what it was asked, for the branch it believed it
was looking at.
The rule exists for a real case. If a maintainer rewrites a branch under commits you have not pushed, fork-point is what stops the rebase from replaying the abandoned upstream commits underneath your work: no duplicates, no phantom conflicts from history the rewrite meant to delete. It guards unpushed work sitting on rewritten history. The trap is that the diary records positions, never ownership. A commit you pushed and a commit the upstream abandoned are indistinguishable in it, and your own push is what put yours on the list. That is the quiet other thing the push did back in the first block.
The flag that doesn’t exist
git rebase can be told not to do this: --no-fork-point, or
rebase.forkPoint=false in config. Neither survives contact with
git pull. The flag first:
$ git pull --rebase --no-fork-point 2>&1 | head -2
error: unknown option `no-fork-point'
usage: git pull [<options>] [<repository> [<refspec>...]]
The config setting looks like it should reach the rebase that pull
runs underneath. Watch it not do that: this next run has
rebase.forkPoint=false set, and GIT_TRACE prints what pull executes:
$ GIT_TRACE=1 git -c rebase.forkPoint=false pull --rebase 2>&1 | grep -oE "run_command: git (merge-base|rebase).*"
run_command: git merge-base --fork-point refs/remotes/origin/search search
run_command: git rebase --no-autostash --onto d5c109ab3f4881e4f58b8de14947c0cb23a202ee 5a818d0272d94df7a2b447d349402105f8df8c0a
$ git log --oneline
d5c109a Match on file type
97d747a Index file paths
5d48fd5 Add search command
Gone again, config and all. The trace is the explanation. Pull computes
the fork point itself (the first line) and hands the verdict to rebase
as an explicit --onto: rebase onto d5c109a, replaying whatever
follows 5a818d0. By the time rebase would consult rebase.forkPoint,
the decision is already spelled out in its arguments, and a setting
can’t override an argument. As of Git 2.51 there is no pull-side flag
and no config that reaches this: every rebase pull across a rewritten
upstream gets the fork-point verdict whenever the reflog can supply
one — and your own push made sure it can.
Integrating it properly
So the fix has to happen outside the pull. First, back to the rescued commit — by hash this time: if you skipped the traced demonstration, your reflog positions differ from mine, and the hash is correct on either path. Then rebase onto the explicit name — naming the upstream is what makes rebase use the plain merge-base boundary:
$ git reset --hard 5a818d0
HEAD is now at 5a818d0 Rank exact matches first
$ git rebase origin/search
Successfully rebased and updated refs/heads/search.
$ git log --oneline
aeae0fd Rank exact matches first
d5c109a Match on file type
97d747a Index file paths
5d48fd5 Add search command
$ git push
To …/origin.git
d5c109a..aeae0fd search -> search
Your change is back on the branch: aeae0fd, a new hash because the
parent changed, same message and content, sitting on top of your
teammate’s work, published with an ordinary fast-forward push. Both
commits survive. This is what the pull would have done had it drawn the
boundary from the graph instead of the diary. If you stashed at the
start, git stash pop brings that work back; the incident is over.
(A bare git rebase with no upstream argument defaults to the same
fork-point rule as pull, so name the upstream when it matters.)
A (forced update) line in any fetch or pull output is the only
warning you get. When you see it, stop pulling by reflex. Read what moved
(git log --oneline origin/search), check whether your own pushed
commits are still part of it, and integrate with an explicit
git rebase origin/<branch> so the boundary comes from the graph.
Or don’t do any of this
The check you just ran by hand is two read-only commands: fork point against merge base, with the at-risk commits sitting between them. A Git client can afford to run that check before every rebase pull.
GitDesktop runs it as a pre-flight check. When the two answers agree, the pull proceeds as normal. When they disagree, it stops before the rebase and asks “Keep or drop these commits?”, with each at-risk commit listed by id, subject, and date. Keeping them replays them on top of the rewritten upstream, the same move as the explicit rebase above, and it’s the focused default. Dropping them is allowed too (sometimes the rewrite really did mean to take your commit with it), and a drop is recorded in Operation history rather than trusted to memory. The transcript above, with its one success line, becomes a question with the evidence attached.
A success message tells you the plan worked. It never tells you the plan was right.
Written while building GitDesktop.
A desktop Git client for GitHub, GitLab and Bitbucket — free and open source, with AI you control or hide entirely.