.
この記事は🎅GMOペパボ エンジニア Advent Calendar 2025 15日目の記事です。
.
はじめに
この記事で得られること
- ✅ git diffを解析して詳細なコミットメッセージを自動生成する方法
- ✅ Lefthook + Node.jsでの実装方法(コピペで動くコード付き)
対象読者
- コミットメッセージを書くのが面倒な人
- 「ファイルを更新」「修正」など曖昧なメッセージを書いてしまう人
- チーム開発でコミットメッセージの質を向上させたい人
解決したい課題
よくあるコミットメッセージの問題
こんなメッセージを書いていませんか?
❌ えいや
❌ fix
❌ 修正
❌ WIP
何が問題か:
- 後から見返しても何をしたか分からない
- git logが役に立たない
- レビュアーが変更内容を理解しづらい
→ 何をしたか全く分からない!
理想のコミットメッセージとは
✅ 変更内容が一目で分かる
✅ 追加された機能や関数が明記されている
✅ 変更ファイルが分かる
例:
feat: タスク追加機能を実装
- addTask関数を追加
- タスクIDの自動採番機能を実装
追加された関数:
- addTask()
- generateId()
変更ファイル:
- src/todo.js (変更)
解決策:Lefthookでコミットメッセージを自動生成
なぜLefthookか
他のツールとの比較:
| ツール | 特徴 | 採用理由 |
|---|---|---|
| husky | Node.js専用、設定が複雑 | ❌ |
| pre-commit | Python依存 | ❌ |
| Lefthook | 言語非依存、高速(Go製) | ✅ |
Lefthookの利点:
- npm経由で管理できる(グローバルインストール不要)
- 設定ファイル1つ(lefthook.yml)で完結
- チーム全員が
npm installだけでセットアップ完了
自動生成の仕組み
git commit 実行
↓
Lefthookのprepare-commit-msgフックが起動
↓
Node.jsスクリプトが実行
↓
git diffを解析
↓
コミットメッセージを自動生成
↓
エディタに表示(編集可能)
実装方法
ステップ1: Lefthookのインストール
# package.json初期化(既存プロジェクトならスキップ)
npm init -y
# Lefthookをインストール
npm install --save-dev lefthook
package.jsonに自動インストールを追加:
{
"scripts": {
"prepare": "lefthook install"
}
}
これでチームメンバーがnpm installするだけでフックが自動設定されます。
ステップ2: lefthook.ymlの設定
プロジェクトルートにlefthook.ymlを作成:
prepare-commit-msg:
commands:
generate-commit-message:
run: node scripts/generate-commit-message.js {1}
{1}はコミットメッセージファイルのパスです。
ステップ3: 自動生成スクリプトの実装
🔰 バージョン1:シンプル版(最小構成)
まずは基本的な実装から:
// scripts/generate-commit-message.js
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const commitMsgFile = process.argv[2];
// 既存メッセージがあればスキップ
const existingMsg = fs.readFileSync(commitMsgFile, 'utf8').trim();
if (existingMsg && !existingMsg.startsWith('#')) {
process.exit(0);
}
// ステージングされた変更を取得
const diff = execSync('git diff --cached --name-status', { encoding: 'utf8' });
if (!diff.trim()) {
console.log('ステージングされた変更がありません');
process.exit(0);
}
// ファイルタイプを判定して簡易メッセージを生成
const lines = diff.trim().split('\n');
const hasNewFiles = lines.some(l => l.startsWith('A'));
let message;
if (hasNewFiles) {
message = 'feat: 新規ファイルを追加';
} else {
message = 'update: ファイルを更新';
}
fs.writeFileSync(commitMsgFile, message + '\n\n' + existingMsg);
console.log('✓ コミットメッセージを自動生成しました');
動作確認:
git add .
git commit
# → 「feat: 新規ファイルを追加」が自動生成される
⚡ バージョン2:改善版(git diff解析)
問題点:
- 「ファイルを更新」では何をしたか分からない
- 追加された関数が分からない
解決策:
git diffの内容を解析して、追加された関数や機能を検出します。
📄 改善版スクリプト全文(クリックで展開)
// scripts/generate-commit-message.js(改善版)
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const commitMsgFile = process.argv[2];
// 既存メッセージチェック
const existingMsg = fs.readFileSync(commitMsgFile, 'utf8').trim();
if (existingMsg && !existingMsg.startsWith('#')) {
process.exit(0);
}
try {
// ファイル名とステータスを取得
const nameStatus = execSync('git diff --cached --name-status', { encoding: 'utf8' });
if (!nameStatus.trim()) {
console.log('ステージングされた変更がありません');
process.exit(0);
}
// 実際の変更内容を取得
const diffContent = execSync('git diff --cached --unified=3', { encoding: 'utf8' });
// 変更内容を解析
const changes = parseChanges(nameStatus, diffContent);
// メッセージを生成
const message = generateCommitMessage(changes);
// ファイルに書き込み
fs.writeFileSync(commitMsgFile, message + '\n\n' + existingMsg);
console.log('✓ コミットメッセージを自動生成しました');
console.log(` ${message.split('\\n')[0]}`);
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}
/**
* 変更内容を解析
*/
function parseChanges(nameStatus, diffContent) {
const lines = nameStatus.trim().split('\\n');
const changes = {
added: [],
modified: [],
deleted: [],
fileTypes: new Set(),
addedFunctions: [],
addedFeatures: []
};
// ファイルステータスを解析
lines.forEach(line => {
const [status, ...files] = line.split('\\t');
const file = files[0];
switch (status[0]) {
case 'A': changes.added.push(file); break;
case 'M': changes.modified.push(file); break;
case 'D': changes.deleted.push(file); break;
}
detectFileType(file, changes.fileTypes);
});
// diff内容から追加された関数を抽出
analyzeDiffContent(diffContent, changes);
return changes;
}
/**
* diff内容を解析して追加された機能を抽出
*/
function analyzeDiffContent(diffContent, changes) {
const lines = diffContent.split('\\n');
for (let line of lines) {
if (!line.startsWith('+')) continue;
// 関数定義を検出(JavaScript/TypeScript)
const funcMatch = line.match(/^\\+\\s*(?:async\\s+)?(?:function\\s+)?(\\w+)\\s*\\([^)]*\\)\\s*\\{?/);
if (funcMatch && !line.includes('//')) {
const funcName = funcMatch[1];
if (!['if', 'for', 'while', 'switch', 'catch'].includes(funcName)) {
changes.addedFunctions.push(funcName);
}
}
// 機能的なキーワードを検出
if (line.includes('追加') || line.includes('add')) {
changes.addedFeatures.push('追加機能');
}
if (line.includes('一覧') || line.includes('list')) {
changes.addedFeatures.push('一覧表示');
}
if (line.includes('削除') || line.includes('delete')) {
changes.addedFeatures.push('削除機能');
}
}
// 重複削除
changes.addedFunctions = [...new Set(changes.addedFunctions)];
changes.addedFeatures = [...new Set(changes.addedFeatures)];
}
/**
* ファイルタイプを検出
*/
function detectFileType(file, typesSet) {
if (file.match(/\\.(md|txt|rst)$/i)) {
typesSet.add('docs');
} else if (file.match(/\\.(json|yml|yaml)$/i)) {
typesSet.add('config');
} else if (file.match(/\\.(js|ts|jsx|tsx)$/i)) {
typesSet.add('code');
}
}
/**
* コミットメッセージを生成
*/
function generateCommitMessage(changes) {
let prefix = 'chore';
let subject = '';
const details = [];
// ドキュメントの変更
if (changes.fileTypes.has('docs') && changes.fileTypes.size === 1) {
prefix = 'docs';
subject = changes.added.length > 0 ? 'ドキュメントを追加' : 'ドキュメントを更新';
return buildMessage(prefix, subject, details, changes);
}
// 新規ファイル追加のみ
if (changes.added.length > 0 && changes.modified.length === 0) {
prefix = 'feat';
if (changes.addedFeatures.length > 0) {
subject = `${changes.addedFeatures[0]}を実装`;
details.push(...changes.addedFeatures.map(f => `- ${f}を追加`));
} else {
subject = '新規機能を追加';
}
if (changes.addedFunctions.length > 0) {
details.push('');
details.push('追加された関数:');
details.push(...changes.addedFunctions.map(f => `- ${f}()`));
}
return buildMessage(prefix, subject, details, changes);
}
// ファイル変更(機能追加・修正)
if (changes.modified.length > 0) {
if (changes.addedFunctions.length > 0 || changes.addedFeatures.length > 0) {
prefix = 'feat';
if (changes.addedFeatures.length > 0) {
subject = `${changes.addedFeatures.slice(0, 2).join('・')}を実装`;
changes.addedFeatures.forEach(f => details.push(`- ${f}を追加`));
} else {
subject = '機能を追加';
}
if (changes.addedFunctions.length > 0) {
if (details.length > 0) details.push('');
details.push('追加された関数:');
details.push(...changes.addedFunctions.map(f => `- ${f}()`));
}
} else {
prefix = changes.fileTypes.has('config') ? 'chore' : 'refactor';
subject = changes.fileTypes.has('config') ? '設定を更新' : 'コードを改善';
}
return buildMessage(prefix, subject, details, changes);
}
return buildMessage(prefix, '変更を適用', details, changes);
}
/**
* メッセージを構築
*/
function buildMessage(prefix, subject, details, changes) {
const lines = [`${prefix}: ${subject}`];
if (details.length > 0 || changes.added.length > 0 || changes.modified.length > 0) {
lines.push('');
}
if (details.length > 0) {
lines.push(...details);
}
const allChangedFiles = [...changes.added, ...changes.modified, ...changes.deleted];
if (allChangedFiles.length > 0) {
if (details.length > 0) lines.push('');
lines.push('変更ファイル:');
if (changes.added.length > 0) {
changes.added.forEach(f => lines.push(`- ${f} (新規)`));
}
if (changes.modified.length > 0) {
changes.modified.forEach(f => lines.push(`- ${f} (変更)`));
}
if (changes.deleted.length > 0) {
changes.deleted.forEach(f => lines.push(`- ${f} (削除)`));
}
}
return lines.join('\\n');
}
導入効果:Before / After
Before(手動でメッセージを書いていた時代)
git log --oneline
a7f06c6 こんなかんじかな
b5da82b いらん関数あった
490e890 ととのえた
❌ 何をしたか分からない
After(Lefthook導入後)
git log --oneline
0bb3ef3 feat: 追加機能・一覧表示を実装
107485e docs: ドキュメントを追加
8a09387 feat: データ保存・完了機能を実装
✅ 一目で分かる!
詳細を見ると:
git log -1 --format="%B"
feat: 追加機能・一覧表示を実装
- 追加機能を追加
- 一覧表示を追加
追加された関数:
- addTask()
- listTasks()
変更ファイル:
- src/todo.js (変更)
✅ 追加された関数まで明記されている!
カスタマイズポイント
検出キーワードの追加
プロジェクトに合わせてキーワードをカスタマイズできます:
// analyzeDiffContent関数内
if (line.includes('認証') || line.includes('auth')) {
changes.addedFeatures.push('認証機能');
}
if (line.includes('バリデーション') || line.includes('validation')) {
changes.addedFeatures.push('バリデーション');
}
他言語への対応
Python、Ruby、Goなどの関数定義にも対応可能:
// Python
const pythonMatch = line.match(/^\\+\\s*def\\s+(\\w+)\\s*\\(/);
// Ruby
const rubyMatch = line.match(/^\\+\\s*def\\s+(\\w+)/);
// Go
const goMatch = line.match(/^\\+\\s*func\\s+(\\w+)\\s*\\(/);
留意点・デメリット
⚠️ 注意点
-
完璧ではない
- 関数名の検出は正規表現ベース
- コメント内の関数定義を誤検出する可能性あり
-
編集は必須
- 自動生成はあくまで「たたき台」
- 必要に応じて編集してから commit
-
キーワード依存
- 日本語コメントがないと機能検出が難しい
- プロジェクトに合わせてカスタマイズが必要
✅ 対策
// コメントを除外する処理を追加
if (funcMatch && !line.includes('//') && !line.includes('/*')) {
changes.addedFunctions.push(funcName);
}
まとめ
この記事で実現したこと
✅ git diffを解析して関数名・機能を自動検出
✅ npm経由でチーム全体に展開可能な仕組み
✅ コピペで動くコード付き
次のステップ
- 導入してみる: まずはシンプル版から試してみる
- カスタマイズ: プロジェクトに合わせてキーワード追加
-
チームで共有:
.gitignoreに注意しつつpush
参考リンク