Skip to content

Git Hooks와 커밋 컨벤션

Youngho Kim edited this page Nov 23, 2022 · 2 revisions
  • 작성자: J045_김영호

Git Hooks

Git Hooks란

Git Hooks는 Git 과 관련한 어떤 이벤트가 발생하였을 때, 특정 스크립트를 실행할 수 있도록 해주는 기능이다. 크게는 Client Hook과 Server Hook으로 나뉜다. 특히, Client Hook은 Commit, Merge 발생 시, 혹은 Push 직전에 클라이언트에서 실행하는 Hook이며, Server Hook은 Repo로의 Push가 발생하였을 때 서버에서 실행하는 Hook을 의미한다.

Client Hook

Client Hook은 Commit Workflow Hook, Email Workflow Hook, 기타 Hook으로 나눌 수 있다.

  • Commit Workflow Hook

    • git commit 명령으로 커밋을 할 때 실행되는 훅
  • Email Workflow Hook

    • git am 명령으로 이메일을 통해 patch 파일을 적용할 때 실행하는 훅
  • 기타 Hook

    • rebase , merge, push와 같은 이벤트를 실행할 때 실행하는 훅 등

    각각에 포함되는 훅은 다음과 같다.

분류 설명
Commit Workflow Hook pre-commit commit 실행 이전에 실행되는 Hook
prepare-commit-msg commit 메세지를 생성하고 편집기를 실행하기 전에 실행되는 Hook
commit-msg commit 메세지를 완성한 후 commit을 최종 완료하기 전에 실행
post-commit commit 완료 후 실행
Email Workflow Hook applypatch-msg git am 명령 실행 시 가장 먼저 실행
pre-applypatch patch 적용 후 실행하며, 이 단계에서 patch를 중단할 수 있음.
post-applypatch git am 명령 마지막에 실행되며, 이 단계에서는 중단할 수 없음.
기타 Hook pre-rebase Rebase 이전에 실행
post-rebase git commit -amend, get rebase와 같이 commit을 변경하는 명령을 실행한 후 실행된다.
post-merge Merge 종료 이후 실행된다.
pre-push git push 명령 실행 시 동작하며, 리모드 정보 업데이트 이후 리모트로 데이터를 전송하기 전에 실행된다. push를 중단시킬 수 있다.

Hooks 적용 방법

모든 Hooks들은 .git/hooks 디렉토리 안에 저장하며, 실행하고자 하는 스크립트를 훅 이름으로 확장자 없이 저장하면 적용된다.

가령, pre-commit 훅을 적용하고자 할 경우, .git/hooks/pre-commit이라는 이름의 스크립트 파일을 저장하면 된다. 이때, exit 코드로 0을 줄 경우 커밋이 실행되며, 아닐 경우 커밋이 취소됨을 인지하자.

Husky

왜 사용해야할까?

보통 Hooks를 사용하는 경우는 팀 내에서 설정한 정책을 따르도록 강제하기 위함이다. 하지만 이런 Hook을 수동으로 .git/hooks에 적용하라고 한다면 실수로든 고의로든 빠뜨리고, 따라서 정책을 지키지 않는 경우가 생기게 된다. 그럴 바에 clone 시에 자동으로 적용되도록 강제하는 편이 좋을 것이다. Husky는 이러한 것을 만족시켜준다.

Husky란

Husky는 Git Hooks를 쉽게 적용할 수 있도록 해주는 npm 모듈이다. Hooks에 대해 자세히 모르더라도 정책들을 관리 및 공유할 수 있다. Husky는 Hooks를 package.json 혹은 .huskyrc에서 정의할 수 있다.

Husky가 추가되는 경우, 특정 Hook을 위한 스크립트의 경로를 재지정한다고 보면 된다.

Husky를 미리 설정해둔 경우, npm install 명령을 입력하여 husky가 다운로드되면, husky의 package.json에 지정된 install 스크립트가 실행되어 자동으로 세팅을 해준다. 그래서 단순히 다운로드하는 것만으로도 미리 세팅된 값들을 자동설정할 수 있는 것이다.

Commitlint

Commit Message 포맷 강제하기

commit 메세지의 포맷을 강제하고자 하는 경우, commitlint 패키지를 사용한다. 해당 패키지는 conventional-commit 규격을 따르며, 해당 규격에 맞추어 규칙을 정하게 된다. 규격은 다음과 같다.

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

위의 경우 type, scope, description, body, footer로 구성요소가 나뉘며, 각각에 대해 사전에 정의된 규칙을 적용할 수 있다. 주의할 점은, 정해진 규칙 외에 새로운 규칙을 추가할 수 없다. 사전에 정의된 규칙 목록과 상세는 링크를 참조한다. (https://commitlint.js.org/#/reference-rules)

각 규칙을 정의할 때에는 다음으로 구성된 배열을 지정해준다. (rule:[Level, Applicable, Value])

  • Level

    • 0 : 규칙을 적용하지 않는다.
    • 1 : 규칙을 확인하되, 경고만 한다.
    • 2 : 규칙과 일치하지 않으면 경고와 함께 commit을 취소한다.
  • Applicable

    • 'always' : 규칙을 그대로 적용한다.
    • 'never' : 규칙에 대해 not을 적용한다.
    • ex) body-empty 규칙을 'always' 로 적용하면 body가 없을 때에만 commit을 허용하며, 'never'로 적용하면 body가 반드시 있어야만 commit을 허용한다.
  • value

    • 해당 규칙에 사용할 값

    commitlint를 사용하고자 할 경우, 반드시 commitlint.config.js 를 생성해야만 한다. 기본 양식은 다음과 같으며, 상황에 따라 추가적인 설정을 가할 수 있다. 일반적으로 extendsrules만을 추가해서 사용한다.

  • JS (commitlint.config.js)

const Configuration = {
  /*
   * Resolve and load @commitlint/config-conventional from node_modules.
   * Referenced packages must be installed
   */
  extends: ['@commitlint/config-conventional'],
  /*
   * Resolve and load conventional-changelog-atom from node_modules.
   * Referenced packages must be installed
   */
  parserPreset: 'conventional-changelog-atom',
  /*
   * Resolve and load @commitlint/format from node_modules.
   * Referenced package must be installed
   */
  formatter: '@commitlint/format',
  /*
   * Any rules defined here will override rules from @commitlint/config-conventional
   */
  rules: {
    'type-enum': [2, 'always', ['foo']],
  },
  /*
   * Functions that return true if commitlint should ignore the given message.
   */
  ignores: [(commit) => commit === ''],
  /*
   * Whether commitlint uses the default ignore rules.
   */
  defaultIgnores: true,
  /*
   * Custom URL to show upon failure
   */
  helpUrl:
    'https://github.com/conventional-changelog/commitlint/#what-is-commitlint',
  /*
   * Custom prompt configs
   */
  prompt: {
    messages: {},
    questions: {
      type: {
        description: 'please input type:',
      },
    },
  },
};

module.exports = Configuration;
  • TS (commitlint.config.ts)
import type {UserConfig} from '@commitlint/types';

const Configuration: UserConfig = {
  /*
   * Resolve and load @commitlint/config-conventional from node_modules.
   * Referenced packages must be installed
   */
  extends: ['@commitlint/config-conventional'],
  /*
   * Resolve and load conventional-changelog-atom from node_modules.
   * Referenced packages must be installed
   */
  parserPreset: 'conventional-changelog-atom',
  /*
   * Resolve and load @commitlint/format from node_modules.
   * Referenced package must be installed
   */
  formatter: '@commitlint/format',
  /*
   * Any rules defined here will override rules from @commitlint/config-conventional
   */
  rules: {
    'type-enum': [2, 'always', ['foo']],
  },
  /*
   * Functions that return true if commitlint should ignore the given message.
   */
  ignores: [(commit) => commit === ''],
  /*
   * Whether commitlint uses the default ignore rules.
   */
  defaultIgnores: true,
  /*
   * Custom URL to show upon failure
   */
  helpUrl:
    'https://github.com/conventional-changelog/commitlint/#what-is-commitlint',
  /*
   * Custom prompt configs
   */
  prompt: {
    messages: {},
    questions: {
      type: {
        description: 'please input type:',
      },
    },
  },
};

module.exports = Configuration;

일반적으로 commitlint는 commitlint가 설치된 프로젝트의 root 폴더에 있는 것을 사용한다. 만약 다른 폴더에 저장하고자 할 경우, --config <config 위치> 를 통해 별도로 지정할 수 있다. husky와 함께 사용할 경우 다음과 같이 쓰게 된다.

npx husky add <git hooks 스크립트 위치> 'npx commitlint --config <config 위치> --edit "$1"'

스크립트가 실행될 때의 시작 경로는 git 폴더가 위치한, root 폴더임을 인지하고, 해당 위치에 commitlint가 없으면 commitlint가 설치된 위치로 이동해주어야만 실행된다는 것을 명심하자.

사용자 정의 룰을 사용하고 싶다면…?

지금까지 공부한 바로는 정의된 룰 이외의 룰은 추가할 수 없고, 원하는 룰을 추가하자고 formatter를 직접 개발하는 것은 과도한 낭비인 경우가 많다. 이러한 경우 conventional-commits의 형식은 최대한 지키되, 원하는 룰을 적용하고자 할 경우 function-rules를 고려해볼만 하다.

plugincommitlint-plugin-function-rules을 이용하면, 함수형 룰을 지정할 수 있어 룰을 자기 입맛대로 변경하는 것이 가능하다. 이를 사용하고자 할 경우, config 파일에 다음과 같은 키워드를 추가하면 된다.

plugins: ['commitlint-plugin-function-rules'],

이 플러그인이 로드될 경우 function-rules/[ruleName] 형식의 룰셋이 추가된다. 함수형 룰은 해당 룰에서만 지정할 수 있으며, 오작동을 방지하기 위해 동명은 룰은 [0]을 주어 기존의 룰은 끄는 것을 권장한다.

함수형 룰은 parse라는, config에 지정된 파서에 따라 메세지를 파싱한 인스턴스를 받는 매개변수가 존재하며, 반환값은 항상 배열로 제공되어야만 한다. 반환값은 [result, errorMessage] 형식을 가져야만 한다.

  • result

    • 룰의 통과 여부를 결정한다. true일 경우 룰을 통과한 것으로 간주한다.
  • errorMessage

    • resultfalse인 경우, 실패 사유를 나타내는데 쓰인다.

    이를 바탕으로 작성한 코드는 다음과 같다. 지키고자 했던 commit 컨벤션은 하단과 같다. (영어 표기 강제는 반영하지 않았다.)

# <타입>: <fe|be> - <제목> <#1>

##### 제목은 최대 50 글자까지만 입력 ############## -> |

# 본문은 위에 작성
######## 본문은 한 줄에 최대 72 글자까지만 입력 ########################### -> |
# --- COMMIT END ---
# <타입> 리스트
#   feat    : 기능 (새로운 기능)
#   fix     : 버그 (버그 수정)
#   refactor: 리팩토링
#   style   : 스타일 (코드 형식, 세미콜론 추가: 비즈니스 로직에 변경 없음)
#   docs    : 문서 (문서 추가, 수정, 삭제)
#   test    : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직에 변경 없음)
#   chore   : 기타 변경사항 (빌드 스크립트 수정, 패키지 설치 등)
# ------------------
#     타입은 영어로 작성하고 제목과 본문은 한글로 작성한다.
#     제목 끝에 마침표(.) 금지
#     제목과 본문을 한 줄 띄워 분리하기
#     본문은 "어떻게" 보다 "무엇을", "왜"를 설명한다.
#     본문에 여러줄의 메시지를 작성할 땐 "-"로 구분
#     관련된 이슈번호는 제목 맨 뒤에 추가한다. ex. #1
# ------------------
module.exports = {
  extends: ['@commitlint/config-conventional'],
  plugins: ['commitlint-plugin-function-rules'],
  rules: {
    // 스코프는 컨벤션과 맞지 않기에, 사용하지 않는 것으로 한다.
    'scope-empty': [2,'always'],
    // 헤더의 길이는 50자로 제한한다.
    'header-max-length': [2, 'always', 50],
    // 본문의 한 줄은 72자로 제한한다. 
    'body-max-line-length': [2, 'always', 72],
    // 타입은 아래의 태그만 사용하도록 한다.
    'type-enum': [2, 'always', ['feat','fix','refactor','style','docs','test','chore']],
    // 이슈 번호로 종료되는지, .으로 끝나지 않는지 확인한다.
    // fe, be로 시작하는지 확인
    // 제목이 한글인지 확인
    'subject-full-stop': [0],
    'function-rules/subject-full-stop': [
      2,
      'always',
      ({ subject, header, body, raw })=>{

        // 1. 이슈 번호로 종료되는가?
        if (subject && !/[^\.] #[0-9]+$/.test(subject))
          return [false, 'subject는 issue 번호를 #[number] 형식으로 끝에 포함해야하며, .(점) 으로 끝나지 말아야 합니다.'];
        
        // 2. FE / BE 로 시작하는가?
        if (subject && !/^(fe|be|all) ?- ?/.test(subject.trim()))
          return [false, 'subject는 "FE - " 혹은 "BE - "로 시작해야 합니다.'];

        // 3. 제목이 한글로 작성되었는가?
        // if (!/[a-z]+/i.test(subject.trim()))
        //   return [false, '제목(subject)은 한글로 작성해주십시오. 부득이한 경우 담당자에게 문의 바랍니다.'];
        
        // 4. 제목과 바디가 공백으로 분리되어있는가?
        if (body && raw && header && raw.search('\n\n') !== header.length)
          return [false, '제목과 본문은 공백으로 구분해주시기 바랍니다.'];
        
        return [true];
    }],
    'body-leading-blank': [0],
    'function-rules/body-leading-blank': [
      2,
      'always',
      ({ body })=>{
        // body가 없으면 굳이 판단하지 않는다.
        if (!body)
          return [true];
        if (body.split('\n').filter(line=>!/ *-/.test(line)).length > 0)
          return [false, '본문의 각 줄은 - 으로 시작해야 합니다.'];
        return [true];
      }
    ],
  },
}

다만 주의할 점은, 룰의 명칭 자체가 달라지는 것이 아니기에 원하는 룰을 구현하기 위해 룰의 명칭과 부합하지 않음에도 이름을 빌려써야 하는 경우가 종종 생긴다는 점을 주의해야하며, 기존의 명칭 외의 다른 이름을 부여하는 것 또한 불가능하기에 혼동에 주의하여야 하고, 따라서 오류 메세지를 최대한 정확하게 작성하는 것이 중요하다.

📚 그라운드 룰

✏️ 컨벤션

🧑‍🏫 멘토링

📁 애자일 프로세스

기획
데일리 스크럼
스프린트 리뷰
스프린트 회고
트러블 슈팅
기타 산출물

📖 기술문서

Week2
Week3
Week4
Week5

🗂 참고문서

Clone this wiki locally