[GIT] Git
Published:
In this post, concept of Git is introduced.
Git 이 무엇인가와 초기 세팅 방법에 대한 기초적인 내용은 여기 참고.
Git Object
Git의 객체에는 Blob, Tree, Commit 이 있는데 모두 다음 방식으로 저장된다.
{type} {size}\0{content}
type: blob, tree, commitsize: content의 바이트 길이\0: 널 바이트content: 실제 내용
이 전체 바이트열을 SHA-1(또는 repo 설정에 따라 SHA-256)로 해싱한 값이 “객체 ID(=해시)”가 된다. 이 해시 값이 abcdef0123 이었다고 가정해보자. 이 해시 값의 앞 두글자 ab를 디렉터리 이름, cdef0123을 파일 이름으로 하여,
.git/objects/ab/cdef0123
에 파일이 저장된다. 파일의 내용으로는 해시값 abcdef0123이 zlib으로 압축되어(허프만 코딩 등 사용) 저장된다.
Blob
Blob은 파일의 내용만 담고, 파일 이름, 경로, 권한, 시간 정보를 담지 않는다. 즉, 저장 형식에서 content에 해당하는 부분이 파일의 내용이라서 같은 내용의 파일이면 경로가 달라도 blob은 동일하다.
Tree
Tree는 디렉터리를 표현하는 객체이고, content로 이 디렉터리 안에 어떤 엔트리(파일, 하위 디렉터리)가 있고, 각각이 어떤 객체를 가리키는지를 담는다. 각 엔트리는 다음 3요소를 가진다. (Git의 tree는 “파일 시스템의 디렉토리 inode” 같은 것이다.)
- mode : 권한/타입
- name : 파일/디렉터리 이름
- object id : 해당 엔트리가 가리키는 blob 또는 tree의 해시
트리는 재귀구조를 가진다. 최상위 tree(root tree)가 가지는 엔트리 중 디렉토리면 → 그 엔트리는 또 다른 tree를 가리킨다. 파일이면 → blob을 가리킨다.
그래서 전체 프로젝트 스냅샷은 root tree → (sub tree들) → blob들 로 이루어진 그래프가 된다.
Commit
Commit은 실제 파일 내용을 직접 담지 않고, ‘‘이 시점의 프로젝트 상태는 이 tree다’’ 라고 가리키는 메타데이터 객체이다.
Commit content는 다음의 필드를 담는다.
tree <tree-oid>: 이 커밋의 루트 트리parent <commit-oid>: 부모 커밋(보통 1개, merge면 2개 이상)author ...: 작성자/시간committer ...: 커밋한 사람/시간- 빈 줄
- 커밋 메시지
즉, commit → tree → (tree/blob …)
commit은 parent를 가리키며 과거로 이어진다. 그래서 Git 히스토리는 리스트가 아니라:
- 일반 커밋: parent 1개 → 선형으로 보일 수 있음
- merge 커밋: parent 2개 이상 → 가지가 합쳐짐
이어서 전체는 DAG(방향 비순환 그래프)
📝 스냅샷
Git은 “diff를 저장한다”기보다, 각 commit이 루트 tree 하나를 가리켜서, 그 tree가 전체 파일 스냅샷을 재구성할 수 있게 한다. 어떤 시점의 프로젝트 전체 파일 구조를 재구성할 수 있는 tree 객체를 스냅샷이라 한다. 다만, 스냅샷이 매번 전체 파일을 복사하는 방식이 아니라, 바뀐 파일만 새 blob이 생기고, 바뀐 경로에 있는 tree들만 새로 새로 생기고, 나머지는 기존 객체를 그대로 공유하는 구조이다.
예를 들어, 다음과 같은 프로젝트 구조가 있다고 해보자.
repo/
├── README.md
└── src/
├── a.c
└── b.c
이걸 Git 객체 그래프로 그리면:
commit C1
↓
root tree T0
├── blob R (README.md)
└── tree Tsrc
├── blob A (a.c)
└── blob B (b.c)
이제 유저가 src/a.c 의 내용을 수정했다고 해보자. git commit을 하기에 앞서 git add를 하면 새 blob이 만들어진다.
- 새 Blob 생성
blob A2 (a.c 새 내용)
이때, 기존 A는 그대로 남아있다. (immutable) 이제 git commit을 하면 다음의 일들이 일어난다.
- src 디렉토리 tree가 바뀜
Tsrc2
├── blob A2 ← 이게 바뀜
└── blob B ← 이건 그대로
Tsrc2라는 새 tree 객체가 생긴다. 하지만 B는 기존 blob 그대로 재사용.
- 루트 트리도 바뀜
T1
├── blob R ← 그대로
└── tree Tsrc2 ← 새 tree
- Commit 새로 생성
commit C2
tree T1
parent C1
새로 생긴 객체: blob A2, tree Tsrc2, tree T1, commit C2
그대로 재사용된 객체: blob R, blob B, blob A (이전 커밋에서 여전히 사용 중), tree Tsrc (이전 커밋이 여전히 가리킴), tree T0, commit C1
C1 상태는 다음과 같았다.
commit C1
│
▼
root tree T0
├── blob R (README.md)
└── tree Tsrc
├── blob A (a.c 원래 내용)
└── blob B (b.c)
C2 상태 (a.c 수정 후)는 다음과 같다.
commit C2
│
▼
root tree T1
├── blob R (공유됨)
└── tree Tsrc2
├── blob A2 (새 blob)
└── blob B (공유됨)
C1과 C2를 모두 포함한 tree 그림을 그리면 다음과 같다. 이제 두 커밋을 동시에 그리면 이렇게 된다.
commit C2
│
▼
root T1
/ \
/ \
blob R Tsrc2
/ \
blob A2 blob B
commit C1
│
▼
root T0
/ \
blob R Tsrc
/ \
blob A blob B
커밋 히스토리 관계를 포함한 그림까지 그리면 아래와 같다.
C2 ──parent──▶ C1
│
└──tree──▶ T1
├──▶ R
└──▶ Tsrc2
├──▶ A2
└──▶ B
C1 ──tree──▶ T0
├──▶ R
└──▶ Tsrc
├──▶ A
└──▶ B
- tree 구조는 트리
- commit 히스토리는 DAG
- 전체 객체 그래프(위 둘을 한 번에 그린 것)은 DAG
Git Branch
Git branch는 특정 commit 객체를 가리키는 이동 가능한 포인터이다.
구체적으로, branch는 .git/refs/heads/[branch_name] 의 파일이고 해당 파일의 내용은 특정 commit 객체의 값(해시 값)이다. 예를 들어, main 브랜치는 다음 경로의 파일이다.
.git/refs/heads/main
그리고 그 내용은 commit 객체 값으로 다음과 같다.
a1b2c3d4e5f6...<40-hex SHA-1 또는 64-hex SHA-256>
새 Commit 을 만들면, 그 브랜치 파일의 내용이 새 commit 해시로 덮어 씌워진다. 예를 들어, commit history가 다음과 같다고 해보자.
C1 → C2
main이 C2를 가리키고 있다고 하자. 여기서 커밋 하나 더 하면, C1 → C2 → C3 가 되고, Git은 단지, .git/refs/heads/main 파일 내용을 C3 해시 값으로 바꾸어 버린다. 이것이 “브랜치가 앞으로 이동한다”는 의미이다.
새 Branch 를 만들면, 현재 Commit 해시를 읽고, .git/refs/heads/ 에 새로운 파일을 만들고, 거기에 같은 해시를 적는다.
git branch feature
main
│
▼
C1 → C2 → C3
▲
│
feature
그 후에 feature에서 commit하면, 다음과 같이 가리키는 commit이 달라진다.
C1 → C2 → C3 → C4
feature → C4
main → C3
Git Head
HEAD는 “현재 작업 중인 위치(branch)”를 가리키는 참조(ref) 이다. .git/HEAD 파일을 열어보면 다음과 같이 적혀있다.
ref: refs/heads/main
즉, HEAD는 main 브랜치를 가리키고 있다 = 현재 작업 branch는 main이다 라는 뜻이다.
Commit하면 HEAD가 가리키는 branch가 새 commit을 가리키게 된다.

Leave a Comment