RedmineのチケットとGitのコミットを紐づけるために、コミットメッセージにrefs #チケット番号を入れることをルール化したい。

クライアントサイドフックであるpre-commit hookを使ってチェックできるが、各リポジトリの.git/hooks/pre-commitにフックを記載しなければいけなく、リポジトリごとに各人でそれぞれ対応しなければいけない。他のメンバーへの展開は漏れが出てくる可能性があるので、個人でpre-commit hookを設定する以外にもサーバサイドフックでチェックするようにしたい。
公式サイトでもサーバサイドフックは「システム管理者がプロジェクトのポリシーを強制させるために使うもの」と書かれている。

pushされたときにコミットメッセージを確認するにはpre-receive hookを使う。通常であればサーバの.git/hooksにpre-receiveを書くが、GitLabの場合は.git/custom_hooks以下に書くことになっている。(GitLabのCustom Git Hooks

cd /var/opt/gitlab/git-data/repositories/<group>/<project>.git
sudo -u git mkdir custom_hooks
cd custom_hooks
sudo -u git vi pre-receive
chmod u+x pre-receive

pre-receiveの中は次のように作成した。

#!/bin/bash

while read oldrev newrev refname
do
 # when new branch created
 if [ "$oldrev" == "0000000000000000000000000000000000000000" ]; then
   if [[ "$refname" == refs/heads/feature/RM* ]]; then
     from="refs/heads/development"
   elif [[ "$refname" == refs/heads/fix/RM* ]]; then
     from="refs/heads/master"
   elif [[ "$refname" == refs/heads/release* ]]; then
     continue;
   elif [[ "$refname" == refs/heads/master || "$refname" == refs/heads/development ]]; then
     # should not reach here.
     echo "[ERROR] $refname branch must exist." 1>&2
     exit 1
   else
     echo "[ERROR] Branch name (other than 'master' and 'development') must start with 'feature/RM', 'fix/RM' or 'release'." 1>&2
     exit 1
   fi

   # get latest ref from the branch from which this new branch must have been created in accordance with team rules.
   oldrev=`git show-ref --hash --verify "$from"`
 fi

 # when branch deleted
 if [ "$newrev" == "0000000000000000000000000000000000000000" ]; then
   continue
 fi

 # check refs in commit messages
 git log --oneline --format=%s "$oldrev".."$newrev" | grep -v '^Merge .*' | awk '{if (! / refs #[0-9]+$/ ) exit 1}'
 ret=$?
 if [ $ret -eq 1 ]; then
   echo "[ERROR] Reference to ticket number is required. Include 'refs #NNN' at the end of a commit message." 1>&2
   exit 1
 fi

 # check tag in commit messages
 git log --oneline --format=%s "$oldrev".."$newrev" | grep -v '^Merge .*' | awk '{if (! /^\[(fix|feature)\]/ ) exit 1}'
 ret=$?
 if [ $ret -eq 1 ]; then
   echo "[ERROR] Tag is required. Include '[fix] or [feature]' at the beginning of a commit message." 1>&2
   exit 1
 fi
done

pre-receiveはプッシュされた直後に1回だけ呼び出され、標準入力から次のようなフォーマット(更新前のコミットID 更新後のコミットID ブランチの参照情報)で渡される。

cfc3a79427568e93e22c490c70b5078b9d77584e 6c100b7afa3d35853dc48de405efee2681b1047c refs/heads/branch_name1
1306c5eb1b43c5141529bc6e55962eac55a7ffb4 22562fea781024df2ded8b9d3b3e21f63f07b418 refs/heads/branch_name2

ブランチごとに渡されるようなので全体をwhileループで囲んでいるが、主要な処理は以下の部分となる。

git log --oneline --format=%s "$oldrev".."$newrev" | grep -v '^Merge branch.*' | awk '{if (! / refs #[0-9]+$/ ) exit 1}'

Redmine番号の記載はコミットメッセージのどの部分にあっても紐づけができるのだが、コミットログ一覧を見たときの見やすさのため、ルールとして概要を記載するコミットメッセージ1行目の行末にRedmine番号を記載させるようにする。git log --onelineでコミットメッセージの1行目を取り出し、format=%sでコミットメッセージ概要部分だけに絞って出力している。
取り出すコミットの範囲は$oldrevから$newrevとすると、今回のプッシュに含まれる全コミットが取得できる。
取り出した全コミットをひとつずつ見ていく。行単位に処理をするのでawkを使うと便利。refs #チケット番号の前後に文字列がくっついてはいけないので、正規表現を refs #[0-9]+$(一番初めがスペース)とし、この正規表現にマッチしない行が出てきた時点でawkからexit 1する。
awkの終了ステータスを確認して1であればpre-receive自体の終了ステータスを1としてexitすると、プッシュが中止される。

その他の細かい制御として、ブランチを削除するときにもpre-receiveが走るのでその場合はチェックをしないようにしたり、ブランチからmasterへマージするときのデフォルトのメッセージ'Merge branch ... into master'となっているコミットはチェック対象からgrep -vで省いたりしている。

ブランチの作成のときにもpre-receiveが走るが、そのときの対処方法だけ複雑なので詳しく書く。
まずチームのルールとして以下を定めているとする。

  • masterブランチ、developmentブランチがある。
  • realease1.0.0, release1.0.1のようにリリースブランチがある。
  • リリースのタイミングでmasterdevelopmentの内容がマージされ、masterからrelease*.*.*が切られる。この時点で3つのブランチの内容はすべて同じ。
  • バグ修正用にfixブランチをmasterから派生させ、ブランチ名をfix/RM番号とする。fixブランチは全ブランチにマージされる。
  • 新機能開発用にfeatureブランチをdevelopmentから派生させ、ブランチ名をfeature/RM番号とする。featureブランチはdevelopmentだけにマージされる。

pushすることによってリモートでブランチ作成が行われるとき、$oldrevには0000000000000000000000000000000000000000という不明な値が入るのでそのままgit log "$oldrev".."$newrev"をすると、すべてのログが出力されてしまう。$oldrevに入るべきはコミット済の最新の参照であるべきなので、masterもしくはdevelopmentの最新の参照を取る必要がある。
masterdevelopmentのどちらから取るべきかは、今pushしようとしているのがfixブランチなのかfeatureブランチかによる。もしfixブランチをpushしようとしているのにdevelopmentブランチの最新の参照を$oldrevに入れてしまうと、masterブランチから派生しているfixブランチからは不明な参照となってしまう可能性がある。その場合は$oldrev0000000000000000000000000000000000000000である場合と同様にすべてのログが出力されてしまう。
チームのルールとしてブランチの命名規約を決めているので、それに従って$refnameをみて、どのブランチから最新の参照を取り出すかを決めている。参照を取り出すにはgit show-ref PATTERNを実行する。