15

I've always followed the rule not to modify the git history once it has been pushed to a remote repository.

But I am wondering if interactive rebasing into a push --force-with-lease bypasses this rule?

Is it perfectly safe for other users if the force-with-lease succeeds or are there any caveats to this strategy?

Thank you in advance for any input.

Pluffee
  • 155
  • 1
  • 8

4 Answers4

15

I would like to describe a plausible case where the --force-with-lease does not save you from overwriting your colleagues work.

It all begins with Bob

doing the following while having an up-to-date master branch checked out:

# Creating a new branch called feature/one
$ git checkout -b feature/one

# Do some changes and git add ...
$ git commit

# Push for the first time
$ git push --set-upstream origin feature/one

# Checkout another branch to work on something else

Situation on Bob's machine

...--F--G--H   <-- master (HEAD)
            \
             o--o   <-- feature/one

Alice continues

Alice picks up the work on feature/one and commits stuff on top of Bob's work and pushes her changes, in the mean time some unrelated pull requests are merged to the master branch. How Alice's working tree looks like

...--F--G--H--I--J   <-- master (HEAD)
           \
            o--o--x--x   <-- feature/one

Bob continues

Bob is tasked to rebase Alices work on the current master branch, and does the following

  1. git pull while he is on the master branch, which basically is a git fetch and a git merge The consequences of this step are important later.

    Situation on Bob's machine:

    ...--F--G--H--I--J   <-- master (HEAD)
                \
                 o--o   <-- feature/one
    
    ...--F--G--H--I--J   <-- origin/master (HEAD)
                \
                 o--o--x--x   <-- origin/feature/one
    

    Bob's machine now contains an up-to-date remote but changes in origin/feature/one are not yet merged to feature/one.

  2. Bob checks out the branch with git checkout feature/one

  3. Bob forgets to do a git pull
  4. Bob rebases his local branch on the master with git rebase -i origin/master

    the situation on bobs machine looks like this:

    ...--F--G--H--I--J   <-- master (HEAD)
                      \
                       o--o   <-- feature/one
    
  5. Bob thinks that he succsessfully rebased his branch and force pushes feature/one to origin/feature/one, because Bob is a nice guy, he pushes with git push --force-with-lease origin feature/one and expects that the option --force-with-lease will prevent his push operation, if he is about to overwrite other peoples work. But the option will not save him, if I understand this blog post correctly, --force-with-lease sees no difference between origin/feature/one on Bob's machine and the the actual origin/feature/one and therefore assumes that Bob's working tree will not overwrite anything on the remote if forced pushed to it. The reason for the lack of difference, lies in the excution of an implicit git fetch as part of git pull earlier (in step 1 of this section) on a different branch.

    After the push, the remote will look like this

    ...--F--G--H--I--J   <-- master (HEAD)
                      \
                       o--o   <-- feature/one
    

    instead of

    ...--F--G--H--I--J   <-- master (HEAD)
                      \
                       o--o--x--x   <-- feature/one
    

    Here is the relevant part of the blog postl linked above:

    The fetch will pull the objects and refs from the remote, but without a matching merge does not update the working tree. This will make it look as if the working copy of the remote is up to date with the remote without actually including the new work, and trick --force-with-lease into overwriting the remote branch

Human
  • 726
  • 8
  • 27
  • 3
    Thank you for an example, I just tested this and can confirm that Alice's commits x-x are lost even when using --force-with-lease. – Pluffee Dec 25 '19 at 15:41
  • Very happy to hear you actually did the work to confirm it. I tried the example only once. – Human Dec 26 '19 at 14:28
  • 1
    If Bob ever did a `git status` on `feature/one` branch after pulled on master, he would see his branch is behind. – lzhh Jun 20 '22 at 06:01
  • 1
    I told Bob he should pay more attention to the status of his repo. – Human Jun 24 '22 at 15:14
  • Good explanation. I'm still missing why the x--x commits would be lost, though, if `git push --force-with-lease` is used. My understanding is it would fail, which is the safety part. – Suncat2000 May 26 '23 at 13:36
  • force-with-lease will only checks if bobs origin/feature/one and the origin/feature/one on the remote differ from each other. Unaware that bob rebases his feature/one on the master branch and pushes. You could do an experiment and prove me wrong. I would actually be thanksful if you take the time to verify by experiment. – Human Jun 01 '23 at 12:00
13

It can be made safer with Git 2.30 (Q1 2021): "git push --force-with-lease[=<ref>](man)" can easily be misused to lose commits unless the user takes good care of their own "git fetch".

A new option "--force-if-includes" attempts to ensure that what is being force-pushed was created after examining the commit at the tip of the remote ref that is about to be force-replaced.

It rejects a forced update of a branch when its remote-tracking ref has updates that we do not have locally.

See commit 3b5bf96, commit 3b990aa, commit 99a1f9a (03 Oct 2020) by Srinidhi Kaushik (clickyotomy).
See commit aed0800 (02 Oct 2020) by Junio C Hamano (gitster).
(Merged by Junio C Hamano -- gitster -- in commit de0a7ef, 27 Oct 2020)

push: add reflog check for "--force-if-includes"

Signed-off-by: Srinidhi Kaushik

Add a check to verify if the remote-tracking ref of the local branch is reachable from one of its "reflog" entries.

The check iterates through the local ref's reflog to see if there is an entry for the remote-tracking ref and collecting any commits that are seen, into a list; the iteration stops if an entry in the reflog matches the remote ref or if the entry timestamp is older the latest entry of the remote ref's "reflog". If there wasn't an entry found for the remote ref, "in_merge_bases_many()" is called to check if it is reachable from the list of collected commits.

When a local branch that is based on a remote ref, has been rewound and is to be force pushed on the remote, "--force-if-includes" runs a check that ensures any updates to the remote-tracking ref that may have happened (by push from another repository) in-between the time of the last update to the local branch (via "git pull", for instance) and right before the time of push, have been integrated locally before allowing a forced update.

If the new option is passed without specifying "--force-with-lease", or specified along with "--force-with-lease=<refname>:<expect>" it is a "no-op".

VonC
  • 1,262,500
  • 529
  • 4,410
  • 5,250
4

It is not safe.

See this atlassian blog post, which describes that git push --force-with-lease is safer than git push -f. However, it partically overwrites the remote making it not safe.

But --force has a lesser-known sibling that partially protects against damaging forced updates; this is --force-with-lease.

The difference is that --force-with-lease does an additional check and aborts if the remote contains work your local repository does not know about. But it still overwrites the remote branch resulting the same problems as -f, it just doesn't do that if someone else pushed in the meantime.

dan1st
  • 12,568
  • 8
  • 34
  • 67
  • Ah, I see thanks! Is it safe if I don't perform a fetch then? – Pluffee Dec 12 '19 at 17:31
  • You mean, after executing `git push --force-with-lease`? – dan1st Dec 12 '19 at 17:33
  • As far as I know, you can always fetch. The problem is when you pull/merge the fetched branch. – dan1st Dec 12 '19 at 17:34
  • If force-with-lease succeeds and I don't have to execute a fetch. – Pluffee Dec 12 '19 at 17:36
  • I think you would have to. It still overwrites your repository. – dan1st Dec 12 '19 at 17:51
  • Thank you for that link. It explains simply and understandably that `git push --force-with-lease` will verify the remote repository reference is that same as it is locally or it will fail the `git push --force`. If it fails, you know you need to pull, which is not foolproof but definitely safer. – Suncat2000 May 26 '23 at 13:33
3

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.

torek
  • 448,244
  • 59
  • 642
  • 775
  • Thank you for pointing out that mistake in the first sentence. I have edited it now, hopefully the question is more accurate. – Pluffee Dec 12 '19 at 18:54