Git의 내부

여기까지 다 읽고 왔든지 바로 여기부터 보기 시작했던지 간에 이제 마지막 장이다. 이번 장은 Git이 어떻게 구현돼 있고 내부적으로 어떻게 동작하는지 설명한다. Git이 얼마나 유용하고 강력한지 이해하려면 이 장의 내용을 꼭 알아야 한다. 이 장은 초보자에게 너무 혼란스럽고 불필요한 내용이라고 이야기하는 사람들도 있다. 그래서 필자는 본 내용을 책의 가장 마지막에 두었고 독자가 스스로 먼저 볼지 나중에 볼지 선택할 수 있도록 했다.

자 이제 본격적으로 살펴보자. 우선 Git은 기본적으로 Content-addressable 파일 시스템이고 그 위에 VCS 사용자 인터페이스가 있는 구조다. 뭔가 깔끔한 정의는 아니지만, 이 말이 무슨 의미인지는 차차 알게 된다.

Git 초기에는 (1.5 이전 버전) 사용자 인터페이스가 훨씬 복잡했었다. VCS가 아니라 파일 시스템을 강조했기 때문이었다. 최근 몇 년간 Git은 다른 VCS처럼 쉽고 간결하게 사용자 인터페이스를 다듬어 왔다. 하지만, 여전히 복잡하고 배우기 어렵다는 선입견이 있다.

우선 Content-addressable 파일 시스템은 정말 대단한 것이므로 먼저 다룬다. 그리고 나서 데이터 전송 원리를 배우고 마지막에는 저장소를 관리하는 법까지 배운다.

Plumbing 명령과 Porcelain 명령

이 책에서는 checkout, branch, remote 같은 30여 가지의 Git 명령을 사용했다. Git은 사실 사용자 친화적인 VCS이기 보다는 VCS로도 사용할 수 있는 툴킷이다. 저수준 명령어가 매우 많아서 저수준의 일도 쉽게 처리할 수 있다. 명령어 여러 개를 Unix 스타일로 함께 엮어서 실행하거나 스크립트에서 호출할 수 있도록 설계됐다. 이러한 저수준의 명령어는 “Plumbing” 명령어라고 부르고 좀 더 사용자에게 친숙한 사용자용 명령어는 “Porcelain” 명령어라고 부른다.

이 책의 앞 아홉 장은 Porcelain 명령어만 사용했다. 하지만, 이 장에서는 저수준 명령인 Plumbing 명령어를 주로 사용한다. 이 명령으로 Git의 내부구조에 접근할 수 있고 실제로 왜, 그렇게 작동하는지도 살펴볼 수 있다. Plumbing 명령어는 직접 커맨드라인에서 실행하기보다 새로운 도구를 만들거나 각자 필요한 스크립트를 작성할 때 사용한다.

새로 만든 디렉토리나 이미 파일이 있는 디렉토리에서 git init 명령을 실행하면 Git은 데이터를 저장하고 관리하는 .git 디렉토리를 만든다. 이 디렉토리를 복사하기만 해도 저장소가 백업 된다. 이 장은 기본적으로 이 디렉토리에 대한 내용을 설명한다. 디렉토리 구조는 아래와 같다:

$ ls -F1
HEAD
config*
description
hooks/
info/
objects/
refs/

이 외에 다른 파일들이 더 있지만, 이 상태가 git init을 한 직후에 보이는 새 저장소의 모습이다. description 파일은 기본적으로 GitWeb 프로그램에서만 사용하기 때문에 이 둘은 무시해도 된다. config 파일에는 해당 프로젝트에만 적용되는 설정 옵션이 들어 있다. info 디렉토리는 .gitignore 파일처럼 무시할 파일의 패턴을 적어 두는 곳이다. 하지만 .gitignore 파일과는 달리 Git으로 관리되지 않는다. hook 디렉토리에는 클라이언트 훅이나 서버 훅을 넣는다. 관련 내용은 “Git Hooks” 에서 설명한다.

이제 남은 네 가지 항목은 모두 중요한 항목이다. HEAD 파일, index 파일, objects 디렉토리, refs 디렉토리가 남았다. 이 네 항목이 Git의 핵심이다. objects 디렉토리는 모든 컨텐트를 저장하는 데이터베이스이고 refs 디렉토리에는 커밋 개체의 포인터를 저장한다. HEAD 파일은 현재 Checkout 한 브랜치를 가리키고 index 파일은 Staging Area의 정보를 저장한다. 각 절마다 주제를 나눠서 Git이 어떻게 동작하는지 자세히 설명한다.

Git 개체

Git은 Content-addressable 파일시스템이다. 이게 무슨 말이냐 하면 Git의 핵심은 단순한 Key-Value(역주 - 예, 파일이름과 파일데이터) 데이터 저장소라는 것이다. 어떤 형식의 데이터라도 집어넣을 수 있고 해당 Key로 언제든지 데이터를 다시 가져올 수 있다. Plumbing 명령어를 예로 들면 hash-object 명령에 데이터를 주면 .git 디렉토리에 저장하고 그 key를 알려준다. 우선 Git 저장소를 새로 만들고 objects 디렉토리에 뭐가 들어 있는지 확인한다:

$ git init test
Initialized empty Git repository in /tmp/test/.git/
$ cd test
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
$ find .git/objects -type f

아직 빈 디렉토리일 뿐 파일은 아무것도 없다. Git은 init 명령으로 저장소를 초기화할 때 objects 디렉토리를 만들고 그 밑에 packinfo 디렉토리도 만든다. Git 데이터베이스에 텍스트 파일을 저장해보자:

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

이 명령은 표준입력으로 들어오는 데이터를 저장할 수 있다. -w 옵션을 줘야 실제로 저장한다. -w가 없으면 저장하지 않고 key만 보여준다. 그리고 --stdin 옵션을 주면 표준입력으로 입력되는 데이터를 읽는다. 이 옵션이 없으면 파일 경로를 알려줘야 한다. hash-object 명령이 출력하는 것은 40자 길이의 체크섬 해시다. 이 해시는 헤더 정보와 데이터 모두에 대한 SHA-1 해시이다. 헤더 정보는 차차 자세히 살펴볼 것이다. Git이 저장한 데이터를 알아보자:

$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

objects 디렉토리에 파일이 하나 새로 생겼다. 데이터는 새로 만든 파일에 저장하며 Git은 데이터를 저장할 때 데이터와 헤더로 생성한 SHA-1 체크섬으로 파일 이름을 짓는다. 해시의 처음 두 글자를 따서 디렉토리 이름에 사용하고 나머지 38글자를 파일 이름에 사용한다.

cat-file 명령으로 저장한 데이터를 불러올 수 있다. 이 명령은 Git 개체를 살펴보고 싶을 때 맥가이버칼처럼 사용할 수 있다. cat-file 명령에 -p 옵션을 주면 파일 내용이 출력된다.

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

다시 한 번 데이터를 Git 저장소에 추가하고 불러와 보자. Git이 파일 버전을 관리하는 방식을 이해할 수 있도록 가상의 상황을 만들어 살펴본다. 우선 새 파일을 하나 만들고 Git 저장소에 저장한다:

$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30

그리고 그 파일을 수정하고 다시 저장한다.

$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

이제 데이터베이스에는 데이터가 두 가지 버전으로 저장돼 있다.

$ find .git/objects -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

파일의 내용을 첫 번째 버전으로 되돌린다.

$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1

두 번째 버전을 다시 적용한다.

$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2

파일의 SHA-1 키를 외워서 사용하는 것은 너무 어렵다. 게다가 원래 파일의 이름은 저장하지도 않았다. 단지 파일 내용만 저장했을 뿐이다. 이런 종류의 개체를 Blob 개체라고 부른다. cat-file -t 명령으로 해당 개체가 무슨 개체인지 확인할 수 있다.

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob

Tree 개체

다음은 Tree 개체를 살펴보자. 이 Tree 개체에 파일 이름을 저장한다. 파일 여러 개를 한꺼번에 저장할 수도 있다. Git은 유닉스 파일 시스템과 비슷한 방법으로 저장하지만 좀 더 단순하다. 모든 것을 Tree와 Blob 개체로 저장한다. Tree는 유닉스의 디렉토리에 대응되고 Blob은 Inode나 일반 파일에 대응된다. Tree 개체 하나는 항목을 여러 개 가질 수 있다. 그리고 그 항목에는 Blob 개체나 하위 Tree 개체를 가리키는 SHA-1 포인터, 파일 모드, 개체 타입, 파일 이름이 들어 있다. simplegit 프로젝트의 마지막 Tree 개체를 살펴보자.

$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859      README
100644 blob 8f94139338f9404f26296befa88755fc2598c289      Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0      lib

master^{tree} 구문은 master 브랜치가 가리키는 Tree 개체를 말한다. lib 항목은 디렉토리인데 Blob 개체가 아니고 다른 Tree 개체다.

$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b      simplegit.rb

Git이 저장하는 데이터는 대강 아래 그림과 같다.

단순화한 Git 데이터 모델.
단순화한 Git 데이터 모델.

직접 Tree 개체를 만들어 보자. Git은 일반적으로 Staging Area(Index)의 상태대로 Tree 개체를 만들고 기록한다. 그래서 Tree 개체를 만들려면 우선 Staging Area에 파일을 추가해서 Index를 만들어야 한다. 우선 Plumbing 명령어 update-indextest.txt 파일만 들어 있는 Index를 만든다. 이 명령어는 파일을 인위적으로 Staging Area에 추가하는 명령이다. 아직 Staging Area에 없는 파일이기 때문에 --add 옵션을 꼭 줘야 한다(사실 아직 Staging Area도 설정하지 않았다). 그리고 디렉토리에 있는 파일이 아니라 데이터베이스에 있는 파일을 추가하는 것이기 때문에 --cacheinfo 옵션이 필요하다. 파일 모드, SHA-1 해시, 파일 이름 정보도 입력한다.

$ git update-index --add --cacheinfo 100644 \
  83baae61804e65cc73a7201a7252750c76066a30 test.txt

여기서 파일 모드는 보통의 파일을 나타내는 100644로 지정했다. 실행파일이라면 100755로 지정하고 심볼릭 링크라면 120000으로 지정한다. 이런 파일 모드는 유닉스에서 가져오긴 했지만, 유닉스 모드를 전부 사용하지는 않는다. Blob 파일에는 이 세 가지 모드만 사용한다. 디렉토리나 서브모듈에는 다른 모드를 사용한다.

Staging Area를 Tree 개체로 저장할 때는 write-tree 명령을 사용한다. write-tree 명령은 Tree 개체가 없으면 자동으로 생성하므로 -w 옵션이 필요 없다.

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30      test.txt

아래 명령으로 이 개체가 Tree 개체라는 것을 확인한다.

$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree

파일을 새로 하나 추가하고 test.txt 파일도 두 번째 버전을 만든다. 그리고 나서 Tree 개체를 만든다.

$ echo 'new file' > new.txt
$ git update-index test.txt
$ git update-index --add new.txt

새 파일인 new.txt와 새로운 버전의 test.txt 파일까지 Staging Area에 추가했다. 현재 상태의 Staging Area를 새로운 Tree 개체로 기록하면 어떻게 보이는지 살펴보자.

$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

이 Tree 개체에는 파일이 두 개 있고 test.txt 파일의 SHA 값도 두 번째 버전인 1f7a7a1이다. 재미난 걸 해보자. 처음에 만든 Tree 개체를 하위 디렉토리로 만들 수 있다. read-tree 명령으로 Tree 개체를 읽어 Staging Area에 추가한다. --prefix 옵션을 주면 Tree 개체를 하위 디렉토리로 추가할 수 있다.

$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
3c4e9cd789d88d8d89c1073707c3585e41b0e614
$ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579      bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

이 Tree 개체로 워킹 디렉토리를 만들면 파일 두 개와 bak이라는 하위 디렉토리가 생긴다. 그리고 bak 디렉토리 안에는 test.txt 파일의 처음 버전이 들어 있다. 아래와 그림과 같은 구조로 데이터가 저장된다.

현재 Git 데이터 구조.
현재 Git 데이터 구조.

커밋 개체

각기 다른 스냅샷을 나타내는 Tree 개체를 세 개 만들었다. 하지만, 여전히 이 스냅샷을 불러오려면 SHA-1 값을 기억하고 있어야 한다. 스냅샷을 누가, 언제, 왜 저장했는지에 대한 정보는 아예 없다. 이런 정보는 커밋 개체에 저장된다.

커밋 개체는 commit-tree 명령으로 만든다. 이 명령에 커밋 개체에 대한 설명과 Tree 개체의 SHA-1 값 한 개를 넘긴다. 앞서 저장한 첫 번째 Tree를 가지고 아래와 같이 만들어 본다.

$ echo 'first commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d

새로 생긴 커밋 개체를 cat-file 명령으로 확인해보자.

$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

first commit

커밋 개체의 형식은 간단하다. 해당 스냅샷에서 최상단 Tree를(역주 - 루트 디렉터리 같은) 하나 가리킨다. 그리고 user.nameuser.email 설정에서 가져온 Author/Committer 정보, 시간 정보, 그리고 한 라인 띄운 다음 커밋 메시지가 들어간다.

이제 커밋 개체를 두 개 더 만들어 보자. 각 커밋 개체는 이전 개체를 가리키도록 한다.

$ echo 'second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'third commit'  | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9

세 커밋 개체는 각각 해당 스냅샷을 나타내는 Tree 개체를 하나씩 가리키고 있다. 이상해 보이겠지만 우리는 진짜 Git 히스토리를 만들었다. 마지막 커밋 개체의 SHA-1 값을 주고 git log 명령을 실행하면 아래와 같이 출력한다.

$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

    third commit

 bak/test.txt | 1 +
 1 file changed, 1 insertion(+)

commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:14:29 2009 -0700

    second commit

 new.txt  | 1 +
 test.txt | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:09:34 2009 -0700

    first commit

 test.txt | 1 +
 1 file changed, 1 insertion(+)

놀랍지 않은가! 방금 우리는 고수준 명령어 없이 저수준의 명령으로만 Git 히스토리를 만들었다. 지금 한 일이 git addgit commit 명령을 실행했을 때 Git 내부에서 일어나는 일이다. Git은 변경된 파일을 Blob 개체로 저장하고 현 Index에 따라서 Tree 개체를 만든다. 그리고 이전 커밋 개체와 최상위 Tree 개체를 참고해서 커밋 개체를 만든다. 즉 Blob, Tree, 커밋 개체가 Git의 주요 개체이고 이 개체는 전부 .git/objects 디렉토리에 저장된다. 이 예제에서 생성한 개체는 아래와 같다.

$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1

내부의 포인터를 따라가면 아래와 같은 그림이 그려진다.

Git 저장소 내의 모든 개체.
Git 저장소 내의 모든 개체.

개체 저장소

내용과 함께 헤더도 저장한다고 얘기했다. 잠시 Git이 개체를 어떻게 저장하는지부터 살펴보자. 대화형 Ruby 쉘을 이용하여 “what is up, doc?”이라는 문자열을 저장하는 방법을 흉내 내 본다.

irb 명령을 실행하여 시작해보자.

$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"

Git은 개체의 타입을 시작으로 헤더를 만든다. 그다음에 공백 문자 하나, 내용의 크기, 마지막에 널 문자를 추가한다.

>> header = "blob #{content.length}\0"
=> "blob 16\u0000"

Git은 헤더와 원래 내용을 합쳐서 SHA-1 체크섬을 계산한다. Ruby에서도 require로 SHA1 라이브러리를 가져다가 흉내 낼 수 있다. require로 라이브러리를 포함하고 나서 Digest::SHA1.hexdigest()를 호출한다.

>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"

Git은 또 zlib으로 내용을 압축한다. Ruby에도 zlib 라이브러리가 있으니 Ruby에서도 할 수 있다. 라이브러리를 포함하고 Zlib::Deflate.deflate()를 호출한다.

>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"

마지막으로 zlib으로 압축한 내용을 개체로 저장한다. SHA-1 값 중에서 맨 앞에 있는 두 자를 가져다 하위 디렉토리 이름으로 사용하고 나머지 38자를 그 디렉토리 안에 있는 파일이름으로 사용한다. Ruby에서는 FileUtils.mkdir_p()로 하위 디렉토리의 존재를 보장하고 나서 File.open()으로 파일을 연다. 그리고 그 파일에 zlib으로 압축한 내용을 write() 함수로 저장한다.

>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32

다 됐다. 이제 Git Blob 개체를 손으로 만들었다. Git 개체는 모두 이 방식으로 저장하며 단지 종류만 다르다. 헤더가 blob이 아니라 그냥 commit이나 tree로 시작하게 되는 것뿐이다. Blob 개체는 여기서 보여준 것과 거의 같지만 커밋이 개체나 Tree 개체는 각기 다른 형식을 사용한다.

Git Refs

git log 1a410e라고 실행하면 전체 히스토리를 볼 수 있지만, 여전히 1a410e를 기억해야 한다. 이 커밋은 마지막 커밋이기 때문에 히스토리를 따라 모든 개체를 조회할 수 있다. SHA-1 값을 날로 사용하기보다 쉬운 이름으로 된 포인터가 있으면 그걸 사용하는 게 더 좋다. 외우기 쉬운 이름으로 된 파일에 SHA-1 값을 저장한다.

Git에서는 이런 것을 ''Refs`` 또는 ''Refs``라고 부른다. SHA-1 값이 든 파일은 .git/refs 디렉토리에 있다. 이 프로젝트에는 아직 Refs가 하나도 없고 단순한 구조의 디렉토리만 준비되어 있다.

$ find .git/refs
.git/refs
.git/refs/heads
.git/refs/tags
$ find .git/refs -type f

Refs가 있으면 커밋을 찾기 쉬워진다. 사실 내부는 아래처럼 단순하다.

$ echo "1a410efbd13591db07496601ebc7a059dd55cfe9" > .git/refs/heads/master

SHA-1 값 대신에 지금 만든 Refs를 사용할 수 있다.

$ git log --pretty=oneline  master
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

Refs 파일을 직접 고치는 것이 좀 못마땅하다. Git에는 좀 더 안전하게 바꿀 수 있는 update-ref 명령이 있다.

$ git update-ref refs/heads/master 1a410efbd13591db07496601ebc7a059dd55cfe9

Git 브랜치의 역할이 바로 이거다. 브랜치는 어떤 작업 중 마지막 작업을 가리키는 포인터 또는 Refs이다. 간단히 두 번째 커밋을 가리키는 브랜치를 만들어 보자.

$ git update-ref refs/heads/test cac0ca

브랜치는 직접 가리키는 커밋과 그 커밋으로 따라갈 수 있는 모든 커밋을 포함한다.

$ git log --pretty=oneline test
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

이제 Git 데이터베이스는 아래 그림처럼 보인다.

브랜치 Refs가 추가된 Git 데이터베이스.
브랜치 Refs가 추가된 Git 데이터베이스.

git branch (branchname) 명령을 실행하면 Git은 내부적으로 update-ref 명령을 실행한다. 입력받은 브랜치 이름과 현 브랜치의 마지막 커밋의 SHA-1 값을 가져다 update-ref 명령을 실행한다.

HEAD

git branch (branchname) 명령을 실행할 때 Git은 어떻게 마지막 커밋의 SHA-1 값을 아는 걸까? HEAD 파일은 현 브랜치를 가리키는 간접(symbolic) Refs다.

간접 Refs라서 다른 것과 다르다. 이 Refs는 다른 Refs를 가리키는 것이라서 SHA-1 값이 없다. 파일을 열어 보면 아래와 같이 생겼다.

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

git checkout test를 실행하면 Git은 HEAD 파일을 아래와 같이 바꾼다.

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

git commit을 실행하면 커밋 개체가 만들어지는데, 지금 HEAD가 가리키고 있던 커밋의 SHA-1 값이 그 커밋 개체의 부모로 사용된다.

이 파일도 손으로 직접 편집할 수 있지만 symbolic-ref라는 명령어가 있어서 좀 더 안전하게 사용할 수 있다. 이 명령으로 HEAD의 값을 읽을 수 있다.

$ git symbolic-ref HEAD
refs/heads/master

HEAD의 값을 변경할 수도 있다.

$ git symbolic-ref HEAD refs/heads/test
$ cat .git/HEAD
ref: refs/heads/test

refs 형식에 맞지 않으면 수정할 수 없다.

$ git symbolic-ref HEAD test
fatal: Refusing to point HEAD outside of refs/

태그

중요한 개체는 모두 살펴봤고 살펴볼 개체가 하나가 남았다. 태그 개체는 커밋 개체랑 매우 비슷하다. 커밋 개체처럼 누가, 언제 태그를 달았는지 태그 메시지는 무엇이고 어떤 커밋을 가리키는지에 대한 정보가 포함된다. 태그 개체는 Tree 개체가 아니라 커밋 개체를 가리키는 것이 그 둘의 차이다. 브랜치처럼 커밋 개체를 가리키지만 옮길 수는 없다. 태그 개체는 늘 그 이름이 뜻하는 커밋만 가리킨다.

Chapter 2 에서 배웠듯 태그는 Annotated 태그와 Lightweight 태그 두 종류로 나뉜다. 먼저 아래와 같이 Lightweight 태그를 만들어 보자.

$ git update-ref refs/tags/v1.0 cac0cab538b970a37ea1e769cbbde608743bc96d

Lightwieght 태그는 만들기 쉽다. 브랜치랑 비슷하지만 브랜치처럼 옮길 수는 없다. 이에 비해 Annotated 태그는 좀 더 복잡하다. Annotated 태그를 만들면 Git은 태그 개체를 만들고 거기에 커밋을 가리키는 Refs를 저장한다. Annotated 태그는 커밋을 직접 가리키지 않고 태그 개체를 가리킨다. -a 옵션을 주고 Annotated 태그를 만들어 확인해보자.

$ git tag -a v1.1 1a410efbd13591db07496601ebc7a059dd55cfe9 -m 'test tag'

태그 개체의 SHA-1 값을 확인한다.

$ cat .git/refs/tags/v1.1
9585191f37f7b0fb9444f35a9bf50de191beadc2

cat-file 명령으로 해당 SHA-1 값의 내용을 조회한다.

$ git cat-file -p 9585191f37f7b0fb9444f35a9bf50de191beadc2
object 1a410efbd13591db07496601ebc7a059dd55cfe9
type commit
tag v1.1
tagger Scott Chacon <schacon@gmail.com> Sat May 23 16:48:58 2009 -0700

test tag

object 부분에 있는 SHA-1 값이 실제로 태그가 가리키는 커밋이다. 커밋 개체뿐만 아니라 모든 Git 개체에 태그를 달 수 있다. 커밋 개체에 태그를 다는 것이 아니라 Git 개체에 태그를 다는 것이다. Git을 개발하는 프로젝트에서는 관리자가 자신의 GPG 공개키를 Blob 개체로 추가하고 그 파일에 태그를 달았다. 아래 명령으로 그 공개키를 확인할 수 있다.

$ git cat-file blob junio-gpg-pub

Linux Kernel 저장소에도 커밋이 아닌 다른 개체를 가리키는 태그 개체가 있다. 그 태그는 저장소에 처음으로 소스 코드를 임포트했을 때 그 첫 Tree 개체를 가리킨다.

리모트

리모트 Refs라는 것도 있다. 리모트를 추가하고 Push 하면 Git은 각 브랜치마다 Push 한 마지막 커밋이 무엇인지 refs/remotes 디렉토리에 저장한다. 예를 들어, origin이라는 리모트를 추가하고 master 브랜치를 Push 한다.

$ git remote add origin git@github.com:schacon/simplegit-progit.git
$ git push origin master
Counting objects: 11, done.
Compressing objects: 100% (5/5), done.
Writing objects: 100% (7/7), 716 bytes, done.
Total 7 (delta 2), reused 4 (delta 1)
To git@github.com:schacon/simplegit-progit.git
  a11bef0..ca82a6d  master -> master

originmaster 브랜치에서 서버와 마지막으로 교환한 커밋이 어떤 것인지 refs/remotes/origin/master 파일에서 확인할 수 있다.

$ cat .git/refs/remotes/origin/master
ca82a6dff817ec66f44342007202690a93763949

refs/heads에 있는 Refs인 브랜치와 달리 리모트 Refs는 Checkout 할 수 없고 읽기 용도로만 쓸 수 있는 브랜치인 것이다. 이 리모트 Refs는 서버의 브랜치가 가리키는 커밋이 무엇인지 적어둔 일종의 북마크이다.

Packfile

테스트용 Git 저장소의 개체 데이터베이스를 다시 살펴보자. 지금 개체는 모두 11개로 Blob 4개, Tree 3개, 커밋 3개, 태그 1개가 있다.

$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/95/85191f37f7b0fb9444f35a9bf50de191beadc2 # tag
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1

Git은 zlib으로 파일 내용을 압축하기 때문에 저장 공간이 많이 필요하지 않다. 그래서 이 데이터베이스에 저장된 파일은 겨우 925바이트밖에 되지 않는다. 크기가 큰 파일을 추가해서 이 기능의 효과를 좀 더 살펴보자. 앞 장에서 사용했던 Grit 라이브러리에 들어 있는 repo.rb 파일을 추가한다. 이 파일의 크기는 약 22K이다.

$ curl https://raw.githubusercontent.com/mojombo/grit/master/lib/grit/repo.rb > repo.rb
$ git add repo.rb
$ git commit -m 'added repo.rb'
[master 484a592] added repo.rb
 3 files changed, 709 insertions(+), 2 deletions(-)
 delete mode 100644 bak/test.txt
 create mode 100644 repo.rb
 rewrite test.txt (100%)

추가한 Tree 개체를 보면 repo.rb 파일의 SHA-1 값이 무엇인지 확인할 수 있다.

$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5      repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b      test.txt

git cat-file 명령으로 개체의 크기는 아래와 같이 확인한다.

$ git cat-file -s 033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5
22044

파일을 수정하면 어떻게 되는지 살펴보자.

$ echo '# testing' >> repo.rb
$ git commit -am 'modified repo a bit'
[master 2431da6] modified repo.rb a bit
 1 file changed, 1 insertion(+)

수정한 커밋의 Tree 개체를 확인하면 흥미로운 점을 발견할 수 있다.

$ git cat-file -p master^{tree}
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob b042a60ef7dff760008df33cee372b945b6e884e      repo.rb
100644 blob e3f094f522629ae358806b17daf78246c27c007b      test.txt

이 Blob 개체는 다른 개체다. 새 Blob 개체는 400 라인 이후에 한 라인을 더 추가한 새 개체이다. Git은 완전히 새로운 Blob 개체를 만들어 저장한다.

$ git cat-file -s b042a60ef7dff760008df33cee372b945b6e884e
22054

그럼 약 22K짜리 파일을 두 개 가지게 된다. 거의 같은 파일을 두 개나 가지게 되는 것이 못마땅할 수도 있다. 처음 것과 두 번째 것 사이의 차이점만 저장할 수 없을까?

가능하다. Git이 처음 개체를 저장하는 형식은 “Loose” 개체 포멧이라고 부른다. 나중에 이 개체를 파일 하나로 압축(Pack)할 수 있다. 이렇게 하여 공간을 절약하고 효율을 높일 수 있다. Git이 이렇게 압축하는 때는 Loose 개체가 너무 많을 때, git gc 명령을 실행했을 때, 리모트 서버로 Push 할 때 압축한다. git gc 명령을 실행해서 어떻게 압축하는지 살펴보자.

$ git gc
Counting objects: 18, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (14/14), done.
Writing objects: 100% (18/18), done.
Total 18 (delta 3), reused 0 (delta 0)

objects 디렉토리를 열어보면 개체 대부분이 사라졌고 한 쌍의 파일이 새로 생겼다.

$ find .git/objects -type f
.git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4
.git/objects/info/packs
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.idx
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.pack

압축되지 않은 Blob 개체는 어떤 커밋도 가리키지 않는 개체다. 즉, “what is up, doc?”과 “test content” 예제에서 만들었던 개체이다. 어떤 커밋에도 추가돼 있지 않으면 이 개체는 dangling 개체로 취급되고 Packfile에 추가되지 않는다.

새로 생긴 파일은 Packfile과 그 Index이다. 파일 시스템에서 삭제된 개체가 전부 이 Packfile에 저장된다. Index 파일에 대해서는 빠르게 찾을 수 있도록 Packfile에 오프셋이 들어 있다. gc 명령을 실행하기 전에 있던 파일 크기는 약 22K 정도였었는데 새로 만들어진 Packfile은 겨우 7K에 불과하다. 짱이다. 개체를 압축하여 디스크 사용량이 ⅔으로 줄었다.

이런 일은 어떤 식으로 처리하는 것인가? 개체를 압축시키면 Git은 먼저 이름이나 크기가 비슷한 파일을 찾는다. 그리고 두 파일을 비교해서 한 파일은 다른 부분만 저장한다. Git이 얼마나 공간을 절약해 주는지 Packfile을 열어 확인할 수 있다. git verify-pack 명령어는 압축한 내용을 보여준다.

$ git verify-pack -v .git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.idx
2431da676938450a4d72e260db3bf7b0f587bbc1 commit 223 155 12
69bcdaff5328278ab1c0812ce0e07fa7d26a96d7 commit 214 152 167
80d02664cb23ed55b226516648c7ad5d0a3deb90 commit 214 145 319
43168a18b7613d1281e5560855a83eb8fde3d687 commit 213 146 464
092917823486a802e94d727c820a9024e14a1fc2 commit 214 146 610
702470739ce72005e2edff522fde85d52a65df9b commit 165 118 756
d368d0ac0678cbe6cce505be58126d3526706e54 tag    130 122 874
fe879577cb8cffcdf25441725141e310dd7d239b tree   136 136 996
d8329fc1cc938780ffdd9f94e0d364e0ea74f579 tree   36 46 1132
deef2e1b793907545e50a2ea2ddb5ba6c58c4506 tree   136 136 1178
d982c7cb2c2a972ee391a85da481fc1f9127a01d tree   6 17 1314 1 \
  deef2e1b793907545e50a2ea2ddb5ba6c58c4506
3c4e9cd789d88d8d89c1073707c3585e41b0e614 tree   8 19 1331 1 \
  deef2e1b793907545e50a2ea2ddb5ba6c58c4506
0155eb4229851634a0f03eb265b69f5a2d56f341 tree   71 76 1350
83baae61804e65cc73a7201a7252750c76066a30 blob   10 19 1426
fa49b077972391ad58037050f2a75f74e3671e92 blob   9 18 1445
b042a60ef7dff760008df33cee372b945b6e884e blob   22054 5799 1463
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob   9 20 7262 1 \
  b042a60ef7dff760008df33cee372b945b6e884e
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a blob   10 19 7282
non delta: 15 objects
chain length = 1: 3 objects
.git/objects/pack/pack-978e03944f5c581011e6998cd0e9e30000905586.pack: ok

033b4 Blob이 처음 추가한 repo.rb 파일인데, 이 Blob은 두 번째 버전인 b042a Blob을 가리킨다. 결과에서 세 번째 컬럼은 압축된 개체의 크기를 나타낸다. b042a의 크기는 22K지만 033b4는 9바이트밖에 안 된다. 특이한 점은 원본을 그대로 저장하는 것이 첫 번째가 아니라 두 번째 버전이라는 것이다. 첫 번째 버전은 차이점만 저장된다. 최신 버전에 접근할 때가 더 많고 최신 버전에 접근하는 속도가 더 빨라야 하기 때문에 이렇게 한다.

언제나 다시 압축할 수 있기 때문에 이 기능은 정말 판타스틱하다. Git은 가끔 자동으로 데이터베이스를 재압축해서 공간을 절약한다. 그리고 git gc 명령으로 직접 압축할 수도 있다.

Refspec

원격의 브랜치와 로컬 Refs를 간단히 매핑하는 것은 많이 봤다. 이 매핑은 실은 좀 더 복잡하다. 아래처럼 리모트 저장소를 추가해보자.

$ git remote add origin https://github.com/schacon/simplegit-progit

이 명령은 origin 이라는 저장소 이름, URL, Fetch 할 Refspec를 .git/config 파일에 추가한다.

[remote "origin"]
    url = https://github.com/schacon/simplegit-progit
    fetch = +refs/heads/*:refs/remotes/origin/*

Refspec 형식은 +<src>:<dest>로 돼 있다. +는 생략 가능하고, <src>는 리모트 저장소의 Refs 패턴이고 <dst>는 매핑되는 로컬 저장소의 Refs 패턴이다. +는 Fast-forward가 아닌 업데이트를 허용하는 것이다.

기본적으로 Git은 git remote add 명령으로 생성한 설정을 참고하여 리모트 서버에서 refs/heads/에 있는 Refs를 가져다 로컬의 refs/remotes/origin/에 기록한다. 로컬에서 서버에 있는 master 브랜치에 접근할 때는 아래와 같이 한다.

$ git log origin/master
$ git log remotes/origin/master
$ git log refs/remotes/origin/master

이 세 명령의 결과가 모두 같다. Git은 모두 refs/remotes/origin/master라고 해석한다.

master 브랜치만 가져올 수 있게 하려면 fetch 부분을 아래와 같이 바꿔준다. 그러면 다른 브랜치는 가져올 수 없다.

fetch = +refs/heads/master:refs/remotes/origin/master

이는 해당 리모트 저장소에서 git fetch 명령을 실행할 때 자동으로 사용되는 Refspec이다. 다른 Refspec을 가져오려면 명령의 아규먼트로 넘긴다. 리모트 브랜치 master를 로컬 브랜치 origin/mymaster로 가져오려면 아래와 같이 실행한다.

$ git fetch origin master:refs/remotes/origin/mymaster

Refspec을 여러 개 넘겨도 된다. 한꺼번에 브랜치를 여러 개 가져온다.

$ git fetch origin master:refs/remotes/origin/mymaster \
     topic:refs/remotes/origin/topic
From git@github.com:schacon/simplegit
 ! [rejected]        master     -> origin/mymaster  (non fast forward)
 * [new branch]      topic      -> origin/topic

여기서 master 브랜치는 Fast-forward가 아니라서 거절된다. 하지만, Refspec 앞에 +를 추가하면 강제로 덮어쓴다.

설정 파일에도 Refspec을 여러 개 적을 수 있다. masterexperiment 브랜치를 둘 다 적으면 항상 함께 가져온다.

[remote "origin"]
    url = https://github.com/schacon/simplegit-progit
    fetch = +refs/heads/master:refs/remotes/origin/master
    fetch = +refs/heads/experiment:refs/remotes/origin/experiment

하지만, Glob 패턴은 사용할 수 없다.

fetch = +refs/heads/qa*:refs/remotes/origin/qa*

그 대신 네임스페이스 형식(디렉토리 형식)으로는 사용할 수 있다. 만약 QA 팀이 Push 하는 브랜치가 있고 이 브랜치를 가져오고 싶으면 아래와 같이 설정한다. 다음은 master 브랜치와 QA 팀의 브랜치만 가져오는 설정이다.

[remote "origin"]
    url = https://github.com/schacon/simplegit-progit
    fetch = +refs/heads/master:refs/remotes/origin/master
    fetch = +refs/heads/qa/*:refs/remotes/origin/qa/*

좀 더 복잡한 것도 가능하다. QA 팀뿐만 아니라, 일반 개발자, 통합 팀 등이 사용하는 브랜치를 네임스페이스 별로 구분해 놓으면 좀 더 Git을 편리하게 사용할 수 있다.

Refspec Push 하기

위와 같은 방식으로 네임스페이스를 사용하여 리모트 브랜치를 구별하여 사용하는 것은 꽤 괜찮은 방법이다. 만약 QA팀이 네임스페이스를 사용하지 않는 브랜치를 리모트에 네임스페이스를 써서 Push 하려면 어떻게 해야 할까? 이럴 땐 Refspec으로 가능하다.

QA 팀이 master 브랜치를 리모트 저장소에 qa/master로 Push 하려면 아래와 같이 한다.

$ git push origin master:refs/heads/qa/master

git push origin을 실행할 때마다 Git이 자동으로 Push 하게 하려면 아래와 같이 설정 파일에 push 항목을 추가한다.

[remote "origin"]
    url = https://github.com/schacon/simplegit-progit
    fetch = +refs/heads/*:refs/remotes/origin/*
    push = refs/heads/master:refs/heads/qa/master

다시 말하자면 위와 같이 설정은 git push origin을 실행할 때 로컬 브랜치 master를 리모트 브랜치 qa/master로 Push 하도록 하는 설정이다.

Refs 삭제하기

Refspec으로 서버에 있는 Refs를 삭제할 수 있다.

$ git push origin :topic

Refspec의 형식은 <src>:<dst>이니까 <src>를 비우고 실행하면 <dst>를 비우라는 명령이 된다. 이 결과 <dst>는 삭제된다.

데이터 전송 프로토콜

Git에서 데이터를 전송할 때 보통 두 가지 종류의 프로토콜을 사용한다. 하나는 “dumb” 프로토콜이고 다른 종류는 “스마트” 프로토콜이다. 두 종류 프로토콜을 통해 Git이 어떻게 데이터를 전송하는지 살펴본다.

Dumb 프로토콜

읽기전용으로만 사용하는 HTTP 저장소를 Clone 하거나 Fetch 할 때가 Dumb 프로토콜을 사용하는 때이다. Dumb 프로토콜이라 부르는 이유는 서버가 데이터를 전송할 때 Git에 최적화된 어떤 작업도 전혀 사용하지 않기 때문이다. 단지 Fetch 과정은 HTTP GET 요청을 여러 번 보낼 뿐이다. 이때 클라이언트는 서버의 Git 저장소 레이아웃이 특별하지 않다고 가정한다.

요즘은 Dumb 프로토콜을 사용하는 경우가 드물다. Dumb 프로토콜을 사용하면 데이터 전송을 비밀스럽게 하기 어려워서 비공개용 저장소의 데이터를 전송하기에 적합하지 않다. 이후에 설명할 스마트 프로토콜을 사용하도록 조언하는 바이다.

simplegit 라이브러리에 대한 http-fetch 과정을 살펴보자.

$ git clone http://server/simplegit-progit.git

우선 info/refs 파일을 내려받는다. 이 파일은 update-server-info 명령으로 작성되기 때문에 post-receive 훅에서 update-server-info 명령을 호출해줘야만 HTTP를 사용할 수 있다.

=> GET info/refs
ca82a6dff817ec66f44342007202690a93763949     refs/heads/master

리모트 Refs와 SHA 값이 든 목록을 가져왔고 다음은 HEAD Refs를 찾는다. 이 HEAD Refs 덕택에 데이터를 내려받고 나서 어떤 Refs를 Checkout 할 지 알게 된다.

=> GET HEAD
ref: refs/heads/master

데이터 전송을 마치면 master 브랜치를 Checkout 해야 한다. 지금은 아직 전송을 시작하는 시점이다. info/refsca82a6 커밋에서 시작해야 한다고 나와 있다. 그래서 그 커밋을 기점으로 Fetch 한다.

=> GET objects/ca/82a6dff817ec66f44342007202690a93763949
(179 bytes of binary data)

서버에 Loose 포맷으로 돼 있기 때문에 HTTP 서버에서 정적 파일을 가져오듯이 개체를 가져오면 된다. 이렇게 서버로부터 얻어온 개체를 zlib로 압축을 풀고 Header를 떼어 내면 아래와 같은 모습이 된다.

$ git cat-file -p ca82a6dff817ec66f44342007202690a93763949
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
parent 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
author Scott Chacon <schacon@gmail.com> 1205815931 -0700
committer Scott Chacon <schacon@gmail.com> 1240030591 -0700

changed the version number

아직 개체를 두 개 더 내려받아야 한다. cfda3b 개체는 방금 내려받은 커밋의 Tree 개체이고, 085bb3 개체는 부모 커밋 개체이다.

=> GET objects/08/5bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
(179 bytes of data)

커밋 개체는 내려받았다. 하지만, Tree 개체를 내려받으려고 하니 아래와 같은 오류가 발생한다.

=> GET objects/cf/da3bf379e4f8dba8717dee55aab78aef7f4daf
(404 - Not Found)

이런! 존재하지 않는다는 404 메시지가 뜬다. 해당 Tree 개체가 서버에 Loose 포맷으로 저장돼 있지 않을 수 있다. 해당 개체가 다른 저장소에 있거나 저장소의 Packfile 속에 들어 있을 때 그렇다. 우선 Git은 다른 저장소 목록에서 찾는다.

=> GET objects/info/http-alternates
(empty file)

다른 저장소 목록에 없으면 Git은 Packfile에서 해당 개체를 찾는다. 이렇게 하면 프로젝트를 Fork 해도 디스크 공간을 효율적으로 사용할 수 있다. 우선 서버에서 받은 다른 저장소 목록에는 없어서 개체는 확실히 Packfile 속에 있다. 어떤 Packfile이 있는지는 objects/info/packs 파일에 들어 있다. 이 파일도 update-server-info 명령이 생성한다.

=> GET objects/info/packs
P pack-816a9b2334da9953e530f27bcac22082a9f5b835.pack

서버에는 Packfile이 하나 있다. 개체는 이 파일 속에 있다. 이 개체가 있는지 Packfile의 Index(Packfile이 포함하는 파일의 목록)에서 찾는다. 서버에 Packfile이 여러 개 있으면 이런 식으로 개체가 어떤 Packfile에 있는지 찾는다.

=> GET objects/pack/pack-816a9b2334da9953e530f27bcac22082a9f5b835.idx
(4k of binary data)

이제 Packfile의 Index를 가져와서 개체가 있는지 확인한다. Packfile Index에서 해당 개체의 SHA 값과 오프셋을 파악한다. 개체를 찾았으면 해당 Packfile을 내려받는다.

=> GET objects/pack/pack-816a9b2334da9953e530f27bcac22082a9f5b835.pack
(13k of binary data)

Tree 개체를 얻어 오고 나면 커밋 데이터를 가져 온다. 아마도 방금 내려받은 Packfile 속에 모든 커밋 데이터가 들어 있을 것이다. 서버에 더는 전송 요청을 보내지 않는다. 다 끝나면 Git은 HEAD가 가리키는 master 브랜치의 소스코드를 복원해놓는다.

스마트 프로토콜

Dumb 프로토콜은 매우 단순하다는 장점이 있으나 데이터를 효율적으로 전송할 수 없다. 스마트 프로토콜로 데이터를 전송하는 것이 더 일반적이다. 이 프로토콜은 리모트 서버에서 처리해야 할 작업이 있다. 서버는 클라이언트가 어떤 데이터를 갖고 있고 어떤 데이터가 필요한지 분석하여 실제로 전송할 데이터를 추려낸다. 서버가 할 일을 두 가지 일로 구분할 수 있는데 데이터를 업로드할 때 하는 일과 다운로드할 때 하는 일이 다르다.

데이터 업로드

리모트 서버로 데이터를 업로드하는 과정은 send-packreceive-pack 과정으로 나눌 수 있다. 클라이언트에서 실행되는 send-pack과 서버의 receive-pack은 서로 연결된다.

SSH

origin URL이 SSH URL인 상태에서 git push origin master 명령을 실행하면 Git은 send-pack을 시작한다. 이 과정에서는 SSH 연결을 만들고 이 SSH 연결을 통해서 아래와 같은 명령어를 실행한다.

$ ssh -x git@server "git-receive-pack 'simplegit-progit.git'"
00a5ca82a6dff817ec66f4437202690a93763949 refs/heads/master□report-status \
    delete-refs side-band-64k quiet ofs-delta \
    agent=git/2:2.1.1+github-607-gfba4028 delete-refs
0000

git-receive-pack 명령은 Refs 정보를 한 라인에 하나씩 보여준다. 첫 번째 라인에는 master 브랜치의 이름과 SHA 체크섬을 보여주는데 여기에 서버의 Capability도 함께 보여준다(여기서는 report-status, delete-refs, 기타 등등과 클라이언트 Identifier를 표시한다).

각 라인의 처음은 4 바이트는 뒤에 이어지는 나머지 데이터의 길이를 나타낸다. 첫 라인을 보자. 005b로 시작하는데 10진수로 91을 나타낸다. 첫 줄의 처음 4 바이트를 제외한 나머지 길이가 91바이트라는 뜻이다. 다음 라인의 값은 003b이고 이는 62바이트를 나타낸다. 마지막 라인은 값은 0000이다. 이는 서버가 Refs 목록의 출력을 끝냈다는 것을 의미한다.

서버에 뭐가 있는지 알기 때문에 이제 서버에 없는 커밋이 무엇인지 알 수 있다. Push 할 Refs에 대한 정보는 send-pack 과정에서 서버의 receive-pack 과정으로 전달된다. 예를 들어 master 브랜치를 업데이트하고 experiment 브랜치를 추가할 때는 아래와 같은 정보를 서버에 보낸다.

0076ca82a6dff817ec66f44342007202690a93763949 15027957951b64cf874c3557a0f3547bd83b3ff6 \
    refs/heads/master report-status
006c0000000000000000000000000000000000000000 cdfdb42577e2506715f8cfeacdbabc092bf63e8d \
    refs/heads/experiment
0000

Git은 예전 SHA, 새 SHA, Refs 이름을 한 줄 한 줄에 담아 전송한다. 첫 라인에는 클라이언트 Capability도 포함된다. SHA-1 값이 모두 0인 것은 없음(無)을 의미한다. experiment Refs는 새로 추가하는 것이라서 왼쪽 SHA-1값이 모두 0이다. 반대로 오른쪽 SHA-1 값이 모두 0이면 Refs를 삭제한다는 의미다.

그다음에 서버에 없는 객체를 전부 하나의 Packfile에 담아 전송한다. 마지막에 서버는 성공했거나 실패했다고 응답한다.

000eunpack ok

HTTP(S)

HTTP를 통해 데이터를 업로드하는 과정도 크게 다르지 않지만 처음 핸드쉐이킹 과정만 약간 다르다. 우선 아래와 같은 요청으로 시작한다.

=> GET http://server/simplegit-progit.git/info/refs?service=git-receive-pack
001f# service=git-receive-pack
00ab6c5f0e45abd7832bf23074a333f739977c9e8188 refs/heads/master□report-status \
    delete-refs side-band-64k quiet ofs-delta \
    agent=git/2:2.1.1~vmg-bitmaps-bugaloo-608-g116744e
0000

첫 번째 클라이언트 요청과 서버의 응답이다. 이어지는 클라이언트 요청은 POST 메소드를 써서 git-upload-pack이 제공하는 데이터를 서버로 전송하는 요청이다.

=> POST http://server/simplegit-progit.git/git-receive/pack

POST 요청은 send-pack의 결과와 Packfile을 데이터로 전송한다. 전송한 데이터가 서버에서 처리된 결과가 HTTP 응답으로 전달된다.

데이터 다운로드

데이터를 다운로드하는 것는 fetch-packupload-pack 과정으로 나뉜다. 클라이언트가 fetch-pack을 시작하면 서버의 upload-pack에 연결되고 서로 어떤 데이터를 내려받을지 결정한다.

SSH

SSH 프로토콜을 사용하면 fetch-pack은 아래와 같이 실행한다.

$ ssh -x git@server "git-upload-pack 'simplegit-progit.git'"

fetch-pack과 연결된 upload-pack은 아래와 같은 데이터를 전송한다.

00dfca82a6dff817ec66f44342007202690a93763949 HEAD□multi_ack thin-pack \
    side-band side-band-64k ofs-delta shallow no-progress include-tag \
    multi_ack_detailed symref=HEAD:refs/heads/master \
    agent=git/2:2.1.1+github-607-gfba4028
003fe2409a098dc3e53539a9028a94b6224db9d6a6b6 refs/heads/master
0000

receive-pack의 응답과 매우 비슷하지만, Capability 부분은 다르다. HEAD Refs(symref=HEAD:refs/heads/master)도 알려주기 때문에 저장소를 Clone 하면 무엇을 Checkout 해야 할지 안다.

fetch-pack은 이 정보를 살펴보고 이미 가지는 개체에는 “have”를 붙이고 내려받아야 하는 개체는 “want”를 붙인 정보를 만든다. 마지막 라인에 “done”이라고 적어서 보내면 서버의 upload-pack은 해당 데이터를 Packfile로 만들어 전송한다.

003cwant ca82a6dff817ec66f44342007202690a93763949 ofs-delta
0032have 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
0009done
0000

HTTP(S)

HTTP로 Fetch 하는 과정은 두 개의 HTTP 요청으로 이루어진다. 첫 번째 요청은 GET 요청으로 응답 결과는 SSH에서 본 내용과 같다.

=> GET $GIT_URL/info/refs?service=git-upload-pack
001e# service=git-upload-pack
00e7ca82a6dff817ec66f44342007202690a93763949 HEAD□multi_ack thin-pack \
    side-band side-band-64k ofs-delta shallow no-progress include-tag \
    multi_ack_detailed no-done symref=HEAD:refs/heads/master \
    agent=git/2:2.1.1+github-607-gfba4028
003fca82a6dff817ec66f44342007202690a93763949 refs/heads/master
0000

이 결과는 SSH 연결을 사용할 때 git-upload-pack 명령을 실행한 것과 비슷하지만 이어지는 두 번째 요청이 다르다.

=> POST $GIT_URL/git-upload-pack HTTP/1.0
0032want 0a53e9ddeaddad63ad106860237bbf53411d11a7
0032have 441b40d833fdfa93eb2908e52742248faf0ee993
0000

전송할 내용은 앞에서 살펴본 것과 같다. 전송한 데이터를 서버에서 처리된 결과가 HTTP 응답으로 전달되고 결과에 따라 Packfile이 포함되어 있을 수 있다.

프로토콜 요약

이번 절을 통해 Git이 사용하는 데이터 전송 프로토콜을 간단하게 살펴보았다. Git이 사용하는 데이터 전송 프로토콜에는 multi_ackside-band 같은 추가적인 많은 기능도 포함하고 있지만, 이 책에서 다룰 수 없어 설명하지는 않는다. 이 책의 내용은 Git이 어떻게 클라이언트와 서버 간에 데이터를 주고받는지 기본적인 느낌을 전달하기 위해 노력한다. 데이터 전송 프로토콜의 많은 기능을 활용해보고 싶다면 Git 소스코드를 살펴보는 것이 좋다.

운영 및 데이터 복구

언젠가는 저장소를 손수 정리해야 할 날이 올지도 모른다. 저장소를 좀 더 알차게(Compact) 만들고, 다른 VCS에서 임포트하고 나서 그 잔재를 치운다든가, 아니면 문제가 생겨서 복구해야 할 수도 있다. 이 절은 이럴 때 필요한 것을 설명한다.

운영

Git은 때가 되면 자동으로 “auto gc” 명령을 실행한다. 이 명령이 실행되는 경우 대부분은 아무런 일도 일어나지 않는다. Loose 개체가 너무 많거나, Packfile 자체가 너무 많으면 Git은 그제야 진짜로 git gc 명령이 일하게 한다. gc 명령은 Garbage를 Collect 하는 명령이다. 이 명령은 Loose 개체를 모아서 Packfile에 저장하거나 작은 Packfile을 모아서 하나의 큰 Packfile에 저장한다. 아무런 커밋도 가리키지 않는 개체가 있고 오랫동안(대략 몇 달쯤) 아무도 쓰지 않는다면 개체를 삭제한다.

Git이 Garbage를 Collect 할 지 말지 자동으로 판단해서 처리하도록 아래와 같이 gc 명령을 실행할 수 있다.

$ git gc --auto

이 명령을 실행해도 보통은 아무 일도 일어나지 않는다. Loose 개체가 7천 개가 넘거나 Packfile이 50개가 넘지 않으면 Git은 실제로 gc 작업을 실행하지 않는다. 원한다면 gc.autogc.autopacklimit 설정으로 그 숫자를 조절할 수 있다.

gc 명령이 하는 일 중 하나는 Refs를 파일 하나로 압축하는 일이다. 예를 들어 저장소에 아래와 같은 브랜치와 태그가 있다고 하자.

$ find .git/refs -type f
.git/refs/heads/experiment
.git/refs/heads/master
.git/refs/tags/v1.0
.git/refs/tags/v1.1

git gc를 실행하면 refs에 있는 파일은 사라진다. 대신 Git은 그 파일을 .git/packed-refs 파일로 압축해서 효율을 높인다.

$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled
cac0cab538b970a37ea1e769cbbde608743bc96d refs/heads/experiment
ab1afef80fac8e34258ff41fc1b867c702daa24b refs/heads/master
cac0cab538b970a37ea1e769cbbde608743bc96d refs/tags/v1.0
9585191f37f7b0fb9444f35a9bf50de191beadc2 refs/tags/v1.1
^1a410efbd13591db07496601ebc7a059dd55cfe9

이 상태에서 Refs를 수정하면 파일을 수정하는 게 아니라 refs/heads 폴더에 파일을 새로 만든다. Git은 Refs가 가리키는 SHA 값을 찾을 때 먼저 refs 디렉토리에서 찾고 없으면 packed-refs 파일에서 찾는다. 그러니까 어떤 Refs가 있는데 refs 디렉토리에서 못 찾으면 packed-refs에 있을 것이다.

마지막에 있는 ^로 시작하는 라인을 살펴보자. 이것은 바로 윗줄의 태그가 Annotated 태그라는 것을 말해준다. 해당 커밋은 윗 태그가 가리키는 커밋이라는 뜻이다.

데이터 복구

Git을 사용하다 보면 커밋을 잃어 버리는 실수를 할 때도 있다. 보통 작업 중인 브랜치를 강제로 삭제하거나, 어떤 커밋을 브랜치 밖으로 끄집어 내버렸거나, 강제로(Hard) Reset 하면 그렇게 될 수 있다. 어쨌든 원치 않게 커밋을 잃어 버리면 어떻게 다시 찾아야 할까?

master 브랜치에서 강제로(Hard) Reset 한 경우를 예로 들어 잃어버린 커밋을 복구해보자. 먼저 연습용 저장소를 만든다.

$ git log --pretty=oneline
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

master 브랜치를 예전 커밋으로 Reset 한다.

$ git reset --hard 1a410efbd13591db07496601ebc7a059dd55cfe9
HEAD is now at 1a410ef third commit
$ git log --pretty=oneline
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

최근 커밋 두 개는 어떤 브랜치도 가리키지 않는다. 잃어 버렸다고 볼 수 있다. 그 두 커밋을 브랜치에 다시 포함하려면 마지막 커밋을 다시 찾아야 한다. SHA 값을 외웠을 리도 없고 뭔가 찾아낼 방법이 필요하다.

보통 git reflog 명령을 사용하는 게 가장 쉽다. HEAD가 가리키는 커밋이 바뀔 때마다 Git은 남몰래 자동으로 그 커밋이 무엇인지 기록한다. 새로 커밋하거나 브랜치를 바꾸면 Reflog도 늘어난다. “Git Refs” 절에서 배운 git update-ref 명령으로도 Reflog를 남길 수 있다. 이런 상황을 대비할 수 있다는 점이 git update-ref를 꼭 사용해야 하는 이유 중 하나다. git reflog 명령만 실행하면 언제나 발자취를 돌아볼 수 있다.

$ git reflog
1a410ef HEAD@{0}: reset: moving to 1a410ef
ab1afef HEAD@{1}: commit: modified repo.rb a bit
484a592 HEAD@{2}: commit: added repo.rb

reflog 명령으로 확인해보니 Checkout 했었던 커밋 두 개만 보여 준다. 구체적인 정보까지 보여주진 않는다. 좀 더 자세히 보려면 git log -g 명령을 사용해야 한다. 이 명령은 Reflog를 log 명령 형식으로 보여준다.

$ git log -g
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Reflog: HEAD@{0} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:22:37 2009 -0700

        third commit

commit ab1afef80fac8e34258ff41fc1b867c702daa24b
Reflog: HEAD@{1} (Scott Chacon <schacon@gmail.com>)
Reflog message: updating HEAD
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

       modified repo.rb a bit

두 번째 커밋을 잃어버린 것이니까 그 커밋을 가리키는 브랜치를 만들어 복구한다. 그 커밋(ab1afef)을 가리키는 브랜치 recover-branch를 만든다.

$ git branch recover-branch ab1afef
$ git log --pretty=oneline recover-branch
ab1afef80fac8e34258ff41fc1b867c702daa24b modified repo a bit
484a59275031909e19aadb7c92262719cfcdf19a added repo.rb
1a410efbd13591db07496601ebc7a059dd55cfe9 third commit
cac0cab538b970a37ea1e769cbbde608743bc96d second commit
fdf4fc3344e67ab068f836878b6c4951e3b15f3d first commit

master 브랜치가 가리키던 커밋을 recover-branch 브랜치가 가리키게 했다. 이 커밋 두 개는 다시 도달할 수 있다. 이보다 안 좋은 상황을 가정해보자. 잃어 버린 두 커밋을 Reflog에서 못 찾았다. recover-branch를 다시 삭제하고 Reflog를 삭제하여 이 상황을 재연하자. 그러면 그 두 커밋은 다시 도달할 수 없게 된다.

$ git branch -D recover-branch
$ rm -Rf .git/logs/

Reflog 데이터는 .git/logs/ 디렉토리에 있기 때문에 그 디렉토리를 지우면 Reflog도 다 지워진다. 그러면 커밋을 어떻게 복구할 수 있을까? 한 가지 방법이 있는데 git fsck 명령으로 데이터베이스의 Integrity를 검사할 수 있다. 이 명령에 --full 옵션을 주고 실행하면 길 잃은 개체를 모두 보여준다.

$ git fsck --full
Checking object directories: 100% (256/256), done.
Checking objects: 100% (18/18), done.
dangling blob d670460b4b4aece5915caf5c68d12f560a9fe3e4
dangling commit ab1afef80fac8e34258ff41fc1b867c702daa24b
dangling tree aea790b9a58f6cf6f2804eeac9f0abbe9631e4c9
dangling blob 7108f7ecb345ee9d0084193f147cdad4d2998293

이 Dangling 커밋이 잃어버린 커밋이니까 그 SHA를 가리키는 브랜치를 만들어 복구할 수 있다.

개체 삭제

Git은 장점이 매우 많다. 물론 단점도 있는데 Clone 할 때 히스토리를 전부 내려받는 것이 문제가 될 때가 있을 수 있다. Git은 모든 파일의 모든 버전을 내려받는다. 사실 파일이 모두 소스코드라면 아무 문제 없다. Git은 최적화를 잘해서 데이터를 잘 압축한다. 하지만, 누군가 매우 큰 바이너리 파일을 넣어버리면 Clone 할 때마다 그 파일을 내려받는다. 다음 커밋에서 그 파일을 삭제해도 히스토리에는 그대로 남아 있기 때문에 Clone 할 때마다 포함된다.

이 문제는 Subversion이나 Perforce 저장소를 Git으로 변환할 때에 큰 문제가 된다. Subversion이나 Perforce 시스템은 전체 히스토리를 내려받는 것이 아니므로 해당 파일이 여러 번 추가될 수 있다. 혹은 다른 VCS에서 Git 저장소로 임포트하려고 하는데 Git 저장소의 공간이 충분하지 않으면 너무 큰 개체는 찾아서 삭제해야 한다.

주의: 이 작업을 하다가 커밋 히스토리를 망쳐버릴 수 있다. 삭제하거나 수정할 파일이 들어 있는 커밋 이후에 추가된 커밋은 모두 재작성된다. 프로젝트를 임포트 하자마자 하는 것은 괜찮다. 아직 아무도 새 저장소를 가지고 일하지 않기 때문이다. 그게 아니면 히스토리를 Rebase 한다고 관련된 사람 모두에게 알려야 한다.

시나리오 하나를 살펴보자. 먼저 저장소에 크기가 큰 파일을 넣고 다음 커밋에서는 삭제할 것이다. 그리고 나서 그 파일을 다시 찾아 저장소에서 삭제한다. 먼저 히스토리에 크기가 큰 개체를 추가한다.

$ curl https://www.kernel.org/pub/software/scm/git/git-2.1.0.tar.gz > git.tgz
$ git add git.tgz
$ git commit -m 'add git tarball'
[master 7b30847] add git tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 git.tgz

이런 tar 파일을 버전관리 하자고 넣을 수는 없다. 다음 커밋에서 다시 삭제한다.

$ git rm git.tgz
rm 'git.tgz'
$ git commit -m 'oops - removed large tarball'
[master dadf725] oops - removed large tarball
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 git.tgz

gc 명령으로 최적화하고 나서 저장소 크기가 얼마나 되는지 확인한다.

$ git gc
Counting objects: 17, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (13/13), done.
Writing objects: 100% (17/17), done.
Total 17 (delta 1), reused 10 (delta 0)

count-objects 명령은 사용하는 용량이 얼마나 되는지 알려준다.

$ git count-objects -v
count: 7
size: 32
in-pack: 17
packs: 1
size-pack: 4868
prune-packable: 0
garbage: 0
size-garbage: 0

size-pack 항목의 숫자가 Packfile의 크기다. 단위가 킬로바이트라서 이 Packfile의 크기는 약 5MB이다. 큰 파일을 커밋하기 전에는 약 2K였다. 필요없는 파일을 지우고 커밋했지만 히스토리에서 삭제되지 않았다. 어쨌든 큰 파일이 하나 들어 있기 때문에 너무 작은 프로젝트인데도 Clone 하는 사람마다 5MB씩 필요하다. 이제 그 파일을 삭제해 보자.

먼저 파일을 찾는다. 뭐, 지금은 무슨 파일인지 이미 알고 있지만 모른다고 가정한다. 어떤 파일이 용량이 큰지 어떻게 찾아낼까? 게다가 git gc를 실행됐으면 전부 Packfile 안에 있어서 더 찾기 어렵다. Plumbing 명령어 git verify-pack로 파일과 그 크기 정보를 수집하고 세 번째 필드를 기준으로 그 결과를 정렬한다. 세 번째 필드가 파일 크기다. 가장 큰 파일 몇 개만 삭제할 것이기 때문에 tail 명령으로 가장 큰 파일 3개만 골라낸다.

$ git verify-pack -v .git/objects/pack/pack-29…69.idx \
  | sort -k 3 -n \
  | tail -3
dadf7258d699da2c8d89b09ef6670edb7d5f91b4 commit 229 159 12
033b4468fa6b2a9547a70d88d1bbe8bf3f9ed0d5 blob   22044 5792 4977696
82c99a3e86bb1267b236a4b6eff7868d97489af1 blob   4975916 4976258 1438

마지막에 있는 개체가 5MB로 가장 크다. 이제 그 파일이 정확히 무슨 파일인지 알아내야 한다. “커밋 메시지 규칙 만들기” 에서 소개했던 rev-list 명령에 --objects 옵션을 추가하면 커밋의 SHA 값과 Blob 개체의 파일이름, SHA 값을 보여준다. 그 결과에서 해당 Blob의 이름을 찾는다.

$ git rev-list --objects --all | grep 82c99a3
82c99a3e86bb1267b236a4b6eff7868d97489af1 git.tgz

히스토리에 있는 모든 Tree 개체에서 이 파일을 삭제한다. 먼저 이 파일을 추가한 커밋을 찾는다.

$ git log --oneline --branches -- git.tgz
dadf725 oops - removed large tarball
7b30847 add git tarball

이 파일을 히스토리에서 완전히 삭제하면 6df76 이후 커밋은 모두 재작성된다. “히스토리 단장하기” 에서 배운 filter-branch 명령으로 삭제한다.

$ git filter-branch --index-filter \
  'git rm --cached --ignore-unmatch git.tgz' -- 7b30847^..
Rewrite 7b30847d080183a1ab7d18fb202473b3096e9f34 (1/2)rm 'git.tgz'
Rewrite dadf7258d699da2c8d89b09ef6670edb7d5f91b4 (2/2)
Ref 'refs/heads/master' was rewritten

--index-filter 옵션은 “히스토리 단장하기” 에서 배운 --tree-filter와 비슷하다. --tree-filter는 디스크에 Checkout 해서 파일을 수정하지만 --index-filter는 Staging Area에서 수정한다.

삭제도 rm file 명령이 아니라 git rm --cached 명령으로 삭제한다. 디스크에서 삭제하는 것이 아니라 Index에서 삭제하는 것이다. 이렇게 하는 이유는 속도가 빠르기 때문이다. Filter를 실행할 때마다 각 리비전을 디스크에 Checkout 하지 않기 때문에 이것이 울트라 캡숑 더 빠르다. --tree-filter로도 같은 작업을 할 수 있다. 단지 느릴 뿐이다. 그리고 git rm 명령에 --ignore-unmatch 옵션을 주면 파일이 없는 경우에 에러를 출력하지 않는다. 마지막으로 문제가 생긴 것은 6df7640 커밋부터라서 filter-branch 명령에 6df7640 커밋부터 재작성하라고 알려줘야 한다. 그렇지 않으면 첫 커밋부터 시작해서 불필요한 것까지 재작성해 버린다.

히스토리에서는 더는 그 파일을 가리키지 않는다. 하지만, Reflog나 filter-branch를 실행할 때 생기는 Refs가 남아있다. filter-branch.git/refs/original 디렉토리에 실행될 때의 상태를 저장한다. 그래서 이 파일도 삭제하고 데이터베이스를 다시 압축해야 한다. 압축하기 전에 해당 개체를 가리키는 Refs는 모두 없애야 한다.

$ rm -Rf .git/refs/original
$ rm -Rf .git/logs/
$ git gc
Counting objects: 15, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (11/11), done.
Writing objects: 100% (15/15), done.
Total 15 (delta 1), reused 12 (delta 0)

공간이 얼마나 절약됐는지 확인한다.

$ git count-objects -v
count: 11
size: 4904
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0

압축된 저장소의 크기는 8K로 내려갔다. 5MB보다 한참 작다. 하지만, size 항목은 아직 압축되지 않는 Loose 개체의 크기를 나타내는데 그 항목이 아직 크다. 즉, 아직 완전히 제거된 것은 아니다. 하지만, 이 개체는 Push 할 수도 Clone 할 수도 없다. 이 점이 중요하다. 정말로 완전히 삭제하려면 git prune --expire 명령으로 삭제해야 한다.

$ git prune --expire now
$ git count-objects -v
count: 0
size: 0
in-pack: 15
packs: 1
size-pack: 8
prune-packable: 0
garbage: 0
size-garbage: 0

환경변수

Git은 늘 bash 셸 환경 안에서 동작한다. 셸 환경변수에 따라 Git의 동작이 달라진다. Git에 영향을 주는 환경변수가 어떤 것들이 있고 또 그 값에 따라 Git이 어떻게 동작하는지 알아두면 꽤 쓸모 있다. Git과 관련된 환경변수 전체를 다루지는 못하지만 알아두면 유용한 변수들은 거의 다 다룬다.

Git에 영향을 주는 변수

Git의 여러 기능 중 일반적인 기능이 동작할 때 영향을 미치는 주요 환경변수는 아래와 같다.

GIT_EXEC_PATH 변수는 Git의 여러 Subprogram(예를 들어 git-commit, git-diff 같은 것들)이 어디에 있는지를 설정한다. 현재 설정을 확인하려면 git --exec-path 명령을 실행한다.

HOME 변수는 일반적으로 변경하지 않는 변수이다. 아주 많은 프로그램이 이 변수를 참조하기 때문이다. Git이 이 변수에 영향을 받는 부분은 사용자(User) 전체에 영향을 주는 Git 환경설정 파일을 찾을 때이다. Git을 포터블로 설치하거나 해서 사용자 환경설정 파일의 위치를 강제로 지정해야 하면 Git을 실행하는 셸의 HOME 변수에 원하는 값을 설정한다.

PREFIX 변수도 비슷한 성격으로 사용자 수준이 아닌 시스템 수준의 환경설정 파일을 찾을 위치를 설정한다. Git이 찾을 위치는 $PREFIX/etc/gitconfig 이다.

GIT_CONFIG_NOSYSTEM 변수를 설정하면 시스템 수준의 환경설정 파일을 적용하지 않는다. 이 변수는 시스템 수준의 환경설정 파일이 자꾸 방해되는데 고칠 권한이 없는 경우 설정하면 유용하다.

GIT_PAGER 변수는 Git이 화면에 출력할 내용이 한 화면이 넘어갈 때 사용할 프로그램을 설정한다. 이 변수에 값을 설정하지 않으면 PAGER 변수의 내용도 참고한다.

GIT_EDITOR 변수는 커밋 내용을 입력하는 상황과 같이 Git이 사용자로부터 어떤 내용을 입력받는 경우 실행시킬 편집기를 설정하는 변수이다. 이 변수에 값을 설정하지 않으면 EDITOR 변수의 내용도 참고한다.

저장소 위치 관련 변수

Git은 현재 작업 중인 저장소를 참조할 때 아래와 같은 환경변수에 영향을 받는다.

GIT_DIR 변수는 .git 디렉토리의 위치를 설정하는 변수다. 이 변수의 값을 설정하지 않으면 현재 디렉토리에서부터 ~/ 까지 한 단계씩 위로 올라가면서 .git 디렉토리가 있는지 찾는다.

GIT_CEILING_DIRECTORIES 변수는 .git 디렉토리를 찾으려고 한 단계씩 위로 올라가는 작업을 제어한다. 사용하는 시스템의 저장장치를 읽고 쓰는 속도가 무지하게 느리면 이 변수를 적절하게 설정한다. 불필요하게 .git 디렉토리를 찾아서 계속 저장장치의 디렉토리를 돌아다니지 않아도 된다.

GIT_WORK_TREE 변수는 Git 저장소가 관리하는 실제 소스코드와 같은 파일이 위치한 디렉토리를 설정한다. 물론 실제 파일을 사용하므로 Bare 저장소가 아닌 경우에만 해당한다. 이 변수를 설정하지 않으면 $GIT_DIR에 설정된 디렉토리의 상위 디렉토리를 사용한다.

GIT_INDEX_FILE 변수는 Index 파일의 위치를 설정한다. Bare 저장소가 아닌 경우에만 해당한다.

GIT_OBJECT_DIRECTORY 변수는 .git/objects 디렉토리 위치를 설정한다. Bare 저장소가 아닌 경우에만 해당한다.

GIT_ALTERNATE_OBJECT_DIRECTORIES 변수는 콜론으로 구분된 디렉토리 리스트(예, /dir/one:/dir/two:…)로 GIT_OBJECT_DIRECTORY에서 찾을 수 없는 개체를 찾을 때 사용할 디렉토리를 설정한다. 크기가 무지하게 큰 파일을 여러 프로젝트에서 공유하고 있다면 이 변수를 적절히 사용한다. 중복되는 내용을 지우고 특정 위치에서 개체를 공유해서 사용하므로 저장공간 낭비를 줄일 수 있다.

Pathspec 관련 변수

“pathspec”은 Git을 쓸 때 파일이나 디렉토리의 경로(* 같은 와일드카드 문자를 사용하는 경우를 포함)를 전달할 때 어떤 방식을 사용하는가에 대한 내용이다. .gitignore 파일에서도 사용하고 git 명령(예, git add *.c)에서도 사용한다.

GIT_GLOB_PATHSPECS, GIT_NOGLOB_PATHSPECS 변수로는 Pathspec을 사용할 때 와일드카드 문자로 어떤 동작을 하게 할 지 설정한다. GIT_GLOB_PATHSPECS 변수의 값을 1로 설정하면 와일드카드 문자는 보통 사용하듯 와일드카드 문자의 역할을 한다(기본값). GIT_NOGLOB_PATHSPECS 변수의 값을 1로 설정하면 와일드카드 문자를 진짜 파일 이름의 와일드카드 문자로만 인식한다. *.c 라고 하면 진짜 파일이름이 “*.c” 인 파일만 해당하고 확장자가 .c 파일은 해당하지 않는다. 환경 변수에 독립적으로 각 명령에서 이를 선택하여 사용할 때는 :(glob) 또는 :(literal)를 명시해서 사용할 수 있다. 예를 들어 :(glob)*.c 같이 말이다.

GIT_LITERAL_PATHSPECS 변수를 설정하면 위 설정 둘 다 적용하지 않는다. 와일드카드 문자는 아무런 쓸모도 없게 되고, 변수에 독립적으로 사용하는 접두어도 마찬가지로 쓸 수 없게 된다.

GIT_ICASE_PATHSPECS 변수를 설정하면 대문자와 소문자를 가리지 않게 된다.

커밋관련 변수

Git이 커밋을 만드는 작업에서 대부분 git-commit-tree 명령을 실행하고 나면 커밋 개체가 만들어진다. 이 명령이 커밋을 만들 때 커밋에 채워넣을 정보를 가져오거나 참고하는 환경변수는 아래와 같다. 환경변수를 설정하지 않는 경우는 환경설정 파일의 내용을 가져와 적용한다.

GIT_AUTHOR_NAME 변수는 “author” 정보로 사용할 이름.

GIT_AUTHOR_EMAIL 변수는 “author” 정보로 사용할 이메일 주소.

GIT_AUTHOR_DATE 변수는 “author” 정보로 사용할 타임스탬프 값.

GIT_COMMITTER_NAME 변수는 “committer” 정보로 사용할 이름.

GIT_COMMITTER_EMAIL 변수는 “committer” 정보로 사용할 이메일 주소.

GIT_COMMITTER_DATE 변수는 “committer” 정보로 사용할 타임스탬프 값.

EMAIL 변수는 어떤 환경설정 파일에도 user.email 설정을 찾을 수 없는 경우 참조하는 변수다. 이 변수마저 설정하지 않으면 Git은 시스템의 현재 사용자정보와 시스템 호스트 정보를 조합하여 사용한다.

네트워크 관련 변수

Git은 HTTP 프로토콜로 데이터를 전송할 때 curl 라이브러리를 사용한다. GIT_CURL_VERBOSE 변수를 설정하면 curl 라이브러리가 출력하는 상세한 정보를 볼 수 있다. curl -v 명령을 사용한 경우와 비슷하다.

GIT_SSL_NO_VERIFY 변수를 설정하면 SSL 인증서를 확인하지 않는다. HTTPS 프로토콜로 저장소를 사용하는데 Self-signed 인증서를 사용할 때 이 변수를 사용한다. 혹은 아직 인증서를 정상적으로 발급하진 않았지만, 테스트를 위해 테스트용 인증서를 사용하는 경우를 예로 들 수도 있다.

GIT_HTTP_LOW_SPEED_TIME 변수에 설정한 시간 동안 GIT_HTTP_LOW_SPEED_LIMIT 변수에 설정한 초당 전송 바이트 수에 미치지 못하는 HTTP 전송속도가 나오면 Git은 데이터 전송을 중지한다. 이 설정은 설정파일의 http.lowSpeedLimit, http.lowSpeedTime 항목보다 우선한다.

GIT_HTTP_USER_AGENT 변수는 Git이 HTTP 데이터 전송을 할 때 헤더에 사용자 에이전트 값으로 사용할 문자열을 설정한다. 기본적으로 사용하는 값은 git/2.0.0 같은 모양의 값이다.

Diff/Merge 관련 변수

GIT_DIFF_OPTS 변수는 이름이 잘못 지어진 변수다. git diff 명령을 실행했을 때 변경된 부분 아래위로 보여주는 라인의 개수를 조절한다. 명령의 옵션으로 사용할 때는 -u<n> 이나 --unified=<n>로 사용한다.

GIT_EXTERNAL_DIFF 변수는 diff.external 설정보다 우선한다. git diff 명령을 실행하면 이 변수에 설정한 명령을 실행한다.

GIT_DIFF_PATH_COUNTER 변수나 GIT_DIFF_PATH_TOTAL 변수의 설정은 GIT_EXTERNAL_DIFF 또는 diff.external에 설정된 프로그램 안에서 유용하게 사용한다. GIT_DIFF_PATH_TOTAL 변수는 diff 명령이 실행할 때 보여주는 모든 파일의 개수를 나타낸다. GIT_DIFF_PATH_COUNTER 변수는 그 파일 중 지금 몇 번째 파일을 보여주고 있는지를 1로 시작하는 Index를 담고 있다.

GIT_MERGE_VERBOSITY 변수는 Recursive Merge 전략에 따른 메시지 출력을 제어한다. 이 변수가 사용할 수 있는 값은 아래와 같이 5개의 수준이다.

  • 0 충돌이 발생한 경우에만 마지막 에러 메시지를 출력

  • 1 충돌이 발생한 경우에만 충돌 내용을 출력함

  • 2 충돌 내용과 변경된 내용을 출력함

  • 3 변경된 내용이 없는 파일이라도 출력함

  • 4 Merge 할 때 열어본 모든 파일을 출력함

  • 5 또는 그 이상의 값을 설정하면 디버그 메시지까지 출력함

이 중 기본 값은 2이다.

디버그 관련 변수

Git이 어디까지 실행했는지 알고 싶은가? Git은 거의 모든 내부 동작에 대한 Trace 로그를 남길 수 있으며 환경변수를 조절해서 Trace 로그를 확인할 수 있다. 변수에 설정할 수 있는 값은 아래와 같다.

  • “true”, “1”, “2” – stderr 표준에러출력으로 Trace 로그를 출력함(1 이상 10 이하의 숫자는 해당 FD로 출력함).

  • /로 시작하는 절대 경로 – Trace 로그를 해당 경로의 파일에 기록함.

GIT_TRACE 변수에 위와 같은 값을 설정하면 특정 카테고리로 지정하지 않은 모든 Trace 메시지를 대상에 기록하거나 출력한다. Alias를 적용하거나 명령에 따라 Subprogram을 실행시킨다거나 하는 Trace를 확인할 수 있다.

$ GIT_TRACE=true git lga
20:12:49.877982 git.c:554               trace: exec: 'git-lga'
20:12:49.878369 run-command.c:341       trace: run_command: 'git-lga'
20:12:49.879529 git.c:282               trace: alias expansion: lga => 'log' '--graph' '--pretty=oneline' '--abbrev-commit' '--decorate' '--all'
20:12:49.879885 git.c:349               trace: built-in: git 'log' '--graph' '--pretty=oneline' '--abbrev-commit' '--decorate' '--all'
20:12:49.899217 run-command.c:341       trace: run_command: 'less'
20:12:49.899675 run-command.c:192       trace: exec: 'less'

GIT_TRACE_PACK_ACCESS 변수에 따라 Packfile 사용 내용을 출력한다. 출력 내용을 보면 첫 번째 열은 접근하는 Packfile의 이름을, 두 번째 열은 Packfile 안에서 오프셋 정보를 보여준다.

$ GIT_TRACE_PACK_ACCESS=true git status
20:10:12.081397 sha1_file.c:2088        .git/objects/pack/pack-c3fa...291e.pack 12
20:10:12.081886 sha1_file.c:2088        .git/objects/pack/pack-c3fa...291e.pack 34662
20:10:12.082115 sha1_file.c:2088        .git/objects/pack/pack-c3fa...291e.pack 35175
# []
20:10:12.087398 sha1_file.c:2088        .git/objects/pack/pack-e80e...e3d2.pack 56914983
20:10:12.087419 sha1_file.c:2088        .git/objects/pack/pack-e80e...e3d2.pack 14303666
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean

GIT_TRACE_PACKET 변수는 네트워크 데이터 전송을 하는 경우 패킷 수준의 Trace 정보를 보여준다.

$ GIT_TRACE_PACKET=true git ls-remote origin
20:15:14.867043 pkt-line.c:46           packet:          git< # service=git-upload-pack
20:15:14.867071 pkt-line.c:46           packet:          git< 0000
20:15:14.867079 pkt-line.c:46           packet:          git< 97b8860c071898d9e162678ea1035a8ced2f8b1f HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed no-done symref=HEAD:refs/heads/master agent=git/2.0.4
20:15:14.867088 pkt-line.c:46           packet:          git< 0f20ae29889d61f2e93ae00fd34f1cdb53285702 refs/heads/ab/add-interactive-show-diff-func-name
20:15:14.867094 pkt-line.c:46           packet:          git< 36dc827bc9d17f80ed4f326de21247a5d1341fbc refs/heads/ah/doc-gitk-config
# []

GIT_TRACE_PERFORMANCE 변수를 설정하면 Git의 성능에 관련된 Trace를 출력한다. 출력한 내용을 살펴보면 어떤 작업이 얼마나 시간이 걸려 실행되었는지 확인할 수 있다.

$ GIT_TRACE_PERFORMANCE=true git gc
20:18:19.499676 trace.c:414             performance: 0.374835000 s: git command: 'git' 'pack-refs' '--all' '--prune'
20:18:19.845585 trace.c:414             performance: 0.343020000 s: git command: 'git' 'reflog' 'expire' '--all'
Counting objects: 170994, done.
Delta compression using up to 8 threads.
Compressing objects: 100% (43413/43413), done.
Writing objects: 100% (170994/170994), done.
Total 170994 (delta 126176), reused 170524 (delta 125706)
20:18:23.567927 trace.c:414             performance: 3.715349000 s: git command: 'git' 'pack-objects' '--keep-true-parents' '--honor-pack-keep' '--non-empty' '--all' '--reflog' '--unpack-unreachable=2.weeks.ago' '--local' '--delta-base-offset' '.git/objects/pack/.tmp-49190-pack'
20:18:23.584728 trace.c:414             performance: 0.000910000 s: git command: 'git' 'prune-packed'
20:18:23.605218 trace.c:414             performance: 0.017972000 s: git command: 'git' 'update-server-info'
20:18:23.606342 trace.c:414             performance: 3.756312000 s: git command: 'git' 'repack' '-d' '-l' '-A' '--unpack-unreachable=2.weeks.ago'
Checking connectivity: 170994, done.
20:18:25.225424 trace.c:414             performance: 1.616423000 s: git command: 'git' 'prune' '--expire' '2.weeks.ago'
20:18:25.232403 trace.c:414             performance: 0.001051000 s: git command: 'git' 'rerere' 'gc'
20:18:25.233159 trace.c:414             performance: 6.112217000 s: git command: 'git' 'gc'

GIT_TRACE_SETUP 변수를 설정하면 Git이 현재 어떤 저장소와 어떤 환경 위에서 동작하고 있는지 파악한 정보를 보여준다.

$ GIT_TRACE_SETUP=true git status
20:19:47.086765 trace.c:315             setup: git_dir: .git
20:19:47.087184 trace.c:316             setup: worktree: /Users/ben/src/git
20:19:47.087191 trace.c:317             setup: cwd: /Users/ben/src/git
20:19:47.087194 trace.c:318             setup: prefix: (null)
On branch master
Your branch is up-to-date with 'origin/master'.
nothing to commit, working directory clean

잡동사니 변수

GIT_SSH 변수를 설정하면 Git이 SSH 리모트로 연결할 때 ssh 명령 대신 설정된 명령을 사용한다. 즉 $GIT_SSH [username@]host [-p <port>] <command> 명령을 실행한 것과 같다. GIT_SSH 변수를 설정하는 방식이 ssh 명령을 사용자 입맛에 맞게끔 고치는 가장 좋은 방법은 아니다. ssh 명령의 다양한 옵션을 사용할 수 없는 방식이기 때문이다. 따로 원하는 옵션들을 적용한 스크립트를 하나 만들고 이 스크립트를 변수에 설정하면 원하는 ssh 옵션을 사용할 수 있다. ~/.ssh/config 환경설정 파일을 편집하여 사용하는 것이 더 나을 수도 있다.

GIT_ASKPASS 변수는 core.askpass 설정보다 우선한다. 이 변수에 설정하는 스크립트나 프로그램은 Git이 사용자에게 암호를 입력받는 상황에서 실행되어 stdout 표준출력으로 출력하는 메시지를 암호로 받아서 처리한다. (“Credential 저장소” 에서 더 자세한 내용을 확인할 수 있다.)

GIT_NAMESPACE 변수를 설정하면 Ref에 접근할 때 네임스페이스로 사용한다. --namespace 옵션과 같다. 이 변수는 서버 측에서 유용하게 사용할 수 있다. 하나의 저장소 안에 여러 Fork를 운영하는 경우 이 변수를 사용하여 Ref를 분리하여 사용할 수 있다.

GIT_FLUSH 변수를 설정하면 Git이 메시지를 화면에 출력할 때 버퍼를 사용하지 않고 즉시즉시 출력한다. 값을 1로 설정하면 평소보다 훨씬 빈번하게 메시지 출력하고 0으로 설정하면 항상 버퍼를 사용한다. 이 변수에 값을 설정하지 않으면 기본적으로 Git은 상황에 맞게 조절하여 출력한다.

GIT_REFLOG_ACTION 변수는 reflog의 설명에 사용된다. 이 변수에 작업 내용에 대한 설명을 담아두고 Git 명령을 실행하면 된다. 예를 들어 아래와 같다.

$ GIT_REFLOG_ACTION="my action" git commit --allow-empty -m 'my message'
[master 9e3d55a] my message
$ git reflog -1
9e3d55a HEAD@{0}: my action: my message

요약

Git이 내부적으로 어떻게 동작하는지 뿐만 아니라 어떻게 구현됐는지까지 잘 알게 됐을 것이다. 이 장에서는 저수준 명령어인 Plumbing 명령어를 설명했다. 다른 장에서 우리가 배웠던 Porcelain 명령어보다는 단순하다. Git이 내부적으로 어떻게 동작하는지 알면 Git이 왜 그렇게 하는가를 더 쉽게 이해할 수 있을 뿐만 아니라 개인적으로 필요한 도구나 스크립트를 만들어 자신의 Workflow를 개선할 수 있다.

Git은 Content-addressable 파일 시스템이기 때문에 VCS 이상의 일을 할 수 있는 매우 강력한 도구다. 필자는 독자가 습득한 Git 내부 지식을 활용해서 필요한 애플리케이션을 직접 만들면 좋겠다. 그리고 진정 Git을 꼼꼼하고 디테일하게 다룰 수 있게 되길 바란다.