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
というタイプで、author
やcommitter
の情報、コミットメッセージなどが記載されていることがわかります。他に、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
これらのmaster
とmeat
ファイルの中身からも分かるように、ブランチとはあるコミットへのリファレンスのことです。一連のコミット群のことではなく、その実態はただのリファレンスです。従って、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.md
、curry-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.md
とdir/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オブジェクトのハッシュ値が、author
やcommitter
などの情報も含めて、計算されているためです。僕とあなたとでは、名前もメールアドレスも、コミットした時間も違うため異なるハッシュ値になります。
この節のまとめ
- 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
ブランチにチェックアアウトすると、HEAD
がmeat
ブランチを参照します。そしてmeat
ブランチはコミット7de2e33
を参照しています。そしてコミット7de2e33
はtreeオブジェクト1940ab0
を参照しています。treeオブジェクト1940ab0
からその時のファイル群の状態を再構築できます。
事実3
「適当なブランチにチェックアウトするとHEAD
の参照先が変化する」
「ブランチはあるコミットを参照している」
この2つの事実から、適当なブランチにチェックアウトすると、その時のファイル群の状態に戻すことができる、というよく知られた事実が再確認できました!
紹介しなかった部分
- Gitオブジェクトには
commit
、tree
、blob
の他にもtag
というタイプのGitオブジェクトがあります。 - indexについては全く触れませんでした。
- Gitオブジェクトの圧縮についても触れませんでした。
その他諸々、紹介しきれなかった部分、もしくはただ僕が知らない部分、などなどたくさんあるかもしれませんが、今回はここで終わろうと思います。ありがとうございました!
参考にしたもの
今回の記事を書くのに、以下のサイトを参考にしました。
こせきの技術日記 Gitの仕組み(1)
非常にわかりやすい記事で、僕の記事はただの縮小再生産のようなものになってしまいました。一応、僕の言葉でわかりやすい説明内容を目指しましたが、「よく分からんかった」「もっと詳しく知りたい」という方は、この種記事の方も参考にしてみてください。超面白かったです。
Author And Source
この問題について(Gitはどのようにバージョン管理をしているのか?), 我々は、より多くの情報をここで見つけました https://zenn.dev/yusei_sayama/articles/dd6df0effe0970著者帰属:元の著者の情報は、元のURLに含まれています。著作権は原作者に属する。
Collection and Share based on the CC protocol