Safely Force Pushing with Git using `--force-with-lease=ref`

There are times where you just have to git push --force, such as, but not limited to, cleaning up your Git history on a branch ahead of a merge. But git push --force is super dangerous because it will wipe out whatever is on the branch at the point you push, so one solution is to just tell people not to push to the branch.

This isn't great UX, so Git added a lesser-known flag to git push which makes it slightly safer to force push to a shared branch.

Setup

Let us assume we have one shared repository called upstream, onto which we have a shared feature branch update which we are committing to:

workspace $ cd upstream
upstream $ git log -p
commit 02de8c9af0eaf0328ab327b5f01fbbb73f76fd0c
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:30:49 2018 +0100

    Initial commit

diff --git a/file b/file
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/file
@@ -0,0 +1 @@
+hello

We also have two other repositories, which have made separate commits on top of their shared initial commit. The first copy of the repo, copy-0:

workspace $ cd copy-0
copy-0 $ git log -p
commit a628fd25faa8762dccd65833ba774ebbd0db7b8a
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:31:43 2018 +0100

    Update

diff --git a/file b/file
index ce01362..83cdac0 100644
--- a/file
+++ b/file
@@ -1 +1 @@
-hello
+hello new

commit 02de8c9af0eaf0328ab327b5f01fbbb73f76fd0c
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:30:49 2018 +0100

    Initial commit

diff --git a/file b/file
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/file
@@ -0,0 +1 @@
+hello

And the second repo, copy-1:

workspace $ cd copy-1
copy-1 $ git log -p
commit bbfdc06bdc94e0773766adaf9ac95a6a7c9063d2
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:31:59 2018 +0100

    Don't overwrite!

diff --git a/file b/file
index ce01362..4b2ecd5 100644
--- a/file
+++ b/file
@@ -1 +1 @@
-hello
+important hello to new client

commit 02de8c9af0eaf0328ab327b5f01fbbb73f76fd0c
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:30:49 2018 +0100

    Initial commit

diff --git a/file b/file
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/file
@@ -0,0 +1 @@
+hello

We want to have our copy-1 push first to our upstream, resulting in:

workspace $ cd copy-1
copy-1 $ git push
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 278 bytes | 278.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To ../workspace/upstream
   02de8c9..bbfdc06  update -> update
workspace $ cd upstream
commit bbfdc06bdc94e0773766adaf9ac95a6a7c9063d2
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:31:59 2018 +0100

    Don't overwrite!

diff --git a/file b/file
index ce01362..4b2ecd5 100644
--- a/file
+++ b/file
@@ -1 +1 @@
-hello
+important hello to new client

commit 02de8c9af0eaf0328ab327b5f01fbbb73f76fd0c
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:30:49 2018 +0100

    Initial commit

diff --git a/file b/file
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/file
@@ -0,0 +1 @@
+hello

Great, this has set up the case where there has been some divergence between copy-0 and the upstream.

Without --force-with-lease

If we push with --force, but not --force-with-lease:

workspace $ cd copy-0
copy-0 $ git push --force
To ../workspace/upstream
Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 4 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (6/6), 429 bytes | 429.00 KiB/s, done.
Total 6 (delta 0), reused 0 (delta 0)
To ../workspace/upstream
 + bbfdc06...a628fd2 update -> update (forced update)

We will see that by the forced update message, our local changes in copy-0 have overriden the important changes that copy-1 had pushed, oops!

workspace $ cd upstream
upstream $ git log -p
commit a628fd25faa8762dccd65833ba774ebbd0db7b8a
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:31:43 2018 +0100

    Update

diff --git a/file b/file
index ce01362..83cdac0 100644
--- a/file
+++ b/file
@@ -1 +1 @@
-hello
+hello new

commit 02de8c9af0eaf0328ab327b5f01fbbb73f76fd0c
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:30:49 2018 +0100

    Initial commit

diff --git a/file b/file
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/file
@@ -0,0 +1 @@
+hello

With --force-with-lease

However, if we now push with --force and --force-with-lease:

workspace $ cd copy-0
copy-0 $ git push --force-with-lease
To ../workspace/upstream
 ! [rejected]        update -> update (stale info)
error: failed to push some refs to '../workspace/upstream'
copy-0 $ git push --force --force-with-lease

We can see that the changes haven't been pushed to the remote, saving us some very angry people.

workspace $ cd upstream
upstream $ git log -p
commit bbfdc06bdc94e0773766adaf9ac95a6a7c9063d2
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:31:59 2018 +0100

    Don't overwrite!

diff --git a/file b/file
index ce01362..4b2ecd5 100644
--- a/file
+++ b/file
@@ -1 +1 @@
-hello
+important hello to new client

commit 02de8c9af0eaf0328ab327b5f01fbbb73f76fd0c
Author: Jamie Tanna <jamie@jamietanna.co.uk>
Date:   Mon Sep 17 20:30:49 2018 +0100

    Initial commit

diff --git a/file b/file
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/file
@@ -0,0 +1 @@
+hello

Caveats

Unfortunately, this is a little too good to be true. As called out by the Stack Overflow question around it, it seems that if at any point you've refreshed your Git references with a git fetch or a git pull, you'll won't be safe:

copy-0 $ git fetch
remote: Enumerating objects: 5, done.
remote: Counting objects: 100% (5/5), done.
remote: Total 3 (delta 0), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
From ../workspace/upstream
 + a628fd2...bbfdc06 master     -> origin/master  (forced update)
copy-0 $ gp --force-with-lease
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 245 bytes | 245.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0)
To ../workspace/upstream
 + bbfdc06...a628fd2 master -> master (forced update)

This must be avoided by specifying the Git ref that we want to specify as our most recent. This ensures the push wouldn't overwrite the remote changes as they wouldn't be contained in the history of the branch that HEAD currently points to:

copy-0 $ git push --force-with-lease=HEAD
To ../workspace/upstream
 ! [rejected]        master -> master (non-fast-forward)
error: failed to push some refs to '../workspace/upstream'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Integrate the remote changes (e.g.
hint: 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Note that the ref could be one of many Git refs, such as the branch name.

How does it all work?

So how does this work? When using the 'lease', Git negotiates with the remote to determine whether its local set of refs are aligned with the refs of the branch the local client is pushing to - if these are different, we're told we have "stale info", otherwise we'll be able to push normally.

*****

Written by Jamie Tanna on 18 September 2018.

Content for this article is shared under the terms of the Creative Commons Attribution Non Commercial Share Alike 4.0 International, and code is shared under Apache License 2.0.

Tags

Categories