← 홈Setup Tip

Setup Tip

Setup Tip — 태그를 push하기 전에 그 태그가 가리키는 커밋을 확인하라

어제 마지막 수정을 반영해 릴리스 태그를 다시 찍던 내 스크립트가, 새 커밋이 아니라 예전 커밋을 가리키는 태그를 먼저 push했다. 태그 push는 배포 CI를 시작하므로 그 실행을 취소하고 태그를 다시 만들어야 했다. 작은 저장소에서 같은 상황을 재현하고, 로컬 태그가 왜 조용히 이기는지, `git rev-parse`의 함정, 다른 클론이 예전 태그를 계속 들고 있는 이유, 그리고 잘못된 커밋의 태그 push를 막는 pre-push 훅까지 정리했다.

무슨 일이 있었나

어제 릴리스 태그를 찍은 뒤에 고쳐야 할 것이 하나 더 나왔다. 수정을 머지하고, 원격 태그를 지우고, 같은 이름의 태그를 새 커밋에 다시 찍는 스크립트를 돌렸다. 그런데 스크립트가 처음 push한 태그는 예전 커밋을 가리키고 있었다. 이 저장소에서는 태그 push가 패키지 배포 CI를 시작하므로, 실행을 취소하고 태그를 다시 만들어야 했다. 취소가 늦었다면 예전 코드가 새 버전 번호로 배포됐을 것이다.

재현: 지운 원격 태그와 남아 있는 로컬 태그

git 2.34.1로 bare 원격 저장소와 클론을 만들고, 커밋 5311e7d0에 주석 태그 v1.0.0을 찍어 push했다. 그 뒤 수정 커밋 26e7294a를 올렸다. 태그를 다시 찍으려고 git push origin :refs/tags/v1.0.0으로 원격 태그를 지우자 git ls-remote --tags origin은 아무것도 출력하지 않았다.

이 상태에서 git push origin v1.0.0을 실행하면 * [new tag] v1.0.0 -> v1.0.0과 함께 성공한다. 경고는 없다. 그런데 git ls-remote --tags origin으로 보면 refs/tags/v1.0.0^{}는 5311e7d0, 즉 예전 커밋이었다. 원격 태그는 지웠지만 로컬 태그는 한 번도 옮기지 않았기 때문이다.

다시 만들기가 실패해도 스크립트는 계속 간다

로컬 태그를 새 커밋에 다시 만들려고 git tag -a v1.0.0 -m v1.0.0 26e7294a를 실행하면 fatal: tag 'v1.0.0' already exists로 실패한다. 스크립트가 set -e 없이 돌거나 이 오류를 무시하면, 다음 줄의 push는 예전 태그를 그대로 보낸다. 실패한 한 줄 뒤에서 성공한 push가 문제를 가린다.

git rev-parse의 함정

주석 태그에 git rev-parse v1.0.0을 실행하면 c50190e4가 나왔다. 이것은 커밋이 아니라 태그 객체의 sha다. 같은 태그에 git rev-parse 'v1.0.0^{commit}'을 실행해야 커밋 5311e7d0이 나온다. 태그를 커밋 sha와 비교하는 검사를 ^{commit} 없이 쓰면 항상 불일치가 나고, 그러면 사람들은 결국 그 검사를 꺼 버린다.

다른 클론은 예전 태그를 계속 들고 있다

git tag -f로 로컬 태그를 옮긴 뒤 원격에 같은 이름의 태그가 이미 있으면 그냥 git push는 Updates were rejected because the tag already exists in the remote.로 거절된다. git push -f origin v1.0.0으로 덮어써야 원격이 26e7294a로 바뀐다.

원격을 고친 뒤에도, 예전 태그를 받아 둔 다른 클론에서 git fetch origin은 아무 출력 없이 끝났고 태그는 여전히 5311e7d0이었다. git fetch --tags origin은 ! [rejected] v1.0.0 -> v1.0.0 (would clobber existing tag)로 거절했다. git fetch --tags --force origin을 실행해야 t [tag update]와 함께 26e7294a로 바뀌었다. 태그를 옮겼다면 그 태그를 받아 둔 모든 곳에 알려야 한다.

안전한 태그 다시 찍기

의도한 커밋을 이름이 아니라 sha로 정하고, 태그를 그 sha에 강제로 다시 만든 뒤, push 전후에 한 번씩 확인한다.

  • want=26e7294a568cffe972c7e6d3d183d0644fe7ad12
  • git tag -f -a v1.0.0 -m v1.0.0 "$want"
  • test "$(git rev-parse 'v1.0.0^{commit}')" = "$want" || exit 1
  • git push -f origin refs/tags/v1.0.0
  • test "$(git ls-remote origin 'refs/tags/v1.0.0^{}' | cut -f1)" = "$want" || exit 1

재현에서 이 순서로 다시 찍자 확인이 통과했고, 원격의 refs/tags/v1.0.0^{}는 26e7294a였다. 로컬 태그 이름을 아예 거치지 않으려면 git push origin 26e7294a…:refs/tags/v1.0.0처럼 sha를 직접 push할 수도 있다. 다만 이렇게 만든 태그는 주석 없는 경량 태그이므로, 배포 과정이 주석 태그를 요구한다면 위 순서를 쓴다.

사람이 잊어도 막기: pre-push 훅

태그 push일 때 태그가 가리키는 커밋을 EXPECT_COMMIT 환경 변수와 비교하고, 변수가 없거나 다르면 거절하는 .git/hooks/pre-push를 만들었다. 훅은 표준 입력으로 로컬 ref, 로컬 sha, 원격 ref, 원격 sha를 받으므로 refs/tags/*일 때 git rev-parse "$lsha^{commit}"를 비교하면 된다. 태그 삭제는 로컬 sha가 0으로만 채워져 오므로 건너뛴다.

재현에서 로컬 태그가 예전 커밋을 가리킨 채 EXPECT_COMMIT를 새 커밋으로 주고 push하자 pre-push: refs/tags/v1.0.0 -> 5311e7d, expected 26e7294; refusing으로 실패했고 원격에는 태그가 생기지 않았다. 변수 없이 push하면 pre-push: set EXPECT_COMMIT to push refs/tags/v1.0.0으로 실패했다. git push --dry-run도 훅을 실행해 같은 이유로 실패하므로 미리 시험해 볼 수 있다. --no-verify를 붙이면 훅을 건너뛴다. 실수를 막는 장치이지, 작정한 push를 막는 장치는 아니다.

정리

태그 이름은 커밋을 가리키는 포인터일 뿐이고, 원격 태그를 지워도 로컬 포인터는 그대로 남는다. 태그를 push하기 전에 ^{commit}으로 가리키는 커밋을 확인하고, push 뒤에는 git ls-remote로 원격이 같은 커밋을 가리키는지 다시 읽는다. 태그 push가 배포를 시작하는 저장소라면 이 두 줄이 배포 취소보다 싸다.