Git 재활 훈련 13일차 - git internals

0

Git

목록 보기
13/14

Git Internal

git의 내부 동작에 관해서 알아보자. 전부를 알 필요는 없고 중요한 몇 가지를 알아보도록 하자.

실습을 위해 react github인 https://github.com/facebook/react에 들어가보도록 하자.

clone하여 tag에 대한 실습을 해보도록 하자.

git clone https://github.com/facebook/react.git
cd ./react

ls .git 명령어를 입력하면 숨겨진 .git 디렉터리에 있는 file들이 보일 것이다.

ls .git
branches  config  description  HEAD  hooks  index  info  logs  objects  packed-refs  refs

이들 중에 중요한 file과 directory를 모아보면 다음과 같다.
1. object
2. config
3. HEAD
4. index
5. refs

하나하나 알아보도록 하자. 먼저 config에 대해서 알아보자.

config

config는 설정을 위한 file이며, local 저장소 단위의 config 설정을 위해 존재하는 file이다. 즉, 해당 config는 local에만 영향을 주지 전역적으로 영향을 주지 않는다.

cat .git/config 
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "origin"]
        url = https://github.com/facebook/react.git
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
        remote = origin
        merge = refs/heads/main
        vscode-merge-base = origin/main

git config docs를 가면 여러 가지 설정 값들이 있다. 이를 참고하여 설정해주면 된다.

git config 명령어를 통해서 config 설정을 해줄 수 있는데, --local을 붙이면 해당 local repo에만 영향을 받는다. 따라서, .git/config의 값이 수정된다.

git config --local user.name "chicken little"
git config --local user.email "chicken@bbq.com"

다음은 user.nameuser.email을 추가한 것이다.

정말 설정되었는 지 확인해보도록 하자.

cat .git/config 
[core]
        repositoryformatversion = 0
        filemode = true
        bare = false
        logallrefupdates = true
[remote "origin"]
        url = https://github.com/facebook/react.git
        fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
        remote = origin
        merge = refs/heads/main
        vscode-merge-base = origin/main
[user]
        name = chicken little
        email = chicken@bbq.com

잘 설정된 것을 볼 수 있다. 만약 --local을 붙이지 않으면 전역으로 config 설정이 된다는 것을 유념하자.

Refs directory

refs는 reference로 각 branch가 가리키는 head reference와 remote에 대한 reference, tag에 대한 commit reference가 있다.

ls .git/refs/
heads  remotes  tags

먼저 heads에 들어가보자.

ls .git/refs/heads/
main

main branch 밖에 없으므로 main file만 있다. 해당 file을 읽어보도록 하자.

cat .git/refs/heads/main 
028c8e6cf5ce2a87147a7e03e503ce94c7a7a0cf

해당 commit hash가 무엇인지 알아보도록 하자.

git log --oneline
28c8e6cf5 (HEAD -> main, origin/main, origin/HEAD) Add Transition Types (#32105)
18eaf51bd5 Support eslint 8+ flat plugin syntax out of the box for eslint-plugin-react-compiler (#32120)
829401dc17 [Flight] Transport custom error names in dev mode (#32116)
...

28c8e6cf5main branch가 referencing하는 commit hash값이라는 것을 알 수 있다.

그럼 branch를 하나 만들어서 해당 branch의 .git/refs/heads/에는 무엇이 있을 지 보도록 하자.

git switch -c temp

echo "temp" >> ./README.md
git add ./README.md
git commit -m "temp commit1"

이제 .git/refs/heads/를 확인해보도록 하자.

ls .git/refs/heads/
main temp

main말고도 temp branch가 생긴 것을 볼 수 있다.

다음으로 temp branch가 가리키는 commit hash를 보도록 하자.

cat .git/refs/heads/temp 
92e550beede531fdfe7b6f106febcf89134136d6

git log --oneline 
92e550beed (HEAD -> temp) temp commit1
028c8e6cf5 (origin/main, origin/HEAD) Add Transition Types (#32105)

현재 HEAD의 값과 일치하는 것을 볼 수 있다.

여기서 재미난 점은 HEAD.git directory에서 볼 수 있다는 것이다.

cat .git/HEAD 
ref: refs/heads/temp

HEAD file이 refs/heads/temp를 지칭하는 것을 볼 수 있다. 해당 값이 바로 92e550beede531fdfe7b6f106febcf89134136d6라는 것이다. 따라서 HEAD는 현재 branch에서 보고 있는 commit hash값을 레퍼런싱하고 있다는 사실을 알 수 있다.

git checkout을 통해서 다른 commit으로 간 다음에 .git/refs/heads/temp 값의 변화를 볼 수 있다.

git log --oneline 
92e550beed (HEAD -> temp) temp commit1
028c8e6cf5 (origin/main, origin/HEAD) Add Transition Types (#32105)
18eaf51bd5 Support eslint 8+ flat plugin syntax out of the box for eslint-plugin-react-compiler (#32120)
829401dc17 [Flight] Transport custom error names in dev mode (#32116)
fd2d279984 [eslint-plugin-react-hooks] Inline meta fields (#32115)
...

fd2d279984 commit으로 이동하고 .git/HEAD 값을 확인해보도록 하자. 즉, head를 detach시키는 것이다.

git checkout fd2d279984
HEAD is now at fd2d279984 [eslint-plugin-react-hooks] Inline meta fields (#32115)

cat .git/HEAD 
fd2d2799840d9066a752bb32bbbb07c93f64a891

현재의 HEAD가 가리키는 commit으로 fd2d2799840d9066a752bb32bbbb07c93f64a891가 나왔다. 이는 우리가 checkout했던 값이다.

반면에 .git/refs/heads/temp에 가면 맨 앞에 있는 commit을 가리키는 것을 볼 수 있다.

cat .git/refs/heads/temp 
92e550beede531fdfe7b6f106febcf89134136d6

이를 통해서 알 수 있는 사실은 다음과 같다. .git/refs/heads/branch에 있는 값은 해당 branch의 가장 앞에 있는 commit을 말하는 것이고, .git/HEAD는 현재 branch가 가리키는 HEAD를 말하는 것이다. 그래서 detached HEAD가 되면 .git/HEAD는 해당 commit값을 가리킨다는 것이다.

              HEAD              temp
                |                 |
                v                 v
commit1 --> commit2 --> ... -> commitN (latest)

정리하면 다음과 같이 된 것이다.

Objects

git의 핵심 directory이다. file backup, content, commit도 여기에 저장된다.

ls .git/objects
5e  77  92  b3  ba  dd  info  pack

16진수로 된 directory와 info, pack 들로 이루어져 졌다.

ls .git/objects/5e/
6d2153667deb1a5fae2d672aeee651819aee3f

6d2153667deb1a5fae2d672aeee651819aee3f라는 file이 있는데, 이 file은 사람이 읽을 수 있는 형식으로 되어있지 않다. 이는 system이 읽는 파일로 commit file, commit 정보, file content들을 git은 snapshot으로 저장한다. 즉, 저장소의 모든 데이터인 전체의 snapshot을 저장하는 것이다.

이 file안에는 4가지 종류의 git 객체가 들어갈 수 있다.
1. commit
2. tree
3. blob
4. annotated tag

이 4개를 해싱 함수를 통해서 해시값으로 관리하는 것이다.

4가지 종류의 git 객체를 분석하기 전에 git은 key와 value를 저장하는 database 역할을 한다는 것이다. git은 content를 저장하면 고유한 key를 반환하는데, 이 key로 저장한 content를 취득할 수 있다. 이 key가 바로 hash값인 것이다.

다음의 예시를 보자

1f7a7a... -> app.js (version1)
83baae... -> app.js (version2) 

1f7a7a, 83baae와 같은 hash값이 하나의 key이고 value가 바로 app.js라는 파일이다. 단 app.js 파일은 version에 따라 다르며 version1은 1f7a7a으로 호출이 가능하고, version2는 83baae으로 호출이 가능하다.

이렇게 git은 key-value database로서 4가지 종류의 객체를 저장하고, 불러오고 사용할 수 있다.

Git hash object로 해싱하기

git hash-object라는 재미난 명령어가 있는데, 이 명령어를 통해서 특정 value에 대해서 SHA-1 hash값을 만들어낼 수 있다. 기본적으로 file을 해싱하지만 --stdin을 사용하면 문자도 해싱이 가능하다. 참고로 해당 명령어는 평소에는 아예 안쓰인다.

echo "hello" | git hash-object --stdin

ce013625030ba8dba906f756967f9e9ca394464a

해시값이 나왔지만, 이 해시값으로 얻을 수 있는 것은 없다. 해시값에 맞는 value를 저장하도록 만드는 방법은 -w 옵션을 추가하는 것이다.

echo "hello" | git hash-object --stdin -w
ce013625030ba8dba906f756967f9e9ca394464a

-w 명령어를 사용하면 objects에 해시값으로 변환된 hello를 저장한다. 단, 이때 해시값의 앞 두 글자만 따와서 directory로 만들고 저장한다.

objects에서 해당 해시값이 있는 지 확인해보도록 하자.

ls .git/objects/
5e  77  92  b3  ba  ce  dd  info  pack

ls .git/objects/ce/
013625030ba8dba906f756967f9e9ca394464a

ce013625030ba8dba906f756967f9e9ca394464a가 있는 것을 볼 수 있다. 이는 우리가 저장한 hello의 해시값인 ce013625030ba8dba906f756967f9e9ca394464a을 말한다.

그럼 해싱값을 사용하여 어떻게 value를 얻어 올 수 있는가?? 그건 git cat-file을 이용하면 된다.

git cat-file -p <object-hash>

우리의 해싱값을 넣어보도록 하자.

git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
hello

사실 전체를 넣을 필요가 없다.

git cat-file -p ce0136
hello

어느정도만 넣어도 이렇게 나온다. 단, 식별이 가능한 길이가 되어야 한다.

git에서는 이렇게 file 정보를 hashing하여 objects에 저장했다가 cat-file처럼 hash값을 통해 file 정보를 가져오고 읽는 것이다.

Blob

blob은 파일의 내용을 담고 있는 객체이다. blob은 여러 file들의 content들을 저장할 수 있는데, 이 때 각 file들의 이름은 저장하지 않는다. 단지 file의 content만 담는 것이다.

우리가 hello를 해시값으로 만들었을 때, objectsce013625030ba8dba906f756967f9e9ca394464a가 생긴 것을 봤다. 013625030ba8dba906f756967f9e9ca394464a도 원본 데이터를 담고 있는 하나의 blob이 되는 것이다.

ls .git/objects/
5e  77  92  b3  ba  ce  dd  info  pack

ls .git/objects/ce/
013625030ba8dba906f756967f9e9ca394464a

5e, 77, 92, `b3, ba, ce, dd 안에 있는 모든 해시이름의 파일들은 하나의 blob으로 content들의 정보를 담고 있다.

명심해야할 것은 blob은 file의 content를 저장하지, file의 이름은 저장하지 않는다는 것이다.

Tree

그런데, 하위 디렉터리 구조가 복잡하게 연결된 파일들에 대해서는 file content뿐만 아니라, file들의 구조, 관계도 매우 중요하다. 이걸 담당하는 것이 바로 tree 객체이다.

tree는 git 객체로 directory 내용을 저장한다. 그래서 tree는 blob을 가리키는 포인터와 tree를 가리키는 포인터 둘 다 가지고 있다. 그래서 다음과 같이 쓸 수 있다.

root/
    app.js
    images/
        pic.png
    README.md
    styles/
        app.css

다음과 같이 root/ directory에 images/, styles/ directory가 있고 file로 app.js, README.md가 있다. tree로 표현하면 다음과 같다.

  • root tree 객체
object 종류해시content
blob1f7a7a47...app.js
tree982871aa...images/
blobbe321a77...README
tree80ff1ae33...styles/

tree안에서 또 다른 tree를 참조하거나, blob을 참조하는 것이다. 또한, tree는 blob과 달리 file이름을 기억한다는 특징이 있다.

                        |root tree|
                            |
      -------------------------------------------------
      |            |               |                   |
      v            v               v                   v
|app.js blob| |README.md blob| |images tree|  |styls tree|
                                  |                   |
                                  v                   v
                           |pic.png blob|      |app.css blob|

tree를 볼 수 있는 방법은 다음과 같다.

git cat-file -p main^{tree}

다음의 결과가 나온다.

040000 tree d0b0e04eb6108e5cd4c4a2c87a7c68f80772bbb1    .codesandbox
100644 blob 48d2b3d27e85ab32b1a9cff47ef95ebd2700b3b0    .editorconfig
100644 blob c30542a3f7e2c9aa9632def09051b3c709f54ffc    .eslintignore
...
040000 tree eb386ac16763fded28806e04e3f852da6caaec2d    packages
100644 blob 16ff05dce9ef23527bd6081adad482f5b30eaf1d    react.code-workspace
040000 tree ff8b09dcda2f2eb00bd45cd85123cb2f43915459    scripts
100644 blob 48eca75afbba876489faa4d7a988b1510e21f96d    yarn.lock

tree에서 다른 tree에 대한 참조 해시값과 bloc 해시값을 가지고 있고, 각 tree와 blob들의 파일 명을 가지고 있다.

Commit은 어떻게 관리되는가?

commit을 생성하면 git은 commit 객체를 생성해서 저장한다. commit도 objects에 저장되는데, 다음과 같은 형식으로 저장된다.

------------------------
|   commit(fa4907...)  |
|----------------------|
| tree    | c38719da...|
| parent  | a323ffa....|
| author  | Sirius     |
|committer| Sirius     |
------------------------
| this is my commit msg|
------------------------

parent는 이전의 commit을 말한다. 하나의 역 linked list 구조를 가지는 것이다. tree는 해당 commit 당시의 전체적인 project 구조를 표현하는데, 해당 commit에서 수정하지 않은 file, directory라도 tree에 전부있다. 단, 수정이 있었던 file이나 directory에 대해서는 hash값이 달라져있고, 수정이 없던 file, directory는 모두 이전 commit들의 해시와 동일하다.

즉, tree는 해당 commit 시점의 전체 project 구조를 전부 기억해놓는다는 것이다.

                                              Head
                                                |
                                                v
------------------------            ------------------------
|   commit(fa4907...)  |            |   commit(fa4907...)  |
|----------------------|            |----------------------|
| tree    | c38719da...|            | tree    | c38719da...|
| parent  | none       |<-----------| parent  | fa4907...  |
| author  | Sirius     |            | author  | Sirius     |
|committer| Sirius     |            |committer| Sirius     |
------------------------            ------------------------
| initial commit       |            | initial commit       |
------------------------            ------------------------

이렇게 parent를 통해 이전 commit을 참조하고 있으니, 이는 git history를 이룰 수 있도록 해준다. tree는 해당 application 구조를 나타내고, blob은 content를 나타낸다.

실습을 위해 demo directory를 만들자 github까지는 필요 없다.

mkdir demo
cd ./demo

echo "# demo" >> README.md
git init
git add README.md
git commit -m "main commit1"
git branch -M main

commit을 보도록 하자.

git log --oneline 
280fa98 (HEAD -> main, origin/main) main commit1

280fa98 commit 해시값이 생성된 것을 볼 수 있다. 이제 해당 해시값을 git cat-file을 통해 읽어보도록 하자.

git cat-file -t 280fa98
commit

commit이라는 객체가 나온다. -p옵션을 사용하면 좀 더 많은 정보를 이쁘게 찍어준다.

git cat-file -p 280fa98

tree 5dadd1311caf60dbb1c640bf8060a87309319eee
author colt <colt@colt.com> 1737541640 +0900
committer colt <colt@colt.com> 1737541640 +0900

main commit1

tree와 metadata가 들어있는 것을 볼 수 있다. tree의 해시값인 5dadd1311caf60dbb1c640bf8060a87309319eee을 풀어헤치면 다음의 결과가 나온다.

git cat-file -p 5dadd1311caf60dbb1c640bf8060a87309319eee
100644 blob fc72a5c1094e203eefcd1c710f060957ebbbaac4    README.md

README.md라는 한 개의 blob으로 이루어진 project 구조가 나온다.

blob의 해시값을 풀어헤치면 다음이 나온다.

git cat-file -p fc72a5c1094e203eefcd1c710f060957ebbbaac4
# demo

이렇게 commit은 tree를 포함하고 tree안에 project 구조를 나타내는 tree와 data를 저장하는 blob으로 구성되어 있다.

다음으로 두번째 commit을 만들어보도록 하자.

echo "meow meow" >> ./cats.txt
git add ./cats.txt
git commit -m "main commit2"

commit history를 보도록 하자.

git log --oneline 
e293a28 (HEAD -> main) main commit2
280fa98 (origin/main) main commit1

HEADe293a28 commit을 가리키는 것을 볼 수 있다. 해당 commit을 열어보자.

git cat-file -p e293a28

tree 50fe5aecf7aee19bc227bf755fe88c529facbe42
parent 280fa982dc9338703d055f25e6fabbd7c0402762
author colt <colt@colt.com> 1737542164 +0900
committer colt <colt@colt.com> 1737542164 +0900

main commit2

parent가 있는 것을 볼 수 있다. 280fa982dc9338703d055f25e6fabbd7c0402762는 바로 이전 commit인 main commit1을 가리키는 것이다.

tree를 확인해보도록 하자.

git cat-file -p 50fe5aecf7aee19bc227bf755fe88c529facbe42
100644 blob fc72a5c1094e203eefcd1c710f060957ebbbaac4    README.md
100644 blob 6f6b544d1a3605ed85b3645784e2773e3564fc25    cats.txt

README.md는 이번 commit에서 만든 blob이 아니다. 즉, 수정되지 않았던 값이라는 것이다. 그래서 해시값을 잘보면 이전과 동일하다. cats.txt blob은 이번에 새로 생겨났으므로 6f6b544d1a3605ed85b3645784e2773e3564fc25이 추가된 것이다.

그렇다면 README.md 값을 바꾸고 commit을 만들면 어떻게 될까??

echo "main commit3" >> ./README.md
git add ./README.md 
git commit -m "main commit3"

3번째 commit이 만들어졌고, README.md를 수정했다. 생성된 commit 객체를 보도록 하자.

git log --oneline

9847503 (HEAD -> main) main commit3
e293a28 main commit2
280fa98 (origin/main) main commit1

9847503을 사용하면 된다.

git cat-file -p 9847503
tree 95a0f9d96e0ac19e8d186eea6cc98423ce69f14b
parent e293a282777340684fd68228791f89810a5adfd6
author colt <colt@colt.com> 1737542803 +0900
committer colt <colt@colt.com> 1737542803 +0900

main commit3

parente293a282777340684fd68228791f89810a5adfd6은 이전 commit인 main commit2의 값으로 e293a28을 지칭한다. 다음으로 tree 해시 값인 95a0f9d96e0ac19e8d186eea6cc98423ce69f14b을 조사하여 어떻게 바뀌었는 지 확인해보도록 하자.

it cat-file -p 95a0f9d96e0ac19e8d186eea6cc98423ce69f14b
100644 blob dc8d45288c3a347647f3c3a8360be0be0ebec923    README.md
100644 blob 6f6b544d1a3605ed85b3645784e2773e3564fc25    cats.txt

cats.txt는 수정된 것이 없으므로 6f6b544d1a3605ed85b3645784e2773e3564fc25와 같이 동일한 해시값을 가진다. 그런데, README.md는 수정되었으므로 이전의 해시값인 fc72a5c1094e203eefcd1c710f060957ebbbaac4와 다른 해시값인 dc8d45288c3a347647f3c3a8360be0be0ebec923을 가진다.

정리하면 git은 commit, blob, tree, tag를 하나의 object들로 표현하고 이들을 key-value로 저장한다. key로 해시를 사용하여 value에 대한 레퍼런싱을 구현한 것이다. commit은 하나의 object로 parent로 이전 commit을 레퍼런싱하여 commit들 간의 history, 즉 관계를 표현한다. 또한 commit안에 있는 tree object는 해당 commit 당시의 project의 구조를 표현하는데, treeblob에 대한 hash값을 가지고 project 구조를 표현한다. blob은 file의 내용을 저장하는 단위로, 모든 tree의 끝은 blob으로 구성되어 있다.

0개의 댓글