claude-auto-commit
Version:
AI-powered Git commit message generator using Claude Code SDK
575 lines (489 loc) • 19.1 kB
JavaScript
import { query } from "@anthropic-ai/claude-code";
import { exec } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
const execAsync = promisify(exec);
class ClaudeAutoCommit {
constructor(options = {}) {
this.language = options.language || 'en';
this.useEmoji = options.useEmoji || false;
this.conventionalCommit = options.conventionalCommit || false;
this.commitType = options.commitType || '';
this.dryRun = options.dryRun || false;
this.verbose = options.verbose || false;
this.push = options.push || false;
this.templateName = options.templateName || '';
this.maxRetries = options.maxRetries || 3;
this.timeout = options.timeout || 30000;
// Cache for git command results to avoid duplicate calls
this._gitCache = {};
// Cache for config file to avoid repeated filesystem access
this._configCache = null;
this._configCacheTime = 0;
this.CONFIG_CACHE_TTL = 5 * 60 * 1000; // 5分間キャッシュ
}
// パフォーマンス測定ユーティリティ
async measure(name, fn) {
if (!this.verbose) {
return await fn();
}
const start = process.hrtime.bigint();
const result = await fn();
const end = process.hrtime.bigint();
const duration = Number(end - start) / 1e6; // ミリ秒
console.log(`⏱️ ${name}: ${duration.toFixed(2)}ms`);
return result;
}
async loadConfig() {
try {
// Check cache first
const now = Date.now();
if (this._configCache && (now - this._configCacheTime) < this.CONFIG_CACHE_TTL) {
if (this.verbose) {
console.log('📄 Configuration loaded from cache');
}
return this._configCache;
}
const configPath = path.join(os.homedir(), '.claude-auto-commit', 'config.json');
const configExists = await fs.access(configPath).then(() => true).catch(() => false);
if (configExists) {
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
// Cache the config
this._configCache = config;
this._configCacheTime = now;
// 設定ファイルからデフォルト値を適用
this.language = this.language || config.language || 'en';
this.useEmoji = this.useEmoji || config.useEmoji || false;
this.conventionalCommit = this.conventionalCommit || config.conventionalCommit || false;
this.verbose = this.verbose || config.verbose || false;
if (this.verbose) {
console.log('📄 Configuration loaded from:', configPath);
}
return config;
}
} catch (error) {
if (this.verbose) {
console.log('⚠️ No configuration file found, using defaults');
}
}
return null;
}
async saveTemplate(name, message) {
try {
const templatesDir = path.join(os.homedir(), '.claude-auto-commit', 'templates');
await fs.mkdir(templatesDir, { recursive: true });
const templatePath = path.join(templatesDir, `${name}.txt`);
await fs.writeFile(templatePath, message, 'utf8');
console.log(`💾 Template saved: ${name}`);
} catch (error) {
console.error(`❌ Failed to save template: ${error.message}`);
}
}
async loadTemplate(name) {
try {
const templatePath = path.join(os.homedir(), '.claude-auto-commit', 'templates', `${name}.txt`);
const template = await fs.readFile(templatePath, 'utf8');
return template.trim();
} catch (error) {
throw new Error(`Template "${name}" not found`);
}
}
async listTemplates() {
try {
const templatesDir = path.join(os.homedir(), '.claude-auto-commit', 'templates');
const files = await fs.readdir(templatesDir);
return files.filter(f => f.endsWith('.txt')).map(f => f.replace('.txt', ''));
} catch (error) {
return [];
}
}
async checkGitRepository() {
try {
await execAsync('git rev-parse --git-dir');
return true;
} catch (error) {
throw new Error('Not a git repository. Please run this command in a git repository.');
}
}
async checkForChanges() {
try {
const { stdout: status } = await execAsync('git status --porcelain', { maxBuffer: 1024 * 1024 });
// Cache the status result for potential reuse in getGitChanges()
this._gitCache.status = status;
return status.trim().length > 0;
} catch (error) {
throw new Error(`Failed to check git status: ${error.message}`);
}
}
async getGitChanges() {
try {
// Use cached status if available, otherwise execute all commands in parallel
let statusPromise;
if (this._gitCache.status !== undefined) {
statusPromise = Promise.resolve({ stdout: this._gitCache.status });
} else {
statusPromise = execAsync('git status --porcelain', { maxBuffer: 1024 * 1024 });
}
// Execute git commands in parallel for better performance
const [statusResult, branchResult, diffResult, diffUnstagedResult] = await Promise.all([
statusPromise,
execAsync('git branch --show-current', { maxBuffer: 1024 * 1024 }),
execAsync('git diff --cached --name-only', { maxBuffer: 1024 * 1024 }),
execAsync('git diff --name-only', { maxBuffer: 1024 * 1024 })
]);
const status = statusResult.stdout;
const branch = branchResult.stdout;
const diff = diffResult.stdout;
const diffUnstaged = diffUnstagedResult.stdout;
// Cache branch result for potential reuse in pushChanges()
this._gitCache.branch = branch;
if (!status.trim()) {
throw new Error('No changes detected');
}
let changes = `Branch: ${branch.trim()}\n\nStatus:\n${status}\n\n`;
// Prepare promises for stats commands if needed
const statsPromises = [];
const statsTypes = [];
if (diff.trim()) {
changes += `Staged files:\n${diff}\n`;
statsPromises.push(execAsync('git diff --cached --stat', { maxBuffer: 1024 * 1024 }));
statsTypes.push('staged');
}
if (diffUnstaged.trim()) {
changes += `Unstaged files:\n${diffUnstaged}\n`;
statsPromises.push(execAsync('git diff --stat', { maxBuffer: 1024 * 1024 }));
statsTypes.push('unstaged');
}
// Execute stats commands in parallel if any
if (statsPromises.length > 0) {
const statsResults = await Promise.allSettled(statsPromises);
statsResults.forEach((result, index) => {
const type = statsTypes[index];
if (result.status === 'fulfilled') {
if (type === 'staged') {
changes += `\nStaged changes summary:\n${result.value.stdout}\n\n`;
} else {
changes += `\nUnstaged changes summary:\n${result.value.stdout}\n`;
}
} else {
if (type === 'staged') {
changes += `\nStaged changes: (too large to display)\n\n`;
} else {
changes += `\nUnstaged changes: (too large to display)\n`;
}
}
});
}
// 大きすぎる場合は切り詰める
if (changes.length > 4000) {
changes = changes.substring(0, 4000) + '\n... (truncated for brevity)';
}
return changes;
} catch (error) {
throw new Error(`Failed to get git changes: ${error.message}`);
}
}
async generateCommitMessage(changes) {
const prompt = this.buildPrompt(changes);
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
if (this.verbose) {
console.log(`🤖 Generating commit message (attempt ${attempt}/${this.maxRetries})...`);
}
const messages = [];
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), this.timeout);
for await (const message of query({
prompt: prompt,
abortController,
options: {
maxTurns: 1,
},
})) {
messages.push(message);
}
clearTimeout(timeoutId);
// 結果を取得
const resultMessage = messages.find(msg => msg.type === 'result');
if (resultMessage && resultMessage.result) {
return resultMessage.result.trim();
}
// assistantメッセージからも試行
const assistantMessage = messages.find(msg => msg.type === 'assistant');
if (assistantMessage && assistantMessage.message && assistantMessage.message.content) {
const content = assistantMessage.message.content;
if (Array.isArray(content) && content[0] && content[0].text) {
return content[0].text.trim();
}
}
throw new Error('No valid response received from Claude');
} catch (error) {
if (error.name === 'AbortError') {
console.log(`⏱️ Attempt ${attempt} timed out`);
} else {
console.log(`❌ Attempt ${attempt} failed: ${error.message}`);
}
if (attempt === this.maxRetries) {
throw new Error(`Failed to generate commit message after ${this.maxRetries} attempts: ${error.message}`);
}
// 指数バックオフで待機
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
console.log(`⏳ Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
buildPrompt(changes) {
let prompt;
if (this.language === 'ja') {
prompt = `以下のGit変更内容に基づいて、適切なコミットメッセージを生成してください。`;
if (this.conventionalCommit) {
prompt += ` Conventional Commits形式(例:feat:, fix:, docs:, style:, refactor:, test:, chore:)を使用してください。`;
if (this.commitType) {
prompt += ` コミットタイプは "${this.commitType}" を使用してください。`;
}
}
if (this.useEmoji) {
prompt += ` 適切な絵文字を含めてください。`;
}
prompt += ` コミットメッセージのみを出力してください。説明や追加のテキストは不要です。`;
} else {
prompt = `Generate an appropriate git commit message based on the following changes.`;
if (this.conventionalCommit) {
prompt += ` Use Conventional Commits format (e.g., feat:, fix:, docs:, style:, refactor:, test:, chore:).`;
if (this.commitType) {
prompt += ` Use "${this.commitType}" as the commit type.`;
}
}
if (this.useEmoji) {
prompt += ` Include appropriate emojis.`;
}
prompt += ` Output only the commit message. No explanation or additional text needed.`;
}
prompt += `\n\nChanges:\n${changes}`;
return prompt;
}
async stageAllChanges() {
try {
await execAsync('git add .');
if (this.verbose) {
console.log('📁 All changes staged');
}
} catch (error) {
throw new Error(`Failed to stage changes: ${error.message}`);
}
}
async createCommit(message) {
try {
// メッセージをエスケープ
const escapedMessage = message.replace(/"/g, '\\"').replace(/\$/g, '\\$');
await execAsync(`git commit -m "${escapedMessage}"`);
return true;
} catch (error) {
throw new Error(`Failed to create commit: ${error.message}`);
}
}
async pushChanges() {
try {
// Use cached branch if available, otherwise get it
let currentBranch;
if (this._gitCache.branch !== undefined) {
currentBranch = this._gitCache.branch.trim();
} else {
const { stdout: branch } = await execAsync('git branch --show-current');
currentBranch = branch.trim();
}
console.log(`🚀 Pushing to ${currentBranch}...`);
await execAsync(`git push origin ${currentBranch}`);
console.log('✅ Changes pushed successfully!');
} catch (error) {
console.error(`❌ Failed to push changes: ${error.message}`);
console.log('💡 You may need to push manually: git push');
}
}
async showStatistics() {
try {
const { stdout: logOutput } = await execAsync('git log --oneline -10');
console.log('\n📊 Recent commits:');
console.log(logOutput);
} catch (error) {
if (this.verbose) {
console.log('⚠️ Could not retrieve commit history');
}
}
}
async run() {
const totalStart = this.verbose ? process.hrtime.bigint() : null;
try {
console.log('🚀 Claude Auto Commit (SDK Version v0.1.0)');
// 設定読み込みとgitリポジトリチェックを並列実行
const [config] = await this.measure('Config and Git setup', async () => {
return await Promise.all([
this.loadConfig(),
this.checkGitRepository()
]);
});
const hasChanges = await this.measure('Change detection', () => this.checkForChanges());
if (!hasChanges) {
console.log('✨ No changes detected. Repository is clean.');
return;
}
await this.measure('Staging changes', () => this.stageAllChanges());
const changes = await this.measure('Git changes analysis', () => this.getGitChanges());
if (this.verbose) {
console.log('🔍 Analyzing changes with Claude Code SDK...');
}
// テンプレート使用の場合
let commitMessage;
if (this.templateName) {
try {
commitMessage = await this.loadTemplate(this.templateName);
console.log(`📋 Using template: ${this.templateName}`);
} catch (error) {
console.log(`⚠️ ${error.message}, generating new message...`);
commitMessage = await this.measure('Commit message generation', () => this.generateCommitMessage(changes));
}
} else {
commitMessage = await this.measure('Commit message generation', () => this.generateCommitMessage(changes));
}
console.log(`\n📝 Generated commit message:`);
console.log(`"${commitMessage}"`);
if (this.dryRun) {
console.log('\n🔍 Dry run mode - commit not created');
// テンプレート保存オプション
if (process.argv.includes('--save-template')) {
const templateName = process.argv[process.argv.indexOf('--save-template') + 1];
if (templateName) {
await this.saveTemplate(templateName, commitMessage);
}
}
return;
}
await this.createCommit(commitMessage);
console.log('\n✅ Commit created successfully!');
if (this.push) {
await this.pushChanges();
}
if (this.verbose) {
await this.showStatistics();
// 総実行時間を表示
if (totalStart) {
const totalEnd = process.hrtime.bigint();
const totalDuration = Number(totalEnd - totalStart) / 1e6; // ミリ秒
console.log(`\n⏱️ Total execution time: ${totalDuration.toFixed(2)}ms`);
}
}
} catch (error) {
console.error(`❌ Error: ${error.message}`);
if (this.verbose) {
console.error('Stack trace:', error.stack);
}
process.exit(1);
}
}
}
function parseArgs() {
const args = process.argv.slice(2);
const options = {};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '-l':
case '--language':
options.language = args[++i];
break;
case '-e':
case '--emoji':
options.useEmoji = true;
break;
case '-c':
case '--conventional':
options.conventionalCommit = true;
break;
case '-t':
case '--type':
options.commitType = args[++i];
options.conventionalCommit = true; // 自動的にconventional形式を有効化
break;
case '-d':
case '--dry-run':
options.dryRun = true;
break;
case '-v':
case '--verbose':
options.verbose = true;
break;
case '-p':
case '--push':
options.push = true;
break;
case '--template':
options.templateName = args[++i];
break;
case '--save-template':
// 引数を消費しない(run()メソッドで処理)
break;
case '--list-templates':
(async () => {
const autoCommit = new ClaudeAutoCommit();
const templates = await autoCommit.listTemplates();
console.log('📋 Available templates:');
if (templates.length === 0) {
console.log(' No templates found');
} else {
templates.forEach(template => console.log(` - ${template}`));
}
process.exit(0);
})();
return;
case '--version':
console.log('Claude Auto Commit v0.1.3');
process.exit(0);
case '-h':
case '--help':
console.log(`
Claude Auto Commit (SDK Version v0.1.3)
Usage: node src/claude-auto-commit.js [options]
Options:
-l, --language <lang> Language for commit message (en, ja)
-e, --emoji Include emojis in commit message
-c, --conventional Use Conventional Commits format
-t, --type <type> Specify commit type (feat, fix, docs, etc.)
-d, --dry-run Preview commit message without creating commit
-v, --verbose Verbose output
-p, --push Push changes after commit
--template <name> Use saved template
--save-template <name> Save generated message as template (dry-run only)
--list-templates List available templates
--version Show version information
-h, --help Show this help message
Examples:
node src/claude-auto-commit.js
node src/claude-auto-commit.js -l ja -e -c
node src/claude-auto-commit.js -t feat --push
node src/claude-auto-commit.js --dry-run --save-template my-template
node src/claude-auto-commit.js --template my-template
Configuration:
Create ~/.claude-auto-commit/config.json for default settings:
{
"language": "ja",
"useEmoji": true,
"conventionalCommit": true,
"verbose": false
}
`);
process.exit(0);
break;
}
}
return options;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const options = parseArgs();
const autoCommit = new ClaudeAutoCommit(options);
autoCommit.run();
}
export default ClaudeAutoCommit;