TIL] git 내부원리

Song-Minhyung·2023년 7월 27일
0

TIL

목록 보기
10/12

📍글의 시작

git의 내부 원리에 대해서는 전혀 몰라가지고 어제 해당 내용을 학습하는데 매우 많은 시간이 소요됐습니다.
얼마나 많이냐면 미션을 제시간 안에 내지 못할정도로 많은 시간을 학습에 소모했습니다.
지금 생각해보면 학습을 하며 정리를 하는데 시간을 많이 썼던게 원인이었던것 같습니다.


📚 정리 시작

0. plumbing, porcelain 명령

git은 원래 vcs를 위한 툴킷으로 만들어졌다. 그래서 많은 저수준 명령어로 구성되어 있고 이 명령어들을 엮어서 실행하도록 설계됐다. 이 명령어들을 plumbing 명령어라한다. 그리고 add commit 등의 사용자가 평소에 실행하는 명령어를 porcelain 명령어라 한다.

1. Git 개체

content-addressable 파일시스템이란 저장소가 key-value로 이루어져 있어 key만 안다면 해당 데이터를 언제든지 가져올 수 있는 시스템을 말한다.

git init 명령어로 저장소를 초기화 하면 .git 디렉토리가 만들어진다.
그리고 .git 에는 아래와 같은 디렉토리 구조가 만들어진다.

.git/config
.git/description
.git/HEAD
.git/hooks/
.git/info/
.git/objects/
.git/refs/

그리고 여기서 objects 디렉토리가 git의 db라 부를 수 있는곳이다.
objects의 디렉토리는 아래와 같고, 속에는 아무 내용도 들어있지 않다.

.git/objects
.git/objects/info
.git/objects/pack

이제 아래 명령어를 실행하면 git db에 새 데이터 객체가 저장되게 된다.

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

-w 명령어를 줘서 저장이 되고, --stdin 옵션으로 표준입력으로 입력되는 데이터를 읽어서 저장하게된다.

이제 objects 디렉토리를 살펴보면 아래와 같이 새로운 디렉토리와 파일이 생긴것을 볼 수 있다.

find .git/objects -type f
.git/objects/3d/d27bceba406899f869f73e92de084c987267cd

git hash-object 명령어를 사용하면 40자리의 체크섬 해시를 생성한다. 여기서 앞 2자리는 폴더 이름으로 쓰고 나머지 38자리를 파일 이름으로 지정해준걸 볼 수 있다.

해당 내용은 git cat-file 명령어로 읽을 수 있다.

git cat-file -p 3dd27bceba406899f869f73e92de084c987267cd
test content

만약 파일이 수정된 상황을 재현하면 어떨까? test.txt 파일을 만들어서 확인해보자

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/3d/d27bceba406899f869f73e92de084c987267cd
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30

위처럼 objects 디렉토리에 두개의 파일이 더 추가된 모습을 볼 수 있다.
이는 각각의 버전을 나타내는데, 만약 test.txt 파일을 지우게 되더라도 Git을 사용해 첫번째 버전의 내용으로 되돌릴 수 있게된다.

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

위는 version1로 되돌렸는데 같은 방식으로 version2로도 되돌릴 수 있다.

그런데 위에서 hash로 사용하는건 어렵고, 원래 파일 이름은 저장되지 않았다. 파일 내용만 저장이 되었다. 이런 종류의 객체는 blob이라 불리고 git cat-file -t 명령으로 blob인지 tree인지 확인할 수 있다.

Tree개체

Git은 Tree 개체에 파일 이름을 저장한다. 파일 여러개를 한번에 저장하는것도 가능하다. Blob은 일반 파일에 대응되고, Tree는 디렉토리에 대응된다.
Tree 하나는 여러개의 항목을 가질 수 있다. 그리고 그 항목에는 Blob개체 혹은 하위 Tree 개체를 나타내는 SHA-1 포인터, 파일모드, 개체타입, 파일 이름등이 들어있다.

예전에 했던 프로젝트에서 해당 tree를 봐보면 이렇다.
파일모드 | tree와 blob| 해시키| 파일이름| 으로 구성되어있따.

git cat-file -p main^{tree}
100644 blob c834d69a460be79c87902819592c04a8ab22eabe	.gitignore
040000 tree fa811b2cbd18fcc09cd2f82634894a4831006b59	BackEnd
040000 tree 1d692ab682a3b950a10bb0d8f68c332aab68e489	FrontEnd

여기서 다른 Tree 개체를 살펴보면 main의 트리와 구조는 같고 내용만 다른걸 볼 수 있다.

git cat-file -p fa811b2cbd18fcc09cd2f82634894a4831006b59
100644 blob 2914ec4b1b6c1fd967079e1565c7bc7bfd8f0614	.gitignore
040000 tree 3ee2c40b68b8f45355fb6783a4a046adf93e41a2	demo

그래서 git이 저장하는 데이터의 구조는 아래와 같다.

다시 원래 하던것으로 돌아와서, git은 일반적으로 staging area(index) 상태대로 Tree 개체를 만들고 기록한다. 그래서 Tree 개체를 만들려면 staging area에 파일을 추가하고 index를 만들어야 한다. git update-index 명령으로 해당 작업의 수행이 가능하다.

git write-tree 명령어로 index에 있는 파일들을 tree로 만들 수 있다. 그리고 해당 tree의 해시값을 얻을 수 있다. --add 옵션을 주는 이유는 실제로 staging area에 들어가 있는 파일이 아니때문이다. --cacheinfo 옵션은 디렉토리에 없는 파일을 추가할 때 사용한다.

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

▶ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579

▶ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30      test.txt

그리고 Tree 아래에 Tree를 추가 할 수도 있다.
아래 명령어는 방금 만든 Tree 개체와 새롭게 추가한 new.txt 파일을 포함하는 새로운 Tree 개체를 만든다. --prefix 옵션으로 디렉토리 이름을 지정할 수 있다.

echo 'new file' > new.txt
▶ git update-index --add --cacheinfo 100644 \
  1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
▶ git update-index --add new.txt

▶ 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 개체 세개를 만들었지만 해당 스냅샷을 불러오려면 SHA-1 값을 기억하고 있어야 한다. 누가, 언제, 왜 저장했는지에 대한 정보는 존재하지도 않는다. 이러한 정보가 커밋 개체에 저장된다.

커밋 개체는 commit-tree로 만들 수 있고, 여기에 Tree 개체에 대한 설명과 SHA-1 값을 넘긴다. 제일 처음에 만든 Tree에 대한 커밋을 한번 만들어본다.

echo 'first commit' | git commit-tree d8329f
31a21d4ad1823be90ae812cf959f7ec97d2ae82a

위에서 얻은 해시값을 git cat-file 명령으로 확인해보면 아래와 같다.

git cat-file -p 31a21d4ad1823be90ae812cf959f7ec97d2ae82a
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Song_Minhyung <song961003@gmail.com> 1690340735 +0900
committer Song_Minhyung <song961003@gmail.com> 1690340735 +0900

first commit

커밋 개체가 저장되는 형식은 해당 스냡샷에서 최상단 Tree를 하나 가리킨다.
그리고 user.name, user.email의 설저에서 author, committer를 만들고 커밋 메세지를 추가한다.

커밋을 두번 더 하며 커밋 개체를 만들어본다. 이 때 각 커밋 개체는 이전 개체를 가리키도록 해야한다.
아래 명령어에서는 위에서 만든 트리(스냅샷에 대해)를 커밋을 해보려 한다.

echo 'second commit' | git commit-tree 0155eb -p 31a21d
dafd08eb72733467b511b972491596344c5f859b

▶ echo 'third commit' | git commit-tree 3c4e9c -p dafd08
c4b8974fd635dc0278bcb0f9f43f67cc057d326c

이제 이렇게 커밋 개체를 만들었으니 마지막 커밋 개체를 git log 명령어로 확인해보자

git log --stat c4b897

commit c4b8974fd635dc0278bcb0f9f43f67cc057d326c
Author: Song_Minhyung <song961003@gmail.com>
Date:   Wed Jul 26 14:11:12 2023 +0900

    third commit

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

commit dafd08eb72733467b511b972491596344c5f859b
Author: Song_Minhyung <song961003@gmail.com>
Date:   Wed Jul 26 14:09:36 2023 +0900

    second commit

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

commit 31a21d4ad1823be90ae812cf959f7ec97d2ae82a
Author: Song_Minhyung <song961003@gmail.com>
Date:   Wed Jul 26 12:05:35 2023 +0900

    first commit

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

짠! 위처럼 커밋을 했던 내역들이 전부 나와있는걸 확인할 수 있다.

지금까지 한 일이 git add 후 git commit을 했을 때 Git 내부에서 일어나는 일이다.
Git은 이 때 파일을 Blob 개체로 저장하고 현 Index에 따라서 Tree 개체를 만든다.
그러고서 이전 커밋 개체와 최상위 Tree 개체를 참고해서 커밋 개체를 만들게 된다.

즉 Blob, Tree, 커밋 개체가 Git의 주요 개체이고 이는 전부 .git/objects 디렉토리에 저장된다.

지금까지 작업을 하며 만든 객체들은 아래와같이 엄청나게 많이있다.

find .git/objects -type f

.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/3d/d27bceba406899f869f73e92de084c987267cd # test.txt
.git/objects/da/fd08eb72733467b511b972491596344c5f859b # second commit
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/31/a21d4ad1823be90ae812cf959f7ec97d2ae82a # first commit
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree2
.git/objects/c4/b8974fd635dc0278bcb0f9f43f67cc057d326c # third commit
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1

이제 이렇게 생성된 개체들의 포인터를 따라가보면 아래와 같다.

개체 저장소

Git은 개체의 타입을 Blob으로 만들면서 이를 시작으로 헤더를 만든다.
그 다음에 공백문자 하나, 내용의 크기, 널 문자를 추가한다.
예를들면 이런식이다

const contents = 'hello, git?';
const header = `blob ${contents.length}\0`;
// blob 6\u0000

그러고서 헤더와 원래 내용을 합친다

const store = header + contents;
//blob 6\u0000abcdef

이제 만들어진 store를 가지고 SHA-1 체크섬을 계산한다.

const sha1 = crypto.createHash('sha1').update(store).digest('hex');
// 6bd9cccb08935498c5c8ceedd19aaa22022e1e2a

마지막으로 zlib을 사용해 내용을 압축한다.
그러고서 sha1 맨 앞 2자리를 폴더 이름으로 하고 나머지 38자리를 파일 이름으로 하여 .git/objects 디렉토리에 저장한다.

const folderName = sha1.slice(0, 2);
const fileName = sha1.slice(2);

const destination = `${__dirname}/study/test/.git/objects/${folderName}/${fileName}`;

const gzipData = zlib.deflateSync(store);

// 폴더가 있는지 확인하는 함수
function isExists(path: string) {
  try {
    accessSync(path, constants.R_OK);
    return true;
  } catch (e) {
    console.log(e);
    return false;
  }
}
// 폴더 경로에 파일을 생성하는 함수
async function writeFile(destination: string, data: Buffer) {
  try {
    const dirname = path.dirname(destination);
    const exist = isExists(dirname);
    console.log(dirname, exist);

    if (!exist) {
      console.log(dirname);
      mkdirSync(dirname, { recursive: true });
    }

    writeFileSync(destination, data);
  } catch (e) {
    throw new Error('파일 생성 실패');
  }
}

writeFile(destination, gzipData);

이 때 생성된 파일명은 32396a20069c0a70bf1b99af4964263d2c79fdcf이다
이 파일을 아래 명령어로 확인해보면 방금 넣은 hello, git? 내용이 있는걸 확인할 수 있다.

git cat-file -p 32396a20069c0a70bf1b99af4964263d2c79fdcf
hello, git?

그리고 또하나 재밌는 점은 방금 넣은 내용을 cli에서 입력해봐도 동일한 결과의 파일명이 나온다.
왜냐면 git도 동일한 방법으로 해싱을 하고 압축을 한후 저장하기 때문이다!
그리고 git cat-file 명령어로 압축을 해제하고 결과를 보여준다.

echo -n "hello, git?" | git hash-object --stdin
32396a20069c0a70bf1b99af4964263d2c79fdcf

git 개체는 모두 이런식으로 저장되며 해더만 blob tree commit으로 다르다.
그런데 커밋 개체나 트리 개체는 각기 다른 방식을 사용한다.

2. Git Refs

만약 아까 위에서 만든 세번째 커밋 로그를 보려면 아래와 같이 sha1 앞 6자리만 적으면 된다.
근데 항상 이 해시값을 기억하기는 어렵다.그래서 git에서는 이렇게 복잡한 이름을 References 혹은 Refs 을 사용해 짧게 적어둔다. 이 sha1을 저장하는 파일은 .git/refs 디렉토리에 있다.

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

아까 만든 세번째 커밋을 master로 설정하고 싶다면 아래와 같이 하면된다.

echo c4b8974fd635dc0278bcb0f9f43f67cc057d326c > ../refs/heads/master

그러면 이제 기나긴 해시가 아니라 mater로 접근할 수 있게 됐다!!

git log --pretty=oneline master
c4b8974fd635dc0278bcb0f9f43f67cc057d326c (HEAD -> master) third commit
dafd08eb72733467b511b972491596344c5f859b second commit
31a21d4ad1823be90ae812cf959f7ec97d2ae82a first commit

원래는 refs 파일을 이런식으로 직접 고치지 않고 update-ref를 사용한다.

git update-ref refs/heads/master c4b8974fd635dc0278bcb0f9f43f67cc057d326c

이번에는 아까 두번째 커밋의 이름을 test로 설정해본다.

git update-ref refs/heads/test dafd08eb72733467b511b972491596344c5f859b

$ git log --pretty=oneline test
dafd08eb72733467b511b972491596344c5f859b (test) second commit
31a21d4ad1823be90ae812cf959f7ec97d2ae82a first commit

이제 git은 아래와 같이 보인다.
그래서 git branch <branch> 명령어를 실행하면 git은 내부적으로 update-ref를 실행하는걸 알 수 있다. 입력받은 브랜치 이름, 현 브랜치의 마지막 커밋의 sha1 값을 가지고서 update-ref 명령을 실행한다.

git branch <branch> 명령을 싱행할 때 git이 마지막 커밋의 sha1 값을 아는 이유는 HEAD에 이 값이 저장되어 있기 때문이다. 이 Refs는 다른 Refs를 가리키는 것이라서 SHA-1 값이 없고 다른 refs를 가리키기만 하고있다.

만약 git checkout test를 실행시키면 HEAD는 refs/heads/test 로 바뀌게된다.

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

이 HEAD를 cat으로 읽을수도 있지만 git symbolic-ref HEAD 명령어로도 읽을 수 있다.

그리고 변경도 가능하다 -> git symbolic-ref HEAD refs/heads/test

TAG

태그 개체는 커밋 개체와 매우 비슷한다. 누가 언제 태그를 달았는지 그리고 어떤 메세지를 달았는지, 어떤 커밋을 가리키는지에 대한 정보가 포함된다.
둘의 차이는 커밋 개체는 Tree를 가리키지만, 태그 개체는 커밋 개체를 가리킨다.

REMOTE

리모트 Refs는 리모트를 추가하고 Push했을 때 마지막 Push한 커밋이 무엇인지 refs/remotes 디렉토리에 자장한다. 예를들어 origin remote를 추가하고 master 브랜치를 push한다면

git remote add origin <주소>git push origin master
오브젝트 나열하는 중: 9, 완료.
오브젝트 개수 세는 중: 100% (9/9), 완료.
Delta compression using up to 10 threads
오브젝트 압축하는 중: 100% (5/5), 완료.
오브젝트 쓰는 중: 100% (9/9), 763 bytes | 763.00 KiB/s, 완료.
Total 9 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:Doosies/refs.git
 * [new branch]      master -> master

마지막으료 교환한 커밋이 무엇인지 refs/remotes/origin/master에서 확인할 수 있다.

cat .git/refs/remotes/origin/master
c4b8974fd635dc0278bcb0f9f43f67cc057d326c

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


아하! 순간

  • 처음에 설계했을 때 재귀적으로 모든 파일을 탐색한 후에 파일 이름만 가지고서 blob을 만들어 줄려고 했습니다. 하지만 트리 구조도 재귀적으로 표현해 주려면 재귀를 돌면서 같은 깊이에서 blob을 만들고 트리를 올라갈 때 tree를 만들어야 한다는것을 깨달았습니다.
  • 구현을 진행하다보니 폴더도 추적하면 node_modules와 같은 폴더도 함께 추적돼서 매우 속도가 느려진다는 사실을 발견했습니다. 그래서 이를 막아주기 위해서 .ignore 파일을 작성해 해당 파일이 적힌 부분은 정규식으로 처리해서 추적하지 않도록 해주었습니다.
  • restore와 log를 구현할 때 둘의 기능이 비슷해서 하나의 함수로 리팩토링 해주었습니다.

🔚 글의 끝

이번에 이렇게 학습 시간을 길게 가져가본 이유는 5시까지 모르는 내용을 정리하고 미션을 시작하는 캠퍼도 있다고 들었기 때문입니다. 그런데 해당 내용을 정리하며 쓸데없는 곳에서 시간을 너무 많이 썼던것 같습니다.

물론 도움이 되지 않은건 아닙니다. 하지만 그 정도가 너무 과했던것 같습니다. 그리고 시간낭비 한 부분도 없지않아 있습니다. 예를들면 그래프 그리는것, 해시값 어떤건지 일일이 하나하나 다 적어주는것 등이 있습니다.

아마 시간 낭비를 하지 않았으면 제시간 안에 미션을 완료했을 수 있다고 생각합니다. 그래서 다음부터는 한번에 정리하지는 않고 이해한 부분만 조금씩 정리하고, 하루가 끝날때 쯔음 해당 내용에 살을 덧붙여서 다시한번 정리하는 식으로 진행해햐 할듯 합니다.

-끗-

profile
기록하는 블로그

0개의 댓글

관련 채용 정보