Git hooks の pre-commit を用いた ESLint 自動化をモノレポ Docker 環境で導入


この記事はZennにも投稿しています。Zennでの閲覧を希望される方は、[こちら](https://zenn.dev/n3xus/articles/935c09fd753428)からご覧ください。

## はじめに

この記事では、Git hooks の pre-commit を活用して、モノレポ構成のプロジェクトにおける ESLint の自動化について解説します。特に、frontendとbackendディレクトリが別のコンテナにおいて動作する環境で、グローバルインストールを避け、各コンテナ内で個別にLintを実行する方法に焦点を当てています。

ESLint：JavaScriptやTypeScriptのコードを静的に解析し、構文エラーやコードスタイルの問題を検出するためのツール

CircleCI2.1 を用いたテスト自動化については下記で紹介しています。
https://qiita.com/n3xus/items/da852d64bead12685114

**対象**:
- モノレポ構成での開発を行っている方
- Dockerコンテナを用いた開発環境を整備している方
- Git hooksやcommit時ESLint自動化に興味があるエンジニア

**ねらい**:
- ESLint自動化の導入に関する知識を提供し、同様の環境構築を目指す方々の助けとなること
- 自身のプロジェクトでの工夫点を紹介し、モノレポ構成におけるGit hooksの活用例を共有すること

## 先に結論

モノレポ構成において、`pre-commit` フックを用いてESLintを自動化することで、個人のマシンに依存せず、各コンテナでLintを実行できる環境を構築しました。これにより、開発プロセスの品質向上と効率化が実現しました。

## 概要

私のプロジェクトは、1つのリポジトリ内にfrontendとbackendのディレクトリが存在するモノレポ構成を採用しています。これにより、各部分が別々のDockerコンテナで動作し、依存関係や開発環境が独立しています。こうした環境で、各コンテナごとにESLintを自動実行するため、`pre-commit` フックを導入しました。

**動作の流れ**:
* `git commit`コマンドを実行した際に`pre-commit`が動作し、git addされているファイルを`ESLint`にかける
* frontend差分があればfrontendの差分に対し`eslint --fix`を実行、backendも同様
* 特にエラーがなければそのままaddとcommitが行われる
* エラーが出ればcommitは中断され、手動で直す

## 工夫点

### 他の選択肢と比較
最初に`lefthook`や`husky+lint-staged`といった選択肢も検討しましたが、これらのツールはグローバルに依存関係をインストールする必要がある場合や、node_modulesへの追加が必要でScriptを一つにまとめられない場合があり、自身のプロジェクトに適していませんでした。

### 個別のLint実行
frontendとbackendの各コンテナ内で独立してLintを実行できるようにするため、プロジェクト内の`pre-commit`フックを工夫しました。このアプローチにより、特定の依存関係やローカルマシンに依存することなく、安定したLintの自動化を実現しました。

### スクリプト管理
`.git/hooks/pre-commit` を直接編集してShell Scriptを記載した場合でも、pre-commitがgit commit時に動作します。しかし、`.git`ディレクトリはGitHub上にpush・公開すべきではありません。そこで、`./script/`ディレクトリ配下にpre-commitの内容を記載し、コンテナ環境の立ち上げ時に `./sh/update-pre-commit.sh` を実行させ、その中で `cp ./script/pre-commit ./.git/hooks/pre-commit` のようにしてあげることで、他のメンバーの環境でも自動で導入できるようにしました。この工夫により、チーム全体での一貫したフックの利用が可能になります。

## 導入手順
`./.git`があるディレクトリという前提で進めます。

### ./script/pre-commitの作成

```sh
#!/bin/sh

echo "Running lint pre-commit..."

staged_files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.ts\|\.tsx$')

lint_frontend() {
  echo "Staged files (frontend): $frontend_files"
  if [ -n "$frontend_files" ]; then
    pushd frontend/ > /dev/null
    docker exec frontend-container yarn eslint --fix $frontend_files
    eslint_exit_code=$?
    popd > /dev/null
    if [ $eslint_exit_code -ne 0 ]; then
      echo "ESLint failed for frontend files. Commit aborted."
      exit 1
    fi
  fi
}

lint_backend() {
  echo "Staged files (backend): $backend_files"
  if [ -n "$backend_files" ]; then
    pushd backend/ > /dev/null
    docker exec backend-container yarn eslint --fix $backend_files
    eslint_exit_code=$?
    popd > /dev/null
    if [ $eslint_exit_code -ne 0 ]; then
      echo "ESLint failed for backend files. Commit aborted."
      exit 1
    fi
  fi
}

frontend_files=$(echo "$staged_files" | grep '^frontend/.*\.\(ts\|tsx\)$' | sed 's|^frontend/||')
lint_frontend

backend_files=$(echo "$staged_files" | grep '^backend/.*\.ts$' | sed 's|^backend/||')
lint_backend

if [ -n "$frontend_files" ] || [ -n "$backend_files" ]; then
  git add $staged_files
fi
```

#### スクリプト解説

1. **ステージングされたファイルの取得**  
   `git diff --cached --name-only --diff-filter=ACM`コマンドを使用して、ステージングされたTypeScriptファイルを取得します。これにより、変更が加えられたファイルだけがLintの対象となります

2. **フロントエンドとバックエンドの分離**  
   スクリプトは、フロントエンドとバックエンドのファイルをそれぞれ別々にLintします。ファイルパスを基にフロントエンド（`frontend/`）とバックエンド（`backend/`）のファイルを抽出し、各コンテナでESLintを実行します

3. **Lintの実行とコミットの中断**  
   `docker exec`コマンドを使用して、それぞれのコンテナ内で`yarn eslint --fix`を実行します。Lintが失敗した場合、コミットは中断され、エラーが表示されます

4. **変更ファイルの再ステージング**  
   ESLintが自動修正を行った場合、修正後のファイルを再度ステージングします。これにより、修正内容がコミットに反映されます

わかりやすくするため色をつける処理などを追加するのもいいと思います。

### ./sh/update-pre-commit.shの作成と実行

```sh
#!/bin/sh

# 実際に使用するマスターの pre-commit ファイルのパスを定義
master_precommit_file='./script/pre-commit'
# Git フックが格納されているディレクトリ
git_hooks_dir='./.git/hooks'
# Git の pre-commit フックファイルのパス
precommit_hook_path="$git_hooks_dir/pre-commit"

# マスターの pre-commit ファイルが存在するか確認
if [ -e "$master_precommit_file" ]; then
  # マスターのファイルと現在の pre-commit フックを比較
  diff -s "$master_precommit_file" "$precommit_hook_path" > /dev/null 2>&1
  if [ $? -ne 0 ]; then
    # 差異があればフックを更新し、ユーザーに通知
    cp "$master_precommit_file" "$precommit_hook_path"
    chmod +x "$precommit_hook_path"
    echo "pre-commit hook updated."
    exit 0
  else
    echo "pre-commit hook is already up to date."
    exit 0
  fi
else
  echo "$master_precommit_file does not exist."
  exit 1
fi
```

上記により、作成したマスターのpre-commitフックファイルをGitのhooksディレクトリにコピーし、必要ならばフックを更新して、pre-commitフックが常に最新の状態であることを確認します。

#### 実行
コンテナを立ち上げる処理を記述している箇所などに下記記述を追加してあげることで、毎回最新のpre-commitにすることができます。

```sh
chmod +x ./sh/update-pre-commit.sh
./sh/update-pre-commit.sh
```

# 最後に
今回紹介した方法により、モノレポ構成のプロジェクトでもfrontendとbackendのLint処理を分離しつつ、自動化することが可能です。このアプローチは、開発者個人の環境に依存せず、一貫したコード品質を保つのに役立ちます。

皆さんのプロジェクトでも、適切なGit hooksの活用を通じて、開発フローの効率化と品質向上を目指してみてください。