Git Hooksでうっかりミスを防ぎたい

この記事は、[富士通クラウドテクノロジーズ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/fjct) 4日目の記事です。
3日目は [@yaaamaaaguuu](https://qiita.com/yaaamaaaguuu) さんの [VMware製品を気軽に検証するためのtips](https://yaaamaaaguuu.hatenablog.com/entry/2020/12/03/042231?_ga=2.152422970.2060075168.1606930021-1363148790.1515407700) でした。
自宅でマイvCenterを作って色々試せるのはすごく勉強になりそうです。

# はじめに

こんにちは。FJCT新人エンジニアの [@Syuparn](https://qiita.com/Syuparn) です。

社会人になって、はじめてチームでの開発を経験しました。
コミットは他の人が読んでも分かりやすいようきれいにすることが大切だと学びました。

...のですが、うっかり変なコミットをマージリクエスト（プルリクエスト）に出してしまうことがあります。~~理想は遥か遠い...~~

そこで本記事では、push前にうっかりミスに気付くためにGit Hooksで設定した内容を紹介します。

紹介した設定ファイルは [GitHub - Syuparn/git-hooks-example](https://github.com/Syuparn/git-hooks-example) にもアップロードしています。

# Git Hooks
Git HooksはGitの標準機能で、commit, push等のアクションの前後に処理を追加（フック）することができます。

[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)

フックの設定ファイルは各ローカルリポジトリの `.git/hooks/` 以下にshell形式で記述します。
このディレクトリにはデフォルトでフックのサンプルが生成されているので、改造しながらフックを自作できます。

```bash:
$ ls .git/hooks/
applypatch-msg.sample*      pre-applypatch.sample*      pre-push.sample*
commit-msg.sample*          pre-commit.sample*          pre-rebase.sample*
fsmonitor-watchman.sample*  pre-merge-commit.sample*    pre-receive.sample*
post-update.sample*         prepare-commit-msg.sample*  update.sample*
```

## 注意！

Git Hooksを有効化するには、設定ファイルに実行権限を与える必要があります。

```:権限がないと実行できない。。。
hint: The '.git/hooks/commit-msg' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config advice.ignoredHook false`
```

`$ chmod +x .git/hooks/(設定ファイル)` で実行可能にしておきましょう。 

# Case 1. うっかりファイル生成/テスト忘れ

- さっきのコミットにタイポがあってCIのテストが落ちた...
- 自動生成ファイル更新し忘れた...
- linterかけ忘れてインデントががちゃがちゃだ...

pushする前にローカルで確認、実行したいことを、**コミットの度にリマインドしてくれる**ようにします。

`post-commit` フックに設定した内容は、毎回のコミットの直後に実行されます。コミットの度にTODOリストが表示されるようにしてみます。

![commit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/018b8311-30c7-2dad-e3a6-a7bbe28f8b60.png)

毎回リマインドすればプッシュまでに打ち忘れに気づくはず...！
~~さらに、休み明けに実行したいコマンドを忘れてしまっても安心です~~
もちろん表示だけでなく、実際にコマンドを実行することも可能です。 [^2]

フックの中身はこのようになっています。

```bash:.git/hooks/post-commit
#!/bin/bash

function show_reminder() {
  # 表示したいTODOリスト(ただの文字列です)
  cat <<-EOS
	--------------------------------------------------
	
	[REMIND] Before pushing, run these commands!
	$ echo "hello, world!"
	$ make
	$ go fmt ./...
	
	--------------------------------------------------
	EOS
	return 0
}

remind_msg=$( show_reminder )
# エスケープシーケンスを前後に付けて、文字を青くして表示
printf '\033[1;36m%s\033[m\n' "$remind_msg"
exit 0
```

# Case 2. うっかりリポジトリ破壊

- tabで補完したら `git push origin master` 😱 [^1]
- さっきのコミットにファイル入れ忘れたからrebaseしてforce pushして...そのブランチじゃない！(手遅れ)

`pre-push` を使うことで、特定のブランチへのプッシュを禁止したり警告を出したりすることができます。

## master/develop/mainへのプッシュを禁止

`pre-push`フックは、異常終了した場合pushを取りやめます。そこで、push先のブランチ名がmaster, develop, mainのいずれかに一致する場合は異常終了するようにします。

![main.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/ee559390-cd60-2b4b-5a2a-5755136bfa65.png)

```bash:.git/hooks/pre-push
#!/bin/bash

# これらのブランチへのプッシュを禁止する
prohibited_branches="(master|develop|main)"

while read local_ref local_sha remote_ref remote_sha
do
	# プッシュ先ブランチ名が正規表現でマッチしたら...
	if [[ "$remote_ref" =~ ^.*/$prohibited_branches$ ]]; then
		cat <<-EOS
		--------------------------------------------------
		
		DO NOT PUSH TO $remote_ref DIRECTLY!
		
		--------------------------------------------------
		EOS

		# 異常終了（プッシュはされない）
		exit 1
	fi
done

# それ以外のブランチはプッシュする
exit 0
```

## force push時に警告を出す

masterへのプッシュは全て禁止すればよいですが、force pushはマージリクエスト（プルリクエスト）の修正に使うことがあるので無くしてしまうのはつらいです。

そこで、代わりに **-f したら戻せないけど大丈夫？** と目立つ警告を出すようにします。

![force.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/65cf6c28-c2bb-f1b8-bd81-c5b70b050155.png)

デフォルトでキャンセルするようにすれば、エンターを連打しても安心です。

```bash:.git/hooks/pre-push
#!/bin/bash

function show_force_alert() {
	cat <<-EOS
	--------------------------------------------------
	
	              ▄▄▄▄
	             ██▀▀▀
	           ███████
	             ██
	  █████      ██
	             ██     
	             ▀▀     
	
	breaks remote branch!
	Do you really want force push? [y/N]
	--------------------------------------------------
	EOS
	return 0
}

# コマンド行全体を取得 (例: "git push -f origin mybranch"）
push_command=$(ps -ocommand= -p $PPID)

while read local_ref local_sha remote_ref remote_sha
do
	# コマンドにforceのオプションがついているか正規表現チェック
	if [[ "$push_command" =~ (force|[[:space:]]\-f[[:space:]]) ]]; then
		alert_msg=$( show_force_alert )
		# 警告文を黄色く表示
		printf '\033[33m%s\033[m\n' "$alert_msg"

		# 入力を受け取る。y以外なら異常終了し、プッシュしない
		exec < /dev/tty
		read yn
		if [[ "$yn" =~ ^[yY]$ ]]; then
			exit 0
		fi

		exit 1
	fi

exit 0
```

正規表現で`-f`の前後にスペースが入っているのは、入れないと`-f`が含まれるブランチ名をforce pushと誤検知するためです(例: `bug-fix`)。

ちなみに、`-f` のデカ文字は`toilet`コマンドで生成しました。デカ文字にしたい文字列とフォントを指定するだけで簡単に作れます。

```bash:
$ echo "-f" | toilet -f mono12
                    
              ▄▄▄▄  
             ██▀▀▀  
           ███████  
             ██     
  █████      ██     
             ██     
             ▀▀     
```

参考：

[Git hooks を使って push を行うときに確認を出し誤 push を防止する \| Developers.IO](https://dev.classmethod.jp/articles/git-hook-pre-push/)

[git push -f origin master したら Get Wild が流れる pre-hook - YuG1224 blog](https://blog.yug1224.com/archives/5745c93c22d440fe91ebae3c/)

# Case 3. うっかり違うブランチをプッシュ

- 危険な操作は制限したから、安心してプッシュできるぞ...あ、そのブランチじゃない！（2度目）

そもそも、**間違えるほど沢山のブランチがローカルにあるのが原因**です。
特に、他のブランチにマージされたブランチは消しても問題ありません(全てのコミットがマージ先にも記録されているため)。

`post-checkout` フックで、ブランチ作成時やマスターに戻ってくる際にいらなくなったブランチを掃除するようにします。

![checkout.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/9c2cc02d-d5e3-db23-1cb6-78c837b0eaff.png)

```bash:.git/hooks/post-checkout
#!/bin/bash

function merged_branches() {
	# マージされたいらないブランチのリスト(ただし、今いるブランチやmaster等は消したらマズいので除外)
	git branch --merged | grep -vE '(^\*|main|master|develop)'
}

# いらないブランチが無ければ何もしない
if [[ $( merged_branches | wc -l ) -eq 0 ]]; then
	exit 0
fi

# ダイアログ表示
cat <<-EOS
--------------------------------------------------
[Cleaner] These branches have already been merged.
EOS

merged_branches

cat <<-EOS
Do you want to delete them? [Y/n]
--------------------------------------------------
EOS

exec < /dev/tty
read yn
if [[ "$yn" =~ ^[nN]$ ]]; then
	# cancel	
	exit 0
fi

# 実際に各ブランチを消す
for branch in $( merged_branches )
do
	git branch -d $branch
done
```

# Case 4. うっかり変なコミットメッセージ

```
$ git log --oneline
c6f65b8 test; medium test for division inputs # あれ？
95db852 fix: raise error if divisor is zero
575b4a8 add: division button to calculator
```

`commit-msg` フックを使えば、コミットメッセージが書式に合わないときはコミットできないようにできます。 これでタイポがあっても安心です。

![commit-msg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/239887/af091abb-9c78-b851-e4c2-100cfced726f.png)

```bash:.git/hooks/commit-msg
#!/bin/bash

# $1 にコミットメッセージの一時保存ファイル名が入っているので、catでメッセージを取り出す
commit_msg=$( cat $1 )

# フォーマットに合っていない場合、異常終了しコミット失敗
if [[ ! "$commit_msg" =~ ^.*:[[:space:]].*$ ]]; then
	cat <<-EOS
		--------------------------------------------------
		
		COMMIT MESSAGE MUST BE
		"word: some explanations..."
		
		--------------------------------------------------
		EOS
	exit 1
fi
```

# 最後に
以上、`git push`のうっかりミス対策の紹介でした。
Git Hooksにはまだまだ種類がたくさんあるので、少しずつ活用していけたらと考えています。

[^1]: 幸いにも、実行したことはまだありません
[^2]: 現状は、テストやlinterが自動で走るのは少し重いかなと思いtodoリスト表示だけにしています