Git : get destination and source branche name during merge operation from client-side hook

Viewed 633

I'm struggling to find how to limit the merge "direction" on some branches. For example, imagine that we have master, staging and development branches in our repo :

  • master should only accept to be fast-forwarded from staging
  • staging should only accept to be fast-forwarded from development
  • development should not be able to be merged directly to master

I know that we can achieve this logic using some server-side hooks such as pre-receive, but I don't have the ability to set my own server-side hooks.

Is there a way to get the destination branch name and the source branch name during a merge from a client-side hook to accept or block the merge operation?

2 Answers

You can do a lot of things in client-side hooks, but you should be aware of several things before you even start down this path:

  1. Anyone can simply turn off their hooks. Only server-side hooks provide real enforcement. Obviously, given your constraints, that's one of those cases of having to accept a half measure, since the full measure just isn't available. But you should remember it.

  2. Git doesn't really care about branch names. Git cares about commits. Branch names are just a way to find commits. The names are entirely under the control of the user, so it's easy for someone to rename master to hoopy, staging to frood, and development to towel, after which any name-based checking is going to fail. This particular example is contrived (and meant to be amusing), but this sort of thing will eventually happen to someone. Be prepared for that.

  3. There aren't enough Git hooks in enough positions to do the kinds of things you want done. In particular, there is no "pre-merge" hook. There is a pre-merge-commit hook, but that's a bit late at best (and completely skipped in some cases).

That last point is the one that will probably kill the idea of using hooks specifically, but you don't have to use a hook here. Just have people run your own command instead of running git merge. This can be an alias, or a little script you write, or whatever you like.

(Another way to do this is with a pre-push hook; here, you'll check to see if the push request would result in a state you don't like, and if so, reject the push request. However, this happens pretty late, well after someone's done things that may be painful to undo or redo. It also won't prevent someone from using a web button on GitHub for instance, but that's another topic entirely.)

Now, as to this part:

Is there a way to get the destination branch name and the source branch name during a merge from a client-side hook to accept or block the merge operation?

You'll be requiring that people run your own command, rather than running git merge. So you can simply require that the (single) argument be a branch name:

somescript <branchname>

That script will (or won't) run git merge branchname, after doing several checks. The terms "destination branch" and "source branch" don't make sense to me, in that I don't know which one you mean by which of these phrases. If and when you do invoke git merge, the merge command will use your current branch—the one you have checked out right now—as one of the two branch tips, and will use the argument you supply, i.e., the branch name, as the other of the two branch tips.

Remember that what git merge itself does is—to simplify it quite a lot—to find two commits that should be merged, if that's possible. One of these is always the current commit. The other is one you name.

To find the current commit, Git looks up the special name HEAD, in all uppercase like this.1 That either resolves to a branch name, or directly to a commit hash ID. If it resolves directly to a commit—when Git is in what it calls detached HEAD mode—Git is now done finding the current commit. When it resolves to a branch name, as is the more normal everyday case, Git then looks up the branch name, which resolves to a commit hash ID.

To find the current branch name, Git looks up the special name HEAD. Note that this is the same lookup as before! However, having found whatever is inside the name HEAD—commit hash ID, or branch name—Git now checks to make sure it is a branch name. If it's not, Git rejects this lookup as an error, saying that you are in detached HEAD mode and there is therefore no current branch name. Otherwise, HEAD contains the branch name. Git returns this branch name: that's the current branch.

Hence, the special name HEAD provides the answer to both questions. You just have to ask Git the right question. The way to ask Git which branch name does HEAD represent? is to use the git symbolic-ref command:

$ git symbolic-ref HEAD
refs/heads/master

Note that this produces the full name of the branch. Branches have full names: you can use a branch's full name when you're mad at it or otherwise want to be really sure you get the right thing. Or, you can use the shorter version, master or whatever, when you feel like using the shorter name—or when you have to, as in git checkout and git switch. Git is not always very consistent.2 You can have git symbolic-ref print the short version with --short.

Anyway, you now have the name of the current branch. The argument you're given is the name of the other branch. Git will translate that argument to a commit hash ID, because git merge is about commits, and specifically, about combining work—except, that is, when it's about doing a fast-forward, which you also mentioned:

master should only accept to be fast-forwarded from staging

Hence you'd want to detect whether the current branch is master, and if so, whether the argument is staging, and if so, whether this merge will be performed as a fast-forward, rather than as a real merge.

This is where we get into the complexity of merging. But first, let's finish off some footnotes.


1Spelling this in lowercase, as head, or even weird mixed FunKY cAPitALizaTION, sometimes works. It doesn't always work. Don't do it! If you hate typing out HEAD in all caps on the command line, consider using the @ synonym. In a script, you can use @ or HEAD as you like; you won't have to retype it all the time so holding down shift, or using your editor to upcase the word head, or whatever, is probably not that painful.

If you're curious: it "works" when Git, not realizing that by head you meant HEAD-in-all-caps, feeds the name to the operating system. This request causes the OS to look for a file named head in the .git directory holding the repository. On some file systems, commonly on macOS and Windows for instance, this will find and open the file named HEAD. That file contains the name of the current branch, so that everything Just Works. It fails when you're on a system where case matters, so that trying to open .git/head doesn't open .git/HEAD, and also when you're in an added work-tree from git worktree add, where .git/HEAD gets you the branch name for the main work-tree. The HEAD for this added work-tree is buried deep inside .git/worktrees/. Git replaces the all-uppercase HEAD with the correct file name, but seeing head, thinks you aren't interested in HEAD, and doesn't do this replacement.

2This is not really one of the cases where Git is badly inconsistent: the full name stuff is about references, and when switching to a branch, we've already chosen to limit ourselves to branch references, rather than general references including tags and such. But Git is notoriously organic, by which I mean in the "just growed", messy evolutionary sense. There is a design, but stuck all over that design are ad-hoc solutions, leading to a system that is not always entirely logical.


How merge works

To understand how git merge works, you need to start with understanding the commit graph. Rather than writing all of that here, let me just outsource this part to Think Like (a) Git, or refer you to any of many of my other Git answers here on StackOverflow.

Now, suppose we have a graph fragment like this one:

          I--J   <-- branch1 (HEAD)
         /
...--G--H
         \
          K--L   <-- branch2

Here, we're indicating that the current branch is branch1 and its tip commit is a commit whose hash ID we're just calling J for convenience.

If we now run git merge branch2, we get Git to look up the name branch2 so as to find the hash ID of commit L. You can do this yourself, using git rev-parse. Remember that git rev-parse might fail, if given a bad branch name; you'll want to check for such things, in real code. But for now we can do this:

hash=$(git rev-parse branch2)

in a shell script, to get the hash ID of commit L. (We might like to get the hash ID of HEAD, perhaps, but we can just use the name HEAD directly. This will only fail if we're on an unborn branch.)

Once Git has both hash IDs, Git needs to find the best shared (common) commit: a commit that's on both branches, from which Git can find changes to the two branch-tip commits J and L respectively. To do that, git merge uses git merge-base, with the --all option:

bases=$(git merge-base --all HEAD $hash)

The merge-base program finds a, or all, merge base(s) of the given commits. It exits zero (success) on finding and printing these. It exits nonzero if there is no common ancestor. So we should test for that:

bases=$(git merge-base --all HEAD $hash) || {
    echo "refusing to merge unrelated histories"
    exit 1
}

If there are multiple merge bases, we have a complex merge case. There's no need to do anything special here: we can just let git merge handle it. The cases we're concerned with, from this point on, are all simple single-merge-base cases.

If we were writing something fancier, we might want to check all of this explicitly. But for our particular case here, we can simplify away the "multiple merge bases" case by just leaving out the --all option, rendering the above as:

base=$(git merge-base HEAD $hash) || {
    echo "refusing to merge unrelated histories"
    exit 1
}

If there are multiple merge bases, git merge-base will pick one of them at (apparent) random and we can just go on with the rest of our tests. The randomly chosen single base will suffice for our tests here. (Proving this is left as an exercise, should you wish to go on to turn this sketch into a full solution. That, or you can assume that my intuition on this is correct: I haven't proven it myself!)

Now, for our example above, the git merge-base command will find the hash of commit H. But there are other cases. Suppose, for instance, that we have this:

...--G--H--I   <-- branch1 (HEAD)
            \
             J--K   <-- branch2

Here, the merge base of commits I and K is commit I.

Or, suppose we have this:

...--G--H--I   <-- branch2
            \
             J--K   <-- branch1 (HEAD)

Here again, the merge base of commits I and K (or K and I—the order doesn't matter) is commit I.

Git will do a real merge in the first case, where the merge base was commit H and the two tip commits were J and L respectively. That's because commit H is behind both branch tips.

In the end, we wind up with three cases:

  • The merge base is the other commit: in this case, there is nothing to merge. The git merge command will say "Already up to date" and just quit.

  • The merge base is our current commit: in this case, the merge can be performed as a fast-forward. For the special case current and other branches that you care about, you want a fast-forward, so you should run git merge and have it do that.

  • The merge base is behind both the current and other commits: in this case, the merge requires a full merge. For the special case current and other branches, you want to forbid this, so you should complain and abort the merge.

To check for these cases, we can compare $base against the hash ID of the current and other branches. We have the hash ID of the other branch in $hash:

if [ $base == $hash ]; then
    echo "Already up to date."
    exit 0  # quit and do nothing
fi

Assuming this doesn't quit, we should go on to the rest of the tests.

To see if the hash ID of the base matches the hash ID of HEAD, we need to grab the hash ID of HEAD. We can do this with git rev-parse:

headhash=$(git rev-parse HEAD) || exit # if rev-parse fails, quit
if [ $bash == $headhash ]; then
    ... is a fast forward ...
else
    ... is a real merge ...
fi

There are alternative tests you can use for many of these conditions. For instance, git merge-base --is-ancestor will test two hash IDs to see if the first given hash ID is a predecessor of, or equal to, the second hash ID, in the partially ordered set determined by the commit graph. That is:

if git merge-base --is-ancestor $h1 $h2; then ...; fi

is a way to test whether hash $h1 precedes or equals hash $h2.

To build your "do a merge but only under the conditions we've decided are OK" script, then, you'll just need to string these various tests together in the right ways:

  • Grab the current branch name, with git symbolic-ref HEAD. If it fails (exits nonzero), there is no current branch (you are in detached HEAD) mode and you should have the script exit immediately. Someone who really wants to run git merge in this state can just run git merge instead of your script. Note that without any quiet flags, git symbolic-ref HEAD will itself print an error message for this detached-HEAD case, so you can just use || exit in a shell script.

  • Optionally, turn that into a hash ID right now with git rev-parse. If that fails, exit now (|| exit as seen above several times). This can only happen when on what Git calls an "unborn branch", in which case, merging will fail too. Or, you can defer this until later, if and when you decide you need the hash ID of the current commit.

  • Check the argument(s) to your script: verify that there is exactly one argument (using $# in a shell script), and that it is a valid name for a commit, using git rev-parse. Then, if the current branch is one of the designated ones, determine:

    • whether the argument is also one of the designated ones
    • whether this means that the merge, if we proceed, must be a fast-forward, or must not be fast forward

    and use these to decide what's allowed.

  • Check whether git merge would attempt a fast-forward, or a real merge. Decide whether these are allowed, required, or forbidden based on the results of the earlier check. Produce an appropriate error and exit for the error cases.

  • Last, run git merge for the allowed cases. (In a script like this, you might want to exec git merge "$1" or similar.)

Overall, this is probably a relatively simple shell script, just a few dozen lines at most. The logic will be easy to modify, and if all else fails, anyone can just run git merge directly instead of this script. All you're doing is providing a tool that checks for common mistakes, because that's all you can do here.

This is not a complete answer because what you are asking is complex, at least given my knowledge on the git topic. Said that, my advice is to make those rules clear to anyone so that you will not have to implement very specific and hard-working checks. Now, since you cannot use the pre-receive server-side hook, one possible client side hook you can use is pre-push. The sample file that you can find by default in the .git folder is a good starting point, and even the documentation is well written.

I made many assumptions about your workflow to make the explanation more clear. By the way, the only check you can do in this hook is whether the branches you are going to push are linear (at least for master and staging). You should replace the part in the sample about the check for the Work-In-Progress commit with this code (not exactly, but it contains the main logic):

cmt_counter=0
first_commit=$(git rev-list --max-parents=0 master)
did_someone_merge=1

while [[ 0 != $(git rev-parse HEAD~${cmt_counter}^2 2> /dev/null > /dev/null || echo $?) ]]
do
    if [[ $(git rev-parse HEAD~${cmt_counter}) = "$first_commit" ]] 
    then
        did_someone_merge=0
        break
    fi

    cmt_counter=$((cmt_counter+1))
done

exit $did_someone_merge

It simply checks if every commit of the target branch (master in this case) has a single parent only. The advantage of looping over all the commits is that you make sure that no one rewrote the history or messed with it, partially.

Instead, about the third rule, I had a really ugly idea but I have not come up with anything better and still I am not sure it works everytime. You could use the pre-merge-commit client-side hook to verify if the branches you are merging are "compatible". You could retrieve the current branch from HEAD, with some command like git name-rev and compare it with the branch whose tip is the one specified in the file .git/MERGE_HEAD.

There are certainly many other ways to do it (better than mine), I repeat: I would not do anything like what I have described because it takes time to do it well and this is not going to be stable, because of bugs or situation you have not considered, or just a change in your workflow. If you are still convinced that this is the right way, maybe a bounty will attract people more skilled than me.

Related