LIVESENSE made*

リブセンスのエンジニアやデザイナーの活動や注目していることをまとめたブログです。

MENU

Gitのコミットの裏側で起こっていること

はじめまして。1ヶ月でエンジニアになろうとした山浦です。

先日Gitのことを突っ込んで調べる機会があり、Gitの仕組みって面白いねということを同僚に話していたら「面白いね。ところでGitって実装できる?実装できないと分かったとは言えないよね?」となぜか煽られるということがありました。

そうか、実装できないと分かったとは言えないのか、それも一理あるかもしれない。そう思い、Gitの仕組みを実装できるレベルまで掘り下げて調べてみました。

今回は実装はしないものの(過度に記事が複雑になるので)、Gitの根幹である git add コマンドと git commit コマンドの裏側で起こっていることを紹介します。

差分かスナップショットか?

ここで早速クイズです。

コミットで保存されているのはソースコードの差分でしょうか?スナップショットでしょうか?

今回の記事の中で解説していきますので、少し考えながら読み進めてみてください。 ちなみにこのデータの持ち方がGitと他のバージョン管理システム (Subversion等) との大きな違いだったりします。

git add コマンドの裏側で起こっていること

それでは、git add コマンドでインデックスに登録する裏側で何が起こっているのかをまず見ていきます。

$ git init sample
$ cd sample
$ echo 'Hello, world!' > sample.txt
$ git add sample.txt

git add コマンドを実行すると裏側では、①圧縮ファイルが作成され②インデックスに追記されます。これらを順に見ていきます。

f:id:livesense-made:20170813134138p:plain

①圧縮ファイルが作成される

git add コマンドを実行すると、addしたファイルを圧縮したファイルが .git/objects 以下に保存されます。

$ find .git/objects -type f
.git/objects/af/5626b4a114abcb82d63db7c8082c3c4756e51b

これが先ほどの git add コマンドで追加されたファイルです。 このファイルはblob (カタマリ) オブジェクトと呼ばれていて、ファイルの内容を圧縮したものになります。

どのようにblobオブジェクトを作成しているかというと、まず、ファイルの内容にヘッダーを付け加えたものをSHA1でハッシュ化します (それがblobオブジェクトのID)。IDの先頭2文字をサブディレクトリの名前に、残り38文字をそのディレクトリ内のblobオブジェクトのファイル名にします。そして、ファイルの内容にヘッダーを付け加えたものをzlibライブラリを用いて圧縮し、blobオブジェクトのファイルに書き込みます。こうやってblobオブジェクトは作成され、ファイルが圧縮保存されます。

f:id:livesense-made:20170820100304p:plain

なお、blobオブジェクトの圧縮前の内容を確認したい時は、blobオブジェクトのIDに対して git cat-file -p コマンドを使うことで確認できます。

$ git cat-file -p af5626b4a114abcb82d63db7c8082c3c4756e51b
Hello, world!

さて、ここでファイルに追記して git add コマンドを実行するとどうなるでしょう。

$ echo 'Good morning.' >> sample.txt
$ git add sample.txt
$ find .git/objects -type f
.git/objects/67/dcebe5e80cb4513b614624763ce08cf3346d8f # 新規追加されたファイル
.git/objects/af/5626b4a114abcb82d63db7c8082c3c4756e51b # 1回目のgit addで生成されたblobオブジェクト

すると、元々のblobオブジェクトは残ったまま、もう一つのファイルが追加されています。このファイルの中身はいったい何でしょう?

$ git cat-file -p 67dcebe5e80cb4513b614624763ce08cf3346d8f
Hello, world!
Good morning.

現状のsample.txtの内容が圧縮されていました。このように、git add コマンドを実行すると、その時点でのファイルの内容全部がblobオブジェクトという形で圧縮され記録されます。

ここでblobオブジェクトに関する重要な点をまとめます。

  • blobオブジェクトのIDはSHA1ハッシュで作成されているため、blobオブジェクトはファイル名に関係なく内容が同じなら同じIDになります。
  • blobオブジェクトのデータベースは追記のみで更新されません。イミュータブルなオブジェクトです。(ファイルを変更・削除してもblobオブジェクトは変更・削除されません)

②インデックスに追記される

ファイルはblobオブジェクトの形式で圧縮保存されるわけですが、blobオブジェクトはファイルの内容を圧縮しただけで、ファイル名の情報をどこにも保持していません。

そこで、ファイルの構造と名前を保持するために登場するのがインデックスです。

git add コマンドを実行すると、blobオブジェクトが作成された後、インデックスに追記されます。インデックスは、.git/index というバイナリファイルで管理されています。プロジェクトのある時点でのディレクトリツリー全体を表すデータを持ちます。

インデックスの中身を確認してみましょう。git ls-files –stage コマンドで .git/indexの内容を見ることができます。

$ git ls-files --stage
100644 67dcebe5e80cb4513b614624763ce08cf3346d8f 0       sample.txt

左からファイルモード (パーミッション)、(blob) オブジェクトのID、ステージ、ファイルパスが表示されています。ステージはマージコンフリクトのためにある数値です。通常0ですが、マージコンフリクトが起きた場合、ベースバージョン、一方のブランチのバージョン、他方のブランチのバージョンの3つをそれぞれステージ1、2、3としてインデックスに保持します。

このように、git add した時点でのblobオブジェクトのIDとファイルパスを紐付けて管理することで、ディレクトリーツリーの情報を保存します。

インデックスには次の特徴があります。

  • 次のコミットの準備をする場所。git commit すると、インデックスの情報を元にコミットされます。
  • バイナリファイルにパーミッション、オブジェクトのID、ファイルパスを持ちます。
  • treeオブジェクトの鋳型となります。treeオブジェクトに関してはこの後紹介します。

以上の2つが、git add コマンドでインデックスに追加する際に裏側で起こっていることです。

git commit コマンドの裏側で起こっていること

さて、ここからはコミットについて見ていきます。

$ git commit -m "first commit"
[master (root-commit) ef56b89] first commit
 1 file changed, 2 insertions(+)
 create mode 100644 sample.txt

git commit コマンドを実行すると裏側では、③treeオブジェクトが作成され④commitオブジェクトが作成され⑤ブランチの先頭を指すリファレンスが書き換えられます。

f:id:livesense-made:20170813153434p:plain ※ 図は①〜④まで

③treeオブジェクトが作成される

git commit コマンドを実行するとまず、treeオブジェクトが .git/objects 以下に保存されます。

$ find .git/objects -type f
.git/objects/2f/b1bd43dc899bcb3d8c1245e359716459ad992a # 今回追加されたファイル
.git/objects/67/dcebe5e80cb4513b614624763ce08cf3346d8f # 2回目のgit addで生成されたblobオブジェクト
.git/objects/af/5626b4a114abcb82d63db7c8082c3c4756e51b # 1回目のgit addで生成されたblobオブジェクト
.git/objects/ef/56b8952e72e3be4b06f61ecef53a9d282a4568 # 今回追加されたファイル

今回追加されたファイルのうち、.git/objects/2f/b1bd43dc899bcb3d8c1245e359716459ad992a を先に見ていきましょう。このファイルは、treeオブジェクトと呼ばれているものになります。

# -t オプションを付けるとオブジェクトのタイプを表示します
$ git cat-file -t 2fb1bd43dc899bcb3d8c1245e359716459ad992a
tree

# -p オプションを付けるとオブジェクトの中身を表示します
$ git cat-file -p 2fb1bd43dc899bcb3d8c1245e359716459ad992a
100644 blob 67dcebe5e80cb4513b614624763ce08cf3346d8f    sample.txt

treeオブジェクトは、左からファイルモード(パーミッション)、オブジェクトのタイプ、オブジェクトのID、ファイルパスが表示されています。中身がインデックスに似ていますね。 treeオブジェクトもインデックスと同じように、オブジェクトのIDとファイルパスを紐付けることで、ディレクトリーツリーを保持するためのものになります。

インデックスとの違いは、新たにコミットするたびにtreeオブジェクトも追加で作成されるという点です。コミットすると、そのたびにtreeオブジェクトとcommitオブジェクトが作成され、その時点でのディレクトリーツリーが毎回保存されます。そうすることでGitは変更履歴を保存しているのです。それに対してインデックスは、インデックスした時点で .git/index を毎回上書します。あくまでインデックスは、変更をまとめてコミットするための準備をする場所です。インデックスすると .git/index にディレクトリーツリーが上書きされ、コミットするとその時点での .git/index の情報を元にtreeオブジェクトが作成されるわけです。

次に、ディレクトリを追加するとtreeオブジェクトがどのようにディレクトリーツリーを保存するのかを見ていきましょう。

$ mkdir src
$ echo 'main file.' > src/main.txt
$ git add src
$ git commit -m "add directory"
[master ebf5ca0] add directory
 1 file changed, 1 insertion(+)
 create mode 100644 src/main.txt

# masterブランチ上での最後のコミットが指しているツリーオブジェクトの中身を表示します
# git cat-file -p 9a49569a4956b912f7ea59f0efbdb4a5c4d18a19aee9bb でも同じです
$ git cat-file -p master^{tree}
100644 blob 67dcebe5e80cb4513b614624763ce08cf3346d8f    sample.txt
040000 tree 94d5c7eab249212c58445ace5acadf10ed991e0c    src

# srcのtreeオブジェクトの中身を表示します
$ git cat-file -p 94d5c7eab249212c58445ace5acadf10ed991e0c
100644 blob 258dda95ffff5919f3ea5894c3bfaafdf225bf57    main.txt

上記のtreeオブジェクトの構造を図で表すと、以下のようになります。

f:id:livesense-made:20170813162224p:plain

このようにtreeオブジェクトは、1つ以上のblobオブジェクトかtreeオブジェクトを保持する階層構造になっています。そして、treeオブジェクトからblobオブジェクトをたどることで、その時点のスナップショットを確認することができます。

ここでtreeオブジェクトの特徴をまとめます。

  • コミット時に作成されます。
  • 構造や名前をもたないblobオブジェクトにファイルシステムとしての構造を与えます。
  • blobオブジェクトかtreeオブジェクトを保持しています。

④commitオブジェクトが作成される

さて、treeオブジェクトにより、コミット時のディレクトリーツリーが分かるようになりました。

しかしまだ、そのコミットを誰が、いつ、何のために変更したのかという情報がありません。それを保持するのがcommitオブジェクトです。

コミットすると、treeオブジェクトが作成された後に、.git/objects 以下にcommitオブジェクトが作成されます。

$ find .git/objects -type f
.git/objects/25/8dda95ffff5919f3ea5894c3bfaafdf225bf57 # 3回目のgit addで生成されたblobオブジェクト
.git/objects/2f/b1bd43dc899bcb3d8c1245e359716459ad992a # 1回目のgit commitで生成されたtreeオブジェクト
.git/objects/67/dcebe5e80cb4513b614624763ce08cf3346d8f # 2回目のgit addで生成されたblobオブジェクト
.git/objects/94/d5c7eab249212c58445ace5acadf10ed991e0c # 最新のコミットで追加されたtreeオブジェクト
.git/objects/9a/4956b912f7ea59f0efbdb4a5c4d18a19aee9bb # 最新のコミットで追加されたtreeオブジェクト
.git/objects/af/5626b4a114abcb82d63db7c8082c3c4756e51b # 1回目のgit addで生成されたblobオブジェクト
.git/objects/eb/f5ca03e94fe2244bb784fc4fddabf80e27a2e7 # 最新のコミットで追加されたcommitオブジェクト
.git/objects/ef/56b8952e72e3be4b06f61ecef53a9d282a4568 # 1回目のgit commitで生成されたcommitオブジェクト

# git cat-file -p HEAD でも同じ。最新コミットのcommitオブジェクトの内容を表示します
$ git cat-file -p ebf5caebf5ca03e94fe2244bb784fc4fddabf80e27a2e7
tree 9a4956b912f7ea59f0efbdb4a5c4d18a19aee9bb
parent ef56b8952e72e3be4b06f61ecef53a9d282a4568
author Harry <harry@author.co.jp> 1502608263 +0900
committer Harry <harry@author.co.jp> 1502608263 +0900

add directory

commitオブジェクトの中身は、コミットが作成された時点のトップレベルのツリー、親コミット、作者とコミッターの情報、空行、コミットメッセージです。このように、ツリーを保存することでコミットした時点のスナップショットが、作者とコミッターの情報からいつ誰が、コミットメッセージからなぜ変更したのかということが分かるようになっています。

なお、親コミットとは直前のコミットになります。親コミットの内容も確認してみましょう。

# parentに記載されているcommitオブジェクトの内容を表示します
$ git cat-file -p ef56b8952e72e3be4b06f61ecef53a9d282a4568
tree 2fb1bd43dc899bcb3d8c1245e359716459ad992a
author Harry <harry@author.co.jp> 1502606667 +0900
committer Harry <harry@author.co.jp> 1502606667 +0900

first commit

今回の親コミットは、最初のコミットになります。そのコミット内容が表示されています。

ここまでのGitリポジトリ内のオブジェクトの様子を図でまとめます。

f:id:livesense-made:20170813170631p:plain

Gitはcommitオブジェクトにtreeオブジェクトを保持することで、その時点でのスナップショットが分かるようにしています。 加えて、直前のコミットを親コミットとして保持しておくことで、コミットの履歴を辿れるようにしているわけです。これがGitのバージョン管理の根幹をなしている仕組みです。

ここでcommitオブジェクトの特徴をまとめます。

  • コミット時点でのスナップショットを示すオブジェクトです。treeオブジェクトのIDを持っていて、それがスナップショットの情報になります。
  • parentを持っていて、履歴をたどることができます。
  • なお、マージコミットの場合はparentを2つ持つことになります。

⑤ブランチの先頭を指すリファレンスが書き換えられる

git commit コマンドを実行すると、commitオブジェクト作成後に、現在のブランチの先頭を指すリファレンスが書き換えられます。

そもそも現在のブランチの情報はどこで保持されているかというと、HEADになります。HEADとは、現在使用しているブランチの先頭を表していて、.git/HEAD に保存されています。早速確認してみましょう。

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

これを見ると、HEADは refs/heads/master へのリファレンスになっていることが分かります。HEADというのは、現在のブランチへのリファレンスで、ブランチをチェックアウトしたタイミングで書き換えられます。

では次に、refs/heads/master を確認してみます。

$ cat .git/refs/heads/master
ebf5ca03e94fe2244bb784fc4fddabf80e27a2e7

$ git cat-file -p ebf5caebf5ca03e94fe2244bb784fc4fddabf80e27a2e7
tree 9a4956b912f7ea59f0efbdb4a5c4d18a19aee9bb
parent ef56b8952e72e3be4b06f61ecef53a9d282a4568
author Harry <harry@author.co.jp> 1502608263 +0900
committer Harry <harry@author.co.jp> 1502608263 +0900

add directory

refs/heads/master は、masterブランチの先頭のcommitオブジェクトへのリファレンスになっていました。

Gitはコミットすると、refs/heads/master に最新コミットのIDを記載することで、現在のブランチの先頭コミットがどれなのかを記録しているのです(ブランチが変わると、refs/heads/ 以下が該当ブランチ名に変わります)。

まとめ

さて、はじめにコミットで保存されているのはソースコードの差分かスナップショットか、というクイズを出したのを覚えていらっしゃるでしょうか?

コミットで保存されているのは「スナップショット」でした。

git add コマンドを実行すると①圧縮ファイルが作成され②インデックスに追記されます。
git commit コマンドを実行すると③treeオブジェクトが作成され④commitオブジェクトが作成され⑤ブランチの先頭を指すリファレンスが書き換えられます。
commmitオブジェクトがtreeオブジェクトのIDを保持することで、コミット時点のスナップショットが分かるようになっているのでしたね。

ちなみに、他のバージョン管理システム (Subversion等) の多くは、基点とするバージョンへの差分としてデータを保持しています。Gitが差分ではなくスナップショットとしてデータを持っていることは、ブランチを運用する際に特に大きなメリットとなります。多くのバージョン管理システムの場合だと、データを差分として保持しているため、ブランチの作成時に全ファイルを新たなディレクトリにコピーする必要があり、ブランチ作成に長い時間がかかります。それに対してGitの場合だと、データをスナップショットとして保持ているため、ブランチの作成時はブランチのリファレンスを追加するだけですみ (ref/heads/ブランチ名 のファイルにcommitオブジェクトへのリファレンスを指定するだけ)、非常に高速です。

Gitの実態は、今回見てきた3つのオブジェクト(blobオブジェクト、treeオブジェクト、commitオブジェクト。これらをGitオブジェクトと呼びます)を中心に構成されています。もちろん他にもリファレンスやタグ等もありますが、これがGitの一番コアな部分です。 Gitのコマンドや操作の多くは、これら3つのオブジェクトに対して何らかの操作をしているだけです。

今回Gitを詳しく調べてみて、Gitは大変シンプルな構成だと思いました。本内容が皆様のGitライフの一助になれば幸いです。

参考文献