Does git have an option that allow me to push and rewrite history safely?
Not exactly, no. What Git has is git push --force-with-lease
.
Remember that Git finds commits by branch names (or other names) initially, but what really matters are the hash IDs. A branch name simply stores one hash ID:
66346e97 (HEAD -> dev) Implement bank_statement view form
Here, the name HEAD
contained the name dev
, and the name dev
contained the hash ID 66346e97
(well, the full 40-character version; Git shows an abbreviation, such as the first 8 characters, to help us poor humans deal with the ugliness of hash IDs).
Commit 66346e97
itself contains the previous commit's hash ID, add8964f
. So having used the name dev
to find 66346e97
, Git then used 66346e97
to find add8964f
. Commit add8964f
then allowed Git to find c632ef4e
.
The general scheme here is that every commit contains the raw hash ID of its immediate predecessor. This is part of the commit's metadata (vs the data, which holds a snapshot of all of your files). If every commit points back to its parent, then we only need to find the hash ID of the last commit, to find every commit, as long as they are in nice neat chains:
... <-F <-G <-H
Here the commit with hash H
is the last, so it points back to the commit with hash G
, which points back to the commit with hash F
, and so on. The name master
might contain the actual hash ID of commit H
, which we can draw this way:
...--F--G--H <-- master
If there are additional branches, they might have commits that eventually point back to H
itself:
...--F--G--H <-- master
\
I--J <-- dev
or they might have commits that point back to, say, F
, or both:
K--L <-- rc1
/
...--F--G--H <-- master
\
I--J <-- dev
In every case, the name points to the last commit, from which we work backwards. When we pick master
, the last commit is H
. When we pick dev
, the last commit is J
. When we pick rc1
(release candidate), the last commit is L
. In every case, that last commit points backwards to some earlier commit.
When we do a "history rewrite" operation, such as rebasing commits K-L
to come after commit H
using:
git checkout rc1; git rebase master
we get new commits with new and different hash IDs:
K'-L' <-- rc1
/
...--F--G--H <-- master
\
I--J <-- dev
The original two commits did not disappear! They are still in the repository; they just don't have a branch name for them any more:
K--L ???
/
/ K'-L' <-- rc1
/ /
...--F--G--H <-- master
\
I--J <-- dev
But if we had done a git push origin rc1
earlier, commits K
and L
—with whatever those hash IDs really are—now exist in another Git repository, over at whatever URL we have in our name origin
. Our own Git remember this, by copying their name rc1
to our name origin/rc1
. So this last graph looks like this:
K--L <-- origin/rc1
/
/ K'-L' <-- rc1
/ /
...--F--G--H <-- master
\
I--J <-- dev
Their Git still has their rc1
pointing to commit L
. They do not have commits K'-L'
at all yet: we only just now made them, using git rebase
.
When using various viewers—including the one you're using—we can have our Git mark commits K'
and L'
as "equal to" commits K
and L
due to their cherry-picked-ness, which resulted from our running git rebase
just now. So that is what you are seeing in your git log
and other viewers.
If we would now like to call up the other Git over at origin
, we can do any one of three things:
Send it commits K'-L'
if needed (it will be needed), then ask it politely to set its branch name rc1
to point to commit L'
. But if their rc1
still points to commit L
, they will refuse, telling us that if they do that, they'll lose the commits K-L
, possibly forever. Of course that's exactly what we want them to do, but what if, while we're thinking about this, someone else has added a new commit to that chain? Well, it will still refuse, in this case.
Or, we can send it commits K'-L'
if needed (it will be), then command it to set its branch name rc1
to point to commit L'
. They'll probably obey. If their rc1
pointed to L
before, that did what we wanted. But if someone else had added a new commit, we just lost that new commit: not what we wanted.
Fortunately there is the --force-with-lease
option, which will do exactly what we need.
If we use git push --force-with-lease
, our Git will:
- Call up their Git using the URL in the name
origin
- Offer them commit
L'
, which ends up sending commits K'
and L'
because they don't have them yet;
Send them the most complicated command yet:
- We think your name
rc1
contains hash ID _____ (fill in the blank from our origin/rc1
, with the hash ID of commit L
).
- If that's right, we command you to set your name
rc1
to point to commit _____ (fill in the blank with the hash ID of L'
). If not, reject this command.
- In any case, tell us what happened.
When we get a response, we can immediately tell whether they made their rc1
point to commit L'
. If they did, all is good. If they didn't, we get a hint about why: did their rc1
point to some other commit, or are they just mad at us and refuse to update their rc1
no matter what? (We get the latter if they're on GitHub and they have set rc1
to be "protected". If we're the admin, we can override the protected state, of course.)
If their rc1
doesn't point to L
any more, we should run git fetch
so that we update our own origin/rc1
. Then we can figure out what to do: do we rebase again? How do we want to proceed? Once we've figured it out and done it, we can do another git push --force-with-lease
. If their rc1
hasn't moved, this one will work. If their rc1
has moved while were figuring stuff out, we fetch again and start over—and the cycle repeats until we can get our work done faster than anyone else can change their rc1
on us.
So:
Or the only option I have is rebase my commits to xxxxxx/dev upstream?
You should decide which commits you'd like them to have, and which commits you'd like them to throw away.
Note that if you do convince that Git to change things ... well, let's assume for the moment that that Git is on GitHub, just for concreteness. If you do convince the Git on GitHub to change their branch names so that they lose some commits, you may have just made problems for some second user with a third Git repository. Perhaps that second user is depending on commits K-L
(0ff85c07
and 3a361741
). That second user can easily accidentally re-introduce those original commits, e.g., by using git merge
.
So any time you're going to use git push --force
, with or without -with-lease
, you need to be sure that all users of that other Git repository have agreed that the other Git repository's branch names get moved around like this. Having gotten an agreement from all the other users in advance, they will now know what to do when this happens.
If you are the only user of the other Git repository, you need only agree with yourself. If you don't agree with yourself, you should probably sit down with yourself and talk it over.