CommitGen — 핵심 구현
이전 글 요약
1편에서 CommitGen의 설계를 잡았다. git diff → AI 분석 → 인터랙티브 확인 → 자동 커밋. 이번 편에서는 실제 구현 코드를 뜯어본다.
메인 플로우: 9단계 파이프라인
src/index.js의 run() 함수가 전체 흐름을 관장한다. 9단계로 나뉜다.
1. Git 저장소 확인
2. Staged 변경사항 조회
3. 파일 목록 출력
4. 설정 병합 (CLI + config + defaults)
5. Diff 획득 및 truncate
6. AI 프롬프트 빌드
7. AI 호출 (스피너)
8. 결과 출력
9. 인터랙티브 메뉴Step 1-2: 사전 검증
// src/index.js
export async function run(cliOptions = {}) {
if (!isGitRepo()) {
console.log(chalk.red('\n❌ Not a git repository.\n'));
process.exit(1);
}
const files = getStagedFiles();
if (files.length === 0) {
console.log(chalk.yellow('\n⚠️ No staged changes found.'));
console.log(chalk.dim(' Stage your changes first: git add <files>\n'));
process.exit(1);
}
}
fail-fast 전략이다. git 저장소가 아니거나 staged 파일이 없으면 즉시 종료한다. 에러 메시지에 해결 방법(git add)을 같이 알려주는 게 포인트다.
Step 3: 컬러 코딩된 파일 목록
files.forEach((f) => {
const color = {
added: 'green',
modified: 'yellow',
deleted: 'red',
renamed: 'cyan',
}[f.status] || 'white';
console.log(chalk[color](` ${f.status.padEnd(10)} ${f.path}`));
});
git status와 비슷한 색상 체계를 사용했다. 추가는 초록, 수정은 노랑, 삭제는 빨강. padEnd(10)으로 status 문자열을 정렬해서 파일 경로가 깔끔하게 줄맞춤된다.
출력 결과:
📋 Staged Changes:
added src/utils/cache.js
modified src/index.js
deleted src/old-module.js
renamed src/helper.jsStep 4: 설정 병합 (Cascading Config)
const config = loadConfig();
const provider = cliOptions.provider || config.provider || 'openai';
const model = cliOptions.model || config.model;
const language = cliOptions.lang || config.language || 'en';
const emoji = cliOptions.emoji !== false && config.emoji !== false;
3단계 우선순위: CLI 옵션 > 설정 파일 > 기본값. emoji만 특별하다. --no-emoji 플래그가 Commander.js에서 options.emoji = false로 변환되기 때문에, 양쪽 모두 false가 아닐 때만 활성화된다.
git diff 파싱 구현
child_process로 git 명령 실행
외부 라이브러리(simple-git 등) 없이 child_process.execSync를 직접 사용했다. 이유는 단순하다 — 사용하는 git 명령어가 4개뿐이다.
// src/utils/gitDiff.js
import { execSync } from 'child_process';
export function getStagedDiff() {
try {
const diff = execSync('git diff --staged', {
encoding: 'utf-8',
maxBuffer: 1024 * 1024 * 10, // 10MB
});
return diff.trim();
} catch {
return '';
}
}
maxBuffer: 10MB로 설정한 이유가 있다. Node.js 기본값은 1MB인데, 대규모 리팩토링의 diff는 이를 쉽게 초과한다. 10MB면 웬만한 커밋은 처리 가능하다.
커밋 실행과 셸 이스케이핑
커밋 메시지에 특수문자가 포함될 수 있으므로 이스케이핑이 필수다.
export function executeCommit(message) {
try {
execSync(`git commit -m ${escapeShellArg(message)}`, {
encoding: 'utf-8',
stdio: 'pipe',
});
return true;
} catch (error) {
throw new Error(`Commit failed: ${error.message}`);
}
}
function escapeShellArg(arg) {
return `"${arg.replace(/"/g, '\\"')}"`;
}
stdio: 'pipe'로 설정해서 git의 출력이 터미널에 직접 찍히지 않게 했다. CommitGen이 자체 성공 메시지를 출력하기 때문이다.
AIKit 연동 구현
AI 호출부
// src/utils/ai.js
import AIKit from '@lukaplayground/aikit';
const DEFAULT_MODELS = {
openai: 'gpt-4o-mini',
claude: 'claude-3-5-sonnet-20241022',
gemini: 'gemini-1.5-flash',
};
export async function generateCommitMessage(prompt, options = {}) {
const { provider = 'openai', model } = options;
const apiKey = getApiKey(provider);
if (!apiKey) {
throw new Error(
`No API key found for "${provider}".\n` +
`Run "commitgen config" to set up, or set the environment variable:\n` +
` - openai: OPENAI_API_KEY\n` +
` - claude: ANTHROPIC_API_KEY\n` +
` - gemini: GEMINI_API_KEY`
);
}
const ai = new AIKit({
provider,
apiKey,
options: {
model: model || DEFAULT_MODELS[provider],
temperature: 0.3,
maxTokens: 500,
},
enableCache: false,
enableCostTracking: false,
});
const response = await ai.chat(prompt);
return cleanMessage(response.text);
}
설정 포인트 몇 가지:
| 설정 | 값 | 이유 |
|---|---|---|
| temperature | 0.3 | 커밋 메시지는 일관적이어야 한다. 창의성보다 정확성 |
| maxTokens | 500 | 커밋 메시지가 500토큰을 넘을 일이 없다 |
| enableCache | false | 같은 diff라도 매번 새로 생성해야 한다 |
| enableCostTracking | false | CLI 도구에서 비용 추적은 불필요 |
AI 응답 정제
AI가 가끔 마크다운 코드 블록으로 감싸거나 따옴표를 붙이는 경우가 있다. 프롬프트에서 "하지 마"라고 해도 100%는 아니다.
function cleanMessage(text) {
let cleaned = text.trim();
// ````을 제거
cleaned = cleaned.replace(/^```[\w]*\n?/gm, '').replace(/\n?```$/gm, '');
// 앞뒤 따옴표 제거
cleaned = cleaned.replace(/^["']|["']$/g, '');
return cleaned.trim();
}
프롬프트 엔지니어링 + 후처리 정제의 이중 방어다.
프롬프트 엔지니어링 상세
프롬프트에 넣는 3가지 컨텍스트
AI에게 diff만 던지면 안 된다. 3가지 컨텍스트를 함께 제공한다.
// src/utils/prompt.js
export function buildPrompt(diff, files, stats, options = {}) {
const filesSummary = files
.map((f) => ` ${f.status}: ${f.path}`)
.join('\n');
return `...
## Changed Files
${filesSummary}
## Diff Stats
${stats}
## Diff Content
\`\`\`
${diff}
\`\`\`
...`;
}
| 컨텍스트 | 제공 이유 |
|---|---|
| Changed Files | 전체 변경 범위를 먼저 파악 (숲) |
| Diff Stats | 변경 규모 판단 (insertions/deletions) |
| Diff Content | 실제 코드 변경 내용 (나무) |
파일 목록 → 통계 → 상세 diff 순서로 배치한 이유가 있다. AI가 큰 그림을 먼저 잡고 세부 사항을 분석하도록 유도하는 구조다.
조건부 프롬프트 생성
언어와 이모지 옵션에 따라 프롬프트가 달라진다.
const langInstruction =
language === 'ko'
? 'Write the commit message in Korean. The type prefix (feat, fix, etc.) should remain in English.'
: 'Write the commit message in English.';
const emojiInstruction = emoji
? 'Include an appropriate emoji at the beginning of the subject line (e.g., ✨ feat, 🐛 fix, ♻️ refactor, 📝 docs, 🧪 test, 🔧 chore, 🎨 style, ⚡ perf).'
: 'Do NOT include any emoji.';
이 방식의 장점은 조건 분기가 프롬프트 빌드 시점에 한 번만 발생한다는 것이다. AI 호출 코드에는 분기가 없다.
인터랙티브 UX 구현
스피너 애니메이션
AI 호출은 1~3초 걸린다. 사용자에게 "작업 중"임을 알려야 한다.
import ora from 'ora';
const spinner = ora({
text: `Analyzing with ${chalk.cyan(provider)}...`,
spinner: 'dots',
}).start();
try {
message = await generateCommitMessage(prompt, { provider, model });
spinner.succeed('Commit message generated!');
} catch (error) {
spinner.fail('Failed to generate commit message');
console.log(chalk.red(`\n${error.message}\n`));
process.exit(1);
}
spinner.succeed()와 spinner.fail()로 성공/실패 상태를 명확히 표시한다.
4가지 후속 액션
const { action } = await inquirer.prompt([{
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: '✅ Commit with this message', value: 'commit' },
{ name: '✏️ Edit message before committing', value: 'edit' },
{ name: '🔄 Regenerate message', value: 'regenerate' },
{ name: '❌ Cancel', value: 'cancel' },
],
}]);
각 액션의 구현:
switch (action) {
case 'commit':
await doCommit(message);
break;
case 'edit': {
const { edited } = await inquirer.prompt([{
type: 'editor',
name: 'edited',
message: 'Edit commit message:',
default: message,
}]);
if (edited.trim()) {
await doCommit(edited.trim());
} else {
console.log(chalk.yellow('\nEmpty message — commit cancelled.\n'));
}
break;
}
case 'regenerate':
await run(cliOptions); // 재귀 호출
break;
case 'cancel':
console.log(chalk.dim('\nCommit cancelled.\n'));
break;
}
"편집"은 Inquirer.js의 type: 'editor'를 사용한다. 시스템의 기본 에디터(vim, nano 등)가 열리면서 메시지를 수정할 수 있다. default: message로 AI가 생성한 메시지를 초기값으로 넣어준다.
"재생성"은 run(cliOptions)를 재귀 호출한다. 같은 옵션으로 전체 플로우를 다시 실행하므로 AI가 새로운 메시지를 생성한다 (temperature 0.3이라 비슷할 수 있지만, 동일하진 않다).
설정 마법사 구현
commitgen config 명령은 인터랙티브 설정 마법사를 실행한다.
// src/commands/configure.js
export async function configure() {
const config = loadConfig();
const answers = await inquirer.prompt([
{
type: 'list',
name: 'provider',
message: 'Default AI provider:',
choices: [
{ name: 'OpenAI (GPT-4o-mini)', value: 'openai' },
{ name: 'Claude (Sonnet 3.5)', value: 'claude' },
{ name: 'Gemini (1.5 Flash)', value: 'gemini' },
],
default: config.provider,
},
// ...
]);
}
API 키 마스킹
기존에 저장된 API 키가 있으면 마스킹해서 보여준다.
const maskedKey = currentKey
? `${currentKey.substring(0, 8)}...${currentKey.slice(-4)}`
: 'not set';
const { key } = await inquirer.prompt([{
type: 'password',
name: 'key',
message: `${providerNames[provider]} API key (${maskedKey}):`,
mask: '*',
}]);
type: 'password'와 mask: '*'로 입력 시 키가 화면에 노출되지 않는다. 기존 키는 앞 8자리와 뒤 4자리만 보여줘서 어떤 키인지 식별할 수 있게 했다.
Before / After 비교
커밋 메시지 작성 과정
| 항목 | Before (수동) | After (CommitGen) |
|---|---|---|
| diff 분석 | 직접 git diff 읽기 |
AI가 자동 분석 |
| type 결정 | feat? fix? refactor? 고민 | AI가 자동 분류 |
| scope 결정 | 어떤 모듈인지 판단 | AI가 파일 경로에서 추론 |
| 메시지 작성 | 직접 타이핑 | AI 생성 + 편집 가능 |
| 소요 시간 | 1~5분 | 3~5초 |
코드량 비교
| 컴포넌트 | 줄 수 | 역할 |
|---|---|---|
| bin/commitgen.js | 38 | CLI 진입점, 명령어 라우팅 |
| src/index.js | 142 | 메인 플로우 오케스트레이션 |
| src/commands/configure.js | 100 | 설정 마법사 |
| src/utils/ai.js | 57 | AIKit 연동 |
| src/utils/config.js | 70 | 설정 파일 관리 |
| src/utils/gitDiff.js | 107 | git 명령 래퍼 |
| src/utils/prompt.js | 45 | 프롬프트 빌더 |
| 합계 | 559 |
559줄로 멀티 프로바이더 AI 커밋 메시지 생성기가 완성된다. AIKit이 AI API 호출의 복잡성을 흡수해주기 때문에 가능한 수치다.
삽질 기록
1. Commander.js의 --no-emoji 처리
Commander.js에서 --no- 프리픽스 옵션은 특별하게 동작한다. --no-emoji를 정의하면 자동으로 options.emoji = false가 된다. 처음에 이걸 몰라서 --emoji false 같은 방식으로 구현하려다 삽질했다.
// 이렇게 하면 --no-emoji가 자동으로 options.emoji = false
.option('--no-emoji', 'disable emoji in commit type')
// 이렇게 하면 안 됨
// .option('--emoji <boolean>', '...') — 문자열 "false"가 들어옴
2. ESM에서 package.json 읽기
ESM 모듈에서는 require가 없다. package.json의 version을 읽으려면 createRequire를 써야 한다.
// ESM에서 require 사용하기
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
import pkg from '../package.json' assert { type: 'json' }도 가능하지만, 아직 실험적 기능이라 createRequire가 더 안정적이다.
3. AI 응답에서 마크다운 래핑
프롬프트에 "Do NOT wrap the result in markdown code blocks"라고 명시해도, 일부 모델(특히 GPT 계열)이 가끔 ```으로 감싸는 경우가 있었다. 프롬프트만으로 100% 방지가 불가능해서 cleanMessage() 후처리를 추가했다.
마무리
이번 편에서 CommitGen의 핵심 구현을 다뤘다. Commander.js CLI 설정, git diff 파싱, AIKit 연동, 프롬프트 엔지니어링, Inquirer.js 인터랙션까지.
다음 편에서는 NPM 패키지 배포 과정과 실제 사용 사례, 그리고 전체 프로젝트 회고를 다룬다.
기술 스택: Node.js, AIKit, Commander.js, Inquirer.js, Chalk, Ora
소스 코드: GitHub
'프로젝트 > CLI 도구' 카테고리의 다른 글
| CommitGen #3 - NPM 배포 & 회고 (0) | 2026.02.13 |
|---|---|
| CommitGen #1 - 왜 커밋 메시지 생성기를 만들었나 (0) | 2026.02.12 |