[CI] Git CLI 커스텀 명령어로 워크플로우 개선하기: yarn commit, yarn push, yarn branch 구현

0x45c·2024년 9월 1일
1

CI

목록 보기
4/5
post-thumbnail

🎯 1. 목표

Git 워크플로우를 개선하고 팀 협업의 효율성을 높이기 위해 커스텀 CLI 도구를 세팅했다. 주요 기능으로는 일관된 커밋 메시지 형식 강제, 브랜치 이름 규칙 적용, 그리고 push 과정의 간소화가 있는데, 브랜치 이름 검증을 위해 Husky의 pre-push 훅은 사용하지 않았다. 대신, 브랜치를 만들 때 컨벤션에 맞춰서 만들어지도록 세팅했다. ( pre-push에서 branch 이름 규칙 검증하는거는 다음에 ...)


✌️ 먼저, 공용 파일들!

git-tools/util.js

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,
};

git-tools/constant.js

// 이슈, 브랜치 라벨
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성능 개선' },
];

package.json

"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"
    }
},

💬 2. commit 메시지 강제하기

커밋 메시지의 일관성을 유지하기 위해 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('❌ 커밋이 취소되었습니다.');
        }
      });
    });
  },
};

🌿 3. branch 생성/삭제/전환하기

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();

[생성]

[전환]

[삭제]


🚀 4. branch push하기

git-tools/push-config.js 파일은 현재 브랜치를 원격 저장소로 푸시하는 과정을 자동화했다.
로컬에 존재하는 브랜치 이름 중 선택할 수 있어서 귀찮게 브랜치 이름을 직접 입력 안해도 된다.

huskypre-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]


🔒 5. 파일 실행 권한 한번에 하기

scripts/update_permissions.sh 스크립트를 통해 Husky 훅 스크립트와 Commitizen 관련 파일들의 실행 권한을 일괄적으로 설정할 수 있게 했다.

scripts/update_permissions.sh

#!/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

📁 6. 전체 폴더 구조

프로젝트의 루트 디렉토리에 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 도구를 한 번 세팅해 놓으니 앞으로의 개발 과정이 훨씬 편해질 것 같다.
초기 설정에 시간이 좀 들긴 했지만, 이를 통해 일관된 커밋 메시지 작성, 올바른 브랜치 관리, 그리고 실수 방지 등 여러 이점을 얻을 수 있게 되었다. 특히 팀 프로젝트에서 이러한 도구의 활용은 협업의 효율성을 크게 높일 수 있을 것으로 기대된다!

틀린 부분이 있거나 개선점들은 알려주시면 감사하겠습니다!!!

profile
열심히 배워가고 있는 3년차 프론트엔드 개발자입니다 :)

0개의 댓글