--force considered harmful; understanding git's --force-with-lease

April 29th 2015 Steve Smith in Git, Workflows, Teams

Git's push --force is destructive because it unconditionally overwrites the remote repository with whatever you have locally, possibly overwriting any changes that a team member has pushed in the meantime. However there is a better way; the option --force-with-lease can help when you do need to do a forced push but still ensure you don't overwrite other's work.

I don't always push --force...

It's well known that git's push --force is strongly discouraged as it can destroy other commits already pushed to a shared repository. This isn't always completely fatal (if the changes are in someone's working tree then they can be merged), but at the very least it's inconsiderate, at worst disastrous. This is because the --force option makes the head of the branch point at your personal history, ignoring any changes that may have occurred in parallel with yours.

One of the most common causes of force pushes is when we're forced to rebase a branch. To illustrate this, let's have a quick example. We have a project with a feature branch that both Alice and Bob are going to work on. They both clone this repository and start work.

Alice initially completes her part of the feature, and pushes this up to the main repository. This is all well and good.

Bob also finishes his work, but before pushing it up he notices some changes had been merged into master. Wanting to keep a clean tree, he performs a rebase against the master branch. Of-course, when he goes to push this rebased branch it will be rejected. However not realising that Alice has already pushed her work, he performs a push --force. Unfortunately, this will erase all record of Alice's changes in the central repository.

The problem here is that when doing a force push Bob doesn't know why his changes have been rejected, so he assumes that it's due to the rebase, not due to Alice's changes. This is why --force on shared branches is an absolute no-no; and with the central-repository workflow any branch can potentially be shared.

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

What --force-with-lease does is refuse to update a branch unless it is the state that we expect; i.e. nobody has updated the branch upstream. In practice this works by checking that the upstream ref is what we expect, because refs are hashes, and implicitly encode the chain of parents into their value.

You can tell --force-with-lease exactly what to check for, but by default will check the current remote ref. What this means in practice is that when Alice updates her branch and pushes it up to the remote repository, the ref pointing head of the branch will be updated. Now, unless Bob does a pull from the remote, his local reference to the remote will be out of date. When he goes to push using --force-with-lease, git will check the local ref against the new remote and refuse to force the push. --force-with-lease effectively only allows you to force-push if no-one else has pushed changes up to the remote in the interim. It's --force with the seatbelt on. A quick demonstration of it in action may help clarify this:

Alice has made some changes to the branch and has pushed to the main repository. But here Bob rebases the branch against master:

ssmith$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: Dev commit #1
Applying: Dev commit #2
Applying: Dev commit #3

Having rebased, he attempts to push, but the server rejects it as it would overwrite Alice's work:

ssmith$ git push
To /tmp/repo
 ! [rejected]        dev -> dev (fetch first)
error: failed to push some refs to '/tmp/repo'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

But Bob assumes that this is due to the rebase, and decides to push it anyway:

ssmith$ git push --force
To /tmp/repo
 + f82f59e...c27aff1 dev -> dev (forced update)

However, if he had used --force-with-lease, he would have had a different result, as git would have checked that the remote branch had not in-fact been updated since Bob last fetched it:

ssmith$ git push -n --force-with-lease
To /tmp/repo
 ! [rejected]        dev -> dev (stale info)
error: failed to push some refs to '/tmp/repo'

Of course, this being git there are some caveats. The standard one is that this only works if Alice has already pushed her changes up to the remote repository. This is not a serious problem, however as when she goes to pull the rebased branch she'll be prompted to merge the changes in; if she wishes she can alternatively rebase her work onto it.

A more subtle problem is that it is possible to trick git into thinking that a branch has not been modified when it has. The main way that this would happen under normal usage is when Bob uses git fetch rather than git pull to update his local copy. 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, as you can see in this example:

ssmith$ git push --force-with-lease
To /tmp/repo
 ! [rejected]        dev -> dev (stale info)
error: failed to push some refs to '/tmp/repo'

ssmith$ git fetch
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From /tmp/repo
   1a3a03f..d7cda55  dev        -> origin/dev

ssmith$ git push --force-with-lease
Counting objects: 9, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (6/6), done.
Writing objects: 100% (9/9), 845 bytes | 0 bytes/s, done.
Total 9 (delta 0), reused 0 (delta 0)
To /tmp/repo
   d7cda55..b57fc84  dev -> dev

The simplest answer to this issue is to simply say "Don't fetch without a merge" (or more commonly just do pull, which does both), but if for some reason you wish to fetch before pushing with --force-with-lease there is a way to do this safely. As with so many things git, refs are just arbitrary pointers to objects, so we can just create our own. In this case we can create a "save-point" copy of the remote ref before we perform the fetch. We can then tell --force-with-lease to use this ref as the expected value rather than the updated remote ref.

To do this we use git's update-ref feature to create a new ref to save the remote state before any rebase or fetch operations. This effectively bookmarks the point at which we start the work we're going to force push to the remote. In this we're saving the state of the remote branch dev to a new ref called dev-pre-rebase:

ssmith$ git update-ref refs/dev-pre-rebase refs/remotes/origin/dev

At this point we can do the rebase, fetch and then use the saved ref to protect the remote repository in case anyone has pushed up changes while we were working:

ssmith$ git rebase master
First, rewinding head to replay your work on top of it...
Applying: Dev commit #1
Applying: Dev commit #2
Applying: Dev commit #3

ssmith$ git fetch
remote: Counting objects: 3, done.
remote: Compressing objects: 100% (3/3), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From /tmp/repo
   2203121..a9a35b3  dev        -> origin/dev

ssmith$ git push --force-with-lease=dev:refs/dev-pre-rebase
To /tmp/repo
 ! [rejected]        dev -> dev (stale info)
error: failed to push some refs to '/tmp/repo'

As we can see --force-with-lease is a useful tool for the git user who sometimes needs to force-push. But it is far from a panacea for all the risks of --force, and it should not be used without first understanding how it works internally and its caveats.

But in its most common use case where the developers just push and pull as normal, with the occasional rebase, it provides some much needed protection against damaging forced pushes. For this reason, I would hope that in a future version of git (but probably not until 3.0) it would become the default behaviour of --force, and that that the current behaviour would be relegated to an option that shows its actual behaviour such as --force-replace-remote.  


You might also enjoy our ebook, "Hello World! A new grad's guide to coding as a team" – a collection of essays designed to help new programmers succeed in a team setting. Grab it for yourself, your team, or the new computer science graduate in your life. Even seasoned coders might learn a thing or two.

Read it online now

Click here to download for your Kindle