Git 워크플로우를 개선하고 팀 협업의 효율성을 높이기 위해 커스텀 CLI 도구를 세팅했다. 주요 기능으로는 일관된 커밋 메시지 형식 강제, 브랜치 이름 규칙 적용, 그리고 push 과정의 간소화가 있는데, 브랜치 이름 검증을 위해 Husky의 pre-push 훅은 사용하지 않았다. 대신, 브랜치를 만들 때 컨벤션에 맞춰서 만들어지도록 세팅했다. ( pre-push에서 branch 이름 규칙 검증하는거는 다음에 ...)
const { execSync } = require('child_process');
/**
* 주어진 메시지를 장식하여 콘솔에 출력합니다.
* @param {string} message - 출력할 메시지
* @param {string} [dividerChar='='] - 구분선에 사용할 문자
* @returns {string} decoratedMessage - 구분선과 합쳐진 메시지
*/
const showDecoratedMessage = (message, dividerChar = '=') => {
const divider = dividerChar.repeat(message.length + 20);
const decoratedMessage = `
${divider}
${message}
${divider}
`;
console.log(decoratedMessage);
return decoratedMessage;
};
/**
* 현재 브랜치 리스트 돌려주기
*/
const getCurrentBranchNames = () => {
const branches = execSync('git branch --format="%(refname:short)"', { encoding: 'utf-8' })
.trim()
.split('\n');
return branches.map((branch) => {
return { value: branch, name: branch };
});
};
module.exports = {
showDecoratedMessage,
getCurrentBranchNames,
};
// 이슈, 브랜치 라벨
module.exports = [
{ value: '✨ feat', name: '✨ feat:\t새로운 기능' },
{ value: '🐛 fix', name: '🐛 fix:\t버그 수정' },
{ value: '🛠️ refactor', name: '🛠️ refactor:\t코드 리팩토링' },
{ value: '🎨 design', name: '🎨 design:\tCSS 등 사용자 UI 디자인 변경' },
{ value: '💎 style', name: '💎 style:\t코드 포맷팅, 코드 변경이 없는 경우' },
{ value: '📦 chore', name: '📦 chore:\t빌드 업무 수정, 패키지 매니저 설정, 자잘한 코드 수정' },
{ value: '💬 comment', name: '💬 comment:\t주석 추가 및 변경' },
{ value: '📚 docs', name: '📚 docs:\t문서 수정' },
{ value: '🚑 !HOTFIX', name: '🚑 !HOTFIX:\t급하게 치명적인 버그를 고치는 경우' },
{ value: '🚀 perf', name: '🚀 perf:\t성능 개선' },
];
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint './src/**/*.{ts,tsx,js,jsx}'",
"lint:fix": "eslint --fix './src/**/*.{ts,tsx,js,jsx}'",
"format": "prettier --check --ignore-path .gitignore .",
"format:fix": "prettier --write --ignore-path .gitignore .",
"prepare": "husky",
"postinstall": "husky install",
"commit": "./scripts/commitizen.sh",
"branch": "node ./git-tools/branch-config.js",
"push": "node ./git-tools/push-config.js"
},
"config": {
"commitizen": {
"path": "./git-tools/commit-config.js"
}
},
커밋 메시지의 일관성을 유지하기 위해 commit-config.js
파일을 구현했다.
이 파일은 사용자로부터 커밋 타입, 스코프, 그리고 설명을 입력받아 정해진 형식의 커밋 메시지를 생성한다.
해당 부분 세팅하는 것은 [CI] Next.js 프로젝트 세팅하기 (TypeScript + TailwindCSS + Prettier + ESLint + Husky + Commitlint + Commitizen) 참고. (commitizen, husky 세팅 포함)
이전에 작성한 글과 유사하나, 중복으로 쓰이는 코드들은 utils.js
, constant.js
로 분리했다.
그리고 git 관련 함수를 git-tools
폴더로 묶어놔서, commitizen.sh
파일의 husky 환경도 수정해야한다.
scripts/commitizen.sh
#!/bin/sh
# Husky 환경 설정, 여기 수정함!
. "./.husky/_/husky.sh"
# pre-commit 훅 실행
if [ -f .husky/pre-commit ]; then
.husky/pre-commit
else
echo "Warning: .husky/pre-commit file not found. Skipping pre-commit hook."
fi
# pre-commit 훅이 성공적으로 실행되었다면 Commitizen 실행 (commit-msg 체크 건너 뜀)
# 직접 git commit 하는 경우가 있으니, 그때도 체크가 필요하기에 commit-msg 유지 필요
if [ $? -eq 0 ]; then
HUSKY=0 cz
else
echo "Pre-commit hook failed. Aborting commit."
exit 1
fi
git-tools/commit-config.js
const typeChoices = require('./constant');
const { showDecoratedMessage } = require('./util');
module.exports = {
prompter: (cz, commit) => {
const questions = [
{
type: 'list',
name: 'type',
message: '1️⃣ 커밋 라벨을 선택하세요:',
choices: typeChoices,
},
{
type: 'input',
name: 'subject',
message: '2️⃣ 커밋 메시지를 입력하세요:',
validate: (input) => {
if (input.length === 0) {
return '커밋 메시지는 비워둘 수 없습니다.';
}
if (input.length > 100) {
return '커밋 메시지는 100자를 넘을 수 없습니다.';
}
return true;
},
},
{
type: 'input',
name: 'ticketNumber',
message: '3️⃣ 이슈 번호를 입력하세요 (숫자만):',
validate: (input) => {
if (!/^\d+$/.test(input)) {
return '유효한 숫자를 입력해주세요.';
}
return true;
},
},
];
cz.prompt(questions).then((answers) => {
const { type, subject, ticketNumber } = answers;
const message = `${type}: ${subject} (#${ticketNumber})`;
showDecoratedMessage(message);
// 확인 질문
cz.prompt([
{
type: 'confirm',
name: 'confirmCommit',
message: '✅ 커밋 메시지가 위와 같아요! 커밋할까요?',
default: false,
},
]).then((confirmAnswer) => {
if (confirmAnswer.confirmCommit) {
commit(message);
} else {
showDecoratedMessage('❌ 커밋이 취소되었습니다.');
}
});
});
},
};
branch-config.js 파일을 통해 브랜치 관련 작업을 간소화했다.
이 스크립트는 브랜치 생성, 삭제, 전환 기능을 제공하며, 사용자 친화적인 인터페이스로 Git 브랜치 관리를 더욱 쉽게 만들었다.
또한 브랜치 생성시에 필요한 정보를 입력받아 컨벤션에 맞게 브랜치를 생성할 수 있게했다.
git-tools/branch-config.js
const inquirer = require('inquirer');
const { execSync } = require('child_process');
const branchLabels = require('./constant');
const { showDecoratedMessage, getCurrentBranchNames } = require('./util');
// 생성/전환/삭제 질문
const ask = async () => {
const { action } = await inquirer.prompt([
{
type: 'list',
name: 'action',
message: '🛠️ 어떤 작업을 수행하시겠어요?',
choices: [
{ name: '🌱 새 브랜치 생성', value: 'create' },
{ name: '🧬 브랜치 전환', value: 'checkout' },
{ name: '🔥 브랜치 삭제', value: 'delete' },
{ name: '🚪 종료', value: 'exit' },
],
},
]);
switch (action) {
case 'create':
await createBranch();
break;
case 'checkout':
await checkoutBranch();
break;
case 'delete':
await deleteBranch();
break;
case 'exit':
console.log('👋 프로그램을 종료합니다.');
return;
}
return;
};
// 브랜치 삭제
const deleteBranch = async () => {
const branchNames = getCurrentBranchNames();
const { branch } = await inquirer.prompt([
{
type: 'list',
name: 'branch',
message: '🔥 어떤 브랜치를 삭제할까요?',
choices: branchNames,
},
]);
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: `❗ 정말로 '${branch}' 브랜치를 삭제하시겠어요? 이 작업은 되돌릴 수 없어요!`,
default: false,
},
]);
if (confirm) {
try {
execSync(`git branch -d ${branch}`);
showDecoratedMessage(`🗑️ '${branch}' 브랜치가 삭제되었어요.`);
} catch (error) {
if (error.message.includes('not fully merged')) {
console.log(`❗ '${branch}' 브랜치가 완전히 병합되지 않았어요. 강제로 삭제하시겠어요?`);
const { forceDelete } = await inquirer.prompt([
{
type: 'confirm',
name: 'forceDelete',
message: '강제 삭제를 진행할까요? (주의: 병합되지 않은 변경사항이 손실될 수 있어요)',
default: false,
},
]);
if (forceDelete) {
try {
execSync(`git branch -D ${branch}`);
showDecoratedMessage(`🗑️ '${branch}' 브랜치가 강제로 삭제되었어요.`);
} catch (forceError) {
console.error(`🙈 앗! 강제 삭제 중 오류가 발생했어요: ${forceError.message}`);
}
} else {
showDecoratedMessage('👌 브랜치 삭제가 취소되었어요.');
}
} else {
console.error(`🙈 앗! 오류가 발생했어요: ${error.message}`);
}
}
} else {
showDecoratedMessage('👌 브랜치 삭제가 취소되었어요.');
}
};
// 브랜치 전환
const checkoutBranch = async () => {
const branchNames = getCurrentBranchNames();
const { branch } = await inquirer.prompt([
{
type: 'list',
name: 'branch',
message: '🧬 어떤 브랜치로 전환할까요?',
choices: branchNames,
},
]);
try {
execSync(`git checkout ${branch}`);
showDecoratedMessage(`🎉 ${branch} 브랜치로 전환되었어요.`);
} catch (error) {
console.error(`🙈 앗! 오류가 발생했어요: ${error.message}`);
}
};
// 브랜치 생성
const createBranch = () => {
const branchNames = getCurrentBranchNames();
try {
const answers = await inquirer.prompt([
{
type: 'list',
name: 'baseBranch',
message: '🌳 1. 어떤 브랜치에서 새 브랜치를 만들까요?',
choices: branchNames,
},
{
type: 'list',
name: 'branchLabel',
message:
'🏷️ 2. 새 브랜치의 이름을 라벨을 골라주세요! (위아래 화살표로 선택, 엔터로 확정):',
choices: branchLabels,
},
{
type: 'input',
name: 'branchName',
message: '🚀 3. 새 브랜치의 이름을 입력해주세요:',
validate: (input) => input.trim() !== '' || '브랜치 이름을 입력해주세요!',
filter: (input) => input.replace(/\s+/g, '-'), // 입력받은 문자열의 공백을 '-'로 치환
},
{
type: 'input',
name: 'issueNumber',
message: '🔢 4. 관련된 이슈 번호를 입력해주세요:',
validate: (input) => {
const trimmed = input.trim();
return /^\d+$/.test(trimmed) || '숫자만 입력해주세요!';
},
},
]);
const { baseBranch, branchLabel, branchName, issueNumber } = answers;
// base branch로 체크아웃
execSync(`git checkout ${baseBranch}`);
// 라벨이 대문자로 시작하도록 치환
const transformed = branchLabel.replace(/\s+/g, '-');
const chars = [...transformed];
// '-' 다음 문자 = 라벨을 대문자로 수정
const labelStart = chars.indexOf('-');
if (chars[labelStart + 1]) {
chars[labelStart + 1] = chars[labelStart + 1].toUpperCase();
}
const tarnsformedLabel = chars.join('');
const finalBranchName = `${tarnsformedLabel}/#${issueNumber}-${branchName}`;
// 새 브랜치 생성
execSync(`git checkout -b ${finalBranchName}`);
showDecoratedMessage(`🎉 야호! 새 브랜치가 생성되었어요: ${finalBranchName}`);
} catch (error) {
console.error(`🙈 앗! 오류가 발생했어요: ${error.message}`);
}
}
ask();
[생성]
[전환]
[삭제]
git-tools/push-config.js
파일은 현재 브랜치를 원격 저장소로 푸시하는 과정을 자동화했다.
로컬에 존재하는 브랜치 이름 중 선택할 수 있어서 귀찮게 브랜치 이름을 직접 입력 안해도 된다.
husky
의 pre-commit
을 설정해서 branch 이름 규칙을 한번 더 체크할 수 있는데 일단 생략했다.
git-tools/push-config.js
const inquirer = require('inquirer');
const { execSync, spawnSync } = require('child_process');
const { showDecoratedMessage, getCurrentBranchNames } = require('./util');
const pushBranch = async () => {
const branchNames = getCurrentBranchNames();
const { branch } = await inquirer.prompt([
{
type: 'list',
name: 'branch',
message: '🚀 어떤 브랜치를 push 하시겠어요?',
choices: branchNames,
},
]);
try {
// 현재 브랜치가 원격 저장소에 있는지 확인
const remoteExists = doesRemoteBranchExist(branch);
if (!remoteExists) {
const { confirmPush } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmPush',
message: `🌟 '${branch}' 브랜치가 원격 저장소에 없습니다. 새로 push 하시겠어요?`,
default: true,
},
]);
if (!confirmPush) {
console.log('👌 Push가 취소되었어요.');
return;
}
}
const result = spawnSync('git', ['push', 'origin', branch], { encoding: 'utf-8' });
const output = result.stdout + result.stderr;
if (output.includes('Everything up-to-date')) {
showDecoratedMessage(`ℹ️ '${branch}' 브랜치는 이미 최신 상태예요!`);
} else if (output.includes('->')) {
showDecoratedMessage(`🎉 '${branch}' 브랜치가 성공적으로 push 되었어요!`);
} else {
showDecoratedMessage(`🤔 Push 결과를 확인해주세요.`);
}
// 에러 코드 확인
if (result.status !== 0) {
throw new Error(`Git push failed with status ${result.status}`);
}
} catch (error) {
console.error(`🙈 앗! Push 중 오류가 발생했어요: ${error.message}`);
if (error.message.includes('rejected')) {
console.log(
'💡 Tip: 원격 브랜치가 로컬 브랜치보다 앞서 있을 수 있어요. pull 먼저 해보는 건 어떨까요?',
);
}
}
};
const doesRemoteBranchExist = (branch) => {
try {
execSync(`git ls-remote --exit-code --heads origin ${branch}`, { encoding: 'utf-8' });
return true;
} catch (error) {
return false;
}
}
pushBranch();
[push]
scripts/update_permissions.sh
스크립트를 통해 Husky 훅 스크립트와 Commitizen 관련 파일들의 실행 권한을 일괄적으로 설정할 수 있게 했다.
#!/bin/bash
# Husky 스크립트에 실행 권한 부여
chmod +x .husky/pre-commit
chmod +x .husky/commit-msg
# scripts 폴더 내 commitizen 스크립트에 실행 권한 부여
chmod +x ./scripts/commitizen.sh
echo "File permissions updated successfully."
권한은 다음과 같은 명령어로 실행하면 된다.
# 권한 오류가 발생하면, 아래 스크립트를 실행하여 필요한 권한 부여하세요~
chmod +x scripts/update_permissions.sh
./scripts/update_permissions.sh
프로젝트의 루트 디렉토리에 git-tools 폴더를 만들고, 그 안에 branch-config.js, commit-config.js, constant.js, push-config.js, util.js 파일들을 배치했다.
또한, scripts 폴더에는 commitizen.sh와 update_permissions.sh 파일을 추가했다.
📦 Root
├─ git-tools
│ ├─ branch-config.js
│ ├─ commit-config.js
│ ├─ constant.js
│ ├─ push-config.js
│ └─ util.js
├─ scripts
│ ├─ commitizen.sh
│ └─ update_permissions.sh
├─ package.json
└─ src
└─ app/
린팅 설정에서는 git-tools/ 폴더를 무시하도록 설정하여 개발 편의성을 높였다. 근데 ts 프로젝트이니, 일관성 있게 ts로 만드는게 맞긴 하나.. 우선 순수하게 개발용 기능이기에 불필요한 공수라고 생각한다.
.eslintrc.json
{
...생략
"ignorePatterns": ["**/git-tools/"]
}
이러한 커스텀 Git CLI 도구를 한 번 세팅해 놓으니 앞으로의 개발 과정이 훨씬 편해질 것 같다.
초기 설정에 시간이 좀 들긴 했지만, 이를 통해 일관된 커밋 메시지 작성, 올바른 브랜치 관리, 그리고 실수 방지 등 여러 이점을 얻을 수 있게 되었다. 특히 팀 프로젝트에서 이러한 도구의 활용은 협업의 효율성을 크게 높일 수 있을 것으로 기대된다!
틀린 부분이 있거나 개선점들은 알려주시면 감사하겠습니다!!!