git 커밋 메시지 앞에 이슈 넘버 입력 자동화하기

git을 사용하는 대부분의 개발 팀에서는 약속된 git 브랜치 전략이나 git 커밋 메시지에 대한 컨벤션을 가지고 있다.
내가 속한 개발 팀 역시 이러한 컨벤션을 가지고 있고, 간단하게는 아래와 같다.

  • 이슈 추적 시스템(issue tracking system)으로 JIRA를 사용하고 있고, 이슈에 해당하는 feature 브랜치를 따서 PR을 올린다.
    • ex) feature/XXX-123
  • PR에 속한 커밋에는 이슈 넘버를 메세지 앞에 붙인다.
    • ex) [XXX-123] message...

이를 바탕으로 내가 자동화하고 싶은 요구사항을 커밋 메시지의 관점에서 정리하면,

  • $ git commit -v 으로 커밋을 생성할 때 적용된다.
  • feat/XXX-123 또는 feature/XXX-123 형식의 이름을 가진 브랜치에서 커밋을 생성할 시에는 커밋 메시지 앞에 [XXX-123] 를 붙인다.
    • ex) commit message: [XXX-123] message...
  • feat/XXX-123, feature/XXX-123 형식의 이름이 아닌 브랜치에서 커밋을 생성할 시에는 커밋 메시지 앞에 [NO-ISSUE] 를 붙인다.
    • ex) commit message: [NO-ISSUE] message...
  • Merge, Revert 커밋 메시지 앞에는 아무것도 붙이지 않는다.
    • ex) commit message: Merge branch '...'

이 요구사항을 어떻게 해결하였는지에 대해 한번 정리해보았다. (시간이 없으신 분들은 맨 밑의 최종 스크립트만 확인해주셔도 될것 같다.)
물론, 각 팀마다 컨벤션은 다르기 때문에 어디까지나 참고용이고 비슷한 작업이 필요할 때 제 글이 도움이 되길 빈다.


자동화하기

prepare-commit-msg

git에서는 git의 각 액션별로 hook을 걸어 실행할 수 있는 스크립트 sample들을 .git/hooks 디렉토리 내에 제공해주고 있다. *.sample 로 끝나는 파일을 입맞에 맞게 편집한 후, .sample를 파일 이름에서 제거하면 스크립트는 해당하는 hook에 걸려 실행된다.
많은 hook들 중에 우리가 건드려야할 건 prepare-commit-msg hook이다. 이 hook은 git이 커밋 메시지를 생성하고 나서 편집기를 실행하기 전에 실행된다.

먼저, prepare-commit-msg 파일을 생성해서 (또는 prepare-commit-msg.sample의 이름을 수정해서) 한번 테스트해보자.

#!/bin/sh
# .git/hooks/prepare-commit-msg

COMMIT_MSG_FILE=$1  
DEFAULT_COMMIT_MSG=$(cat $COMMIT_MSG_FILE)  
echo "$DEFAULT_COMMIT_MSG" > $COMMIT_MSG_FILE  

prepare-commit-msg 이 실행될 때 첫번째 인자로 커밋 메시지 파일 이름을 받게 된다. 이 파일의 이름은 .git/COMMIT_EDITMSG 으로 커밋이 생성될 시 여기 파일에 커밋 메시지가 기록된다.
위의 예제에서는 이 파일 전체를 읽어서 다시 이 파일에 그대로 쓰도록 스크립트를 짰기 때문에, 결국 우리가 지금처럼 커밋을 생성할 때와 아무런 차이가 없다.

브랜치 이름에서 이슈 넘버 추출

먼저, 현재 내가 checkout한 브랜치 이름을 가져와야 한다. 이는 아래 git 명령어로 간단하게 가져올 수 있다.

$ git rev-parse --abbrev-ref HEAD
master  

feat/XXX-123 또는 feature/XXX-123 패턴에 해당하는 브랜치 이름에서 XXX-123 을 추출하기 위해 아래와 같이 스크립트를 작성해보았다.

CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)  
ISSUE_TICKET=""  
if [[ $CURRENT_BRANCH_NAME =~ (feature|feat)/([A-Z]+-[0-9]+) ]]; then  
  ISSUE_TICKET="${BASH_REMATCH[2]}"
fi  
echo $ISSUE_TICKET  

여기서 위에서 선언하지도 않은 BASH_REMATCH 변수가 나와서 이건 뭐야 하는 분들도 계실텐데, 그런 분들은 여기 를 참고하시면 좋을 것 같다.

최종

여기까지 종합해서, 요구사항에 맞춰 prepare-commit-msg 를 완성해보면,

#!/bin/sh
# .git/hooks/prepare-commit-msg

COMMIT_MSG_FILE=$1  
DEFAULT_COMMIT_MSG=$(cat $COMMIT_MSG_FILE)

CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)  
ISSUE_TICKET="NO-ISSUE"  # 추출된 이슈 없는 경우 커밋 메시지는 "[NO_ISSUE] ..."  
if [[ $CURRENT_BRANCH_NAME =~ (feature|feat)/([A-Z]+-[0-9]+) ]]; then  
    ISSUE_TICKET="${BASH_REMATCH[2]}"
fi

echo "[$ISSUE_TICKET] $DEFAULT_COMMIT_MSG" > $COMMIT_MSG_FILE  

최종_최종

아, 여기서 한가지 요구사항 반영하지 않은 것이 있다.

Merge, Revert 커밋 메시지 앞에는 아무것도 붙이지 않는다.

현재 위의 스크립트에서는 Merge, Revert 커밋이 생성될 시에도 아래처럼 이슈 넘버가 앞에 붙게 된다.

커밋 메시지가 "Merge" 또는 "Revert" 로 시작할 시에는 이슈 넘버를 붙이지 않도록 수정하였다.

#!/bin/sh
# .git/hooks/prepare-commit-msg

COMMIT_MSG_FILE=$1  
DEFAULT_COMMIT_MSG=$(cat $COMMIT_MSG_FILE)

CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)  
ISSUE_TICKET="NO-ISSUE"  # 추출된 이슈 없는 경우 커밋 메시지는 "[NO_ISSUE] ..."  
if [[ $CURRENT_BRANCH_NAME =~ (feature|feat)/([A-Z]+-[0-9]+) ]]; then  
  ISSUE_TICKET="${BASH_REMATCH[2]}"
fi

if grep -q -E "^Merge|^Revert" $COMMIT_MSG_FILE; then  
    # Merge, Revert 커밋은 이슈 넘버 붙이지 않음
    echo "$DEFAULT_COMMIT_MSG" > $COMMIT_MSG_FILE
else  
    echo "[$ISSUE_TICKET] $DEFAULT_COMMIT_MSG" > $COMMIT_MSG_FILE
fi


최종_진짜최종

사실 여기까지만 해놓아도, git을 일반적으로 사용하기에는 충분한 스크립트이다.
하지만 git의 다양한 고급(?) 명령어들을 사용할 때 중복으로 이슈 넘버가 붙는 문제점이 생겨서 이를 위한 추가적인 예외 처리가 더 필요하다. (블로그에 이 글을 쓴 이유기도 하다.)

예를 들어, 나는 마지막 내 커밋을 수정할 때 $ git commit --amend,
특정 커밋을 가져올 때 $ git cherry-pick [commit sha] 을 자주 사용한다.
둘다 이미 존재하는 커밋을 이용한다는 공통점이 있는데, 이 경우에 이슈 넘버가 한번 더 붙게 되어 아래처럼 중복되게 된다.

사실 위의 Merge, Revert 커밋 예외 처리도 어떻게 보면 커밋 메시지 텍스트에 종속되는 workaround 였는데, 이제 commit ammend와 cherry-pick까지 대응하려면 결국 근본적으로 내가 생성할 커밋의 출처를 알아내야만 한다.

여기서 아까 위에서 언급된, prepare-commit-msg 의 sample 파일 (prepare-commit-msg.sample)을 다시 열어보자.

# .git/hooks/prepare-commit-msg.sample
# ...
# ...
# The third example adds a Signed-off-by line to the message, that can
# still be edited.  This is rarely a good idea.

COMMIT_MSG_FILE=$1  
COMMIT_SOURCE=$2  
SHA1=$3  

사실 우리는 첫번째 인자, COMMIT_MSG_FILE 만 사용하였지만, 두번째 인자로 커밋의 출처를 의미하는 COMMIT_SOURCE 변수가 하나 더 들어온다.

Git 공식 문서 에도 이 파라미터에 대한 설명을 볼 수 있다.

The second is the source of the commit message, and can be: message (if a -m or -F option was given); template (if a -t option was given or the configuration option commit.template is set); merge (if the commit is a merge or a .git/MERGE_MSG file exists); squash (if a .git/SQUASH_MSG file exists); or commit, followed by a commit object name (if a -c, -C or --amend option was given).

하지만 이것만으로는 명확하게 이해가 안 되는 부분이 있어, 직접 테스트해보았고 각 케이스별 COMMIT_SOURCE 의 값을 정리해보았다.

  • git commit -v: 없음
  • git commit -m (-m 옵션으로 커밋 생성): message
  • git revert (Revert 커밋 생성): merge
  • git merge (Merge 커밋 생성): merge
  • git commit --amend (마지막 커밋 수정): commit
  • git cherry-pick (Cherry pick하여 생성): message

아까 Merge, Revert 커밋의 케이스까지 포함해서 결국 한마디로

git commit -v: 없음

이 경우에만 이슈 넘버를 붙이고, 그 외의 경우는 모두 지금과 동일하게 커밋 메시지를 생성하면 된다는 결론이 나온다.
아까의 if 조건을 COMMIT_SOURCE 값을 체크하는 조건으로 수정하면,

#!/bin/sh
# .git/hooks/prepare-commit-msg

COMMIT_MSG_FILE=$1  
COMMIT_SOURCE=$2  
DEFAULT_COMMIT_MSG=$(cat $COMMIT_MSG_FILE)

CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)  
ISSUE_TICKET="NO-ISSUE"  # 추출된 이슈 없는 경우 커밋 메시지는 "[NO_ISSUE] ..."  
if [[ $CURRENT_BRANCH_NAME =~ (feature|feat)/([A-Z]+-[0-9]+) ]]; then  
  ISSUE_TICKET="${BASH_REMATCH[2]}"
fi

if [ -n "$COMMIT_SOURCE" ]; then  
    echo "$DEFAULT_COMMIT_MSG" > $COMMIT_MSG_FILE
else  
    echo "[$ISSUE_TICKET] $DEFAULT_COMMIT_MSG" > $COMMIT_MSG_FILE
fi  

References