According to my understanding of git pull --rebase origin master
, it should be the equivalent of running the following commands:
(from branch master): $ git fetch origin
(from branch master): $ git rebase origin/master
I seem to have found some case where this doesn't work as expected. In my workspace, I have the following setup:
- branch
origin/master
references branchmaster
on remoteorigin
- branch
master
is set up to trackorigin/master
, and is behind master by several commits. - branch
feature
is set up to track local branchmaster
, and ahead ofmaster
by several commits.
Sometimes, I will lose commits by running the following sequence of steps
(from branch master): $ git pull --rebase
(from branch master): $ git checkout feature
(from branch feature): $ git pull --rebase
At this point, the few commits ahead I was on feature
have now been lost. Now, if I reset my position, and instead do the following:
(from branch feature): $ git reset --hard HEAD@{2} # rewind to before second git pull
(from branch feature): $ git rebase master
The commits have been applied correctly and my new commits on feature
are still present. This seems to directly contradict my understanding of how git pull
works, unless git fetch .
does something stranger than I expected.
Unfortunately, this is not 100% reproducible for all commits. When it does work for a commit, though, it works every time.
Note: My git pull --rebase
here should actually be read as a --rebase=preserve
, if that matters. I have the following in my ~/.gitconfig
:
[pull]
rebase = preserve
(Edit, 30 Nov 2016: see also this answer to Why is git rebase discarding my commits?. It is now virtually certain that it is due to the fork-point option.)
There are a few differences between manual and
pull
-basedgit rebase
(fewer now in 2.7 than there were in versions of git predating the--fork-point
option ingit merge-base
). And, I suspect your automatic preserve-merges may be involved. It's a bit hard to be sure but the fact that your local branch follows your other local branch which is getting rebased is quite suggestive. Meanwhile, the oldgit pull
script was also rewritten in C recently so it's harder to see what it does (though you can set environment variableGIT_TRACE
to1
to make git show you commands as it runs them internally).In any case, there are two or three key items here (depending on how you count and split these up, I'll make it into 3):
git pull
runsgit fetch
, then eithergit merge
orgit rebase
per instructions, but when it runsgit rebase
it uses the new fork-point machinery to "recover from an upstream rebase".When
git rebase
is run with no arguments it has a special case that invokes the fork-point machinery. When run with arguments, the fork-point machinery is disabled unless explicitly requested with--fork-point
.When
git rebase
is instructed to preserve merges, it uses the interactive rebase code (non-interactively). I'm not sure this actually matters here (hence "may be involved" above). Normally it flattens away merges and only the interactive rebase script has code to preserve them (this code actually re-does the merges since there's no other way to deal with them).The most important item here (for sure) is the fork point code. This code uses the reflog to handle cases best shown by drawing part of the commit graph.
In a normal (no fork point stuff needed) rebase case you have something like this:
where
A
andB
are commits you had when you started your branch (so thatB
is the merge-base),C
throughE
are new commits you picked up from the remote viagit fetch
, andI
throughK
are your own commits. The rebase code copiesI
throughK
, attaching the first copy toE
, the second to the-copy-of-I
, and the third to the-copy-of-J
.Git figures out—or used to, anyway—which commits to copy using
git rev-list origin/foo..foo
, i.e., using the name of your current branch (foo
) to findK
and work backwards, and the name of its upstream (origin/foo
) to findE
and work backwards. The backwards march stops at the merge base, in this caseB
, and the copied result looks like this:The problem with this method occurs when the upstream—
origin/foo
here—is itself rebased. Let's say, for instance, that onorigin
someone force-pushed so thatB
was replaced by a new copyB'
with different commit wording (and maybe a different tree as well, but, we hope, nothing that affects ourI
-through-K
). The starting point now looks like this:Using
git rev-list origin/foo..foo
, we'd select commitsB
,I
,J
, andK
to be copied, and try to paste them on afterE
as usual; but we don't want to copyB
as it really came fromorigin
and has been replaced with its own copyB'
.What the fork point code does is look at the reflog for
origin
to see ifB
was reachable at some time. That is, it checks not justorigin/master
(findingE
and scanning back toB'
and thenA
), but alsoorigin/master@{1}
(pointing directly toB
, probably, depending on how frequently you rungit fetch
),origin/master@{2}
, and so on. Any commits onfoo
that are reachable from anyorigin/master@{n}
are included for consideration in finding a Lowest Common Ancestor node in the graph (i.e., they're all treated as options to become the merge base thatgit merge-base
prints out).(It's worth noting a defect of sorts here: this automated fork point detection can only find commits that were reachable for the time that the reflog entry is maintained, which in this case defaults to 30 days. However, that's not particularly relevant to your issue.)
In your case, you have three branch names (and hence three reflogs) involved:
origin/master
, which is updated bygit fetch
(the first step of yourgit pull
while branchmaster
)master
, which is updated by both you (via normal commits) andgit rebase
(the second step of yourgit pull
), andfeature
, which is updated by both you (via normal commits) andgit rebase
(the second step of your secondgit pull
: you "fetch" from yourself, a no-op, then rebasefeature
onmaster
).Both rebases are run with
--preserve-merges
(hence non-interacting interactive mode) and--onto new-tip fork-point
, where thefork-point
commit ID is found by runninggit merge-base --fork-point upstream-name HEAD
. Theupstream-name
for the first rebase isorigin/master
(well,refs/remotes/origin/master
) and theupstream-name
for the second rebase ismaster
(refs/heads/master
).This should all Just Work. If your commit graph at the start of the whole process is something like what you've described:
then the first
fetch
brings in some commits and makesorigin/master
point to the new tip:and the first rebase then finds nothing to copy (the merge-base of
master
andB
—B
=fork-point(master, origin/master)—is justB
so there is nothing to copy), giving:The second fetch is from yourself and a no-op/skipped entirely, leaving this as the input to the second rebase. The
--onto
target ismaster
which is commitE
and the fork-point ofHEAD
(feature
) andmaster
is also commitB
, leaving commitsI
throughK
to copy afterE
as usual.If some commit(s) are being dropped, something is going wrong in this process, but I can't see what.