39

A foxtrot merge is a merge where 'origin/master' merges in as a 2nd (or later) parent, like so:

Commit 'D' is a foxtrot merge because 'origin/master' is its 2nd parent.

Commit 'D' is a foxtrot merge because 'origin/master' is its 2nd parent. Notice how first-parent history from 'origin/master' contains commit 'B' at this moment.

But in my git repo I need all merges involving 'origin/master' to keep 'origin/master' as the 1st parent. Unfortunately git doesn't care about parent-order when it evaluates whether a commit is eligible for fast-forward. This causes the first parent history on my master branch to sometimes lose commits that used to be there (e.g., output of "git log --first-parent").

Here's what happens when commit 'D' from the earlier diagram is pushed:

How can I prevent this push? First-parent history of 'origin/master' no longer contains commit 'B' after the foxtrot merge is pushed!

How can I prevent this push? First-parent history of 'origin/master' no longer contains commit 'B' after the foxtrot merge is pushed!

Obviously no commits or work are actually lost, it's just that in my environment I really need "git log --first-parent" to be a stable accumulative record of commits - if you like, a kind of "Write-Once Read-Many" (WORM) database. I have scripts and processes that use "git log --first-parent" to generate changelogs and release notes, as well as to manage ticket transitions in my ticketing system (JIRA). Foxtrot merges are breaking my scripts!

Is there some kind of pre-receive hook I could install in my git repositories to prevent foxtrot merges from getting pushed?

p.s. The commit graphs in this stackoverflow question were generated using http://bit-booster.com/graph.html.

torek
  • 448,244
  • 59
  • 642
  • 775
G. Sylvie Davies
  • 5,049
  • 3
  • 21
  • 30
  • 3
    I'm curious where this "foxtrot merge" terminology comes from... – Edward Thomson Mar 12 '16 at 20:17
  • http://bit-booster.blogspot.cz/2016/02/no-foxtrots-allowed.html – choroba Mar 12 '16 at 20:23
  • 6
    I invented it! https://bit-booster.blogspot.com/2016/02/no-foxtrots-allowed.html – G. Sylvie Davies Mar 12 '16 at 20:24
  • Why just `master` branch? I think this applies for any branch. – hIpPy Aug 06 '17 at 03:09
  • Yes, it applies to all branches. Took me a few months to realize that. – G. Sylvie Davies Aug 06 '17 at 03:12
  • Why not make better scripts? What's the obsession with using `git log --first-parent`? Sorry, I've seen people talk about this issue for years now, and I guess I'm a big dummy because I still don't get what the problem is. I always come to the conclusion that this is trying to make the tail (git log) wag the dog (git _everything-else_). – CivFan Aug 07 '20 at 17:35
  • Related topic: How to *automatically solve* foxtrot commits in the origin-repo side? https://stackoverflow.com/questions/70437161/how-to-automatically-fix-foxtrot-merges-in-my-the-master-branch-in-git – Yaakov Shoham Dec 21 '21 at 14:38

3 Answers3

18

The following pre-receive hook will block those:

#/bin/bash

# Copyright (c) 2016 G. Sylvie Davies. http://bit-booster.com/
# Copyright (c) 2016 torek. http://stackoverflow.com/users/1256452/torek
# License: MIT license. https://opensource.org/licenses/MIT
while read oldrev newrev refname
do
if [ "$refname" = "refs/heads/master" ]; then
   MATCH=`git log --first-parent --pretty='%H %P' $oldrev..$newrev |
     grep $oldrev |
     awk '{ print \$2 }'`

   if [ "$oldrev" = "$MATCH" ]; then
     exit 0
   else
     echo "*** PUSH REJECTED! FOXTROT MERGE BLOCKED!!! ***"
     exit 1
   fi
fi
done

If you're using Github / Gitlab / Bitbucket Cloud, you may need to look into creating some kind of call into their commit status apis (here's api docs for: bitbucket, github; not sure if gitlab has one), because you don't have access to the pre-receive hooks, and even if you did, you'd still have to deal with people clicking the "merge" button directly in the web ui of those products (in which case there is no "push").

With Bitbucket Server you can install the add-on I created.

Once it's installed you click "enable" on the "Protect First Parent Hook" in a given repository's "hook" settings:

enter image description here

It will block foxtrot merges via push and via the "merge" button in the Bitbucket Server UI. It does this even if its license is expired, making the "Protect First-Parent Hook" a free component of the larger add-on.

Here's an example of my Bit-Booster "Protect First Parent" pre-receive hook in action:

$ ​git pull
$ git push

remote: *** PUSH REJECTED BY Protect-First-Parent HOOK ***
remote: 
remote: Merge [1f70043b34d3] is not allowed. *Current* master must appear
remote: in the 'first-parent' position of the subsequent commit. To see how
remote: master is merging into the wrong side (not as 1st parent), try this:
remote: 
remote:   git show --graph -s --pretty='%h %d%n' \
remote:      1f70043b34d3 1f70043b34d3~1 origin/master
remote: 
remote: To fix, there are two traditional solutions:
remote: 
remote:   1. (Preferred) rebase your branch:
remote: 
remote:       git rebase origin/master
remote:       git push origin master
remote: 
remote:   2. Redo the merge in the correct direction:
remote: 
remote:       git checkout master 
remote:       git reset --hard origin/master 
remote:       git merge --no-ff 1f70043b34d3eaedb750~1
remote:       git push origin master
remote: 

For more background on foxtrot merges I wrote a blog post.

G. Sylvie Davies
  • 5,049
  • 3
  • 21
  • 30
  • 3
    Nice, though I'd use, e.g., `awk '{ print $2 }'` rather than `cut` to avoid column counting (and potential breakage should git ever switch to SHA-256 for instance, although so much *other* stuff would break that this might not be worth worrying about...). – torek Mar 12 '16 at 20:47
  • 3
    I'll try that! Are you okay licensing that change to me under the MIT license? – G. Sylvie Davies Mar 12 '16 at 20:53
  • 1
    Done! Thanks, @torek – G. Sylvie Davies Mar 15 '16 at 19:45
  • I suggest minor tweaks (see https://gist.github.com/rmandvikar/5486d953fc74b60eba0b46a339f6a16a/revisions). From my quick testing, I think `--pretty='%P'` and `awk '{ print \$1 }'` will work as only the parent hashes are needed. – hIpPy Aug 06 '17 at 07:02
  • Do you know backlog.com, we have git repos on there, I don't know how to config this – yelliver Jul 19 '18 at 13:41
6

Here is a hook code which will do what you are asking for:

pre-receive hook

#!/bin/sh

# Check to see if this is the first commit in the repository or not
if git rev-parse --verify HEAD >/dev/null 2>&1
then
    # We compare our changes against the previous commit
    against=HEAD^
else
    # Initial commit: diff against an empty tree object
    against=4b825dc642cb6eb9a060e54bf8d69288fbee4904
fi

# Redirect output to screen.
exec 1>&2

# Check to see if we have updated the master branch
if [ "$refname" eq "refs/heads/master" ];
then

    # Output colors
    red='\033[0;31m';
    green='\033[0;32m';
    yellow='\033[0;33m';
    default='\033[0;m';
    
    # personal touch :-)
    echo "${red}"
    echo "                                         "
    echo "                   |ZZzzz                "
    echo "                   |                     "
    echo "                   |                     "
    echo "      |ZZzzz      /^\            |ZZzzz  "
    echo "      |          |~~~|           |       "
    echo "      |        |-     -|        / \      "
    echo "     /^\       |[]+    |       |^^^|     "
    echo "  |^^^^^^^|    |    +[]|       |   |     "
    echo "  |    +[]|/\/\/\/\^/\/\/\/\/|^^^^^^^|   "
    echo "  |+[]+   |~~~~~~~~~~~~~~~~~~|    +[]|   "
    echo "  |       |  []   /^\   []   |+[]+   |   "
    echo "  |   +[]+|  []  || ||  []   |   +[]+|   "
    echo "  |[]+    |      || ||       |[]+    |   "
    echo "  |_______|------------------|_______|   "
    echo "                                         "
    echo "                                         "
    echo "      ${green}You have just committed code ${red}  " 
    echo "      Your code ${yellow}is bad.!!!      "
    echo "      ${red} Do not ever commit again    "
    echo "                                         "
    echo "${default}"
fi;

# set the exit code to 0 or 1 based upon your needs
# 0 = good to push
# 1 = exit without pushing.
exit 0;
Community
  • 1
  • 1
CodeWizard
  • 128,036
  • 21
  • 144
  • 167
3

I wrote this to provide feedback early (pre-receive hook is also needed). I use post-merge and pre-push hooks for this. It's not possible to prevent a foxtrot merge in commit-msg hook as the information to detect one is available only after. In post-merge hook, I simply warn. In pre-push hook, I throw and block the push. See d3f1821 ("foxtrot: Add sub-hooks to detect foxtrot merges", 2017-08-05).

~/.git-hooks/helpers/foxtrot-merge-detector:

#!/bin/sh

#usage:
#   foxtrot-merge-detector [<branch>]
#
# If foxtrot merge detected for branch (current branch if no branch),
# exit with 1.

# foxtrot merges:
# See http://bit-booster.blogspot.cz/2016/02/no-foxtrots-allowed.html
# https://stackoverflow.com/questions/35962754/git-how-can-i-prevent-foxtrot-merges-in-my-master-branch

remoteBranch=$(git rev-parse --abbrev-ref "$1"@{u} 2>/dev/null)
# no remote tracking branch, exit
if [[ -z "$remoteBranch" ]]; then
    exit 0
fi
branch=$(git rev-parse --abbrev-ref "${1-@}" 2>/dev/null)
# branch commit does not cover remote branch commit, exit
if ! $(git merge-base --is-ancestor $remoteBranch $branch); then
    exit 0
fi
remoteBranchCommit=$(git rev-parse $remoteBranch)
# branch commit is same as remote branch commit, exit
if [[ $(git rev-parse $branch) == $remoteBranchCommit ]]; then
    exit 0
fi
# remote branch commit is first-parent of branch, exit
if [[ $(git log --first-parent --pretty='%P' $remoteBranchCommit..$branch | \
    cut -d' ' -f1 | \
    grep $remoteBranchCommit | wc -l) -eq 1 ]]; then
    exit 0
fi
# foxtrot merge detected if here
exit 1

And then use it as

pre-push hook:

#!/bin/sh

remote="$1"
url="$2"
z40=0000000000000000000000000000000000000000
while read local_ref local_sha remote_ref remote_sha
do
    if [ "$local_sha" = $z40 ]; then
        # handle delete, do nothing
        :
    else
        # ex $local_ref as "refs/heads/dev"
        branch=$(git rev-parse --abbrev-ref "$local_ref")
        ~/.git-hooks/helpers/foxtrot-merge-detector "$branch"
        # check exit code and exit if needed
        exitcode=$?
        if [ $exitcode -ne 0 ]; then
            echo 1>&2 "fatal: foxtrot merge detected, aborting push"
            echo 1>&2 "fatal: branch $branch"
            exit $exitcode
        fi
    fi
done

post-merge:

#!/bin/sh

~/.git-hooks/helpers/foxtrot-merge-detector
# check exit code and exit if needed
exitcode=$?
if [ $exitcode -ne 0 ]; then
    echo -e "  ${Yellow}WARNING:${None} foxtrot merge detected"
    # swallow exit code
fi
hIpPy
  • 4,649
  • 6
  • 51
  • 65