Gitはどのようにバージョン管理をしているのか?


会社の同期と一緒にお試し勉強会をしていて、せっかくなので発表内容を記事としてまとめることにしました。

環境

  • MacOS Monterey 12.3.1
  • git version 2.35.1

.gitの誕生

Git管理下に置きたいディレクトリの中でgit initを実行すると.gitという隠しディレクトリが作成されます。Gitはこの.gitディレクトリの中のファイルを操作することで、Git管理下にあるファイル群のバージョン管理をしています。

% mkdir repo01; cd repo01
% git init
% ls -a # .gitディレクトリが作成されている
.    ..   .git

GitにはGitオブジェクトという超重要概念があります。Gitでのバージョン管理とは、このGitオブジェクトの管理と言っても過言ではありません。
このGitオブジェクトは.gitディレクトリの中のobjectsというディレクトリの中に保存されていきます。今回は、このGitオブジェクトがどんなものなのかを観察していき、Gitがどのようにバージョン管理をしているのかを簡単に説明したいと思います!

.gitディレクトリの中には他にも、HEADが現在何を参照しているかが記載されているHEADファイルや、各ブランチがどのコミットを参照しているかなどの情報が記載されているrefsディレクトリなどがこの.gitの中に保存されています。これらについても軽く触れようと思っています!

コミットの観察

まずはいくつか適当なコミットを積んで、その過程で出来上がったコミットを観察していくことにしましょう。

% touch README.md
% git add -A && git commit -m 'README.mdの作成'

% touch curry-ingredients.md
% git add -A && git commit -m 'curry-ingredients.mdの作成'

% echo '# カレーのレシピ' > README.md
% git commit -am 'README.mdの更新'

% echo '- にんじん' >> curry-ingredients.md
% echo '- じゃがいも' >> curry-ingredients.md
% echo '- カレールー' >> curry-ingredients.md
% git commit -am 'curry-ingredients.mdの更新'

まずは最新のコミットの情報をgit logで調べてみます。

% git log -1
commit 4b793ca13b6963934cc977499c02e938dfbf0006 (HEAD -> master)
Author: Sayama <[email protected]>
Date:   Sat Apr 9 01:15:24 2022 +0900

    curry-ingredients.mdの更新

この4b793ca13b6963934cc977499c02e938dfbf0006という意味の分からない文字列は、あるGitオブジェクトのハッシュ値になっています。実際のGitオブジェクトは

.git/objects/4b/793ca13b6963934cc977499c02e938dfbf0006

というファイルに記されています[1]。しかしこのファイルはバイナリ形式になっているため、直接覗くことはできません。そこでgit cat-fileというコマンドで中身を見ていきます。

% git cat-file -t 4b793ca # -tでGitオブジェクトのタイプが見れます
commit

% git cat-file -p 4b793ca # -pでGitオブジェクトの中身が見れます
tree 433babe388be6759d8e6d51e2b8c388854e51324
parent 9454992f15f88a528fd812d258c9d0c6d27cfa4d
author Sayama <[email protected]> 1649434524 +0900
committer Sayama <[email protected]> 1649434524 +0900

curry-ingredients.mdの更新

このGitオブジェクトはcommitというタイプで、authorcommitterの情報、コミットメッセージなどが記載されていることがわかります。他に、2つの重要な情報が記載されています。
parentという項目にあるGitオブジェクト:9454992f15f88a528fd812d258c9d0c6d27cfa4d
treeという項目にある謎のGitオブジェクト:433babe388be6759d8e6d51e2b8c388854e51324

一旦、tree項目に書かれた謎のGitオブジェクトは無視しましょう。parentという項目にあるGitオブジェクトを再度調べてみます。

% git cat-file -t 9454992
commit

% git cat-file -p 9454992
tree d13fd5f50504dfa30597a297bb65d1581c4623dd
parent b70fbd783643abe10eeed15a2e87fcba55f420ac
author Sayama <[email protected]> 1649434487 +0900
committer Sayama <[email protected]> 1649434487 +0900

README.mdの更新

またしてもcommitというタイプのGitオブジェクトでした。このタイプのGitオブジェクトのことを我々はコミットと呼んでいます。コミットはコミットメッセージなどの情報の他に一つ前のコミットである親コミットのハッシュ値を持っています。
このように最新のコミットは、ひとつ前の親コミットを参照し、そのコミットはまたひとつ前の親コミットを参照し、と言った具合に参照の列を作っています。
この参照の列によりバージョンの時間的経過を表すことができます。コミット、すなわちcommitタイプのGitオブジェクト全体のことを、ヒストリーまたはタイムラインと言います。

一旦、分かったことを簡単にまとめると、4b793caというコミットは9454992という親コミットを参照していて、9454992というコミットはb70fbd7という親コミットを参照していて、というような参照の列があるという事がわかりました。

% git log --oneline
4b793ca (HEAD -> master) curry-ingredients.mdの更新
9454992 README.mdの更新
b70fbd7 curry-ingredients.mdの作成
b3ddb5d README.mdの作成

この節のまとめ

  • コミットはひとつ前の親コミットを参照していて、その親コミットはそのまた親コミットを参照していて、と参照の列が作られている。
  • 出来上がった参照の列がバージョンの時間的経過を表す。

ブランチの実態

前節では、コミットとは何かということをみました。
続いてはブランチとは何かについて観察していこうと思います。いくつか追加でコミットを積んでおきます。

% git checkout -b meat
% echo '- 鶏肉' >> curry-ingredients.md
% git commit -am '材料に鶏肉を追加'

% git checkout master
% echo '美味しいカレーを作ろう!' >> README.md
% git commit -am 'README.mdの更新'

今回meatというブランチを作成しました。
ブランチの実態を見るために.git/refs/headsの中を覗いてみると、新たにmeatというファイルが作成されていることがわかります。

% ls .git/refs/heads
master meat

% cat master
96dcb8ee86c2c6f10c913f4d4b64e442cb2b3d33
% cat meat
7de2e332852ff9b2d0d9a8d1dfcedb9ab500c55e

これらのmastermeatファイルの中身からも分かるように、ブランチとはあるコミットへのリファレンスのことです。一連のコミット群のことではなく、その実態はただのリファレンスです。従って、1000個くらいブランチを作ったとしても、データのサイズは大したものになりません。

もうひとつ重要なリファレンスがあります。それがHEADです。HEADは現在のバージョンを指し示すリファレンスのことです。HEADが指す参照値は.git/HEADというファイルに記載されています。

% cat .git/HEAD
ref: refs/heads/master

どうやらHEADはmasterブランチを参照しているようです。色々とチェックアウトをしていくとHEADファイルの中身も変化していきます。

% git checkout meat # meatブランチへチェックアウト
% cat .git/HEAD
ref: refs/heads/meat

% git checkout 9454992 # 9454992コミットへチェックアウト
% cat .git/HEAD
9454992f15f88a528fd812d258c9d0c6d27cfa4d

このようにHEADはブランチを参照することもあれば、コミットを直接参照することもあります。

HEADが直接コミットを参照している状態のことをdetached HEADというふうに言います。
detached HEADの状態でコミットを積むこともできますが、特に何もせずに既存のブランチにチェックアウトしてしまうと、誰からも参照されないコミットが出来上がってしまいます。そして、参照の向きを考えると、あなたが数日後にこのコミットを見つけようと思っても、かなり厳しいんじゃないでしょうか。このようなごみコミットは時間が経てばGitのガベージコレクションが掃除してくれますが、あまり気持ちのいい状態とは言えません。detached HEADの状態にする時は、このようなことを分かった上で行うべきです。

この節のまとめ

  • ブランチはあるコミットを参照するだけのリファレンス。
  • HEADはブランチやコミットを参照するだけのリファレンス。

Gitオブジェクトの観察

treeオブジェクト

ところで、少し前にcommitオブジェクトの中身を調べた時に、treeという項目に謎のGitオブジェクトのハッシュ値が書かれていました。ここではこの謎のGitオブジェクトを調べていこうと思います!
調べる前に、説明のために使いたいコミットをひとつだけ積もうと思います。

% git checkout master
% mkdir dir
% echo '# カレーのレシピ\n美味しいカレーを作ろう!' > dir/sample.txt
% git add -A && git commit -m 'README.mdと同じ内容のファイルを作成'

では早速調べていきましょう!

% git log --oneline -1
b3c2c24 (HEAD -> master) README.mdと同じ内容のファイルを作成
%  git cat-file -p b3c2c24
tree 0cdbafebf15332c0788686f2457a87d8ea3ddbf5 # こいつを調べる
parent 96dcb8ee86c2c6f10c913f4d4b64e442cb2b3d33
author Sayama <[email protected]> 1649443408 +0900
committer Sayama <[email protected]> 1649443408 +0900

README.mdと同じ内容のファイルを作成

% git cat-file -t 0cdbafe
tree

% git cat-file -p 0cdbafe
100644 blob 944b8ef2e83aea596fd2a662d629042f3e92edc3	README.md
100644 blob 87f3f8afa28796b2eeda4094bee471acbde78dcc	curry-ingredients.md
040000 tree 6fc8f11b5d479640d1c79f9f8697c35f66d08f67	dir

このGitオブジェクトはtreeというタイプだということがわかりました。今後はこのタイプのGitオブジェクトのことを簡単にtreeオブジェクトということにします。
実はtreeオブジェクトはディレクトリを表すGitオブジェクトのことです。中にあるファイルやディレクトリの種類/権限を表す6桁の数字、対応するGitオブジェクトのタイプ/ハッシュ値、ファイル名/ディレクトリ名が記載されていることがわかります。
treeオブジェクト0cdbafeは、Git管理下のルートディレクトリに対応していて、確かに2つのファイルREADME.mdcurry-ingredients.mdと、1つのサブディレクトリdirを持ち、git cat-file -p 0cdbafeの出力内容と対応していそうです。

blobと書かれたGitオブジェクトは一旦置いておき、念の為、dirディレクトリに対応しているtreeオブジェクトの中身も見てみましょう。

% git cat-file -p 6fc8f11
100644 blob 944b8ef2e83aea596fd2a662d629042f3e92edc3	sample.txt

確かにdirディレクトリの中にはsample.txtが保存されていて、git cat-file -p 6fc8f11の出力内容とも合致しそうです。

ここでひとつポイントになっている点は、treeオブジェクトは自分自身のディレクトリ名の情報を持っていないことです。

blobオブジェクト

treeオブジェクトがディレクトリに対応していることが分かったので、残りのblobと書かれたGitオブジェクトを調べていこうと思います。README.mdに対応していると思われるGitオブジェクトの中身を見てみましょう。

% git cat-file -t 944b8ef
blob

% git cat-file -p 944b8ef
# カレーのレシピ
美味しいカレーを作ろう!

予想はついていましたが、このGitオブジェクトはblobというタイプだということが確認できました。今後はこのタイプのGitオブジェクトのことを簡単にblobオブジェクトということにします。
勘の良い方は既に分かっていたかもしれませんが、blobオブジェクトとはファイルを表すGitオブジェクトのことです。blobオブジェクトの中にはファイルの中身そのものが記載されています。また、ここでもポイントになるのが、このblobオブジェクトは自分自身のファイル名の情報を持っていないということです。

以上、分かったことを一旦まとめたいと思います。

  • commitタイプのGitオブジェクト(コミット)には、親コミットのハッシュ値以外に、ルートディレクトリに対応したtreeオブジェクトのハッシュ値も記載されている。
  • treeタイプのGitオブジェクトは、ディレクトリに対応していて、自身に含まれるファイルやサブディレクトリに対応するGitオブジェクトのハッシュ値を持っている。ただし自分の名前は知らない。
  • blobタイプのGitオブジェクトは、ファイルに対応していて、自身の中身が記載されている。ただし自分の名前は知らない。

treeやblobオブジェクトは自分の名前を知らなくてもいい

treeオブジェクトやblobオブジェクトがディレクトリやファイルに対応しているが、自分自身の名前の情報を持っていないことを上で観察しました。自分の名前を知らない、これらtreeオブジェクトやblobオブジェクトから元のディレクトリ・ファイル群を再現できないのでは?と一瞬思うかもしれません。しかし、無問題です!
あるblobオブジェクトは、対応するファイルの中身を知っています。そしてそのファイル名は、このblobオブジェクトを含むtreeオブジェクトに記載されています。このtreeオブジェクトに対応するディレクトリの名前は、その親のtreeオブジェクトに記載されていて、その親の名前のそのまた親に記載されて、という具合に、ルートディレクトリに対応するtreeオブジェクトまで遡れば、全てのファイルの名前と中身、全てのディレクトリの名前と中身がきちんと再現できることがわかります。そしてルートディレクトリに名前は必要ありません。

※今回は説明のために、木構造の葉にあたる部分のblobオブジェクトから出発して、自身の名前をその親のtreeオブジェクトに訊ねて、そのtreeオブジェクトの名前はその親のtreeオブジェクトに訊ねて、という順番にしましたが、参照の向きを考えれば、ディレクトリ・ファイル群の再構築はルートディレクトリに対応するtreeオブジェクトから出発します。

この節のまとめ

  • コミットはルートディレクトリに対応するtreeオブジェクトを参照している。
  • treeオブジェクトはディレクトリに対応している。
  • blobオブジェクトはファイルに対応している。
  • これらtreeオブジェクトとblobオブジェクトがあれば、ディレクトリ・ファイル群の状態を再構築できる。

【余談 ☕️ 】参照の向きと、親子

commitオブジェクトの時は、参照先を親コミットと呼びましたが、tree/blobオブジェクトの時は参照元のtreeオブジェクトのことを親と呼びました。

親コミットという言い方は一般的ですが、tree/blobオブジェクトに関しては僕のオリジナルです。ディレクトリ構造を考えたときに、サブディレクトリを子と呼びたい気持ちから、参照元を親と呼んでいます。

ハッシュ値の計算

先ほど、treeオブジェクトを調べた際に、README.mddir/sample.txtに対応するblobオブジェクトのハッシュ値が全く同じものだったことに気づいてましたか?

% git cat-file -p 0cdbafe
100644 blob 944b8ef2e83aea596fd2a662d629042f3e92edc3	README.md
100644 blob 87f3f8afa28796b2eeda4094bee471acbde78dcc	curry-ingredients.md
040000 tree 6fc8f11b5d479640d1c79f9f8697c35f66d08f67	dir

% git cat-file -p 6fc8f11
100644 blob 944b8ef2e83aea596fd2a662d629042f3e92edc3	sample.txt

実はblobオブジェクトのハッシュ値は、そのファイルの中身の情報だけから計算されます。ファイル名などの情報は一切使いません!
あなたのPCで、これらREADME.mdと同じ内容のファイルを作成したなら、僕と全く同じハッシュ値をもつblobオブジェクトが作成されているはずです!

もうひとつ例を見ておきましょう。例えば、初め方のコミットで2つの空ファイルを追加しています。対応する空ファイルのハッシュ値はやはりどちらもe69de29になっています[2]

% git cat-file -p b70fbd7
tree eda14422e5a939043e04ea00fc403e0ee1798a44 # 空のファイルが2つあるはず
..

% git cat-file -p eda1442
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391	README.md
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391	curry-ingredients.md

このように、ファイルの中身だけを考えていれば、同じ中身なのにファイル名が違うため、異なるblobオブジェクトが必要、というような無駄が無くなります。

treeオブジェクトのハッシュ値も全く同じ精神で計算されています。treeオブジェクトの中に書かれている、ファイル/ディレクトリ名やそれらに対応するGitオブジェクトのハッシュ値から、自身のハッシュ値が計算されています。
もしあなたが、この記事の内容を辿ってきたなら、あなたのPCで計算されたblobオブジェクト、treeオブジェクトのハッシュ値は全く同じものになっているはずです!

一方で、commitオブジェクトのハッシュ値はおそらく僕のそれと別の値になっていると思います。
これはcommitオブジェクトのハッシュ値が、authorcommitterなどの情報も含めて、計算されているためです。僕とあなたとでは、名前もメールアドレスも、コミットした時間も違うため異なるハッシュ値になります。

この節のまとめ

  • Gitオブジェクトはその中身の情報からハッシュ値が計算されている。

変化の伝播

最後に、Gitがどのようにコミット単位でファイル群の状態を記憶しているのかを説明して終わりにしたいと思います。

あるファイルが変更されると、その変更されたファイルのblobオブジェクトのハッシュ値が変化します。すると、親のtreeオブジェクトに記載されているblobオブジェクトのハッシュ値も変更されるため、もちろんそのtreeオブジェクトのハッシュ値も再計算され、別のハッシュ値が与えられます。すると、ハッシュ値が変化したこのtreeオブジェクトの親のtreeオブジェクトも、同様の理由により新たなハッシュ値が与えられます。この変化の伝播が繰り返されると必ずルートディレクトリに対応するtreeオブジェクトのハッシュ値が変化します。

事実1
「blobオブジェクトやtreeオブジェクトのハッシュ値が、その中身から計算されていること」
「treeオブジェクトには子であるblob/treeオブジェクトのハッシュ値が記載されていること」
この2つの事実から、木構造の葉にあたる部分のblobオブジェクト(ファイル)が変化すると、最終的にルートディレクトリのハッシュ値が変化する、ということがわかりました。もう少し別の言い方をすると、コミット単位で、その時のルートディレクトリに対応するtreeオブジェクトのハッシュ値がそれぞれ与えられる、ということがわかりました。

そして、commitオブジェクト(コミット)にはルートディレクトリに対応するtreeオブジェクトのハッシュ値が記載されているので、そのコミットから、その時のファイル群の状態を再構築することができます。

事実2
「commitオブジェクトは、ルートディレクトリに対応するtreeオブジェクトを参照している」
「ルートディレクトリに対応するtreeオブジェクトから、その時のファイル群の状態を再構築できる」
この2つの事実から、Gitはコミット単位でファイル群の状態を覚えておくことができる、ということがわかりました。

例えば、meatブランチにチェックアアウトすると、HEADmeatブランチを参照します。そしてmeatブランチはコミット7de2e33を参照しています。そしてコミット7de2e33はtreeオブジェクト1940ab0を参照しています。treeオブジェクト1940ab0からその時のファイル群の状態を再構築できます。

事実3
「適当なブランチにチェックアウトするとHEADの参照先が変化する」
「ブランチはあるコミットを参照している」
この2つの事実から、適当なブランチにチェックアウトすると、その時のファイル群の状態に戻すことができる、というよく知られた事実が再確認できました!

紹介しなかった部分

  • Gitオブジェクトにはcommittreeblobの他にもtagというタイプのGitオブジェクトがあります。
  • indexについては全く触れませんでした。
  • Gitオブジェクトの圧縮についても触れませんでした。

その他諸々、紹介しきれなかった部分、もしくはただ僕が知らない部分、などなどたくさんあるかもしれませんが、今回はここで終わろうと思います。ありがとうございました!

参考にしたもの

今回の記事を書くのに、以下のサイトを参考にしました。
こせきの技術日記 Gitの仕組み(1)
非常にわかりやすい記事で、僕の記事はただの縮小再生産のようなものになってしまいました。一応、僕の言葉でわかりやすい説明内容を目指しましたが、「よく分からんかった」「もっと詳しく知りたい」という方は、この種記事の方も参考にしてみてください。超面白かったです。

脚注
  1. 4bが別れてディレクトリ名になっているのは、おそらく検索性を高めるためだと思っています。しかし、その実は知りません。 ↩︎

  2. 空ファイルのようなあるあるファイルのハッシュ値は、世界中の様々なPCで全く同じものが作成されています。このハッシュ値を検索してみると、いくつかヒットするので、確かにblobオブジェクトのハッシュ値はその中身にしか依存していないんだなということが実感できます。 ↩︎