Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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?

Direct commits to the master branch should be not allowed. Only merges into the master branch should be allowed.

Solution

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?

Thank you!

like image 891
Marcel Balzer Avatar asked Mar 16 '15 10:03

Marcel Balzer


People also ask

How do you run pre-commit hook without committing?

Just run git commit . You don't have to add anything before doing this, hence in the end you get the message no changes added to commit .

What is pre-receive hook in git?

Pre-receive hooks enforce rules for contributions before commits may be pushed to a repository. Pre-receive hooks run tests on code pushed to a repository to ensure contributions meet repository or organization policy. If the commit contents pass the tests, the push will be accepted into the repository.

Why you shouldn't commit directly to master?

Not committing to master prevents colliding commits and having to merge each time 2 people change the same file.


1 Answers

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 $r1 and $r2. The two revspecs are, of course, just the old and new IDs for the proposed push.

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 --no-merges) tells 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.

Hence:

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 $r3 the --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 rev-list syntax, B..A means A ^B, i.e., exactly B is inverted.

like image 160
torek Avatar answered Nov 09 '22 17:11

torek