追記
先日外部向けに、この記事の内容に追加補足などを加えて発表しました。動画のアーカイブ、資料も公開しましたので、もし動画の方がわかりやすい方はこちらをオススメします。
注意: 動画の質疑の中で、 github のリリース機能が、アノテートタグを使っていると明言してしまいましたが、間違いです。gitのデータ上はただの軽量タグで、 release の内容は軽量タグに紐づく形で、 github のアプリケーション上で管理されているはずです。
はじめに
調べてもう1年放置していた内容なんですが、アドベントカレンダーで重い腰を上げました。
Gitの内部の仕組みを知りたい(動機)
毎日使うといってもいいGitですが、どうやって履歴を管理してるんだとか、よくわからないまま使っているのが急に怖くなりました。
Gitを触り始めで、よく以下のような疑問が沸くと思います。
- どうやってGitは履歴を管理してるんだ?
- ブランチ is 何?枝なの?
- HEAD is 何?
- よくブランチみたいな複雑そうなものうまく動いてんな。
何番煎じかわかりませんが、Gitが履歴をどのように保存しているのか、まとめてみました。
Gitのデータ保持の仕組みを知っていれば、もしかしたらGitの操作で困った時に解決しやすくなるかも?
いきなり結論
- Gitのリポジトリは Gitオブジェクト の集まりで、単なる**有向非巡回グラフ(DAG)とポストイット**で履歴を管理している
- コミットは差分ではなく、スナップショット。
- gitの数あるコマンドは、最終的にDAGへのGitオブジェクトの追加、ポストイットの移動を行なっているだけ。
以下Gitの内部データ構造についてみていきますが、重要な概念を理解する必要があります。
Gitオブジェクト と リファレンス です。これらの存在を強く意識すると理解しやすいです。
Gitオブジェクト
Gitオブジェクトの特徴は以下です。
- すべてzlibライブラリで可逆圧縮されたもの
- SHA-1ハッシュによって識別子がついている
- Gitオブジェクトは4種類
- 「./git/objects」ディレクトリを見ると確認できる
- イミュータブル。一度作られたら、なかなか消えない
blob
、tree
、commit
、tag(アノテーションタグのみ)
がGitオブジェクトです。
このGitオブジェクトは簡単に確認することができます。こちらは自分のプロジェクトのキャプチャをとったものです。
.git/objects
以下にあるものが、Gitオブジェクトです。1
なにやらハッシュ値っぽいファイルが見えるかと思います。
後述しますが、gitのコマンドを使うと、どのGitオブジェクトなのか、何が書いてあるのか確認することができます。
Gitオブジェクトは、「オブジェクトのタイプ(blob, treeといった種類)などの情報」と、「オブジェクト自体の内容」を連結した後、zlibで可逆圧縮し、それに対してハッシュ値をとっているため、内容が変わらなければ、同じハッシュ値になります。
これは重要なので覚えておいてください。
以下、blob、tree、commit、それぞれについてみていきます。
tagオブジェクトのみは、特殊なため、リファレンスの時に説明した方がわかりやすいので、リファレンスのところで説明します。
blobオブジェクト
blobは、テキストデータ、画像データなど、ファイルデータの中身そのものです。ファイル名などの情報は含まれていません。
試しに中身を見てみます。
Gitオブジェクト自体は、zlibライブラリで可逆圧縮されているので、普通にファイルを開いても意味がないんですが、git cat-file -p <Gitオブジェクトのハッシュ値>
コマンドで Gitオブジェクトの中身を見ることができます。
また、 -t
オプションで、Gitオブジェクトの type を見ることができます。commit, blob, tree など。
git cat-file -p e59c6f
version: '3'
services:
mysql:
image: mysql:8.0.22
ports:
- 3306:3306
environment:
MYSQL_ROOT_PASSWORD: xxx
MYSQL_DATABASE: xxx
MYSQL_USER: xxx
MYSQL_PASSWORD: xxx
volumes:
- ${PWD}/mysql/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
これは、ファイル名の情報はありませんが、docker-compose.yml
の記述データです。
treeオブジェクト
ディレクトリを表すGitオブジェクトです。
「ファイル名」と、「blobオブジェクト、または、別のtreeオブジェクトへの参照」を持っています。
以下のようなイメージです。
ここでも、 git cat-file
でtreeオブジェクトの中身を確認すると、以下のように 参照しているGitオブジェクトのタイプ(blob or tree)
、 そのGitオブジェクトのハッシュ値
、ファイル名(ディレクトリ名)
の情報を確認することができます。
$ git cat-file -p 5f8d20
100644 blob 00a51aff5e5a83d6313f3bd15fadc601a205b66f .gitattributes
040000 tree c7c10265c620e68769281650badc4cc7cc12f444 .github
100644 blob 28e3917784d3e4b7dd3609c0a4aecaf576451bb0 .gitignore
100644 blob 0be9e3f9f8b1f2778e0b266002c087f3024c9c35 Procfile
100644 blob c5ac20facc95a36e4c891920400dfc3d5ee5a5bd README.md
040000 tree 93db3842aa7b3a21546c48c202613039d57720b9 app
040000 tree 61d5c120ca403ee2d5490e4adf0b54c113a9b51f bin
040000 tree d6c08b8aaa435b436f05ad968c778480ac104ad8 data
100644 blob e59c6f67903a62f94cb107a1d89ba75fd5d5e6ff docker-compose.yml
040000 tree c0bc136941e80b1bfbc939eb491caeca3bccd2c9 gradle
100755 blob 4f906e0c811fc9e230eb44819f509cd0627f2600 gradlew
100644 blob 107acd32c4e687021ef32db511e8a206129b88ec gradlew.bat
040000 tree 6fa0cd56f29a9760c54e58e2069b2cd0609283cb mysql
100644 blob f064fb24d549188cf82858bb868bf1c8ebfaf629 settings.gradle
100644 blob 9146af5386451d165fe7b632434f2071acac5280 system.properties
commitオブジェクト
commitオブジェクトは、以下の情報を持ちます。
- トップレベルのtreeオブジェクトへの参照
- コミットしたユーザの情報
- タイムスタンプ
- コミットメッセージ
- 親コミットへの参照
- 親がないcommitは、initial commit
- 親が2つあるのは、merge commit
以下のようなイメージです。
こちらも git cat-file
でcommitオブジェクトの中身を確認すると、上記の情報を確認できます。
$ git cat-file -p 1aaa
tree 5f8d202e38d52bd011fe7ef881bad5c8ac6edad4
parent e6c06ea543e56eb9230659dd4baf7214730d69b5
author Taro Yamada <[email protected]> 1613406374 +0900
committer Taro Yamada <[email protected]> 1613406374 +0900
Bean Validationの導入
commitが持っている情報であるトップレベルのtreeオブジェクトから、参照しているtreeオブジェクト、blobオブジェクトと辿れば、その時点のプロジェクトのディレクトリを再構成できるようになっています。
Gitオブジェクトを振り返ると、つまりこう!
Gitオブジェクトのつながりを見ると、**有向非巡回グラフ(DAG)**になっています。
あるGitオブジェクトから別のGitオブジェクトに参照を持ちますが、巡回するような向きに参照を持っていません。
リファレンス(refs)
次はリファレンスについてみていきます。
- commitオブジェクトのポインタ(ハッシュ値を持ってる)。
- 簡単に指し示す先を変更できる(tagはできない)
-
.git/refs
や、.git/HEAD
で確認できる。 - 3種類
- branch
- HEAD
- tag
リファレンスはcommitオブジェクトを指し示すポインタ です。プロジェクトのディレクトリの図を出しましたが、リファレンスファイルの中は、commitオブジェクトのハッシュ値が書かれているだけです。
ポストイットが貼られてるイメージを持つと理解しやすいです。
以下、branch
、HEAD
、tag
についてみていきます。
branch
branch
は、commit(Gitオブジェクト)を指す単純なポインタです。
remoteのブランチも一緒です。特に違いはありません。
以下のように .git/refs/
を見ると branch を確認できます。それぞれのブランチはcommitを指しているだけです。
$ tree .git/refs/
.git/refs/
├── heads
│ └── main
├── remotes
│ └── origin
│ └── main
├── stash
└── tags
└── release-1.0
branchリファレンスの中身を見るとcommitを指していることがわかります。
$ cat .git/refs/heads/main
1aaa212c3c4f412309b3c33dc51e78f2810a4ed6 # refs/heads/mainは、commitのハッシュ値を指す
$ git log --oneline -n 1
1aaa212 (HEAD -> main, tag: day17) Bean Validationの導入
HEAD
現在チェックアウトしている、リファレンスのポインタです。(リファレンスのリファレンス)
直接commitオブジェクトを指し示すときもあります。(detached HEAD)
$ cat .git/HEAD
ref: refs/heads/main <- HEADはrefs/heads/mainファイルを指す
HEADで指し示しているCommitオブジェクトの状態が、プロジェクトルートで展開されるものになります。
tag
tagは2種類あります。
軽量タグ
と アノテーションタグ(注釈付きタグ)
です。
軽量タグは、Commitオブジェクトへのリファレンスです。
アノテーションタグは、tagオブジェクトへのリファレンスです。
先ほど説明を後回しにした**tagオブジェクト(Gitオブジェクト)は、Commitオブジェクトへの参照とメッセージという追加情報(注釈付き)**を持ちます。
つまり、アノテーションタグは、メッセージなど追加の情報(注釈付き)をtagオブジェクト(Gitオブジェクト)にかためて、そのtagオブジェクトを指し示します。
少しややこしいですが、以下の図を見るとわかりやすいかもしれません。
左の吹き出しが軽量タグで、右の吹き出しがアノテーションタグです。
gitオブジェクトとリファレンスの視点でgit操作を見る
以上が、Gitオブジェクトとリファレンスの説明でしたが、その視点でgit操作を見てみます。
git add
以下のような図の状況を考えます。
contacts.txt
ファイルを編集し、git add
でステージングすると、Gitオブジェクトはどうなるでしょうか。
この場合、以下のように 新しいblobオブジェクト が作成されます。
当然ファイルの中身が異なるので、ハッシュ値も別になり、元のcontacts.txt
(blobオブジェクト)とは別のblobオブジェクトができます。
git commit
先ほどの例の続きで、 git commit
すると、以下のように treeオブジェクトが作成されます。そして、commitオブジェクトが作成されます。
また、mainブランチが、新しくできたcommitオブジェクトに移動します。
HEADはmainブランチを指し示していたので、当然新しいコミットを指していることになります。
ここで重要なのが、内容に変更がないblobやtreeのGitオブジェクトは、commitのたびにコピーされるわけではないということです。
blobもtreeも内容が変わらなければ、同じハッシュ値になります。
データ容量の節約ができていると捉えることができます。スマートですね。
上記の図にある、新しいtree(赤色)は、もともと存在しているblobやtreeを参照しています。
git fetch
先ほどの例は忘れて、次の図の状態を仮定します。fetch前のローカルリポジトリの状態です。
git fetch
をすると、リモートリポジトリのgitオブジェクトやリファレンスがローカルリポジトリに反映されます。
ただし、ローカルのbranchや、HEADといったリファレンスは動きません。
git merge(fast-forward)
git merge --ff
を行うと、branchが移動します。commitオブジェクトは作られません。
先ほどの例の続きだと、以下のようになります。
git merge(non-fast-forward)
また新しく、以下の状況を仮定します。
ローカルでcommitオブジェクトが作成されて、あとでfetchしたとします。
リモートリポジトリの新しい変更であるcommitオブジェクトを、ローカルのbranchに取り込みたいと考えます。
しかし、すでに作成されたcommitオブジェクトはイミュータブルなので、変更することができません。
この状態で我々にできることは、gitのオブジェクトの追加か、リファレンスの移動しかないです。
リファレンスを移動しても、両方の変更を取り込めないので、新しいcommitを作ります。これがmergeコミットです。
git merge origin/main
すると、両方の差分を取り込んだ、新しいcommitオブジェクトが作られます。2
git rebase
これも新しい状況を仮定します。
ローカルの変更でいくつかコミットし、リモートもいくつかコミットされた状態で、変更を取り込みたい場合、マージコミットをしてしまうと、ステッチ(縫い目)の状態になってしまうので、履歴を追いづらくなってしまったりするため、rebaseをしたりすることがあるかと思います。
これをrabaseすると、以下のように新しいcommitが作成されます。(rebaseのやり方にもよります)
繰り返しになりますが、一度作成されたcommitオブジェクトは変更できないので、rebaseするときも、新しいコミットが作成されることになります。
rabaseした後、前のg、e、dのcommitオブジェクトはどうなるかというと、実はリポジトリ内に残っています。すぐには削除されないため、復活が可能です。3
最後に言わせてください
リーナス・トーバルズすげぇ!!!!!!4