Git refs, refspec 그리고 Gerrit 이벤트

Slide:

매일 사용하는 Git에서 각 브랜치와 커밋은 로컬의 파일시스템에 상태가 저장됩니다. 그런데 구체적으로 어떻게 이 상태들이 저장되는 걸까요? 그리고, git 커맨드에서 각 브랜치와 커밋을 참조하는 방법에는 어떤 것들이 있을까요?

이런 고민을 많이 하게 되는 시점은 바로 CI/CD 라인을 설계할 때입니다. Github이나 GitLab 같은 경우는 이러한 CI/CD 지원이 굉장히 잘 되어 있어서 별다른 문제가 없습니다. 하지만 Gerrit의 경우는 브랜치 또는 태그 생성, 체인지 업로드, 또는 체인지 머지와 같은 이벤트 웹훅으로만 이를 지원하기 때문에 CI/CD 파이프라인에 연동하는 것이 까다로운 편입니다. 이번 포스팅에서는 게릿 이벤트를 이해하기 위해서 필요한 지식인 Git의 "ref"에 대해서 자세히 정리해 보겠습니다.

refs

일반적으로 특정 커밋은 커밋 해시(Commit hash)를 통해서 참조가 가능합니다. 현재 브랜치에 가장 최근에 머지된 커밋으로부터 과거의 모든 커밋 히스토리를 보려면 git log 를 사용하면 됩니다. 여기서 특정 커밋의 커밋 해쉬를 찾을 수 있습니다. 아래 예시에서는 main 브랜치를 기준으로 가장 최근 커밋을 찾았고, 이에 해당하는 fc593648dc05cecb3a2fa425ef68c826a440a9ad 가 해쉬값입니다.

$ git log

commit fc593648dc05cecb3a2fa425ef68c826a440a9ad
Author: Indosaram <freedomzero91@gmail.com>
Date:   Sun Oct 2 15:15:12 2022 +0900

현재 브랜치가 아닌 특정 브랜치에 가장 최근에 머지된 커밋의 해시를 찾는 방법은 다음과 같습니다. 현재 브랜치가 main 이므로 위에서 찾은 해시값과 같은 결과가 나오게 됩니다.

$ git rev-parse main

fc593648dc05cecb3a2fa425ef68c826a440a9ad

3가지 refs

refs란 git에서 우리가 원하는 커밋을 좀더 쉽게 참조할 수 있도록 만들어진 기능으로, 총 3가지가 존재합니다.

  • heads : 로컬 브랜치
  • remotes : 리모트 브랜치
  • tag : 태그

여기서 태그(tag)란, 일반적으로 릴리즈에 사용되는 기능으로 보통 "v1.0"과 같이 표시합니다. 커밋이 푸시될 때마다 브랜치는 계속 업데이트되지만, 태그는 생성될 때의 커밋의 상태를 나타내는 정적인 기능입니다.

로컬 저장소의 refs의 정보는 .git 폴더 밑의 refs 폴더에서 확인 가능합니다. 해당 폴더는 아래와 같은 구조를 가지고 있습니다. 위에서 설명한 대로 heads, remotes, tags 3개 폴더를 가지고 있습니다.

.git/refs
|
├── heads
│   └── main
├── remotes
│   └── origin
│       ├── HEAD
│       └── main
└── tags

예를 들어, 로컬 브랜치의 헤드를 알고 싶다면 아래와 같이 파일을 읽어들이면 됩니다. 위에서 구했던 main 브랜치의 HEAD 와 동일한 커밋임을 알 수 있습니다.

$ cat heads/main

fc593648dc05cecb3a2fa425ef68c826a440a9ad

로컬에서만 사용되는 refs

로컬 저장소에서 사용되는 몇 가지 특별한 refs가 존재합니다. 이 refs들은 .git 폴더 안에 위치해 있습니다. 예를 들어 HEAD 라는 refs는 다음과 같이 현재 git이 바라보고 있는 브랜치와 커밋이 어디인지를 나타냅니다.

$ cat .git/HEAD
ref: refs/heads/master

$ git checkout remote
Switched to branch 'remote'

$ cat .git/HEAD
ref: refs/heads/remote

이러한 로컬 refs는 총 5가지가 존재합니다. 이 중에서 HEAD 가 무슨 의미인지만 알아도 충분합니다.

  • HEAD – 현재 체크아웃(checked-out)된 commit 또는 branch를 나타냅니다.
  • FETCH_HEAD – 리모트에서 가장 최근에 가져온(fetched) 브랜치를 나타냅니다.
  • ORIG_HEAD –  HEAD 에 큰 변경사항을 만들지 전의 백업 레퍼런스입니다.
  • MERGE_HEADgit merge 를 사용해서 현재 브랜치에 다른 브랜치를 머지할 때, 합치고 싶은 다른 브랜치의 커밋을 나타냅니다.
  • CHERRY_PICK_HEAD – 체리픽(cherry-picking)을 수행할 때 사용하는 레퍼런스입니다.

상대 레퍼런스(relative refs)

로컬 레퍼런스로부터 상대적인 위치를 나타내는 방법

git show HEAD~2

refname :, e.g. master, heads/master, refs/heads/master

refspec

refspec은 로컬에 저장되어 있는 브랜치를 리모트에 있는 브랜치와 연결시켜주는 역할을 수행합니다. git은 리모트와 로컬 모두에서 작동하기 때문에 로컬에서 수행한 작업을 리모트에도 반영시킬 수 있습니다. 이때 로컬에서 한 작업을 리모트의 어떤 브랜치에 적용시킬지를 정해야하는데, 이 정보를 refspec에 작성합니다.

개발자들이 주로 사용하는 깃허브의 경우를 예로 들면, 처음 저장소를 만들 때 아래와 같은 명령어를 입력하라는 화면이 나옵니다.

여기서 중요한 부분은 바로 다음 부분입니다.

git remote add origin https://github.com/Indosaram/test.git

여기서 "remote"에 뭔가를 추가(add)한다는 것을 알 수 있습니다. 결국 "origin"을 "https://github.com/Indosaram/test.git"라는 리모트 저장소로 지정한다는 의미입니다. 여기서 "origin"은 로컬 저장소가 바라보고 있는 리모트 저장소를 의미합니다. 그렇기 떄문에 우리가 로컬에서 커밋을 만드는 등의 작업을 수행한 다음, 이 변경사항을 리모트에 반영하려면 다음과 같이 입력합니다.

git push -u origin main

지금까지 설명한 내용을 바탕으로 유추해보면, 이 명령어는 리모트의 main 브랜치에 현재 변경사항을 업로드한다는 의미인 것입니다. 이렇게 추가한 origin에 대한 정보가 바로 refspec에 저장되어 있습니다.

git의 refspec

Github, Gitbucket과 같은 일반적인 리모트 저장소 서비스들은 git의 기본 refspec 설정을 사용합니다. git에서는 refspec을 [+]<src>:<dest>의 형태로 정의합니다. 여기서 <src> 는 로컬 브랜치를, <dest> 는 리모트 브랜치를 의미합니다.

+ 기호는 리모트 저장소가 Fast-forward 업데이트를 하지 않도록 강제하는 옵션으로, 아래 예제와 같이 선택적으로 사용 가능합니다.
<src>:<dest>

로컬 저장소에서 .git/config 파일을 열어서 remote "origin" 부분을 확인하면 다음과 같습니다.

[remote "origin"]
	url = git@github.com:Indosaram/test.git
	fetch = +refs/heads/*:refs/remotes/origin/*

+가 포함되어 있기 때문에 Fast-forward를 강제하지 않습니다. 그리고 *가 포함되어 있기 때문에 로컬의 모든 브랜치를 리모트의 모든 브랜치를 기준으로 업데이트한다는 의미입니다. 여러 개의 브랜치가 들어 있는 로컬 저장소에서 git fetch 를 하면 여러 브랜치가 동시에 업데이트되는 것이 바로 이 때문입니다.

Gerrit의 refspec

게릿은 커밋 단위가 아니라 체인지(change)단위로 변경 사항을 추적합니다. 따라서 로컬의 변경 사항이 리모트의 어떤 패치셋에 반영될지를 매번 커밋을 푸시할 때마다 명시해 주어야 합니다. 이때 refs/for 라는 레퍼런스를 사용하는데, 아래 예제에서는 master 브랜치를 가리키고 있습니다. 즉 아래 명령어를 실행하면 리모트의 master 브랜치에 새로운 체인지가 생성됩니다.

git push origin HEAD:refs/for/master

한 번 체인지가 생성된 다음에는 자동적으로 체인지 일련번호(change id)가 생성됩니다. 따라서 동일한 명령어로 같은 체인지에 새로운 패치셋을 이어서 업로드할 수 있습니다.

$ git log -1
  commit 29a6bb1a059aef021ac39d342499191278518d1d
  Author: A. U. Thor <author@example.com>
  Date: Thu Aug 20 12:46:50 2009 -0700

      Improve foo widget by attaching a bar.

      We want a bar, because it improves the foo by providing more
      wizbangery to the dowhatimeanery.

      Bug: #42
      Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
      Signed-off-by: A. U. Thor <author@example.com>
      CC: R. E. Viewer <reviewer@example.com>
참고로, git 클라이언트에서 commit-msg 훅을 설치했다면 로컬에서 커밋을 만들 때 이 change-id가 생성됩니다.

게릿 이벤트

게릿은 저장소에 발생하는 이벤트를 외부에 알릴 수 있는 훅(Hook)을 가지고 있습니다. 그런데 여기서 이벤트란 무엇일까요? 게릿이 가지고 있는 이벤트 목록은 아래 링크에서 참고하시면 됩니다.

https://gerrit.googlesource.com/plugins/hooks/+/HEAD/src/main/resources/Documentation/hooks.md

이 중에서 제일 헷갈리는 훅인 ref-update 만 따로 살펴보려고 합니다.

ref-update

게릿이 다음 세 가지를 수신한 경우를 ref-update 라고 합니다. 세 가지 모두 새로운 커밋이 브랜치에 푸시된다는 공통점이 있습니다.

  • 브랜치에 직접 푸시
  • 패스트포워드(FF, Fast-forward)가 아닌 업데이트
  • ref 삭제

비슷한 훅으로 commit-received 가 있는데, commit-received 는 훅의 실행이 실패하면 코드가 업데이트되지 않고, 그 결과가 사용자에게 반환된다는 차이점이 있습니다. 따라서 모든 커밋 푸시가 브랜치에 반영되지는 않습니다. 반면 ref-update 는 커밋 푸시가 되면 반드시 실행되는 훅입니다.

레퍼런스

Refs and the Reflog

10.5 Git의 내부 - Refspec

Supported Hooks