Git: pre-receive hook to allow only merges and not direct commits into master
I have a problem creating a
pre-receive hook on a git remote branch, doing what I want.
What’s the problem?
- How to assign different git per-folder permissions while you maintain the commit binding?
- Find Git branch name in post-update hook
- Git post-checkout - how to reject push if Composer installation fails
- For every commit, create an equivalent compiled commit in a separate repo or branch
- Cannot trigger post-commit git hook on git submodule
- Deploying a website to multiple environments with git
Direct commits to the master branch should be not allowed. Only merges into the master branch should be allowed.
My solution until now is to check, if there are changes in the push from a user, where the master is affected. But the problem is that I can’t differentiate if the change is a direct commit or a merge.
#!/bin/sh while read oldrefid newrefid refname do if [ "$refname" = "refs/heads/master" ]; then echo $(git merge-base $oldrefid $newrefid) echo "---- Direct commit to master branch is not allowed ----" echo "Changes only with a merge from another branch" exit 1 fi done
Has anyone an idea, how to check if the change is a merge?
2 Solutions collect form web for “Git: pre-receive hook to allow only merges and not direct commits into master”
What you get in a pre-receive hook is the previous and the new tip of the branch, so you’re going to have to check the list of commits that got added, and see if any of them are not merges:
nonmerges=$(git rev-list --no-merges --first-parent $oldrefid..$newrefid | wc -l) [ "$nonmerges" -eq 0 ] && exit 0
--first-parent limits the output to commits from the main line, i.e. commits that got merged in (reachable via second/third/… parent) are skipped.
Possibly complication: fast-forward merges (which are difficult to distinguish from a series of normal commits).
Here’s the short answer: look at the value produced by:
git rev-list --count --max-parents=1 $oldrefid..$newrefid
You want this to be zero. Read on for the explanation (and caveats).
Your loop has the right outline:
- read all ref updates;
- for those that update the branch(es) (or other references) you care about, perform some check.
The trick lies in performing the check. Consider the two other pieces of information you receive, namely the old and new SHA-1 IDs, and that in these hooks, one (but not both) of these two SHA-1 IDs may be all
0s (meaning the ref is being created or deleted).
To insist that the change not be a creation or deletion, your test should ensure that neither SHA-1 is all-zeros. (If you’re willing to assume that only deletion need to be checked you can just check that the new SHA-1 is not all-zero. But if creation could occur—which is only the case if somehow the
master branch gets deleted after all, e.g., by someone logging on to the server receiving pushes, and manually deleting it—you’ll still need to make sure that the old SHA-1 is not all-zero for the final test. Clearly this kind of deletion is possible, the question is whether you want to write code to handle the case.)
In any case, the most typical push simply updates the reference. Note that any new commits have already been written to the repository (they’ll be garbage-collected if you refuse the push), so your task at this point is to:
- find and verify any commits that the reference used to name, that it will no longer name (these are commits that would be removed by a non-fast-forward push); and
- find and verify any commits that the reference will name now, that it did not used to name (these are the new commits that would be added by a push, whether it is fast-forward or not: remember that a new push could remove one or more commits while adding one or more, at the same time).
To find these two sets of commits, you should use
git rev-list, since that’s precisely its job: to produce a list of SHA-1s specified by some expression. The two expressions you want here are “all commit IDs find-able from one revision, that are not already find-able from some other ID”. In
git rev-list terms these are
git rev-list $r1 ^$r2,1 or equivalently,
git rev-list $r2..$r1, for two revision-specifiers
$r2. The two revspecs are, of course, just the old and new IDs for the proposed
The order of these two IDs determines which set of commits
git rev-list lists: the ones that would be removed—this set is empty for a fast-forward operation—and the ones that would be added.
In this particular case, your goal is not to produce these lists of commits themselves (although that would work), but rather, to select something from these lists.
You may wish to prevent commit deletions (i.e., to enforce fast-forward-ness even if the user doing the
push specified a force flag). In this case, simply verifying that the “to be removed” list is empty suffices. You can do that by making sure that the list is actually empty, or—simpler in a shell script—having
git rev-list count them for you and check that the resulting number is zero.
You definitely wish to prevent additions that are not merges, but allow additions that are. In this case, adding
--max-parents=1 (which can also be spelled
git rev-list to suppress commits that have two or more parents, i.e., merges. Adding
--count gets you a count of commits that meet this “not a merge because zero or one parent” constraint. If this count is zero, then any commits being added must by definition be merges.
n=$(git rev-list --count --max-parents=1 $oldrefid..$newrefid) if [ $n -gt 0 ]; then echo "disallowed: push adds $n non-merge commit(s)" 1>&2 exit 1 fi
for instance, will suffice to enforce this particular constraint.
1Almost but not quite equivalent, you can write
git rev-list $r1 --not $r2: the difference is that the effect of
--not lingers, so that if you were to add yet another revision ID
--not will apply to
r3. That is,
git rev-list A ^B C means
yes-A, not-B, yes-C but
A --not B C means
yes-A, not-B, not-C. Note that in
A ^B, i.e., exactly
B is inverted.