Gitでのコミットメッセージの良い書き方

1. 概要

コミットメッセージは、デバッグ時、コードレビュー時、あるいはChangelogを書く際に、あなたが最も頻繁に読み直すものであり、書いたばかりのコードよりも何度も目にすることになります。この記事では、コミットメッセージの標準的な構造を理解し、Conventional Commitsを適用し、commitlint + huskyでメッセージを自動検証する方法を紹介します。この記事を読めば、明確で一貫性のあるコミットを書くことができ、チームはgitの履歴から自動的にChangelogを生成することができます。

2. なぜコミットメッセージが重要なのか

良いコミットメッセージは3つの質問に答えます:何が変わったか、なぜ変わったのか、どのような影響があるか。メッセージがなければ、各コミットに対してgit showを実行して作者の意図を推測しなければなりません。これは最初からきちんとしたメッセージを書くのに比べて、何倍も時間がかかります。

同じ変更に対する2つのメッセージを比較してみましょう:

# 悪い例
fix bug

# 良い例
fix(payment): OrderServiceに顧客情報がない場合のnull pointer処理

悪いメッセージはどのバグについて、どこにあるのかを示していません。良いメッセージはモジュール(payment)、アクション(fix)、具体的な原因を示しており、5秒読めば理解できます。

3. コミットメッセージの標準的な構造

Gitは3部構成を推奨しています:subject(件名)、body(本文)、footer(フッター)。

<type>(<scope>): <subject>

<body>

<footer>
  • subject:短い要約、50文字以下、現在形で書く(“added”ではなく“add”を使う)。
  • body:なぜ変更したのかを説明し、何を変更したのかではない(diffはすでに何が変わったかを示している)。
  • footer:メタデータ(breaking change、関連するissue、co-author)。

完全な例:

fix(order): 支払い失敗時のリトライでの重複請求を回避

PaymentService.charge()はidempotency keyを持たないため、
ネットワークタイムアウト後、クライアントがリトライすると、
システムが同じOrderに対してAPIゲートウェイを2回呼び出してしまう。

idempotencyKey = order.getId() + "-" + attemptCountを
リクエストヘッダに追加し、ゲートウェイが自動的にデデュープするようにした。

Closes #482

3.1. subjectの行

Subjectは最も重要な行です。これはgit log --oneline、PR title、changelogに表示されます。ルール:

# 間違い - 大文字で始まり、ピリオドで終わり、過去形を使用
Fixed the bug in order service.

# 正しい - 小文字で始まり、ピリオドなし、現在形を使用
fix: 電子ウォレットでの支払い時に在庫が差し引かれない

注記: 50文字の制限は絶対的なルールではありませんが、72文字を超えると多くのターミナルとGitHub UIで切り詰められます。簡潔さを心がけましょう。

3.2. bodyセクション

Bodyは文脈を説明するために使用します。コード差分では伝わらないもの:なぜこのソリューションを選んだのか、他のソリューションが除外された理由、副作用がないかなど。

refactor(cart): CartServiceを同期から非同期に変更

以前のCartService.checkout()はInventoryServiceと
ShippingServiceを順次呼び出していました。
合計レイテンシ~800ms。CompletableFutureで
2つの呼び出しを並行実行するように変更し、
レイテンシを~450msに短縮しました。

公開APIは変更せず、内部実装のみを変更しました。

Subjectとbodyを1行の空行で区切ります。gitとコミット解析ツールはすべてこの規約に依存しています。

4. Conventional Commits

Conventional Commitsは現在最も一般的な標準であり、ツールが自動的に分類してchangelogを生成できるように、固定されたtypeを定義しています。

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

よく使用されるtype:

Type 意味
feat 新機能の追加
fix バグ修正
docs ドキュメントのみの変更
style コードのフォーマット、ロジックの変更なし
refactor コード構造の変更、動作の変更なし
test テストの追加/修正
chore 小さな作業(ビルド、依存関係、設定)
perf パフォーマンスの改善

scopeがモジュール名である例:

feat(auth): Google OAuth2ログインのサポートを追加

GoogleOAuth2ProviderがOAuth2LoginProviderを実装。
application.propertiesにclient-id、client-secretを追加。

4.1. Breaking change

後方互換性を破壊する変更の場合、type/scopeの後に!を付けるか、footerBREAKING CHANGE:で標記します。

feat(api)!: GET /orders/{id}のレスポンスをOrderDtoに変更

BREAKING CHANGE: フィールド"customerName"をオブジェクト
"customer"(id、name、emailを含む)に変更。
クライアントはパーサーを更新する必要があります。

semantic-releaseとCI/CDツールはBREAKING CHANGE:行に基づいてメジャーバージョンを自動的にバンプします。

5. コミットメッセージの自動検証

良い規約はチームが一貫して従う場合にのみ有効です。commitlintを使用して、pushする前にローカルで形式が間違ったコミットをブロックします。

<!-- Nodeプロジェクトには適用されません。husky-git-hooksにMaven wrapperを使用する場合の依存関係の例のみです -->

実際にはcommitlintはNode上で実行され、npmでインストールされます:

npm install --save-dev @commitlint/cli @commitlint/config-conventional husky

設定ファイルを作成:

// commitlint.config.js
module.exports = { extends: ['@commitlint/config-conventional'] };

Huskyを通じてcommit-msgフックをアタッチ:

npx husky init
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg

形式が間違ったコミットを試してみましょう:

git commit -m "fixed bug"
⧗   input: fixed bug
✖   subject may not be empty [subject-empty]
✖   type must be one of [feat, fix, docs, style, refactor, test, chore, perf]
✖   found 2 problems, 0 warnings
husky - commit-msg script failed (code 1)

コミットは即座にブロックされ、正しい形式に修正してからコミットを作成する必要があります。

5.1. Java/Spring Bootプロジェクトでの使用

プロジェクトがMavenの場合、husky + commitlintを並行して使用できます(Nodeランタイムのみが必要で、Javaビルドには影響しません)。あるいは、純粋なJavaを望む場合は、簡単な検証スクリプトを書いてcore.hooksPath経由で呼び出します:

#!/bin/bash
# .githooks/commit-msg
PATTERN="^(feat|fix|docs|style|refactor|test|chore|perf)(\([a-z-]+\))?: .{1,50}"
MSG=$(cat "$1")

if ! [[ "$MSG" =~ $PATTERN ]]; then
  echo "コミットメッセージがConventional Commits形式に従っていません" >&2
  exit 1
fi
git config core.hooksPath .githooks
chmod +x .githooks/commit-msg

5.2. 形式チェックスクリプトのテスト作成

カスタム検証スクリプトを保守している場合(commitlintを使用していない場合)、正規表現に対するテストが必要です。CI内で再利用するためにparse ロジックをJavaクラスにパッケージ化する場合を想定します:

public class CommitMessageValidator {

    private static final Pattern PATTERN =
        Pattern.compile("^(feat|fix|docs|style|refactor|test|chore|perf)" +
                         "(\\([a-z0-9-]+\\))?: .{1,50}");

    public boolean isValid(String message) {
        String subject = message.lines().findFirst().orElse("");
        return PATTERN.matcher(subject).find();
    }
}
class CommitMessageValidatorTest {

    private final CommitMessageValidator validator = new CommitMessageValidator();

    @Test
    void shouldAcceptConventionalFormat() {
        boolean result = validator.isValid("fix(order): リトライ時の重複請求を回避");

        assertThat(result).isTrue();
    }

    @Test
    void shouldRejectMessageWithoutType() {
        boolean result = validator.isValid("fixed the bug");

        assertThat(result).isFalse();
    }

    @Test
    void shouldRejectUnknownType() {
        boolean result = validator.isValid("update: 何かを追加");

        assertThat(result).isFalse();
    }
}

このテストにより、正規表現を後で修正するときにvalidateロジックがregression(回帰)しないことが保証されます。例えば、新しいtypeを追加したときにテストを更新し忘れていないかなど。

6. よくある間違い

大きすぎるコミット、曖昧なメッセージ。 15の異なるファイルを修正する1つのコミットに“update code”というメッセージを付けるのは、複数の小さなコミットに分割すべきというサイン。各コミットは明確な目的を持つべきです(atomic commit)。

1つのコミットに複数の種類の変更を混ぜる。 同じコミット内で無関係なバグ修正とrefactorを一緒に行うと、git revertとgit bisectが使いにくくなります。修正をrevertすると、refactorも一緒に失われる可能性があります。

bodyがsubjectを繰り返している。 Bodyは文脈を補足すべき。最初の行を長い文で言い換えるべきではありません。

# 繰り返し、無意味
fix(order): 合計金額計算のエラーを修正

OrderServiceの合計金額計算エラーを修正。

絵文字/装飾文字を内容の代わりに使用する。 絵文字は悪くありませんが、実際の説明の代わりに使用すると、自動生成されたchangelogが情報を失います。

注記: Conventional Commitsは、CI/CDで自動的にchangelogを生成したり、semantic versioningを行うチームに適しています。チームが小さく、コミット数が少ない場合、ルールを厳しく適用すると不要な摩擦が生じる可能性があります。機械的な形式の遵守よりも、明確さを優先してください。

7. まとめ

良いコミットメッセージは長である必要はなく、正しい構造が必要なだけです:現在形の簡潔なsubject、理由を説明するbody、必要なメタデータを記載するfooter。Conventional Commitsを適用することで、チームがgitの履歴をすばやく読むことができ、ツールがchangelogとversioningを自動化できます。commitlintまたはカスタムgit hookを設定して、規約を自動的な制約に変え、個々のチームメンバーの意識に依存することを避けましょう。

gitconventional-commitsbest-practicescommitlintdevops