시리즈 소개 이 시리즈는 TypeScript로 실전에서 쓸 수 있는 CLI 도구를 직접 만들어보며 CLI Wrapper의 개념과 설계를 익히는 본편 5부작 + 번외편 2부입니다. 이번 편은 Type A Wrapper(기존 CLI 래핑) 를 다룹니다.
git명령을 내부에서 호출하는gitx를 만들면서child_process, shell injection 방어, 명령 체이닝, 대화형 프롬프트까지 커버합니다.
이전 편 복습
3편에서 greet-cli를 완성하면서 Type B Wrapper 의 전형을 익혔습니다. fetch로 외부 API를 호출하고, AbortController로 타임아웃을 걸고, chalk·ora로 UX를 입히고, --json 모드로 다른 도구와 조합 가능한 CLI를 만들었죠.
이번 편은 정반대 세계입니다. 외부 API가 아니라 외부 프로세스를 다룹니다. git이라는 이미 잘 만들어진 CLI를 내부에서 호출해서, 자주 쓰는 워크플로우를 한 줄로 줄이는 gitx를 만들 거예요.
들어가며
Type A Wrapper(기존 CLI 래핑)는 겉보기엔 Type B보다 쉬워 보입니다. “git 명령 실행만 하면 되는데 뭐가 어려워?” 싶죠. 그런데 실제로는 고려할 게 꽤 많습니다.
- 사용자 입력을 shell에 넘기면 보안 구멍이 뚫린다 (shell injection)
git push같은 건 출력을 실시간으로 보여줘야 하는데,git rev-parse는 결과를 캡처해야 한다 (스트리밍 vs 캡처)- 여러 git 명령을 연속으로 실행할 때 중간 실패를 어떻게 다룰지 (체이닝과 원자성)
- upstream이 없는 브랜치, 충돌, 빈 변경사항 같은 git의 온갖 실패 모드를 사용자에게 친절하게 전달
이번 편에서는 이 모든 걸 gitx라는 도구를 만들며 해결합니다.
1. gitx 요구사항
2편에서 했던 방식대로, 먼저 한 문장으로 목적을 정의합니다.
gitx — 매일 반복되는 git 워크플로우를 단축하는 래퍼.
git명령을 내부에서 실행하는 Type A Wrapper의 학습 예제로서,child_process사용·shell injection 방어·명령 체이닝·대화형 프롬프트의 전형을 담는다.
구현할 명령어 세 개
| 명령어 | 내부에서 실행하는 git | 핵심 문제 |
|---|---|---|
gitx save <message> | git add → commit → push | 여러 명령 체이닝, 중간 실패 처리 |
gitx sync | git fetch → rebase | 전제 조건 검증(clean tree, upstream) |
gitx cleanup | git branch --merged → branch -d ... | 대화형 선택, 부분 실패 |
각 명령어는 Type A의 서로 다른 측면을 보여줍니다. 이 조합이면 실무에서 만나는 대부분의 패턴을 커버합니다.
2. 프로젝트 세팅
2편과 거의 동일합니다. greet-cli와 같은 방식으로 프로젝트를 초기화하고 의존성을 설치합니다.
mkdir gitx && cd gitx
npm init -y
npm install commander chalk ora @inquirer/prompts
npm install -D typescript @types/node tsx
새로 등장한 건 @inquirer/prompts 입니다. cleanup 명령에서 “이 브랜치들 중 어떤 걸 삭제할까요?” 같은 대화형 선택 UI를 만들 때 씁니다. 과거에는 inquirer 단일 패키지를 썼지만, 최근엔 필요한 프롬프트만 가져다 쓰는 @inquirer/prompts 로 분리됐어요.
package.json과 tsconfig.json은 2편과 동일하되, tsconfig.json에 types: ["node"] 를 추가해주세요. @types/node가 자동으로 잡히지 않는 환경이 있습니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
package.json의 "bin" 항목은 "gitx": "dist/index.js" 로 설정합니다.
3. child_process 세 가지 API — 뭘 써야 하나
본격적인 구현에 들어가기 전에, Node.js의 child_process 모듈이 제공하는 세 가지 API를 정리합니다. 이게 헷갈리면 Type A Wrapper를 설계할 때 첫 단추부터 어긋납니다.
| API | 출력 처리 | shell 사용 | 언제 쓰나 |
|---|---|---|---|
exec | 버퍼에 한번에 | 기본 사용함 | 거의 쓸 일 없음 (위험) |
execFile | 버퍼에 한번에 | 사용 안 함 | 작은 출력을 한 번에 받을 때 |
spawn | 스트림으로 | 선택적 (기본 안 함) | 대부분의 경우 (권장) |
exec를 피해야 하는 이유
exec('git add .') 같은 코드는 내부적으로 /bin/sh -c "git add ." 을 실행합니다. 문자열을 셸에 넘기는 거죠. 문제는 사용자 입력이 섞이면 셸이 그걸 해석한다는 겁니다.
// ❌ 절대 이렇게 쓰지 마세요
import { exec } from 'node:child_process';
exec(`git commit -m "${userMessage}"`);
// userMessage = '"; rm -rf ~; echo "pwned'
// → 실제 실행: git commit -m ""; rm -rf ~; echo "pwned"
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 쉘이 세미콜론을 구분자로 인식해서 rm 실행
이건 실제 CLI 도구에서 발견되는 가장 흔한 보안 버그입니다. gitx save "제목; malicious code" 같은 입력이 실제 명령 실행으로 이어지는 거죠.
spawn으로 안전하게
spawn은 인자를 배열로 받고, 기본적으로 shell을 거치지 않습니다.
import { spawn } from 'node:child_process';
// ⭕ 안전함 — shell 해석 자체가 없음
spawn('git', ['commit', '-m', userMessage], { shell: false });
이 호출에서 userMessage는 순수한 인자로 git 프로세스에 전달됩니다. 세미콜론이든 따옴표든 백틱이든 git이 그냥 커밋 메시지의 일부로 볼 뿐이에요.
원칙: CLI Wrapper에서 외부 명령을 실행할 때는 항상 spawn (또는 execFile) + shell: false. 편해 보인다는 이유로 exec나 shell: true를 쓰면 언젠가 반드시 사고가 납니다.
4. git 실행 유틸리티 만들기
가장 먼저 만들 건 모든 git 명령 호출이 거쳐갈 공통 유틸입니다. src/git.ts에 위치시킵니다.
import { spawn } from 'node:child_process';
export interface GitRunResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* git 명령을 실행하고 결과를 캡처한다.
* shell=false 로 실행하므로 shell injection 불가능.
*/
export function runGit(args: string[]): Promise<GitRunResult> {
return new Promise((resolve) => {
const child = spawn('git', args, { shell: false });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
child.on('close', (code) => {
resolve({ stdout, stderr, exitCode: code ?? 1 });
});
});
}
/**
* git 명령을 실행하되 출력을 부모 프로세스로 직접 스트리밍한다.
* (git push처럼 진행 상황을 사용자가 실시간으로 봐야 하는 경우)
*/
export function runGitStreaming(args: string[]): Promise<number> {
return new Promise((resolve) => {
const child = spawn('git', args, { shell: false, stdio: 'inherit' });
child.on('close', (code) => resolve(code ?? 1));
});
}
두 함수의 차이가 중요합니다.
runGit — 출력을 캡처
git rev-parse --short HEAD 같은 명령은 결과(커밋 해시)를 프로그램이 써야 합니다. 사용자에게 바로 보여주는 게 아니라요. 이럴 땐 stdout을 버퍼에 모아서 반환합니다.
runGitStreaming — 출력을 그대로 통과
반면 git push는 사용자가 진행 상황을 실시간으로 봐야 합니다. “Counting objects: 15%… 32%… Writing objects…” 같은 메시지가 그대로 터미널에 나와야 하죠. 이럴 때 stdio: 'inherit' 을 쓰면 자식 프로세스의 stdin/stdout/stderr이 부모(= gitx)와 똑같은 것을 공유합니다.
판단 기준: 출력을 파싱해야 하면 runGit, 사용자가 직접 봐야 하면 runGitStreaming.
exit code는 ??로 기본값 처리
resolve({ stdout, stderr, exitCode: code ?? 1 });
child.on('close', (code) => ...) 의 code는 프로세스가 시그널로 종료되면 null 이 됩니다. null을 그대로 내보내면 호출하는 쪽에서 if (exitCode !== 0) 같은 체크가 이상해져요. ?? 연산자로 “null이면 1″로 기본값을 주는 게 실전 패턴입니다.
5. save 명령어 — 체이닝과 중간 실패 처리
가장 쓰임새가 많은 gitx save부터 만듭니다. src/commands/save.ts.
import chalk from 'chalk';
import ora from 'ora';
import { runGit, runGitStreaming } from '../git.js';
export async function runSave(
message: string,
opts: { push?: boolean },
): Promise<void> {
// 1. 변경사항이 있는지 확인
const status = await runGit(['status', '--porcelain']);
if (status.exitCode !== 0) {
console.error(chalk.red('git status 실행 실패'));
console.error(status.stderr);
process.exit(status.exitCode);
}
if (status.stdout.trim() === '') {
console.log(chalk.yellow('변경된 파일이 없습니다. 작업을 중단합니다.'));
process.exit(0);
}
const changedCount = status.stdout.trim().split('\n').length;
// 2. add
const addSpinner = ora(`${changedCount}개 파일 스테이지 중...`).start();
const add = await runGit(['add', '.']);
if (add.exitCode !== 0) {
addSpinner.fail('스테이지 실패');
console.error(add.stderr);
process.exit(add.exitCode);
}
addSpinner.succeed(`${changedCount}개 파일 스테이지`);
// 3. commit
const commitSpinner = ora('커밋 생성 중...').start();
const commit = await runGit(['commit', '-m', message]);
if (commit.exitCode !== 0) {
commitSpinner.fail('커밋 실패');
console.error(commit.stderr || commit.stdout);
process.exit(commit.exitCode);
}
const hashResult = await runGit(['rev-parse', '--short', 'HEAD']);
const hash = hashResult.stdout.trim();
commitSpinner.succeed(`커밋 완료 (${chalk.cyan(hash)})`);
// 4. push (기본은 푸시, --no-push 시 건너뜀)
if (opts.push === false) {
console.log(chalk.gray('--no-push 지정: 푸시를 건너뜁니다'));
return;
}
const upstream = await runGit([
'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}',
]);
if (upstream.exitCode !== 0) {
console.log(
chalk.yellow(
'현재 브랜치에 upstream이 설정되어 있지 않습니다. ' +
'`git push -u origin <branch>`를 먼저 실행해주세요.',
),
);
process.exit(1);
}
console.log(chalk.gray(`${upstream.stdout.trim()}에 푸시합니다...`));
const pushCode = await runGitStreaming(['push']);
if (pushCode !== 0) {
console.error(chalk.red('푸시 실패'));
process.exit(pushCode);
}
console.log(chalk.green('✔ 푸시 완료'));
}
코드가 길지만 구조는 단순합니다. 4단계의 체이닝을 각 단계마다 성공 여부를 확인하면서 진행합니다.
핵심 패턴 1 — 전제 조건 먼저 검증
if (status.stdout.trim() === '') {
console.log(chalk.yellow('변경된 파일이 없습니다. 작업을 중단합니다.'));
process.exit(0);
}
Wrapper의 중요한 가치 중 하나가 “아무것도 안 하는 명령도 친절하게 끝내는 것” 입니다. 원본 git commit은 변경사항이 없으면 다소 퉁명스러운 에러를 냅니다. gitx는 한 걸음 앞에서 확인하고 깔끔하게 종료해줍니다. 종료 코드도 0 (정상 종료) 으로 설정했어요 — 에러가 아니라 “할 일이 없어서 안 한 것”이니까요.
핵심 패턴 2 — 중간 실패 시 즉시 중단
각 단계에서 exitCode !== 0 이면 그 자리에서 메시지 찍고 process.exit(code) 로 끝냅니다. JavaScript의 예외 대신 이 패턴을 쓰는 이유는:
- 각 실패가 서로 다른 종료 코드를 전파해야 하기 때문 (git의 원래 exit code를 유지)
- 쉘 스크립트에서
gitx save "fix" && do-something같이 쓸 때 정확한 실패 신호를 줘야 하기 때문
핵심 패턴 3 — --no-push 의 commander 관례
opts.push === false 로 체크하는 게 이상해 보일 겁니다. 이게 commander의 “부정형 플래그 관례” 예요.
.option('--no-push', '푸시는 건너뜁니다')
--no-push 옵션을 정의하면 commander는 이걸 push라는 불리언 옵션을 false로 만드는 플래그로 해석합니다. 즉:
- 플래그 없을 때:
opts.push === undefined(또는 기본값 true) --no-push지정 시:opts.push === false
그래서 if (opts.noPush) 가 아니라 if (opts.push === false) 로 체크해야 합니다. 처음 CLI를 만들 때 반드시 한 번은 겪는 함정이니 기억해두세요.
핵심 패턴 4 — push는 스트리밍으로
다른 명령은 runGit을 쓰지만 push만 runGitStreaming을 씁니다.
const pushCode = await runGitStreaming(['push']);
큰 리포지토리에서는 push가 수 초~수십 초 걸릴 수 있습니다. 이때 사용자가 "Enumerating objects: 42%..." 같은 원본 git의 진행 표시를 그대로 보는 게 스피너 하나 돌리는 것보다 훨씬 안심됩니다.
6. sync 명령어 — 전제 조건 검증
src/commands/sync.ts — git fetch 후 rebase 를 실행합니다.
import chalk from 'chalk';
import ora from 'ora';
import { runGit } from '../git.js';
export async function runSync(): Promise<void> {
const current = await runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
if (current.exitCode !== 0) {
console.error(chalk.red('현재 브랜치를 확인할 수 없습니다.'));
process.exit(current.exitCode);
}
const currentBranch = current.stdout.trim();
const upstream = await runGit([
'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}',
]);
if (upstream.exitCode !== 0) {
console.error(chalk.yellow('현재 브랜치에 upstream이 없습니다.'));
process.exit(1);
}
const upstreamName = upstream.stdout.trim();
// working tree가 깨끗한지 확인 (rebase 충돌 방지)
const status = await runGit(['status', '--porcelain']);
if (status.stdout.trim() !== '') {
console.error(chalk.red('커밋되지 않은 변경사항이 있습니다.'));
console.error(chalk.gray('먼저 `gitx save` 또는 `git stash`를 실행해주세요.'));
process.exit(1);
}
const fetchSpinner = ora('원격 저장소에서 가져오는 중...').start();
const fetch = await runGit(['fetch', '--prune']);
if (fetch.exitCode !== 0) {
fetchSpinner.fail('fetch 실패');
console.error(fetch.stderr);
process.exit(fetch.exitCode);
}
fetchSpinner.succeed('fetch 완료');
const rebaseSpinner = ora(`${upstreamName} 위에 리베이스 중...`).start();
const rebase = await runGit(['rebase', upstreamName]);
if (rebase.exitCode !== 0) {
rebaseSpinner.fail('리베이스 실패 — 충돌이 발생했습니다');
console.error(rebase.stdout);
console.error(
chalk.yellow(
'충돌을 수동으로 해결한 뒤 `git rebase --continue` 또는 `git rebase --abort`를 실행하세요.',
),
);
process.exit(rebase.exitCode);
}
rebaseSpinner.succeed(`${currentBranch}가 ${upstreamName}와 동기화되었습니다`);
}
이번에 눈여겨볼 점 — 친절한 에러 복구 가이드
console.error(
chalk.yellow(
'충돌을 수동으로 해결한 뒤 `git rebase --continue` 또는 `git rebase --abort`를 실행하세요.',
),
);
rebase 충돌은 자동으로 해결할 수 없는 상황입니다. 이때 사용자가 다음에 뭘 해야 하는지를 알려주는 게 좋은 Wrapper의 특징이에요. 그냥 git의 에러 메시지를 보여주고 끝내는 게 아니라, “너의 상황에선 이런 선택지가 있다”고 한 줄 더 덧붙이는 거죠.
원본 CLI보다 더 친절하고 상황에 특화된 에러 메시지를 주는 것 — 이게 Type A Wrapper의 주된 가치 중 하나입니다.
7. cleanup 명령어 — 대화형 프롬프트
마지막 명령어는 가장 재밌는 cleanup입니다. 머지된 로컬 브랜치를 찾아서 사용자가 체크박스로 선택해서 삭제합니다.
import chalk from 'chalk';
import { checkbox, confirm } from '@inquirer/prompts';
import { runGit } from '../git.js';
export async function runCleanup(): Promise<void> {
// 현재 브랜치
const current = await runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
if (current.exitCode !== 0) {
console.error(chalk.red('현재 브랜치를 확인할 수 없습니다.'));
process.exit(current.exitCode);
}
const currentBranch = current.stdout.trim();
// 머지된 로컬 브랜치 목록 (보호 브랜치 제외)
const merged = await runGit(['branch', '--merged']);
if (merged.exitCode !== 0) {
console.error(chalk.red('머지된 브랜치 조회 실패'));
console.error(merged.stderr);
process.exit(merged.exitCode);
}
const protectedBranches = new Set([
'main', 'master', 'develop', currentBranch,
]);
const candidates = merged.stdout
.split('\n')
.map((line) => line.replace(/^\*/, '').trim())
.filter((name) => name && !protectedBranches.has(name));
if (candidates.length === 0) {
console.log(chalk.yellow('삭제할 머지된 브랜치가 없습니다.'));
return;
}
// 대화형 선택
const selected = await checkbox({
message: '삭제할 브랜치를 선택하세요 (스페이스로 선택, 엔터로 확정)',
choices: candidates.map((name) => ({ name, value: name })),
});
if (selected.length === 0) {
console.log(chalk.gray('선택된 브랜치가 없습니다. 종료합니다.'));
return;
}
// 최종 확인
const proceed = await confirm({
message: `${selected.length}개 브랜치를 삭제할까요?`,
default: false,
});
if (!proceed) {
console.log(chalk.gray('취소되었습니다.'));
return;
}
// 순차 삭제
let failedCount = 0;
for (const name of selected) {
const res = await runGit(['branch', '-d', name]);
if (res.exitCode === 0) {
console.log(chalk.green(`✔ 삭제: ${name}`));
} else {
console.log(chalk.red(`✘ 실패: ${name} — ${res.stderr.trim()}`));
failedCount++;
}
}
if (failedCount > 0) {
console.log(chalk.yellow(`${failedCount}개 브랜치 삭제 실패`));
process.exit(1);
}
}
핵심 패턴 1 — 보호 브랜치 개념
const protectedBranches = new Set(['main', 'master', 'develop', currentBranch]);
git branch --merged 는 머지된 모든 브랜치를 돌려주는데, 거기엔 main, master, develop, 그리고 현재 체크아웃된 브랜치가 포함됩니다. 이걸 삭제 후보로 보여주면 대형 사고가 나요. Set으로 걸러냅니다.
실무에서는 .gitxrc 같은 설정 파일로 보호 브랜치 목록을 사용자화하는 게 일반적이에요. (5편에서 설정 파일을 다루는데, 여기선 하드코딩으로 넘어갑니다.)
핵심 패턴 2 — 파싱 시 * 제거
.map((line) => line.replace(/^\*/, '').trim())
git branch --merged 의 출력은 이렇게 생겼습니다.
feature/a
feature/b
* master
*은 현재 브랜치 표시입니다. 이걸 제거하지 않으면 브랜치명이 "* master" 가 되어서 이후 처리가 엉망이 돼요. 원본 CLI 출력을 파싱할 때는 이런 장식 문자에 주의하는 게 Type A의 일상입니다.
더 안전한 방법은 git branch --merged --format='%(refname:short)' 를 쓰는 겁니다. 장식 없이 브랜치명만 나오거든요. 다만 이 편에서는 학습 목적으로 기본 출력 파싱을 보여드렸습니다.
핵심 패턴 3 — 2단계 확인
체크박스로 선택받은 후, 다시 한 번 confirm 으로 확정을 받습니다.
const proceed = await confirm({
message: `${selected.length}개 브랜치를 삭제할까요?`,
default: false, // 엔터만 누르면 "아니오"
});
default: false 가 중요합니다. 파괴적 작업의 기본값은 항상 “안 함” 이어야 해요. 사용자가 의도 없이 엔터를 연타해도 데이터가 날아가지 않게.
핵심 패턴 4 — 부분 실패 수집
let failedCount = 0;
for (const name of selected) {
const res = await runGit(['branch', '-d', name]);
if (res.exitCode === 0) { ... }
else { failedCount++; }
}
if (failedCount > 0) process.exit(1);
브랜치 삭제는 “10개 중 3개만 실패” 하는 상황이 흔합니다 (로컬에서 다른 브랜치와 머지 관계 등). 첫 실패에서 중단하는 게 아니라 전부 시도하고 최종 결과를 리포트하는 게 사용자 경험상 낫습니다.
8. index.ts — 명령어 등록 + 최상위 에러 처리
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import { runSave } from './commands/save.js';
import { runSync } from './commands/sync.js';
import { runCleanup } from './commands/cleanup.js';
const program = new Command();
program
.name('gitx')
.description('git 워크플로우를 단축하는 래퍼')
.version('0.1.0');
program
.command('save <message>')
.description('변경사항을 스테이지·커밋·푸시합니다')
.option('--no-push', '푸시는 건너뜁니다')
.action((message: string, opts: { push?: boolean }) => runSave(message, opts));
program
.command('sync')
.description('원격 브랜치와 동기화합니다 (fetch + rebase)')
.action(() => runSync());
program
.command('cleanup')
.description('머지된 로컬 브랜치를 대화형으로 삭제합니다')
.action(() => runCleanup());
program.parseAsync(process.argv).catch((err: Error) => {
// @inquirer/prompts의 ExitPromptError 등 최상위 에러 처리
if (err.name === 'ExitPromptError') {
console.log(chalk.gray('\n중단되었습니다.'));
process.exit(130);
}
console.error(chalk.red(`예상치 못한 오류: ${err.message}`));
process.exit(1);
});
주목할 점 — parseAsync().catch(...)
3편의 greet-cli에서는 최상위 .catch가 없었습니다. 모든 에러를 각 명령 내부에서 process.exit(1)로 처리했기 때문이죠.
gitx에서는 최상위에 .catch가 필요합니다. 왜냐하면 @inquirer/prompts가 던지는 에러(예: 사용자가 Ctrl+C로 프롬프트를 중단) 가 명령 함수 내부의 try-catch로 잡기 애매하기 때문입니다.
ExitPromptError(Ctrl+C로 중단) → exit code 130 (SIGINT 관례)- 그 외 예상 못한 에러 → exit code 1 + 메시지
exit code 130은 SIGINT로 중단된 프로세스의 관례적인 종료 코드입니다. 128 + 2(SIGINT) 예요. 이걸 지키면 쉘 스크립트에서 if [ $? -eq 130 ]; then ... 같은 처리가 가능합니다.
9. 실행해보기
이제 테스트용 git 저장소에서 실제로 동작하는지 확인합니다.
# 빌드 또는 npm link
npm run build
npm link
# 테스트 저장소로 이동해서 변경 만들기
cd ~/some-repo
echo "hello" > new-file.txt
# save 명령 (push 없이)
$ gitx save "test: 새 파일 추가" --no-push
✔ 1개 파일 스테이지
✔ 커밋 완료 (a1b2c3d)
--no-push 지정: 푸시를 건너뜁니다
# 변경사항이 없을 때
$ gitx save "empty" --no-push
변경된 파일이 없습니다. 작업을 중단합니다.
# cleanup
$ gitx cleanup
? 삭제할 브랜치를 선택하세요 (스페이스로 선택, 엔터로 확정)
◉ feature/login
◯ hotfix/typo
◉ feature/profile
? 2개 브랜치를 삭제할까요? (y/N) y
✔ 삭제: feature/login
✔ 삭제: feature/profile
대화형 프롬프트가 제대로 뜨고, 체크박스 선택이 동작하고, 삭제가 진행되면 성공입니다.
10. 이번 편에서 놓치기 쉬운 것들
1) PATH에 git이 없을 때
Mac/Linux에서는 보통 git이 PATH에 있지만, Windows나 특이한 환경에서는 없을 수도 있습니다. spawn('git', ...)은 그럴 때 ENOENT 에러를 발생시킵니다. 현재 코드는 이걸 잡지 않아요.
실무에서는 git.ts 에 이런 가드를 추가합니다.
child.on('error', (err) => {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve({
stdout: '',
stderr: 'git 실행 파일을 찾을 수 없습니다. git이 설치되어 있고 PATH에 포함되어 있는지 확인하세요.',
exitCode: 127,
});
} else {
resolve({ stdout: '', stderr: err.message, exitCode: 1 });
}
});
Exit code 127은 “command not found” 의 관례입니다.
2) 큰 출력 처리
runGit은 stdout 전체를 메모리에 버퍼링합니다. 대부분의 git 명령 출력은 작아서 문제없지만, git log --all 같은 건 수백 MB가 될 수 있어요. 그럴 때는 runGitStreaming을 쓰거나, 필요한 만큼만 --max-count=50 처럼 제한해야 합니다.
3) Windows에서 줄바꿈
stdout을 .split('\n') 하는 코드가 Windows에서는 \r\n을 제대로 처리하지 못합니다. 크로스플랫폼 CLI를 만든다면 .split(/\r?\n/) 형태로 쓰는 게 안전합니다. 우리 cleanup 코드도 이렇게 고치는 게 좋아요.
4) git의 로케일 의존 출력
어떤 git 명령은 사용자의 LANG 설정에 따라 출력 메시지가 달라집니다. 출력을 파싱하는 코드라면 spawn 옵션에 env: { ...process.env, LANG: 'C', LC_ALL: 'C' } 를 넣어서 영어 출력으로 강제하는 게 안전합니다. 우리 예제에선 --porcelain이나 구조화된 출력을 파싱하니 상대적으로 안전하지만, 실무에선 반드시 기억해야 할 포인트입니다.
다음 편 예고
5편 — 실전 품질: 테스트, 배포, 유지보수
다음 편에서는 지금까지 만든 greet-cli와 gitx를 남도 쓸 수 있는 도구로 완성합니다.
- 설정 파일 지원 —
~/.gitxrc로 보호 브랜치 커스터마이징 (cosmiconfig) - vitest로 단위 테스트 —
child_process와fetch를 모킹하는 법 - npm 배포 —
npm publish,prepublishOnly훅,.npmignore - 시맨틱 버저닝과 CHANGELOG — 0.x 동안의 관례
- GitHub Actions로 자동 릴리스 — 태그 푸시 → 자동 publish
- README 베스트 프랙티스 — 뱃지, 설치법, 스크린샷
- 다음 단계 — 플러그인 시스템, TUI, 번외편(Python/Go 이식) 예고
5편을 마치면 실제로 npm install -g gitx 같은 명령으로 누구나 설치할 수 있는 진짜 CLI 도구가 완성됩니다.
마치며
이번 편의 핵심을 세 문장으로 요약하면 이렇습니다.
- 외부 명령 실행은
spawn+shell: false+ 인자 배열이 정답이다.exec나shell: true는 shell injection의 문을 연다. 사용자 입력을 받는 모든 Wrapper는 이 원칙을 지켜야 한다. - 출력 처리 방식은 “파싱 필요 → 캡처 / 사용자가 봐야 함 → 스트리밍”으로 나뉜다. 두 패턴(runGit / runGitStreaming)을 만들어두고 용도별로 선택한다.
- 좋은 Type A Wrapper는 원본 CLI보다 더 친절하다. 전제 조건을 먼저 검증하고, 상황별 복구 가이드를 제공하고, 파괴적 작업에는 이중 확인을 건다. 그게
git위에gitx라는 층을 쌓는 명분이다.
여기까지 왔으면 두 개의 실전 CLI 도구가 여러분의 터미널에서 동작하고 있을 겁니다. greet-cli(Type B)와 gitx(Type A) — 두 유형을 모두 구현해보신 거예요.
다음 편에서는 이 도구들을 실제 세상으로 내보냅니다. 👋