본문 바로가기
프로젝트/CLI 도구

CommitGen #2 - 핵심 구현

by 루까(Luka) 2026. 2. 13.
반응형

CommitGen — 핵심 구현

이전 글 요약

1편에서 CommitGen의 설계를 잡았다. git diff → AI 분석 → 인터랙티브 확인 → 자동 커밋. 이번 편에서는 실제 구현 코드를 뜯어본다.


메인 플로우: 9단계 파이프라인

src/index.jsrun() 함수가 전체 흐름을 관장한다. 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.js

Step 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

반응형