I want to add a condition to my GitHub Action workflow. Whenever a git push -f
or git push --force
is done instead of a normal git push
. Is this possible?

- 15,957
- 6
- 40
- 79
-
sure, have you tried it? though force is kind of mute as it would be a fresh clone, why would it need forcing? – Lawrence Cherone Nov 08 '21 at 10:46
-
3@LawrenceCherone It seems you answered a different question. Do you know what GitHub Actions are? – phd Nov 08 '21 at 10:53
-
@phd I sure do know what GitHub Actions are. I don't get your point. – Lawrence Cherone Nov 08 '21 at 11:39
-
@LawrenceCherone I dont tried it because i dont know how? It should look something like this. `if: ${{ github.event_name == 'push --force'}}, right? I don't know the event alias to git push --force. – Maik Lowrey Nov 08 '21 at 11:58
-
2Maik, [this SO thread](https://stackoverflow.com/questions/10319110/how-to-detect-a-forced-update) might help you to check if a git push has been forced or not (the commands will be the same in a Github Actions workflow). – GuiFalourd Nov 08 '21 at 12:03
-
4@LawrenceCherone The question is: What [`on:`](https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows) condition to use to trigger a workflow on a forced push? Your answer "*sure, have you tried it?*" is a rather strange answer for the question. – phd Nov 08 '21 at 12:19
-
@phd [answers go here](https://stackoverflow.com/questions/69882152/is-it-possible-to-check-a-git-push-force-in-a-github-action?noredirect=1#post-form), not in comments – Lawrence Cherone Nov 08 '21 at 13:08
2 Answers
Not to diminish @torek's huge, detailed answer about git internals... But the much simpler answer is that yes, you can check the ${{ github.event.forced }}
boolean in an action triggered by a push. Conveniently, Github already does all of the hard work, and has for years. That's how they know to add the "xxx force pushed" note in PRs. :)
The github context contains the webhook payload in the event attribure. Docs explaining that all are below:

- 3,793
- 1
- 23
- 30
You cannot tell (anywhere, much less in a GitHub action) whether the --force
flag was given to a git push
operation.
You can, however, tell whether a proposed or accepted update to a reference requires the flag. Whether—and if so how—you can do that in a GitHub action is a separate question; I'll provide a partial answer below, but someone who is interested in writing GitHub actions will need to take this further.
Core concepts at the Git level: refs and name spaces
In Git, we have references (or "refs"). Almost all references live in namespaces underneath refs/
. The almost weasel word here accounts for the fact that Git sometimes calls entries like HEAD
, ORIG_HEAD
, CHERRY_PICK_HEAD
, and so on "references"—though it sometimes calls them pseudo-refs instead—and none of them start with refs/
.1 We don't have to worry about the pseudo-refs here and can concentrate solely on the real refs.
A ref is either symbolic, in which case it holds the name of another ref,2 or else it holds one hash ID. When a ref does hold a hash ID, that must be a valid hash ID for some object existing in the repository.3 Some refs should never change and some should move only in a fast-forward operation. This brings us to the classifications of refs:
- refs that live in
refs/heads/
are branch names; - refs that live in
refs/tags/
are tag names; - refs that live in
refs/remotes/
are remote-tracking names; 4 - and most others don't have any specific name.
Branch names must meet one extra constraint: the hash ID they store must be that of a commit object. Remote-tracking names are copied from some other Git's branch names, so they too will meet this constraint.6
Tag names should never "move": that is, once a tag name exists and stores some hash ID, it should store that same hash ID forever. This means that any request to update a tag name, rather than simply to create one from scratch, should be rejected unless forced.
Branch names should move, but only in fast-forward fashion (see below). This means that a branch name update request should be accepted only conditionally. Remote-tracking names should follow the same rules as branch names.
Other names have other purposes; in general, we don't know what constraints, if any, might be placed upon them. You can pick one out of the list of things Git does—e.g., refs/notes/
for git notes
, refs/replace/
for git replace
, and so on—and investigate these on your own, but the above are the Big Three, and most of what we care about here.
1Internally, Git is gradually acquiring a real database of sorts to hold refs. Currently they're stored very ad-hoc, as files in a file system and/or as entries in a simple text file. The pseudo-refs are never stored in the text file and the real refs are always stored within the refs/
directory and literally make use of the file system. This causes problems because some file systems, e.g., the default ones on Windows and macOS, are case-insensitive, and yet Git refs are intended to be case-sensitive, and are case-sensitive everywhere else. This is part of what is driving the need for the real database. This is bringing into internal sharp focus the difference between a regular ref and a pseudo-ref; this may need to be formalized at some point.
2Generally only the HEAD
pseudo-ref and remote-tracking names that end in /HEAD
should be symbolic. If you make other refs that are symbolic, they work ... oddly. In the past they worked even more oddly; as progress occurs due to what's in footnote 1, this has improved, but for now, I would advise avoiding symbolic refs for normal use, except of course these standardized HEAD
ones.
3This constraint clashes with the ideas behind partial clones and I can see this changing at some point.
4Git calls these remote-tracking branch names, but a branch name is a name you can pass to git switch
to get "on" a branch. A remote-tracking name is not allowed here. So a "remote-tracking branch name" is not a "branch name". If we were to parenthesize this, a la diagramming a sentence, we would have to be sure to consider it a ((remote tracking) branch) name, not a (remote tracking) (branch name). Why not just drop the redundant, and otherwise heavily overused in Git, word "branch" entirely?5
5That's a rhetorical question, but the obvious answer is "stubbornness and/or obstinacy".
6It might be interesting to deliberately break a repository by forcing the wrong kind of hash ID into a branch name, and then see which Git implementations accept that wrong-kind-of-hash-ID and put it into their remote-tracking names upon running git fetch
. That is: who actually enforces this constraint? It naturally evolves from branch names, but only if the other Git follows the rules.
Core concepts at the Git level: fast-forward
To understand the notion of fast-forwarding in Git, we must start with a quick overview of commits. Commits, in Git, form a Directed Acyclic Graph or DAG. That's because each commit:
- has a unique hash ID;
- contains the raw hash IDs of some other commits; and
- Git imposes a strong constraint on these: the hash IDs must be those of valid, existing commits at the time we create any new commit.
This setup means that the commits themselves are the nodes or vertices in a graph, and the list of stored hash IDs form the one-way edges or arcs connecting from each node to some other nodes. The final constraint means that the one-way arcs always point backwards, to earlier nodes, which—through a simple transitive closure observation—shows us that we do in fact have an acyclic directed graph.
As such, given any distinct pair of node IDs N1 and N2, we can ask the two questions does N1 precede N2 and does N2 precede N1. That is, there's a partial ordering of the graph. Node N1 precedes node N2 if there is a path through the graph from N2 to N1:
... <-F <-G <-H <-...
Node F
precedes node H
here because we can work our way backwards from node H
to node G
and then from node G
to node F
.
(Having a N1 ≺ N2, plus the obvious H = H
and H ≠ G
, means we can also define ≼, "precedes or equals", and inverting it gives us "does not succeed" and so on. But note that while F
≺ H
implies both H
≻ F
and F ⊀ H
—H
succeeds F
and F
does not precede H
—merely knowing that I
⊀ J
does not imply J
≻ I
:
I
/
...--H
\
J
Here, I
and J
are simply unordered with respect to each other. Both succeed H
, but we can't say that either I
or J
comes "first" after H
. The partial order is not a total order.)
While Git doesn't provide a "succeeds or equals" test at the shell level, it does provide a "precedes or equals" test, using git merge-base --is-ancestor
(at least since Git 1.8; previously you had to use a more complicated set of expressions). Since ≼ "flips around" to ≽ and vice versa, we can test for N1 ≼ N2 with git merge-base --is-ancestor N1 N2
, or test for N1 ≽ N2 with git merge-base --is-ancestor N2 N1
.
Without motivation, then, we can define "is fast forward" as "succeeds", or perhaps as "succeeds or equals" depending on how finicky we want to be. But let's look at motivation.
Suppose we have this graph:
I--J
/
...--F--G--H
\
K--L
where "earlier" commits are to the left and "later" commits are to the right, so that F
≺ G
, G
≺ H
, and so on. As before, there's no ordering relationship from top to bottom, just from left to right.
Git finds commits, on a branch, by using the branch name to point to the commit that we choose to call the "last" commit on the branch. So if name br
points to commit F
—contains F
's hash ID—then all commits up to and including F
are "on" the branch.
If we move F
"forward", in the direction the arrows don't go, one hop to G
, commit F
remains "on the branch". In fact, that's probably how we made G
in the first place: by being "on" some branch that ended at F
, with no commits after F
at all. Then we made one new commit, and it got G
's hash ID, and pointed back to F
... and Git stored the new commit's hash ID in the branch name, so that the branch name pointed to G
.
This pattern repeats over and over in Git, so we made H
by being "on" a branch ending at G
and making commit H
, which added H
to that branch. But then we must have made at least one more branch name:
...--F--G--H <-- main (HEAD), br1, br2
We then pick one of the two new names—such as br1
—to be "on", using git checkout
or git switch
to move onto branch br1
, and make a new commit:
I <-- br1 (HEAD)
/
...--F--G--H <-- main, br2
We make another new commit J
while "on" br1
, then git checkout br2
or git switch br2
to get "on" br2
:
I--J <-- br1
/
...--F--G--H <-- main, br2 (HEAD)
and then make two more commits to get K-L
:
I--J <-- br1
/
...--F--G--H <-- main
\
K--L <-- br2 (HEAD)
That's how we got this in the first place.
Now, if we move a branch name—such as main
—"forward" to (say) J
, we get this:
I--J <-- main, br1
/
...--F--G--H
\
K--L <-- br2 (HEAD)
All the commits that were on main
—everything up through H
—are still on main
.
If we decide, now, to force (note key word) branch name main
to move to L
now, though, we get:
I--J <-- br1
/
...--F--G--H
\
K--L <-- main, br2 (HEAD)
Two commits that were on branch main
have now vanished from main
. We can still find them—we still have the name br1
for that—but they disappeared from main
.
Let's look at our definition of "is fast forward" now: A name motion is a fast forward if the new tip commit succeeds the old tip commit. So when we moved main
to point to J
, that was a fast-forward operation. It kept all the commits that were on main
, on main
. But when we moved it from here to point to commit L
, that was a non-fast-forward operation. It dropped commits I-J
from main
.
We can also move main
in a non-fast-forward fashion by just resetting away the extra commits:
I--J <-- br1
/
...--F--G--H <-- main
\
K--L <-- br2 (HEAD)
or even by moving it back to point to commit F
, for instance.
These are all the ways that we can move a label:
- not at all (e.g.,
H
toH
); - to a successor commit (e.g.,
H
toJ
); - to a predecessor (e.g.,
H
toF
orJ
toH
); or - to an unordered commit (e.g.,
J
toL
).
The not-at-all "movement" is a degenerate case but Git will call this a "fast-forward" if asked, or at least, won't call it a "non-fast-forward".
The to-successor case is also a fast-forward, or if you don't want to count the no-movement case, is the only fast-forward case.
The to-predecessor and to-unordered movements are non-fast-forward operations.
Now we know what this is all about
A fast forward operation on a label—such as a branch name or remote-tracking name—in Git moves the label in a way such that all the commits that were reachable from the old tip are still reachable, from the new tip commit.
A non-fast-forward operation on such a label moves the name such that some commits "fall off the end". New commits may or may not appear, as in the case where we moved main
from J
to L
, but some commits dis-appear.
Since we generally find commits by starting with a label—such as a branch name—and working backwards, those movements that make commits vanish are "more dangerous". These are our "non-fast-forwards". Because they are dangerous, Git makes us use the --force
option.
Note that you can use the --force
option with a safe movement! That is, a git push
that does a safe branch update is itself a fast-forward operation, but you can use git push --force
to achieve this. It's just that you did not have to.
Applying this knowledge in certain Git hooks
Some Git hooks—specifically, pre-push, pre-receive, and post-receive—get a lot of information. For the pre-receive and post-receive hooks, you get, on standard input, a series of lines, each of which contains three whitespace-separated strings:
- old-value (a hash ID);
- new-value (a hash ID); and
- ref-name
For the pre-receive hook, these are proposed updates. The sending Git, which has been run with or without --force
, is suggesting that you, the receiving repository, should create, update, or delete the given ref-name.
The proposed operation is a "name create" if the old-value is all-zero.7
The proposed operation is a "name delete" if the new-value is all-zero.
Otherwise, the proposed operation is a "name update".
The operation hasn't actually happened yet, but in your Git hook, you can inspect the new commits, if there are new commits, or the new Git objects, if there are new Git objects. You can tell what the object's type is using git cat-file -t
if needed, but if the name is a branch name, the object's type is automatically commit
because branch names must point to commits: Git would have rejected this earlier otherwise. So if the name has the form refs/heads/*
, you can assume that both old and new hash IDs are commit hash IDs (well, unless one is zero, but then we're not in the update case in the first place).
This means you can now use git merge-base --is-ancestor
. If the proposed new value is a successor of the present value:
if git merge-base --is-ancestor $old_hash $new_hash; then
echo operation is a fast forward
then this is a fast-forward operation, and --force
wasn't required. We don't know if it was used but it was definitely not required.
If the proposed new value isn't a successor:
else
echo operation must have been forced
fi
then we know that for us to get this far, the user must have provided the --force
flag.
The post-receive hook is very similar. There are two key differences between pre-receive and post-receive:
The pre-receive hook's exit status matters: pre-receive's job is to inspect the requested updates and verify that they are OK. Here, a Git server can do things like implement "protected branches", for instance. (GitHub's pre-receive hooks do this and therefore you're not allowed to impose your own pre-receive hook.)
The post-receive hook's exit status is not supposed to matter: post-receive's job is to do something like send email saying that some branch was updated. It's already happened; it is too late to stop it. Post-receive hooks should generally exit 0 anyway though, to avoid tripping any Git bugs.8
At pre-receive time, the proposed update is proposed. At post-receive time, it's already happened: the old-value is just what was stored in that name before the push finished.
So, while you cannot see the --force
flag itself, you can test for its effect. However, this is in the context of pre- and post-receive hooks, not in GitHub Actions.
7This removes the all-zeros hash ID from the universe of allowed hash IDs. I'm not sure if Git ever checks to see if what comes out of its hashing function is all-zeros, but there's only a 1 out of 2160 chance of this at the worst, so it probably never happens.
8The post-checkout hook in particular is not supposed to affect the result of git checkout
or git switch
. However, if the hook exits nonzero, so does git checkout
itself, in at least some versions of Git. This makes other programs think the git checkout
failed! So make sure your post-whatever hooks exit zero, just in case there's a similar bug in an old or new version of Git.
GitHub Actions are not Git hooks
GitHub Actions are triggered on GitHub, by GitHub, on certain events. These events include git push
, but also include a lot more things.
Some variables are available. The GitHub documentation doesn't seem to be all that great, because this page mentions variables such as github.ref
:
if: ${{ github.ref == 'refs/heads/main' }}
and then goes on to describe various "property names". These appear to be set only in these ${{...}}
contexts (what this page seems to be about). A different page describes environment variables that are apparently available in other contexts. It's not immediately clear to me whether there is any sensible way to access Git repository refs in the ${{...}}
section. There are also ways to set things (property names?) in the workflow commands.
What I think you would need to do
In order to do this sort of thing in a GitHub action, which generally runs a bit too late, you would have to:
- read the current value of a Git branch name ref, such as
refs/heads/branch
; - compare that to the stored value from a previous run, taking care to handle the first run specially since there will be no such stored value;
- use the
git merge-base --is-ancestor
test to check for fast-forward vs non-fast-forward, using the stored previous hash and the current hash; - update the stored value based on the current hash.
This would allow you to detect, not whether --force
was used, but rather whether --force
had been required (and thus was in fact used), in some particular git push
.
The obvious caveats would be that this can't work until it has run at least once, to create the stored value; you must invent a reference namespace in which to store values (e.g., refs/prevpush/$branch
where branch
is the result of the shell expanding ${ref#refs/heads/}
, so that refs/heads/feature/short
becomes feature/short
); and any actions that affect refs, but bypass the GitHub action, could invalidate the stored value and lead to a misfire.
Besides all these caveats, there's one other crucial point here for most users: detecting that there was a forced-push is usually the wrong way to go about solving whatever the real problem to be solved might be. All we get, by our careful dancing about here, is a flag: "some push must have been forced". What we usually really want to know is: "what hash ID are we supposed to use?" That question is much easier to answer, so if you're going down this path, make sure you're doing so for a really good reason.

- 448,244
- 59
- 642
- 775
-
GitHub does show if a commit was done using `git push --force` it says something along the lines of "user force pushed
into – Azteca Aug 01 '23 at 22:20" and if you want to see the 'deleted' commit it says this warning message: `This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.`