JavaScript

ESLint・Prettier・husky・lint-stagedの設定|部分stageを守るコミット前チェック

husky 9とlint-stagedで、ESLint・Prettierをコミット前に実行する設定例。flat config、部分stage、lint失敗時の復元、整形差分の分離、CIの全体チェックを実際のGitで確認します。

この記事の目次
  1. 4つの道具を、コミットされるファイルの順路に置く
  2. リポジトリ直下へ設定をそろえる
  3. ESLintとPrettierの担当を重ねない
  4. 同じJavaScriptファイルは、配列の順に処理する
  5. 実際にコミットして、成功・部分stage・失敗を比べる
  6. 導入時の一括整形と、CIでの全体チェックを分ける

修正したファイルの一部だけをstageして、残りは次のコミットへ回したい。そこへ自動整形を入れると、どの差分がコミットに入るのか不安になることがあります。

huskyでコミット前にlint-stagedを呼び、JavaScriptはESLintのあとにPrettierを順番に実行します。 ただしlint-stagedが選ぶのは「stageされたファイル」です。整形が変更行だけに限定されるわけではないので、導入時にはGitのindexと作業ツリーの両方を確認します。

この記事では、JavaScriptのES Modulesを使う小さなリポジトリで設定を組み、部分的なstageとlint失敗も試します。npmの操作自体を確認したい場合は、npmコマンドとpackage.jsonの基本から読めます。

4つの道具を、コミットされるファイルの順路に置く

道具 この設定での仕事 結果を見る場所
husky git commitの前にコマンドを起動する .husky/pre-commit
lint-staged stageされた対象ファイルを選び、処理へ渡す lint-staged.config.js
ESLint 未定義の変数などを検出し、可能なものを修正する eslint.config.js
Prettier 空白、改行、引用符などの表記をそろえる .prettierrc.json

ESLintで直せないエラーがあれば、コミットを止めます。整形で直せるものはPrettierへ任せ、その結果もindexへ反映されます。hookにgit add .を足して、関係ないファイルまで追加する必要はありません。

以下はNode.js 24.13.0、ESLint 10.10.0、Prettier 3.9.6、husky 9.1.7、lint-staged 17.5.1で確認した構成です。lint-staged 17.5.1のNode要件は22.22.1以上で、ここではNode 24系を使います。

スポンサーリンク

リポジトリ直下へ設定をそろえる

新しい検証用ディレクトリをGitリポジトリにし、以下のファイルを配置します。package.jsonと.gitが同じ階層にある前提です。既存プロジェクトへ入れる場合は、今のscriptsやhookを読んでから必要な設定を統合し、ファイル全体を置き換えないようにします。

mkdir precommit-example
cd precommit-example
git init

package.jsonは次の形です。ツールは開発依存に固定し、実際に解決された依存関係はnpm installで作るpackage-lock.jsonに記録します。

{
  "name": "next50-precommit-example",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "prepare": "husky",
    "lint": "eslint . --max-warnings 0",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "lint:staged": "lint-staged",
    "check": "npm run lint && npm run format:check"
  },
  "devDependencies": {
    "@eslint/js": "10.0.1",
    "eslint": "10.10.0",
    "eslint-config-prettier": "10.1.8",
    "husky": "9.1.7",
    "lint-staged": "17.5.1",
    "prettier": "3.9.6"
  }
}

ESLintとPrettierの担当を重ねない

eslint.config.jsでは、JavaScriptの推奨ルールのあとにPrettierと競合する表記ルールを無効化する設定を置きます。この例のコードは計算用のES Modulesです。ブラウザのdocumentやNode固有のprocessを使うプロジェクトでは、その実行環境のglobal設定も必要になります。

import js from '@eslint/js';
import prettier from 'eslint-config-prettier/flat';
import { defineConfig } from 'eslint/config';

export default defineConfig([
  { ignores: ['dist/**', 'coverage/**'] },
  {
    files: ['**/*.{js,mjs}'],
    extends: [js.configs.recommended, prettier],
    languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
  },
]);

eslint-config-prettierはPrettierを実行するものではありません。Prettier公式の連携説明に沿って、linterのルールと整形の競合を減らすために使っています。整形コマンドは後で別に実行します。

.prettierrc.jsonでは、このプロジェクトでそろえる表記を決めます。

{
  "singleQuote": true,
  "semi": true,
  "trailingComma": "all"
}

.editorconfigには、対応エディターでの文字コード・改行・インデントを指定します。Markdownの行末空白には意味があるため、その自動削除は分けています。

root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

.prettierignoreでは、依存関係や生成物、ツールが生成するlockfileなどを、この例の手動整形から外します。最後のverified-results.jsonは、本記事の検証結果ファイルです。

node_modules/
dist/
coverage/
.husky/_/
package-lock.json
verified-results.json

.gitignoreも置き、node_modulesやhuskyの内部生成ファイルをコミットへ入れないようにします。

node_modules/
dist/
coverage/
.husky/_/

同じJavaScriptファイルは、配列の順に処理する

lint-staged.config.jsは次の設定です。

export default {
  '*.{js,mjs}': ['eslint --fix --max-warnings 0', 'prettier --write'],
  '*.{json,css,md}': 'prettier --write',
};

JavaScriptは「eslint –fix → prettier –write」の順です。JSON・CSS・MarkdownはPrettierだけへ渡します。二つのglobが同じファイルを書き換えないように分けています。

lint-stagedのタスク実行の説明では、globごとの処理は既定で並列になります。*.jsでESLintを書き、別の*でPrettierを走らせると、同じJSを書き換える処理が重なり得ます。同じファイルへの複数コマンドは、この例のように配列にまとめます。

最後に、.husky/pre-commitへ次の一行を保存します。旧版の設定を追加で貼り付けず、今回のhusky 9の構成として使います。

npm run lint:staged

動作確認用にsrc/total.jsも作ります。

export function total(unitPrice, quantity) {
  return unitPrice * quantity;
}

ファイルを配置したら、依存関係を導入し、全体のチェックを実行します。npm install時にはprepareも実行されます。すでに依存関係を入れたあとでprepareを追加した場合は、npm run prepareを一度実行します。

npm install
npm run check
git config --get core.hooksPath

今回の構成ではhooksPathは.husky/_になりました。huskyの手動設定と同じく、prepareでhookの実行に必要なファイルを用意しています。共有するのは.husky/pre-commitで、生成された.husky/_の中身を手編集する運用にはしません。

実際にコミットして、成功・部分stage・失敗を比べる

設定しただけでは、hookが呼ばれることも、未完成の変更が混ざらないことも確認できません。最初のコミットがある検証用リポジトリで、次のケースを試しました。

試した状態 確認した結果
整形されていないJSの変更をstage 整形後の内容でコミット成功。別の未追跡ファイルは追加されない
同じファイルの一部だけstage stageした値だけコミットされ、別の未stage変更は作業ツリーに残る
未定義変数を含むJSをstage no-undefで停止。HEAD、index、未stage差分は実行前の状態に戻る
整形するとHEADと同じになる変更だけをstage 空コミットになるため、既定の動作では停止
未stageのJSに未定義変数がある stage対象のhookには入らず、全体のnpm run lintでは検出される

部分stageの検証では、同じファイルにfirstとsecondという値を置きました。firstを1から2へ変えた状態をstageしたあと、作業ツリーでsecondを1から3へ変更しました。コミットに入ったのはfirst=2・second=1で、作業ツリーにはfirst=2・second=3が残りました。

これは、今回の独立した変更で確かめた結果です。lint-stagedは未stageの部分を一時的に隠し、処理後に復元しますが、formatterは対象ファイル全体を整形します。近い行を大きく書き換えると復元で競合することもあるため、「変更した行だけが必ず整形される」とは考えないようにします。

処理が止まったときは、まずログと現在の差分を見ます。

git status --short
git diff --cached
git diff
git stash list

自動復元ができなかった場合は、残されたバックアップやpatchをログで確認し、現在の作業を退避してから復旧を判断します。理由を確認せずgit reset –hardで一掃したり、stashを削除したりすると、救える差分を失うおそれがあります。--no-stashや--no-hide-partially-stagedを付ければ同じ動作になる、というわけでもありません。

手元で試す場合も、コミット前に対象を限定してstageします。次はsrc/total.jsだけを対象にする例です。

git add src/total.js
git diff --cached
git commit -m "Update total calculation"

コミット後は内容を開き、整形と意図した修正が入っているか確認します。未定義変数などのエラーで止まったら、コードを直してstageし直します。hookを飛ばすことを通常の解決方法にはしません。

導入時の一括整形と、CIでの全体チェックを分ける

既存コードの表記がばらばらな状態では、最初に触ったファイルへ大きな整形差分が出ます。まず設定だけの変更を確認し、一括整形が必要なら、機能変更を混ぜないコミットとして扱います。

そのときも、最初からリポジトリ全体へnpm run formatをかけるのではなく、生成物や管理対象を確認して対象を絞ります。例えば、合意したsrc配下だけを整えるなら、次のように指定できます。

npm exec -- prettier --write src
npm run check
git diff --stat
git diff

差分の内容が表記の変更に収まっていることと、既存のテスト・buildが通ることを確認してからコミットします。命名や処理の分割まで同時に行うと、整形の影響だけを見分けにくくなります。読みやすさそのものを改善する観点は、読みやすいJavaScriptの考え方で整理しています。

ローカルのhookは無効にすることもでき、変更していないファイルまでは検査しません。CIでは依存関係を固定して入れ、npm run checkで全体を検査します。この例のcheckはlintと整形確認なので、アプリのテストやbuildは別途既存の手順を続けます。

HUSKY=0 npm ci
npm run check

上はCIなどでhookをインストールせずに依存関係を入れるUnix系shellの例です。開発依存もインストールする前提であり、--omit=devへ変えるとhuskyやESLint自体がなくなる点は別に考えます。CIの環境変数欄でHUSKYを0へ設定する方法もあります。

GitのGUIからだけ失敗するなら、ターミナルでの成功をそのまま当てはめず、GUIが使うNodeとPATHを確認します。複数packageがあるリポジトリも、今回の「Gitとpackage.jsonが同じ階層」という条件から外れるため、hookの実行場所を調整します。

整形の議論を減らす仕組みを入れたあとも、確認する中心はコミットの差分です。どのファイルが処理され、何が自動修正され、何が理由で止まったかを読めれば、AIが書いたコードにも自分で書いたコードにも、同じ確認を適用できます。

スポンサーリンク