0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Lefthookでgit diffを解析して意味のあるコミットメッセージを自動生成する

Last updated at Posted at 2025-12-15

.

この記事は🎅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*\\(/);

留意点・デメリット

⚠️ 注意点

  1. 完璧ではない

    • 関数名の検出は正規表現ベース
    • コメント内の関数定義を誤検出する可能性あり
  2. 編集は必須

    • 自動生成はあくまで「たたき台」
    • 必要に応じて編集してから commit
  3. キーワード依存

    • 日本語コメントがないと機能検出が難しい
    • プロジェクトに合わせてカスタマイズが必要

✅ 対策

// コメントを除外する処理を追加
if (funcMatch && !line.includes('//') && !line.includes('/*')) {
  changes.addedFunctions.push(funcName);
}

まとめ

この記事で実現したこと

✅ git diffを解析して関数名・機能を自動検出
✅ npm経由でチーム全体に展開可能な仕組み
✅ コピペで動くコード付き

次のステップ

  1. 導入してみる: まずはシンプル版から試してみる
  2. カスタマイズ: プロジェクトに合わせてキーワード追加
  3. チームで共有: .gitignoreに注意しつつpush

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?