git hooksでの運用を楽にした話

課題

ノンエンジニア向けに、gitで運用するに当たり、下記の課題が出てきた。

○コミットする前に不適切な名前は弾きたい
gitで登録しているファイル名がそのままURLのパスになるため、日本語や意味の取れないURLにふさわしくない文字列の場合、そもそもコミットさせないようにしたい。
○masterにコミットをそもそもさせないようにしたい
Pushしたときにmasterにコミットしてpushできないとエラーを吐かせる様になっているが、そのコミットを別のブランチにcherry pickするということは、GitHub Desktopではできないので、そもそもmasterブランチにコミットさせないようにしたい
○noindexをつけるべきではないhtmlファイルに、noindexが入っていればコミットさせないようにしたい
これは、検索されるべきLPページにnoindexが入っていて、検索botに見つけてもらえないという事故が発生したので、そのようなことが起きないように機械でチェックできるようにしたい
○masterブランチへのきりかえ忘れをcheck out前に防ぎたい
○仕組みがノンエンジニアでも簡単に導入し、更新できる手段がほしい

解決策

上記の課題に対して解決策として、git hooksを使うことにした。
しかし、git hooks自体はgitで管理できないため、何かしらの方法でチーム全員が簡単に導入でき、更新できるようにする必要があった。

画像1


そこで、ノンエンジニア向けに配布するため、最新のgit hooksを全員が使えるようにwindows向けのバッチを書いた

git hooksとは

そもそもgit hooksとは、

他のバージョンコントロールシステムと同じように、Gitにも特定のアクションが発生した時にカスタムスクリプトを叩く方法があります。 このようなフックは、クライアントサイドとサーバーサイドの二つのグループに分けられます。 クライアントサイドフックはコミットやマージといったクライアントでの操作の際に、サーバーサイドフックはプッシュされたコミットの受け取りといったネットワーク操作の際に、それぞれ実行されます。 これらのフックは、さまざまなな目的に用いることができます。
(https://git-scm.com/book/ja/v2/Git-%E3%81%AE%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%9E%E3%82%A4%E3%82%BA-Git-%E3%83%95%E3%83%83%E3%82%AF より引用)

一言でいうとコマンドを実行する後や、前にスクリプトを実行させることができるということだ。

pre-commit フックは、コミットメッセージが入力される前に実行されます。 これは、いまからコミットされるスナップショットを検査したり、何かし忘れた事がないか確認したり、テストが実行できるか確認したり、何かしらコードを検査する目的で使用されます。
(Git-のカスタマイズ-Git-フックより引用)

なにか特別な事しないと利用できないのかと思っていたが、実はgit initした時点でgit hooksはリポジトリに導入されている。

画像2

git hooks pre-commitのサンプルの紹介

実際に動かしてみないと想像ができないので動かしてみた。
ドキュメントに記載されている通り、.sampleを取ればすぐにpre-commitを使うことができる。

画像3

pre-commit.sampleについて

これは、非ASCIIファイル名になっているものをコミットしようとするとエラーを出すスクリプトだ。
機能としては
○非ASCIIファイル名になっているものを検知
○どうしても非ASCIIファイル名のものをコミットしたい場合の対処手段提供の2つである。

#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments.  The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".

if git rev-parse --verify HEAD >/dev/null 2>&1
then
	against=HEAD
else
	# Initial commit: diff against an empty tree object
	against=$(git hash-object -t tree /dev/null)
fi

# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)

# Redirect output to stderr.
exec 1>&2

# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
	# Note that the use of brackets around a tr range is ok here, (it's
	# even required, for portability to Solaris 10's /usr/bin/tr), since
	# the square bracket bytes happen to fall in the designated range.
	test $(git diff --cached --name-only --diff-filter=A -z $against |
	  LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
	cat <<\EOF
Error: Attempt to add a non-ASCII file name.

This can cause problems if you want to work with people on other platforms.

To be portable it is advisable to rename the file.

If you know what you are doing you can disable this check using:

 git config hooks.allownonascii true
EOF
	exit 1
fi

# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

解説のために説明と例を色々書いたもの

###################################
# 非ASCIIファイル名ではないかチェック
###################################
# HEADのハッシュを返えす。標準エラー出力の結果を標準出力にマージして、/dev/nullに捨てる
if git rev-parse --verify HEAD >/dev/null 2>&1
then
	against=HEAD
else
	# 初期コミット:空のツリーオブジェクトとの差分
	against=$(git hash-object -t tree /dev/null)
fi
# ASCII以外のファイル名を許可する場合は、この変数をtrueに設定します。
allownonascii=$(git config --type=bool hooks.allownonascii)
# 標準出力を標準エラー出力にリダイレクトします。
exec 1>&2
if [ "$allownonascii" != "true" ] &&

 ## ここでファイル名をチェック。
 # 非ASCIIファイル名になっている場合0にならないため検知できる

 ### HEADと比較して コミットするファイル一覧を出力する(例:ああああ.md)
 # git diff --cached --name-only --diff-filter=A -z $against
 # --diff-filter=A とはAddしたもののみフィルターするという意味
 # 参照 https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203

 ## LC_ALL=C tr -d '[文字セット1]'に含まれる文字があったら削除する
 #一致しないものはそのまま残される (例:ああああ)

 ## wc コマンド――テキストファイルの文字数や行数を数える
 # -c オプション バイト数を表示する (例: 12)
 [ $(git diff --cached --name-only --diff-filter=A -z $against | LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 ]

then
 echo "引っかかったファイル" $(git diff --cached --name-only --diff-filter=A -z $against | LC_ALL=C tr -d '[ -~]\0')
	cat <<\EOF
 エラー:非ASCIIファイル名を追加しようとしました。
 確認事項: アルファベットの大文字、小文字、数字と制御文字以外のひらがななどが入ったファイル名を作っていませんか?
 何をしているのかわかっている場合は、次のコマンドを使用してこのチェックを無効にできます。
 git config hooks.allownonascii true
EOF
	exit 1
fi

エラー結果の例
エラー文を日本語にして怖さを下げて、次に何をすればいいかを明記することで問い合わせ対応の煩雑さを彫らすことができた。

画像7

画像8

masterにコミットをそもそもさせないgit hooks

branch=`git symbolic-ref HEAD --short`
if [ ${branch} = master ]; then
 cat <<\EOF
 エラー:masterブランチにcommitはできません。
EOF
	exit 1
fi

masterブランチへのきりかえ忘れをcheck out前に防ぎたい


#!/bin/sh

# 新しくブランチをcheckoutした後に、リモートのmasterがローカルのmasterと一致していなかったら,
# リモートのmaster分の変更を切ったばかりのブランチにマージする

# エラーになった時点で終了
set -e

# post-checkoutの機能として3つの引数が与えられる
## https://git-scm.com/docs/githooks#_post_checkout
## 前の HEAD の ハッシュ値
readonly PREV_HEAD=$1
## 新しい HEAD の ハッシュ値
readonly CURRENT_HEAD=$2
## チェックアウトがブランチチェックアウト (ブランチの変更、flag=1) かファイルチェックアウト (インデックスからファイルを取得、flag=0) かを示すフラグ
readonly CHECKOUT_TYPE=$3

echo $PREV_HEAD
echo $CURRENT_HEAD

# ブランチの変更ではない場合はここで終了させる
if [ ${CHECKOUT_TYPE} -eq 0 ]; then
 exit 0
fi

# リモートのmasterブランチをローカルのmasterブランチに落とす
git pull origin master

# リモートのmasterブランチのハッシュを取得する
remoteMaterBranch=$(git rev-parse origin/master)
echo $remoteMaterBranch

# 比較して、ハッシュが一致していなかったらコミットを積まれる前に、マージする
if [ $CURRENT_HEAD != $remoteMaterBranch ] ; then
git rebase master

cat <<\EOF
 お知らせ:最新のmasterブランチと、ローカルのmasterブランチが一致していなかったため更新しました。
EOF
fi

最新のgit hooksを全員が使うためのwindows向けのバッチを作成する

画像4

git hooksのドキュメントによると

By default the hooks directory is $GIT_DIR/hooks, but that can be changed via the core.hooksPath configuration variable

ということなので、core.hooksPathでhooksディレクトリを変更できる。
バッチファイル(install.bat)のスクリプト全体はこちら

@echo off
cd /d %~dp0

git config --local core.hooksPath .githooks
git config --local core.filemode false

上記のスクリプトは

@echo off

バッチファイル内でコマンドの実行結果のみを表示させるようにechoはオフにしている
参考: エコー機能のON/OFFを切り替える(ECHO)

cd /d %~dp0

バッチファイルをドライブ文字とパスだけに展開させる
「%0」はそのバッチファイル自身
「~」・・・ドライブ、ファイルパスから「”」を取り除きます。
「%~d0」・・・%0をドライブ文字だけに展開
「%~p0」・・・%0をパス名だけに展開
「%~dp0」・・・%0をドライブ文字とパスだけに展開します。
例えば

”C:\hogehoge\fuga”

C:\hogehoge\fuga


参考:「%~dp0」を理解すれば、Windowsバッチ中でディレクトリ名やファイル名を自由に使えるから便利

git config --local core.hooksPath .githooks

これは
hooksディレクトリを、.githooksディレクトリに変更
local (つまりこのリポジトリのみ適応)
を指している

git config --local core.filemode false

これは開発者はmac 運用者はwindowsを使っていて、Linux と Windows ではファイルのパーミッションの扱いが異なるのでパーミンションが変わってしまうということがあります。ファイルのパーミンションの変更を無視させるようにした。

参考: 【Git】ファイルパーミッションの変更(chmod)を無視する方法

まとめ

今回始めてgit hooksという仕組みを知り、導入することにした。.git配下にhooksがあるので、どうやって管理&配信しようかと思ったが、同僚のcore.hooksPathで設定できるということを教えていただいたおかげでこの仕組みを実現できた。本当にありがたい。
シェル芸はあまり得意ではないので、だいぶレビューを手こずらせてしまったが、同僚先生に感謝申し上げたい。


エンジニアとして働いている成長記録やおもしろいと思ったこと色々書いていこうとおもいます 頂いたご支援は、資料や勉強のための本、次のネタのための資金にし、さらに面白いことを発信するために使います 応援おねがいします