VonC's answer shows a way to use --force-with-lease
with the expect
part; here are the technical details. Note: internally, the name for "force with lease" is "CAS", which is short for Compare And Swap. CAS instructions in computer programming (assembler languages, usually) are one way that CPUs implement atomicity. If you're familiar with the use of CAS instructions, the rest should be immediately obvious.
The --force
flag itself, and --force-with-lease
, are relatively simple to explain. Normally, a git push
operation ends with one or more sets of instructions of the form:
- Please set reference _______ (fill in the blank with a reference name) to _______ (fill in the blank with a raw hash ID). Let me know whether this request was accepted or rejected.
The --force-with-lease
option changes these requests to one of the form:
- I believe refname _______ (fill in the blank with a reference name) is currently set to _______ (fill in the blank with a raw hash ID). If so, set it to _______ (fill in the blank with a raw hash ID)! But if not, reject this attempt. Let me know whether this request was accepted or rejected.
The --force
option is simpler: rather than expanding the request as above, it just changes the please part to a forceful command:
- Set reference _______ (fill in the blank with a reference name) to _______ (fill in the blank with a raw hash ID)! Let me know if that worked.
So these are the various kinds of requests or commands that git push
can send, at the end, to finish off the push operation.
What goes in the blanks
A reference name or refname is simply a branch name, or tag name, or other such name. Internally, it's best to spell these out completely: a branch name B
is spelled out as refs/heads/B
, while a tag name T
is spelled out as refs/tags/T
. The git push
command itself can fill in the refs/heads/
part if you leave it out, by realizing that some name like main
or master
or topic
is a branch name, not a tag name, for instance.
A hash ID is one of those big ugly hexadecimal numbers you see in git log
output, such as faefdd61ec7c7f6f3c8c9907891465ac9a2a1475
. For our purposes (setting and/or checking branch names) these are always commit hash IDs (but there are three other types of internal Git object).
A normal, un-forced git push
, which sends over one of those "please, if it is OK" kind of requests, requires that the new value for some existing branch name simply "extend the branch", as it were. Technically the requirement is that for each request, the new commit must be a descendant of the current commit, as found by that branch name. Such a request is a fast-forward, in Git terminology, and will be accepted.
A non-fast-forward occurs when this kind of request does not meet the descendant test. To make the Git that is receiving the push request actually execute such a request, it must take the form of a forceful command, without the default "please, if it is OK" part. Using --force
executes just such a command. The receiving Git can still reject it, but will not reject it simply because it is not a fast-forward.
The danger in using a plain force operation is that you might assume that the receiving Git repository has some particular set of commits. That is, suppose the receiving Git has, on December 1st, these commits:
...--G--H <-- main
That is, the last commit in their main
branch has hash ID H
. But there is a bug in commit H
. So, several days later, we decide we should remove commit H
. We send them a forceful command: Set your main
branch to remember commit G
, instead of commit H
! We believe this will result in them having:
H ???
/
...--G <-- main
Commit H
will be pushed aside and eventually forgotten entirely (deleted from the repository). But here it is December 4th, not December 1st, and unbeknownst to us, their repository now looks like this:
...--G--H--I--J <-- main
Commit I
fixes the bug in commit H
, and commit J
adds a crucial new feature. We now send the forceful command to them and they obey, resulting in:
H--I--J ???
/
...--G <-- main
The bug is gone, but so is the crucial new feature. Now everyone gets mad because we destroyed the feature. If we didn't pick up commit J
, and nobody else has it, and the Git repository that received our forceful push command cleans out forgotten commits quickly, that commit may be gone entirely by the time we realize our mistake.1 We may have to spend days reproducing the feature.
Using --force-with-lease
, however, we can make our forceful update safe. Instead of simply telling them set your main
to G
, we send them a forceful request that starts with I think your main
identifies H
. Since it doesn't identify H
, the forceful command simply fails. We then run git fetch
to obtain the actual information from their repository, discover that commits I
and J
exist, discover that I
fixes the bug,2 and discover that J
adds the important feature.
Hence, --force-with-lease
is never any worse than plain --force
, and usually much better. It would be better if Git only had the "with lease" version of --force
in the first place. But it was invented long after Git was popular, so for backwards compatibility, it is not the default.
You can spell --force-with-lease
without an optional =<refname>:<expect>
. If you do so, Git will fill in the refname from the next argument, and fill in the <expect>
part from the remote-tracking name that goes with the refname you use. Sometimes, as VonC noted, your system may behave badly (cough Microsoft VSC cough) in the name of "convenience", and make the expect
part important to use manually.
1This is why having multiple copies of the repository, and/or backups, can be a good idea. Since Git is distributed, there are usually and automatically multiple copies of the repository, on different machines. See if you can find the lost commits on one of those machines.
2If we discover that commit I
does not fix the bug, we can now come up with some commit K
that adds on to J
that does fix the bug, rather than simply removing H
entirely. We can then git push
this new commit without --force
.
Note that we could, all along, have invented a commit I
that fixed the bug but added on to H
, and pushed that. Such a commit is called a revert in Git, and adding a revert, rather than subtracting a commit, is often the way to go. That's because Git is built to add new commits, not to take some existing ones away. Taking commits away can only be a good idea when nobody depends on those commits (and even then there are those who argue against taking commits away).