チャプター ▾ 第2版

10.2 Gitの内側 - Gitオブジェクト

Gitオブジェクト

Gitはコンテンツアドレス可能なファイルシステムです。素晴らしいですね。それは何を意味するのでしょうか? Gitの核となるのは、シンプルなキーと値のデータストアであるということです。これは、どのような種類のコンテンツでもGitリポジトリに挿入でき、Gitは後でそのコンテンツを取得するために使用できる一意のキーを返してくれる、という意味です。

実演として、git hash-objectというplumbingコマンドを見てみましょう。このコマンドは、データを受け取り、それを.git/objectsディレクトリ(オブジェクトデータベース)に保存し、そのデータオブジェクトを参照する一意のキーを返します。

まず、新しいGitリポジトリを初期化し、objectsディレクトリに何も(予想通り)ないことを確認します。

$ git init test
Initialized empty Git repository in /tmp/test/.git/
$ cd test
$ find .git/objects
.git/objects
.git/objects/info
.git/objects/pack
$ find .git/objects -type f

Gitはobjectsディレクトリを初期化し、その中にpackinfoのサブディレクトリを作成しましたが、通常のファイルは何もありません。では、git hash-objectを使って新しいデータオブジェクトを作成し、それを手動で新しいGitデータベースに保存してみましょう。

$ echo 'test content' | git hash-object -w --stdin
d670460b4b4aece5915caf5c68d12f560a9fe3e4

最もシンプルな形では、git hash-objectは渡されたコンテンツを受け取り、それをGitデータベースに保存するために使われるであろう一意のキーを返すだけです。-wオプションは、キーを返すだけでなく、そのオブジェクトをデータベースに書き込むようにコマンドに指示します。最後に、--stdinオプションは、処理するコンテンツを標準入力から取得するようにgit hash-objectに指示します。そうしない場合、コマンドは使用するコンテンツを含むファイル名引数をコマンドの最後に期待します。

上記のコマンドの出力は40文字のチェックサムハッシュです。これはSHA-1ハッシュであり、保存しているコンテンツとヘッダー(これについては後で説明します)のチェックサムです。これでGitがデータをどのように保存したかを確認できます。

$ find .git/objects -type f
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

再びobjectsディレクトリを調べると、その新しいコンテンツのファイルが格納されていることがわかります。これがGitがコンテンツを最初に保存する方法です。コンテンツごとに1つのファイルとして、コンテンツとそのヘッダーのSHA-1チェックサムで名前が付けられます。サブディレクトリにはSHA-1の最初の2文字が、ファイル名には残りの38文字が使用されます。

オブジェクトデータベースにコンテンツが保存されたら、git cat-fileコマンドでそのコンテンツを調べることができます。このコマンドは、Gitオブジェクトを検査するためのスイスアーミーナイフのようなものです。cat-file-pを渡すと、まずコンテンツのタイプを特定し、適切に表示するように指示します。

$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

これで、コンテンツをGitに追加したり、再度取り出したりできるようになりました。ファイル内のコンテンツに対してもこれを行うことができます。例えば、ファイルに対して簡単なバージョン管理を行うことができます。まず、新しいファイルを作成し、その内容をデータベースに保存します。

$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30

次に、ファイルに新しい内容を書き込み、再度保存します。

$ echo 'version 2' > test.txt
$ git hash-object -w test.txt
1f7a7a472abf3dd9643fd615f6da379c4acb3e3a

これで、オブジェクトデータベースにはこの新しいファイルのどちらのバージョンも(最初に保存したコンテンツと同様に)含まれています。

$ find .git/objects -type f
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4

この時点で、test.txtファイルのローカルコピーを削除し、Gitを使用してオブジェクトデータベースから、最初に保存したバージョンを回復することができます。

$ git cat-file -p 83baae61804e65cc73a7201a7252750c76066a30 > test.txt
$ cat test.txt
version 1

あるいは2番目のバージョンを回復することができます。

$ git cat-file -p 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a > test.txt
$ cat test.txt
version 2

しかし、ファイルの各バージョンのSHA-1キーを覚えるのは現実的ではありません。さらに、システムにはファイル名が保存されておらず、コンテンツだけが保存されています。このオブジェクトタイプはブロブと呼ばれます。Gitの任意のオブジェクトのSHA-1キーを指定すると、git cat-file -tでそのオブジェクトのタイプをGitに表示させることができます。

$ git cat-file -t 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a
blob

ツリーオブジェクト

次に調べるGitオブジェクトのタイプはツリーです。これはファイル名の保存問題を解決し、ファイルのグループをまとめて保存することも可能にします。GitはUNIXファイルシステムに似た方法でコンテンツを保存しますが、少し簡略化されています。すべてのコンテンツはツリーオブジェクトとブロブオブジェクトとして保存され、ツリーはUNIXのディレクトリ項目に、ブロブはほぼinodeやファイルの内容に対応します。単一のツリーオブジェクトには、1つ以上のエントリが含まれており、それぞれがブロブまたはサブツリーのSHA-1ハッシュであり、それに関連付けられたモード、タイプ、およびファイル名が含まれています。例えば、最新のツリーが次のようなプロジェクトがあるとします。

$ git cat-file -p master^{tree}
100644 blob a906cb2a4a904a152e80877d4088654daad0c859      README
100644 blob 8f94139338f9404f26296befa88755fc2598c289      Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0      lib

master^{tree}という構文は、masterブランチ上の最後のコミットが指すツリーオブジェクトを指定します。libサブディレクトリがブロブではなく、別のツリーへのポインタであることに注目してください。

$ git cat-file -p 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0
100644 blob 47c6340d6459e05787f644c2447d2595f5d3a54b      simplegit.rb
注記

使用しているシェルによっては、master^{tree}の構文を使用する際にエラーが発生する場合があります。

WindowsのCMDでは、^文字はエスケープに使用されるため、これを避けるためには^を二重にする必要があります: git cat-file -p master^^{tree}。PowerShellを使用する場合、{}文字を使用するパラメータは、パラメータが誤って解析されるのを避けるために引用符で囲む必要があります: git cat-file -p 'master^{tree}'

ZSHを使用している場合、^文字はグロビングに使用されるため、式全体を引用符で囲む必要があります: git cat-file -p "master^{tree}"

概念的には、Gitが保存しているデータは次のようになります。

Simple version of the Git data model
図 173. Gitデータモデルの簡易版

自分のツリーをかなり簡単に作成できます。Gitは通常、ステージングエリア(またはインデックス)の状態を取り込み、そこから一連のツリーオブジェクトを書き出すことでツリーを作成します。したがって、ツリーオブジェクトを作成するには、まずファイルをステージングしてインデックスを設定する必要があります。test.txtファイルの最初のバージョンである単一のエントリを持つインデックスを作成するには、plumbingコマンドgit update-indexを使用します。このコマンドを使って、test.txtファイルの以前のバージョンを新しいステージングエリアに人工的に追加します。ファイルがまだステージングエリアに存在しないため(まだステージングエリアが設定されていません)、--addオプションを渡す必要があります。また、追加するファイルがディレクトリにはなくデータベースにあるため、--cacheinfoオプションも必要です。次に、モード、SHA-1、およびファイル名を指定します。

$ git update-index --add --cacheinfo 100644 \
  83baae61804e65cc73a7201a7252750c76066a30 test.txt

この場合、モードとして100644を指定していますが、これは通常のファイルを意味します。その他のオプションには、実行可能ファイルを意味する100755と、シンボリックリンクを指定する120000があります。このモードは通常のUNIXモードから取られていますが、はるかに柔軟性がありません。これら3つのモードだけがGit内のファイル(ブロブ)に対して有効です(ただし、ディレクトリやサブモジュールには他のモードが使用されます)。

これで、git write-treeを使ってステージングエリアをツリーオブジェクトとして書き出すことができます。-wオプションは必要ありません。このコマンドを呼び出すと、そのツリーがまだ存在しない場合、インデックスの状態から自動的にツリーオブジェクトが作成されます。

$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30      test.txt

以前に見たのと同じgit cat-fileコマンドを使用して、これがツリーオブジェクトであることを確認することもできます。

$ git cat-file -t d8329fc1cc938780ffdd9f94e0d364e0ea74f579
tree

次に、test.txtの2番目のバージョンと新しいファイルを使って新しいツリーを作成します。

$ echo 'new file' > new.txt
$ git update-index --cacheinfo 100644 \
  1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt
$ git update-index --add new.txt

これで、ステージングエリアにはtest.txtの新しいバージョンと新しいファイルnew.txtが含まれています。そのツリーを書き出し(ステージングエリアまたはインデックスの状態をツリーオブジェクトに記録し)、どのようなものか見てみましょう。

$ git write-tree
0155eb4229851634a0f03eb265b69f5a2d56f341
$ git cat-file -p 0155eb4229851634a0f03eb265b69f5a2d56f341
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

このツリーには両方のファイルエントリがあり、test.txtのSHA-1が以前の「バージョン2」のSHA-1(1f7a7a)であることに注目してください。試しに、最初のツリーをこのツリーのサブディレクトリとして追加してみましょう。git read-treeを呼び出すことで、ツリーをステージングエリアに読み込むことができます。この場合、このコマンドに--prefixオプションを使用することで、既存のツリーをサブツリーとしてステージングエリアに読み込むことができます。

$ git read-tree --prefix=bak d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git write-tree
3c4e9cd789d88d8d89c1073707c3585e41b0e614
$ git cat-file -p 3c4e9cd789d88d8d89c1073707c3585e41b0e614
040000 tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579      bak
100644 blob fa49b077972391ad58037050f2a75f74e3671e92      new.txt
100644 blob 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a      test.txt

もし今書き出した新しいツリーからワーキングディレクトリを作成した場合、ワーキングディレクトリのトップレベルに2つのファイルと、test.txtファイルの最初のバージョンを含むbakという名前のサブディレクトリが得られます。Gitがこれらの構造に対して持つデータは、次のようなものと考えることができます。

The content structure of your current Git data
図 174. 現在のGitデータのコンテンツ構造

コミットオブジェクト

上記すべてを実行した場合、追跡したいプロジェクトの異なるスナップショットを表す3つのツリーができたことになりますが、以前の問題は残っています。スナップショットを呼び出すためには、3つのSHA-1値をすべて覚えておく必要があります。また、誰がいつ、なぜスナップショットを保存したかについての情報もありません。これらはコミットオブジェクトが保存してくれる基本的な情報です。

コミットオブジェクトを作成するには、commit-treeを呼び出し、単一のツリーのSHA-1と、もしあれば直前のコミットオブジェクトを指定します。最初に書き出したツリーから始めます。

$ echo 'First commit' | git commit-tree d8329f
fdf4fc3344e67ab068f836878b6c4951e3b15f3d
注記

作成時間や著者データが異なるため、異なるハッシュ値が得られます。また、原則として任意のコミットオブジェクトはそのデータがあれば正確に再現できますが、本書の作成履歴上の詳細により、表示されているコミットハッシュが指定されたコミットと一致しない場合があります。この章の以降の箇所では、コミットおよびタグのハッシュを自分のチェックサムに置き換えてください。

これで、新しいコミットオブジェクトをgit cat-fileで確認できます。

$ git cat-file -p fdf4fc3
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700

First commit

コミットオブジェクトの形式はシンプルです。その時点でのプロジェクトのスナップショットのためのトップレベルツリーを指定します。もしあれば親コミット(上記のコミットオブジェクトには親はありません)。著者/コミッター情報(user.nameuser.emailの設定とタイムスタンプを使用)。空白行、そしてコミットメッセージが続きます。

次に、他の2つのコミットオブジェクトを書き込みます。それぞれが直前のコミットを参照します。

$ echo 'Second commit' | git commit-tree 0155eb -p fdf4fc3
cac0cab538b970a37ea1e769cbbde608743bc96d
$ echo 'Third commit'  | git commit-tree 3c4e9c -p cac0cab
1a410efbd13591db07496601ebc7a059dd55cfe9

3つのコミットオブジェクトはそれぞれ、作成した3つのスナップショットツリーのいずれかを指しています。不思議なことに、最後のコミットのSHA-1に対してgit logコマンドを実行すると、実際のGitの履歴を見ることができるようになりました。

$ git log --stat 1a410e
commit 1a410efbd13591db07496601ebc7a059dd55cfe9
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:15:24 2009 -0700

	Third commit

 bak/test.txt | 1 +
 1 file changed, 1 insertion(+)

commit cac0cab538b970a37ea1e769cbbde608743bc96d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:14:29 2009 -0700

	Second commit

 new.txt  | 1 +
 test.txt | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

commit fdf4fc3344e67ab068f836878b6c4951e3b15f3d
Author: Scott Chacon <schacon@gmail.com>
Date:   Fri May 22 18:09:34 2009 -0700

    First commit

 test.txt | 1 +
 1 file changed, 1 insertion(+)

素晴らしい。これで、フロントエンドコマンドを一切使用せずにGitの履歴を構築するための低レベル操作を実行しました。これは基本的に、git addgit commitコマンドを実行したときにGitが行うことです。つまり、変更されたファイルに対してブロブを保存し、インデックスを更新し、ツリーを書き出し、トップレベルツリーと直前のコミットを参照するコミットオブジェクトを書き出すのです。これら3つの主要なGitオブジェクト(ブロブ、ツリー、コミット)は、最初は.git/objectsディレクトリに別々のファイルとして保存されます。これが、現在例のディレクトリにあるすべてのオブジェクトであり、それぞれの内容がコメントされています。

$ find .git/objects -type f
.git/objects/01/55eb4229851634a0f03eb265b69f5a2d56f341 # tree 2
.git/objects/1a/410efbd13591db07496601ebc7a059dd55cfe9 # commit 3
.git/objects/1f/7a7a472abf3dd9643fd615f6da379c4acb3e3a # test.txt v2
.git/objects/3c/4e9cd789d88d8d89c1073707c3585e41b0e614 # tree 3
.git/objects/83/baae61804e65cc73a7201a7252750c76066a30 # test.txt v1
.git/objects/ca/c0cab538b970a37ea1e769cbbde608743bc96d # commit 2
.git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 # 'test content'
.git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 # tree 1
.git/objects/fa/49b077972391ad58037050f2a75f74e3671e92 # new.txt
.git/objects/fd/f4fc3344e67ab068f836878b6c4951e3b15f3d # commit 1

すべての内部ポインタをたどると、次のようなオブジェクトグラフが得られます。

All the reachable objects in your Git directory
図 175. Gitディレクトリ内のすべての到達可能なオブジェクト

オブジェクトの保存

前に述べたように、Gitオブジェクトデータベースにコミットするすべてのオブジェクトにはヘッダーが保存されます。Gitがオブジェクトをどのように保存するかを見てみましょう。ここでは、ブロブオブジェクト(この場合、「what is up, doc?」という文字列)をRubyスクリプト言語でインタラクティブに保存する方法を説明します。

irbコマンドで対話型Rubyモードを開始できます。

$ irb
>> content = "what is up, doc?"
=> "what is up, doc?"

Gitはまず、オブジェクトのタイプ(この場合はブロブ)を識別することから始まるヘッダーを構築します。ヘッダーの最初の部分に、Gitはスペースに続いてコンテンツのバイトサイズを追加し、最後にヌルバイトを追加します。

>> header = "blob #{content.bytesize}\0"
=> "blob 16\u0000"

Gitはヘッダーと元のコンテンツを連結し、その新しいコンテンツのSHA-1チェックサムを計算します。Rubyで文字列のSHA-1値を計算するには、requireコマンドでSHA1ダイジェストライブラリを含め、その文字列を使ってDigest::SHA1.hexdigest()を呼び出します。

>> store = header + content
=> "blob 16\u0000what is up, doc?"
>> require 'digest/sha1'
=> true
>> sha1 = Digest::SHA1.hexdigest(store)
=> "bd9dbf5aae1a3862dd1526723246b20206e5fc37"

これをgit hash-objectの出力と比較してみましょう。ここでは、入力に改行が追加されるのを防ぐためにecho -nを使用しています。

$ echo -n "what is up, doc?" | git hash-object --stdin
bd9dbf5aae1a3862dd1526723246b20206e5fc37

Gitは新しいコンテンツをzlibで圧縮します。これはRubyでzlibライブラリを使って行うことができます。まず、ライブラリをrequireし、次にコンテンツに対してZlib::Deflate.deflate()を実行する必要があります。

>> require 'zlib'
=> true
>> zlib_content = Zlib::Deflate.deflate(store)
=> "x\x9CK\xCA\xC9OR04c(\xCFH,Q\xC8,V(-\xD0QH\xC9O\xB6\a\x00_\x1C\a\x9D"

最後に、zlibで圧縮されたコンテンツをディスク上のオブジェクトに書き込みます。書き出したいオブジェクトのパスを決定します(SHA-1値の最初の2文字がサブディレクトリ名になり、残りの38文字がそのディレクトリ内のファイル名になります)。Rubyでは、FileUtils.mkdir_p()関数を使用してサブディレクトリが存在しない場合に作成できます。次に、File.open()でファイルを開き、結果のファイルハンドルに対してwrite()を呼び出して、以前にzlib圧縮されたコンテンツをファイルに書き出します。

>> path = '.git/objects/' + sha1[0,2] + '/' + sha1[2,38]
=> ".git/objects/bd/9dbf5aae1a3862dd1526723246b20206e5fc37"
>> require 'fileutils'
=> true
>> FileUtils.mkdir_p(File.dirname(path))
=> ".git/objects/bd"
>> File.open(path, 'w') { |f| f.write zlib_content }
=> 32

git cat-fileを使用してオブジェクトの内容を確認してみましょう。

---
$ git cat-file -p bd9dbf5aae1a3862dd1526723246b20206e5fc37
what is up, doc?
---

これで、有効なGitブロブオブジェクトを作成できました。

すべてのGitオブジェクトは同じ方法で保存されますが、タイプが異なります。文字列のブロブの代わりに、ヘッダーはコミットまたはツリーで始まります。また、ブロブのコンテンツはほとんど何でも構いませんが、コミットとツリーのコンテンツは非常に特定された形式です。

scroll-to-top