Gitは日常的に使うツールですが、「内部で何が起きているのか」を意識する機会はあまり多くありません。
この記事では、.gitディレクトリの中身の変化を追いながら、Gitの動作を具体的に理解していきます。
実際に手を動かしながら読むのがおすすめです。
git version 2.39.2で動かしています。
ハッシュ値は環境によって異なる可能性がありますがご了承ください。
git init
git initを実行すると、.gitディレクトリが作成されます。この内部には様々なファイルやディレクトリがありますが、ここでは、gitの基本的な動作を司る特に重要なものを紹介します。
-
HEAD
現在どのブランチで作業しているかを示すファイル -
refs/
ブランチがどのコミットを指しているかを記録するディレクトリ -
objects/
Gitのデータ本体(blob・tree・commitなど)を格納する場所 -
index
次のコミットに含めるファイルの一覧を保存する場所
いわゆるステージングエリア
これらについて、git initした直後は以下の状態になっています。
| ファイルorディレクトリ | 中身 |
|---|---|
| HEAD | ref: refs/heads/main(環境によってはmaster) |
| refs/ | 空のheads/とtags/
|
| objects/ | 空のinfo/とpack/
|
| index | 存在しない |
git add
まずは、git add <ファイル名>を実行したときに何が起こるかを見ていきます。
ここでは例として、Hello, World!と記述したfirst_file.txtを作成し、git add first_file.txtを実行します。
$ echo 'Hello, World!' > first_file.txt # 改行を含む
$ git add first_file.txt
1. blobオブジェクトの作成
Gitは、まずaddしたファイルの内容を読み取り、ハッシュ値を計算します。
続いて、.git/objects/の中に、ハッシュ値の最初の2文字をディレクトリ名、残りの38文字をファイル名として保存します。このファイルを保存するためのオブジェクトはblobオブジェクトといいます。
git cat-file -p <ハッシュ値>でオブジェクトの内容を見ることができます。
$ git cat-file -p <blobのハッシュ値>
<オブジェクトの内容>
ファイルの中身がHello, World!の場合、ハッシュ値は8ab686eafeb1f44702738c8b0f24f2567c36da6dになります。つまり、.git/objects/8a/b686eafeb1f44702738c8b0f24f2567c36da6dというファイルが作成されることになります。
$ git cat-file -p 8ab686eafeb1f44702738c8b0f24f2567c36da6d
Hello, World!
2. indexファイルの更新(作成)
次に、Gitは.git/indexファイルを更新(または作成)します。ここにはファイル名やblobのハッシュ値、メタ情報などが書き込まれます。
この.git/indexがステージングエリアの実体ということになります。
git ls-files --stageでindexの中身を確認することができます。
$ git ls-files --stage
100644 <ハッシュ値> 0 <ファイルパス>
100644 <ハッシュ値> 0 <ファイルパス>
...
Hello, World!と記述したfirst_file.txtをaddした場合には、以下のようになります。
$ git ls-files --stage
100644 8ab686eafeb1f44702738c8b0f24f2567c36da6d 0 first_file.txt
git addによる.git内部の状態変化を整理すると以下のようになります。
| ファイルorディレクトリ | 中身 |
|---|---|
| HEAD | 変化なし |
| refs/ | 変化なし |
| objects/ | first_file.txt の中身を格納したblobが1つ増えた |
| index | first_file.txt とそのハッシュ値が登録された |
git commit
続いて、git commit -m "Add first_file.txt"を実行して、.gitの変化を追います。
1. treeオブジェクトの作成
indexの内容をもとに、ディレクトリ構造を表現するtreeオブジェクト.git/objects/に作成されます。
この時のディレクトリ名、ファイル名の命名は先述のblobオブジェクトと同一です。
中身は以下のようになっています。
$ git cat-file -p <treeのハッシュ値>
100644 blob <blobのハッシュ値> <ファイル名>
今回の例では、treeオブジェクトのハッシュ値はf763f2fdf960db3952bda520ca37badfce4041ecとなります。
$ git cat-file -p f763f2fdf960db3952bda520ca37badfce4041ec
100644 blob 8ab686eafeb1f44702738c8b0f24f2567c36da6d first_file.txt
2. commitオブジェクトの作成
次に、先ほどのtreeオブジェクトを指し示すcommitオブジェクトが生成されます。
中身は以下のようになっています。
$ git cat-file -p <commitのハッシュ値>
tree <treeのハッシュ値>
parent <親commitのハッシュ値>
author <名前> <メール> <タイムスタンプ>
committer <名前> <メール> <タイムスタンプ>
<コミットメッセージ>
親コミットは複数になる場合もあります。
今回の場合は初めてのコミットなので親コミットは存在せず、以下のようになります。
$ git cat-file -p <commitのハッシュ値>
tree f763f2fdf960db3952bda520ca37badfce4041ec
author <名前> <メール> <タイムスタンプ>
committer <名前> <メール> <タイムスタンプ>
Add first_file.txt
commitオブジェクトには名前やメール、タイムスタンプが含まれるため、commitオブジェクトのハッシュ値は人や時間によって異なる値になります。
3. .git/refs/heads/mainファイルの更新(作成)
Gitは、今作ったcommitオブジェクトのハッシュ値を.git/refs/heads/mainというテキストファイルに書き込みます。
これはmainブランチの実体ファイルであり、mainブランチがどのコミットを指しているかを保持しています。
$ cat .git/refs/heads/main
<commitのハッシュ値>
これにより、mainブランチと特定のコミットが紐づけられます。
ここで、最初からHEADがrefs/heads/mainを指していたことを思い出してください。コミットした結果、mainが新しいコミットを指すようになったため、結果的に「今の作業場所(HEAD)」も最新のコミットに追従していることになります。
git commitによる.git内部の状態変化を整理すると以下のようになります。
| ファイルorディレクトリ | 中身 |
|---|---|
| HEAD | 変化なし |
| refs/ | refs/heads/mainにcommitのハッシュ値が書き込まれた |
| objects/ | tree, commitが1つずつ増えた |
| index | 変化なし |
git branch & git switch
続いて、ブランチを作成してそのブランチに移動した時の挙動を確認していきます。
1. .git/refs/heads/featureの作成
まず、git branch featureで新しいブランチを作ると、.git/refs/heads/featureが作成されます。
このテキストファイルには、その瞬間にHEADが指していたコミットのハッシュ値がそのままコピーされます。
$ git branch feature
$ cat .git/refs/heads/feature
<commitのハッシュ値>
つまり、mainとfeatureという2つのブランチが同じコミットを指している状態になります。
2. HEADの更新
続いて、git switch featureで新しく作成したブランチに移動します。
すると、HEADの指すブランチが変わります。
$ cat .git/HEAD
ref: refs/heads/main
$ git switch feature
Switched to branch 'feature'
$ cat .git/HEAD
ref: refs/heads/feature
3. indexの更新
続いて、対象コミットの内容でindexが更新されます。
今回の場合は同じコミットを指すブランチに移動したので、変化はありません。
4. ワーキングディレクトリの同期
最後に、indexの状態に合わせて、ワーキングディレクトリのファイルを書き換えます。ワーキングディレクトリとは、.git内部ではなく、実際にファイルを編集する場所のことです。
今回の場合はindexが変化していないので、変化はありません。
git branch & git switchによる.git内部の状態変化を整理すると以下のようになります。
| ファイルorディレクトリ | 中身 |
|---|---|
| HEAD | refs/heads/featureを指すようになった |
| refs/ | refs/heads/featureにcommitのハッシュ値が書き込まれた |
| objects/ | 変化なし |
| index | 変化なし |
git merge (fast-forward)
続いて、マージした時の挙動を確認していきます。
featureブランチで新しいファイルを作成してコミットし、それをmainブランチにマージしてみましょう。
$ echo 'hogehoge' > second_file.txt
$ git add second_file.txt
$ git commit -m "Add second_file.txt"
$ git switch main
$ git merge feature
1. .git/refs/heads/mainファイルの更新
Gitはマージの際、まず2つのブランチの歴史を比較します。
今回の場合は、mainの歴史を辿っていけばそのままfeatureに辿り着けます。このとき、Gitは新しいマージコミットを作る必要がないと判断し、「ポインタの早送り」だけを行います。
具体的には、.git/refs/heads/mainの中身を feature と同じ「最新コミットのハッシュ値」に書き換えます。
このようなマージ方式を、fast-forwardといいます。
git merge (3-way merge)
次は、featureブランチとmainブランチそれぞれで新しいコミットを作成し、マージをしてみます。
$ echo 'fugafuga' > third_file.txt
$ git add third_file.txt
$ git commit -m "Add third_file.txt"
$ git switch feature
$ echo 'piyopiyo' > fourth_file.txt
$ git add fourth_file.txt
$ git commit -m "Add fourth_file.txt"
$ git switch main
$ git merge feature
コミットメッセージを求められますが、特に変更する必要はありません。
:wqでファイルを保存、終了してください。
1. treeオブジェクト、commitオブジェクトの作成
今回は、mainをfeatureまで「早送り」するだけではthird_file.txtの変更が消えてしまうため、fast-forwardは不可能です。
そこで、Gitは以下の3つの地点を比較します。
- mainの最新commit
- featureの最新commit
- 共通の祖先commit
この3点を比較して、自動で両方の変更を取り込んだ状態を計算し、マージが行われます。
このようなマージ方式を3-way mergeといいます。
なお、競合がある場合は手動解決が必要になります。
Gitはマージ結果を反映させた新しいtreeオブジェクト、commitオブジェクトを作成します。
commitオブジェクトの中身を見てみましょう。
$ cat .git/refs/heads/main
<作成されたcommitのハッシュ値>
$ git cat-file -p <作成されたcommitのハッシュ値>
tree <treeのハッシュ値>
parent <mainの最新commitのハッシュ値>
parent <featureの最新commitのハッシュ値>
author <名前> <メール> <タイムスタンプ>
committer <名前> <メール> <タイムスタンプ>
Merge branch 'feature'
このGitが作成したコミットには、親コミットが2つあるということが確認できます。
2. .git/refs/heads/mainファイルの更新
最後に、.git/refs/heads/mainが新しく作られたマージコミットのハッシュ値に書き換わります。.git/refs/heads/featureは変更されません。
最後に
Gitはオブジェクトとポインタで構成されたシンプルな仕組みによって動いています。
挙動を理解しながら楽しんでgitコマンドを使いこなしていきましょう。