I've always followed the rule to not modify commits that has been pushed to a remote repository.
It is not possible to modify commits. It does not matter whether they have been sent to another repository or not: you cannot change any existing commit.
That's not what you're doing with git push -f
either, though. This still does not modify existing commits! What this does is tell the other Git—the one receiving the push—that it should change a name, even if the change to the name would "lose" some commit(s).
The key concept here is reachability. See Think Like (a) Git to learn all about reachability. The short version, though, is this: each Git commit has a "true name" that is its raw hash ID. Each Git commit also contains the raw hash ID of some set of earlier commits.1 We say that this commit points to the earlier commit(s). Meanwhile, a name—like a branch name—points to (contains the hash ID of) exactly one commit: specifically, the last commit that is to be considered "contained in the branch".
So we can draw this:
... <-F <-G <-H <--master
where the uppercase letters stand in for the big ugly hash IDs. If H
is the last commit in a branch like master
, the name master
points to H
. Meanwhile H
contains the hash ID of its parent commit G
, so H
points to G
. G
contains the hash ID of its parent F
, and so on, all the way back to the very first commit.
While the internal arrows all point backwards like this, it's easier to draw them as connecting lines in StackOverflow postings, so I'm going to do that now. Let's look at how we add a new commit to master
. We run:
git checkout master
# ... do some work, run `git add` ...
git commit
The git checkout
step attaches the special name HEAD
to the branch name, so that Git knows which branch name to update, in case we have more than one branch name:
...--F--G--H <-- master (HEAD)
\
o--o <-- develop
for example. We do the work and make a new commit, which we'll call I
. Git writes out commit I
, makes it point back to commit H
—the one we were using up until we made I
—and then makes the name master
point to new commit I
:
...--F--G--H--I <-- master (HEAD)
Now suppose we git push
this update to some other repository. That other repository has its own branch names, independent of ours, but we were totally in sync with that other repository when we started: it had the same commits, with the same hash IDs, up through H
. So we sent the other Git our commit I
, and then asked them: Other Git at origin
, please, if it's OK, make your master
name point to commit I
. They say OK, and now they have their master pointing to this new commit I
too, and we're all in sync again.
But now we realize: gah, we made a mistake! We'd like to stop using I
and make a new and improved commit J
instead! Maybe the mistake was as simple as a typo in the commit message, or maybe we have to fix a file and git add
it first, but eventually we run:
git commit --amend
Despite the name of the flag, this doesn't change any existing commit. It can't! What it does is make a totally new commit J
. But instead of making J
point back to I
, it makes J
point to I
's parent H
:
J <-- master (HEAD)
/
...--F--G--H--I [abandoned]
Commit I
can no longer be found in our repository, because the name we used to find it—master
—doesn't find it any more. The name now finds commit J
. From J
, we step back to H
. It seems as if we've changed commit I
. We haven't, though, and in fact it's still there in our repository, and—if we haven't fiddled with any of the configuration knobs in Git—it will stay there for at least 30 days, because there are some semi-secret names2 by which we can find I
's hash ID, and thus view commit I
again after all.
1These have to be earlier / older commits:
To put the hash ID of some commit into some new commit you're making, the hash ID of that other commit must exist. (Git won't let you use the hash ID of a commit that doesn't exist.) So these are existing commits, in this commit you propose making now.
Git then makes the new commit and assigns it a new and unique hash ID: one that has never occurred before. This new commit, now that it is made, cannot be changed. Indeed, no commit can ever change. So the hash IDs inside each new commit are those of older commits.
As a result, commits always point backwards, to earlier commits. Git therefore works backwards.
2These are mostly in Git's reflogs. For some operations that move branch names, Git stores the hash ID temporarily in another special name ORIG_HEAD
as well. This name gets overwritten by the next operation that saves a hash ID in ORIG_HEAD
, but ORIG_HEAD
is particularly useful right after a failed git rebase
, for instance.
This is where --force
comes in
We now have this:
J <-- master (HEAD)
/
...--F--G--H--I [abandoned]
in our own repository. We'd like the other Git repository—the one over at origin
—to have this too. But if we run git push
, our Git calls up their Git, sends over commit J
, and then says: Please, if it's OK, make your master
name point to commit J
. If they do that, they will "lose" commit I
too! They are finding I
through their name master
; if they move their master
to point to J
, they won't be able to find I
.3
In the end, then, they'll just say no, I won't do that. Your Git shows you the rejected
message:
! [rejected] master -> master (non-fast forward)
telling you that they refuse to set their master
the same way that you have your master
set, because they'd lose some commits (that's the "non-fast-forward" part).
To overcome that, you can send a forceful command: Set your master
! They may or may not obey, but if they don't obey, it's no longer because they'll lose commits: the "force" option says to do it even if they will lose commits as a result.
The drawback here is: what if someone else has built another new commit atop your commit I
, while you were fixing your I
with your replacement J
? Then their Git—the one over at origin
—actually has:
...--F--G--H--I--K <-- master
If you use git push --force
to tell them to set their master
to J
, they'll end up with:
J <-- master
/
...--F--G--H--I--K [abandoned]
and the abandoned commits include not only your I
(which you wanted gone) but someone else's K
too.
Enter --force-with-lease
What --force-with-lease
does is to use your Git's memory of their Git's master
. Note that when you run git fetch
to get commits from them, your Git stores, in its own storage-areas, their branch names, modified to have origin/
in front of them and to become your remote-tracking names. So in your own Git you actually have this:
J <-- master (HEAD)
/
...--F--G--H--I <-- origin/master
Your origin/master
remembers that their master
remembers commit I
.
When you use git push --force-with-lease
, your Git calls up their Git, sends commit J
as usual. This time, though, instead of either Please set your master
to J
if it's OK or Set your master
to J!, your Git sends a request of the form:
I think your master
points to I
. If so, forcefully move it to point to J
instead.
This introduces a new way to reject the operation. If their master
now points to K
, they'll still say no. But if their master
still points to I
—the commit you want them to abandon—they will probably obey the forceful push and make their master
point to J
.
If they do obey, your Git updates your own origin/master
to point to J
too. This maintains the property that your origin/*
names remember, to the best of your Git's ability, where their Git's branch names point. But this can get stale, so you may need to run git fetch origin
(or just git fetch
) to update your remote-tracking names. How often you need to run git fetch
depends on how fast their Git updates.
Of course, if you do run git fetch
, you'd best check to see if your origin/master
still points where you thought! Pay attention to the output from git fetch
: it tells you if your Git has updated your own origin/master
. If their master
has moved, someone else has fiddled with their commits, and you might need to know this.
3Server Gits generally don't have reflogs enabled, so they'll garbage collect abandoned commits a lot sooner than our own local clones, too.