약 9분

깃 리포지토리의 동작 방식 이해하기

.git 내부를 뜯어보자
깃 리포지토리의 동작 방식 이해하기
Photo by Elad Golan / Unsplash

지금껏 다양한 깃 명령어들을 사용해왔지만, 깃이 정확히 어떻게 동작하는지는 모르고 있었다.

이번에 OSSCA(오픈소스 컨트리뷰션 아카데미)에 참여하게 됐는데, 깃 리포지토리에서 커밋 시 .git 폴더 안의 내용들이 어떻게 변하는지 알아보는 과제를 받았다.

과제 수행 과정에서 알게 된 점들을 기록해두려 한다.

git init

git init은 깃 리포지토리를 초기화(initialize)하는 명령이다

어떻게? .git이란 숨김 폴더를 하나 만든다:

.git/hooks 안에는 여러 깃 훅 예시 파일들이 들어있다

.sample로 끝나서 적용되지는 않음

.git/info/exclude 파일은 .gitignore 파일과 동일한 패턴 매칭 문법을 사용해 git에서 특정 파일들을 추적하지 않도록 할 수 있다

# 라인은 주석이므로 사실상 빈 파일

.git/refs/heads에는 브랜치 정보가 담기게 되는데, 첫번째 커밋 전에는 브랜치 정보가 없으므로 빈 디렉토리에서 시작한다

.git/config 파일에는 리포지토리 단위의 깃 설정과 remote 정보 같은 내용들이 담기게 된다

이때 전역 git 설정과 리포지토리의 .git/config의 값이 충돌할 경우, 리포지토리의 설정값이 우선순위를 가진다. (user.name, user.email 등이 겹친다면 리포지토리의 설정이 적용된다)

.git/HEAD 파일은 현재 checkout된 위치를 나타내는데, 두 가지 상태를 가질 수 있다:

  1. 일반적인 상태 (현재 브랜치를 가리키는 symbolic reference)
  2. detatched HEAD
TODO: HEAD 설명 더 채우기

.git/FETCH_HEAD 파일에는 마지막 git fetch 결과가 담기는데, 처음에는 remote가 없기 때문에 빈 파일이다

.git/description 파일은 GitWeb 같은 웹 인터페이스에서 저장소 설명을 보여주기 위한 파일이라고 하는데, GitHub, GitLab, Gitea에서는 사용되지 않는다고 하니 이런게 있다는 것 정도만 알아두면 될 것 같다


git add

간단한 파일을 하나 만들고 git add 명령을 실행하면, git commit 명령을 수행하기 전에도 .git/objects 에 새로운 blob 객체가 생기는 것을 확인할 수 있다:

➜ git ls-files -s

100644 9f4d96d5b00d98959ea9960f069585ce42b1349a 0	hello.md

➜ git cat-file -p 9f4d96d5b00d98959ea9960f069585ce42b1349a

Hello Git

blob 이란 Binary Large OBjects의 약어로, 파일 내용을 zlib 압축 알고리즘으로 압축한 객체다.

압축을 풀고 읽으면 해당 파일의 모든 내용이 담겨있는 것을 확인해볼 수 있다

+ 디렉토리 안에 들어있는 파일의 수가 너무 많아지면 파일 시스템의 성능이 저하될 수 있기 때문에, 오브젝트의 파일 이름 중 앞 2글자는 디렉토리 이름으로 사용하고 나머지 38글자를 파일명으로 사용한다

git commit

커밋을 하고 나면 .git/objectscommit, tree 두가지 객체가 더 생성되는 것을 확인할 수 있다

committree를 가리키고, tree는 파일 이름과 blob을 매핑하는 구조다:

➜  git cat-file -p 6808b6d1928c9f9b5aeac8cc22ff55153fa62a41

tree e11db9408991281fa8fc0b0bd455ecaffbf74cc2
author doorcs <itsdoorcs@gmail.com> 1777275722 +0900
committer doorcs <itsdoorcs@gmail.com> 1777275722 +0900

hello.md 커밋

➜  git cat-file -p e11db9408991281fa8fc0b0bd455ecaffbf74cc2

100644 blob 9f4d96d5b00d98959ea9960f069585ce42b1349a	hello.md

git branch

깃에서 브랜치는 어떻게 관리될까?

브랜치 정보는 .git/refs/heads 안에서 파일로 관리된다.
파일 내용도 별다를 게 없는데, 특정 커밋 해시값이 저장되어 있는 텍스트 파일이다

main 브랜치로부터 develop 브랜치를 새로 만들게 되면 .git/refs/heads 안에 develop이라는 이름의 파일이 생성되는데, main 파일과 동일한 커밋 해시값이 들어있는 것을 확인할 수 있다

blob 중복 관리

빈 파일을 두 개 만들고 git add 명령을 실행하면 blob 객체가 하나만 생성되는 것을 확인할 수 있다.

깃은 git add 명령을 통해 staging area에 올라오는 모든 파일에 대한 blob 파일을 만드는데, 이때 파일에 들어있는 내용을 기준으로 추적하기 때문에 내용이 겹칠 경우 동일한 blob을 재사용한다

빈 파일 두개를 커밋한 다음 commit이 가리키는 tree 의 내용을 살펴보면
empty1.md, empty2.md 두 파일이 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 라는 동일한 blob 과 매핑되어 있는 것을 확인해볼 수 있다:

➜ git cat-file -p 0c04eb9141cec2836a4bb1293abb3db0179fb604

tree 137acbf1803395bc2d236f19af50db56a02a4eca
parent 6808b6d1928c9f9b5aeac8cc22ff55153fa62a41
author doorcs <itsdoorcs@gmail.com> 1777277752 +0900
committer doorcs <itsdoorcs@gmail.com> 1777277752 +0900

empty1, empty2 커밋

➜ git cat-file -p 137acbf1803395bc2d236f19af50db56a02a4eca

100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391	empty1.md
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391	empty2.md
100644 blob 9f4d96d5b00d98959ea9960f069585ce42b1349a	hello.md

git merge

현재 브랜치별 커밋 상태는 다음과 같다:

  • main 브랜치
    • 6808b6d1928c9f9b5aeac8cc22ff55153fa62a41 (hello.md 커밋)
  • develop 브랜치
    • 6808b6d1928c9f9b5aeac8cc22ff55153fa62a41 (hello.md 커밋)
    • 0c04eb9141cec2836a4bb1293abb3db0179fb604 (empty1, empty2 커밋)

main 브랜치로 돌아와서 develop 브랜치와 겹치지 않는 새로운 파일을 커밋하게 되면 브랜치와 커밋 히스토리가 갈라지게 된다:

왼쪽은 main 브랜치, 오른쪽은 develop 브랜치의 git history

이 경우 두 브랜치의 작업 내용들을 합치려면 merge commit이 필요하다

➜ git checkout main

Switched to branch 'main'

➜ git merge develop

Merge made by the 'ort' strategy.
 empty1.md | 0
 empty2.md | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 empty1.md
 create mode 100644 empty2.md

지금은 merge conflict가 없어서 간단하게 merge 가능

merge commit 이후 git log 명령을 실행하면 커밋이 시간 순서에 맞게 잘 합쳐진것을 확인해볼 수 있다:

일반적인 commit 객체는 parent 커밋을 한 개 가지지만,
merge commit 객체는 parent 커밋을 두 개 가진다:

➜ git cat-file -p 95ee2c0c402f0a0a4bc81987e812eada0c0fedbf

tree e737137c198438d3f50927bbfdfdfb6951499049
parent cf2e9f4ab0d764c2f94534779eb16a524eecf289
parent 0c04eb9141cec2836a4bb1293abb3db0179fb604
author doorcs <itsdoorcs@gmail.com> 1777280109 +0900
committer doorcs <itsdoorcs@gmail.com> 1777280109 +0900

Merge branch 'develop'

갈라졌던 깃 브랜치가 다시 합쳐지는 과정을 시각화해보면 다음과 같다:

*   95ee2c0 (HEAD -> main) Merge branch 'develop'
|\
| * 0c04eb9 (develop) empty1, empty2 커밋
* | cf2e9f4 newblob 커밋
|/
* 6808b6d hello.md 커밋

git log --oneline --graph


정리

깃을 구성하는 핵심 객체들 ( .git/objects )

  • COMMIT
    • <Tree> + <Parent> + <Author> + <Committer> + <Message> 구조 (항목 간 줄바꿈)
    • tree 객체를 가리키고 있음
    • parent commit 객체도 가리키고 있음 (최초 1회 커밋은 parent commit이 없다)
    • merge commit 객체의 경우 parent commit 정보를 두 개 이상 가지고 있기도 함
    • + author, committer, commit message 정보도 가지고 있음
      • 이때 author 정보와 committer 정보는 달라질 수도 있다
  • TREE
    • <Mode> <Type> <SHA-1 hash> <Name> 구조의 리스트 (여러줄일 수 있다)
    • tree 객체의 줄 수 == 해당 tree 의 직속 하위 요소 수 (디렉토리, 파일 모두 포함)
      • Mode
        • 100644: 일반 파일
        • 100755: 실행 파일 (Executable)
        • 040000: tree (디렉토리 안에 디렉토리가 있는 경우를 표현)
      • Type
        • blob (파일)
        • tree (디렉토리)
      • SHA-1: 해시값
      • Name: 파일명 또는 디렉토리명
  • BLOB (Binary Large OBjects)
    • <Header> <Content> 구조 (한 줄)
      • Header
        • 아래 구조 고정:
        • blob <Size>\0
      • Content
        • zlib 압축 알고리즘으로 압축된 파일 원본 데이터
    • git commit 시점이 아닌 git add 시점에 생성된다
    • 파일 내용 전체를 담고 있음
    • 파일명은 내용에 포함되지 않는다
      • file rename commit은 blob 수정이 아니라 tree 수정!!
커밋, 트리, 블롭은 모두 40자리 해시 값으로 표현된다

이때 한 디렉토리에 너무 많은 파일이 들어가면 파일시스템의 성능에 영향을 줄 수 있기 때문에,
앞 2자리는 디렉토리 이름으로 사용하고 나머지 38자를 파일명으로 사용한다!