sorry if this question has already been awnsered but I couldn’t find the awnser.
I’m here because of a pretty traumatic experience at work: I rebased master into the wrong branch and then executed a git push
..
Which for a moment seemed to have screwed things up pretty bad. Now I am trying to understand what went wrong, and why my changes were pushed even though I did not use the --force
flag.
A little info on our git branching strategy:
We have different older “version” branches on which we only implement bugfixes (branch v1.0, branch v2.0 etc.), because we need to support these older versions. Then theres the master branch, on which we actually implement new features.
Whenever a bug is found in an old version of the software, f.e. branch v1.0, we branch out from that version to create a feature branch for a bugfix. Then we rebase the changes in this feature branch on top of the v1.0 branch and do a fast-forward merge on v1.0 afterwards. Then through a merge commit we merge v1.0 into v2.0 and finally into master so that the bug is fixed in all newer versions of the product as well. So the flow for making a bugfix/change on an older version branch (f.e. branch v1.0) looks like this:
git rebase origin/v1.0
to move the changes from the feature branch on top of the latest v1.0 branchgit push -f origin feature_branch
So for example for a bugfix on v1.0 of the software the merging goes like this:
FB -> v1.0 -> v2.0 -> master
In short: v1.0 is the oldest version of the product, v2.0 will contain all commits from v1.0 plus additional feature commits made in version v2.0 and master will contain all commits from v2.0 plus additional feature commits meant for the new release of the product.
What I did by mistake: So as I said, when merging a bugfix back into the parent branch I would need to rebase the changes on top of the parent branch first as there might have been other changes on the parent in the meantime, and then do a fast-forward merge back into the parent.
I was working on a feature that was supposed to go into the master branch only, so naturally I tried to rebase master into my branch to get my changes on top of all other changes. But instead of rebasing master branch into my feature branch, I was on the v1.0 branch and rebased master into the v1.0 branch (so, not my feature branch).. This resulted into master being rebased into the v1.0 branch. To make matters worse, without throroughly checking I then pushed the v1.0 branch as well.. The result: The v1.0 branch now looked exactly like master.. not good.
Now my question is: I simply executed an erroneous git push
, I didn’t --force push
the v1.0 branch.
How I understand rebasing, is that when you do a rebase you rewrite the history of a branch so you need to use git push —force, otherwise the remote will not accept your changes.
Was there no history rewriting happening in this case, because master already contained all commits of the v1.0 branch plus a bunch of additional commits that the v1.0 branch did not contain?
I would really like to understand this properly, because if I would have needed to do a forced push, more alarm bells would have started ringing for me and I’d like to think this wouldn’t have happened.
This is a bit long because you make a point about wanting to really understand what's going on, so I'm going to provide more information than just a direct answer to your question. But if you take nothing else away from this: validate your local state before pushing. (And a close second: be more skeptical of force pushes.)
People are used to thinking that "rebase" == "need to force push", and the two are in a way related; but it's not merely the act of rebasing that creates the need to force push. It's the act of removing commits from the history of a branch (say branchX), and then it is only branchX that needs to be force-pushed.
So with that in mind, let's walk through your workflow - first as it's intended to work, and then as it happened with this mistake. For a starting point, your repo might look like
... O <--(origin/v1.0)(v1.0)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
Here ...
means "some history we don't really care about", x
means "a commit, but not one I'm going to specifically reference by name in this discussion", M
means "a merge commit, but not one I'm going to specifically reference by name in this discussion". Other letters mean commits I might reference by name. If I might reference a merge by name, I'll call it something like M1
. Then /
, \
, and --
show parent-child relationships among commits (newer commit to the right), and a name in parens is a ref (e.g. a branch) with an arrow that shows the ref's current commit.
In addition to the local branches, I've shown the remote tracking refs - i.e. your repo's understanding of where the branches are on the remote.
So...
Intended Behavior
1) Branch out from v1.0
... O <--(origin/v1.0)(v1.0)(feature_branch)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
Here we've just created a new ref that points to the same commit as the version branch.
2) Make some changes
A <--(feature_branch)
/
... O <--(origin/v1.0)(v1.0)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
3) git rebase origin/v1.0
This step seems a little pessimistic. Do you have frequent, concurrent changes to old versions of your product? If not, I would consider only doing this as an exception-handling step for cases when there are, in fact, new changes on v1.0
. The above graph would be unchanged, but if we assume there were intervening changes
A <--(feature_branch)
/
| B <--(origin/v1.0)
|/
... O <--(v1.0)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
then this step would give you
A' <--(feature_branch)
/
B <--(origin/v1.0)
/
| A
|/
... O <--(v1.0)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
Note that feature_branch
"moved", so that A
was removed from its history, while B
and a new commit (A'
- a patch-wise copy of A
) was added to its history.
I still show A
in the picture, because it still exists though nothing references it at the moment. (But I'll be talking about it again shortly...) An important point this reinforces, that is often misunderstood: rebase
did not "move" A
. It created a new commit, A'
, which is different from A
. That is why I say that A
was removed from feature_branch
's history.
Anyway, all other refs retain all of the history they already had.
4) git push -f origin feature_branch
This is a little confusing, because you don't show that you had previously pushed feature_branch
. If you hadn't, then the -f
flag isn't needed - because even though you removed commits from feature_branch
's local history, the remote doesn't know anything about that.
That is, refining what I said above - a force push is needed only when pushing a ref from whose history you've removed a commit that was part of the remote's history for that branch.
So let's suppose prior to rebasing you had pushed feature_branch
, and the diagram really looks like
A' <--(feature_branch)
/
B <--(origin/v1.0)
/
| A <--(origin/feature_branch)
|/
... O <--(v1.0)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
(This is the real reason I kept A
in the diagram.) Now you would be unable to push feature_branch
without the -f
flag, because the push would remove A
from the remote's understanding of feature_branch
's history.
But now is a good time to mention... building on my comments about step 3, you should be wary of a workflow that uses force-pushing as a normal step. Just like the remote knows A
as part of feature_branch
and has to be told history was edited, if any other developer has fetch
ed or pull
ed feature-branch
, then the force-push will put their repo in a broken state. They'll have to recover, especially if they made additional changes on feature-branch
; and if they do it incorrectly, it could undo the rebase.
That said, the post-push
picture would be
A' <--(feature_branch)(origin/feature_branch)
/
B <--(origin/v1.0)
/
... O <--(v1.0)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
(This time I removed A
, because we're done worrying about it. It's still there, still reachable in the reflog, but eventually gc
will destroy it unless you take steps to resurrect it.)
5) fast-forward merge feature_branch
into v1.0
Presumably you also mean to push v1.0
after the fast-forward. Because it is a fast-forward (even for the remote), no force push is needed; that is, every commit that the remote ever saw as part of v1.0
, is still part of v1.0
.
... O -- B -- A' <--(v1.0)(origin/v1.0)(feature_branch)(origin/feature_branch)
\
.. M -- x <--(origin/v2.0)(v2.0)
\
... M -- x <--(origin/master)(master)
5 and 6) merge forward
This is straightforward, and again the pushes needn't be forced.
... O ----- B ---- A' <--(v1.0)(origin/v1.0)(feature_branch)(origin/feature_branch)
\ \
.. M -- x ------- M <--(or-igin/v2.0)(v2.0)
\ \
... M -- x -- M <--(origin/master)(master)
Alright. Now as I understand it, the problem came up when you built a feature from master
. At this point I'm going to add distinct names to a couple x
commits, and remove some refs we won't be talking about
... O ----- B ---- A' <--(v1.0)(origin/v1.0)
\ \
.. M -- V ------- M <--(or-igin/v2.0)(v2.0)
\ \
... M -- W -- M <--(origin/master)(master)
so after steps 1 and 2 you have
... O ----- B ---- A' <--(v1.0)(origin/v1.0)
\ \
.. M -- V ------- M <--(or-igin/v2.0)(v2.0)
\ \
... M -- W -- M <--(origin/master)(master)
\
C -- D <--(feature2)
But then you started working through the above workflow as though it were a v1.0
feature. So for step 3
git rebase origin/v1.0
And as you know, this is going to be trouble. Everything in the current branch's histroy, that is not in origni/v1.0
history, is treated as "need to copy".
V' -- W' -- C' -- D' <--(feature)
/
... O ----- B ---- A' <--(v1.0)(origin/v1.0)
\ \
.. M -- V ------- M <--(or-igin/v2.0)(v2.0)
\ \
... M -- W -- M <--(origin/master)(master)
\
C -- D
Merge commits are ignored (default behavior of rebase
; they aren't expected to introduce distinct changes anyway, though conflict resolutions and/or "evil merges" can break this assumption). But V
and W
are not ignored. As always, note that V
and W
remain and the histories of every branch except the current branch you rebased is left unchanged.
As with the above workflow, you could now push feature
. And as above, if you had ever push
ed feature
prior to the rebase, you would now have to force-push it... but your typical workflow has duped you into expecting that anyway, so while it should raise a red flag it wouldn't.
Either way, you can see that v1.0
will happily fast-forward onto feature
(because feature
's history includes all of v1.0
's history anyway), and that means v1.0
will push without force.
So that's how it went wrong, but what to do going forward?
My first piece of advice is to get less comfortable with casual force-pushes. At a glance, since force-pushing and history rewrites (like rebase
) are in some way related, that might sound like a reason to use merges instead of rebase... but that wouldn't really help. If you had your feature coded on a branch from master
... O ----- B ---- A' <--(v1.0)(origin/v1.0)
\ \
.. M -- V ------- M <--(or-igin/v2.0)(v2.0)
\ \
... M -- W -- M <--(origin/master)(master)
\
C -- D <--(feature2)
but then mistakenly think you need to go to v1.0
, the merge will work just as silently and the result will be just as wrong.
-------------------------------- M <--(v1.0)
/ /
... O ----- B ---- A' <--(origin/v1.0) |
\ \ |
.. M -- V ------- M <--(origin/v2.0)(v2.0) |
\ \ |
... M -- W -- M <--(origin/master)(master) |
\ /
C ---------------------- D <--(feature2)
This still incorporates all v2.0
and master
changes into v1.0
.
So what can you do?
You could build your workflow around the optimistic assumption that v1.0
would not receive conflicting changes. In that case you would
1) create branch from v1.0
2) make changes
3) fast-forward v1.0
onto feature
(git merge --ff-only feature
)
4) attempt to push v1.0
without force
Now if you try to incorporate your change into the wrong branch, there's a chance the merge will fail (due to the --ff-only
). This will only help if the branches have in fact diverged; but it's at least no worse than status quo.
If you do go to the correct branch, and if step 4 succeeds, then you're done. If step 4 fails and gives an error about non-fast-forward changes, then (because this is an exception rather than your intended step) it hints that you should check and make sure why it failed. If all does look ok, then next you could
git pull --rebase
This is a shorthand for fetching the remote changes and rebasing the local changes on top of them. This is considered a "potentially dangerous" operation per the docs, but so is what you're doing; and at least this puts some structure around it, so that as long as you merged correctly this will do what you mean it to do.
Then you should always, as a matter of habit, take a quick look at the local result before pushing - because problems are always easier to fix before they've been pushed. So look at the log and if anything looks odd, examine it. Refer back to the requirement/story/card/whatever that told you to do the work and validate what branch you're adding it to. Maybe visualize the overall state of the repo with tools like gitk
.
Bottom line, git
is very flexible and very powerful, so if you explicitly tell it to do the wrong thing, it will probably do it. That means you have to know what thing to tell it to do. The good news is, it's generally not too hard to recover from mistakes. It's easiest before a mistake is push
ed, but there's more or less always a way.