Perl から git オブジェクトを覗く

git のオブジェクト・データベースはファイル・ベースのハッシュ・キー・バリュー・ストアになっています。2形式あり、キーをファイル名にしてエントリごとファイルに格納する単純なものと、複数のエントリをファイルにパックしたものとがあります。git の最近のコミットは単純形式で格納されているため、まず、単純形式を読み取ってみます。

http://git-scm.com/book/ja/Gitの内側-Gitオブジェクト

さて、Perl からは CPAN モジュールの Git-PurePerl-0.48 を使って読み書きできますけど、このモジュールは Moose ウェアなので動かせる環境を選ぶのが難点です。少なくとも、共用レンタルサーバCGI や、Arm ベースの低クロック少メモリ・サーバで動かすには向いていません。ざっと読んで受けた印象では、Moose 使わずに、Class-Accessor-Fast 使って書き直せそうですが、エッセンスをコピペしながら別途軽量モジュールを書いた方が楽かなという気もします。ということで、Git-PurePerl からコード断片をコピペしながら、git データベースを覗いてみることにします。

まず、空のリポジトリを作成します。

$ git init

リポジトリには git の 4 種類のオブジェクトを格納することができます。git のオブジェクトのタイプは、blob、tree、commit、tag の 4 種類で、blob がファイル、tree がディレクトリに対応しています。commit と tag はそのものずばり。これら4種類は、同じ手順でリポジトリへ格納したり、読み出したりできます。オブジェクトのキーがパス名になり、値を zlib で圧縮したものがファイルに入ります。キーは圧縮前の値の SHA1 ダイジェストでパス名等の文字列表現で表すときは 16 進数列にします。commit と tag の内容ではキーは文字列表現になりますが、tree ではキーは 20 バイトのバイナリになっています。圧縮前の値は、タイプ名、空白、バイト数、ヌル文字、バイナリ内容の順に連結したものです。

#!/usr/bin/env perl
use warnings;
use strict;
use Encode;
use Path::Class;
use Compress::Zlib qw(compress uncompress);
use Digest::SHA1 qw(sha1_hex);

my $blob = encode_utf8("test content\n");  #<-- FIX \n 抜け
my $length = length $blob;
my $content = "blob $length\0" . $blob;
my $sha1 = sha1_hex($content);
my $dirname = substr $sha1, 0, 2;
my $basename = substr $sha1, 2;
my $dir = dir('.git', 'objects', $dirname);
-d $dir or mkdir $dir;
my $compressed = compress($content);
$dir->file($basename)->spew(iomode => '>:raw', $compressed);
print $sha1, "\n";

これを put.pl に保存して、動かしてみます。git から読み出すと、保存した内容をとりだすことができ、オブジェクトの追加が正常にできていることがわかります。

$ perl put.pl 
d670460b4b4aece5915caf5c68d12f560a9fe3e4
$ git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

今度は Perl から取り出しましょう。最初にワンライナで内容を展開し、od(1) コマンドでダンプしてみます。

$ perl -MCompress::Zlib -e 'local $/; print uncompress(<>)' < .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 | od -Ax -tcx1
000000   b   l   o   b       1   3  \0   t   e   s   t       c   o   n
        62  6c  6f  62  20  31  33  00  74  65  73  74  20  63  6f  6e
000010   t   e   n   t  \n
        74  65  6e  74  0a
000015

この展開されたものから、上とは逆に git オブジェクトのタイプ、長さ、バイナリに分離し、バイナリを標準出力へ表示します。

#!/usr/bin/env perl
use warnings;
use strict;
use English qw(-no_match_vars);
use Path::Class;
use Compress::Zlib qw(compress uncompress);

my $sha1 = shift @ARGV;
my $dirname = substr $sha1, 0, 2;
my $basename = substr $sha1, 2;
my $compressed = file('.git', 'objects', $dirname, $basename)->slurp(iomode => '<:raw');
my $content = uncompress($compressed);
my($type, $length) = $content =~ m/\A(\w+)[ ](\d+)\0/msx;
my $blob = substr $content, $LAST_MATCH_END[0];
$length == length $blob or die 'Length mismatch.';
print $blob;

これを get.pl に保存し、上のハッシュ文字列を与えて動かしてみます。

$ perl get.pl d670460b4b4aece5915caf5c68d12f560a9fe3e4
test content

git の cat-file と同じ内容を取り出せます。これで、tree 以外は git の cat-file と同じ出力が得られるようになりました。

tree 形式はバイナリ・フォーマットなので、取得した $blob をそのまま出力しても表示が崩れます。どのような内容になっているのか見るために、最初のリンク先の手順にしたがって、git で tree を作成します。

$ echo 'version 1' > test.txt
$ git hash-object -w test.txt
83baae61804e65cc73a7201a7252750c76066a30
$ git update-index --add --cacheinfo 100644 83baae61804e65cc73a7201a7252750c76066a30 test.txt
$ git write-tree
d8329fc1cc938780ffdd9f94e0d364e0ea74f579
$ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30    test.txt
$ perl -MCompress::Zlib -e 'local $/; print uncompress(<>)' < .git/objects/d8/329fc1cc938780ffdd9f94e0d364e0ea74f579 | od -Ax -tcx1
000000   t   r   e   e       3   6  \0   1   0   0   6   4   4       t
        74  72  65  65  20  33  36  00  31  30  30  36  34  34  20  74
000010   e   s   t   .   t   x   t  \0 203 272 256   a 200   N   e 314
        65  73  74  2e  74  78  74  00  83  ba  ae  61  80  4e  65  cc
000020   s 247     032   r   R   u  \f   v 006   j   0
        73  a7  20  1a  72  52  75  0c  76  06  6a  30
00002c

git の cat-file の出力と展開したバイナリを見比べてみると、バイナリのエントリは、モードの8進数表記、ファイル名、ヌル、SHA1 のバイナリ形式 20 バイトを連結してあることがわかります。

そこで、tree の場合だけ、表示を特別扱いするようにしてみます。put.pl の最後の行の print を書き換えます。ファイル形式にはシンボリック・リンクもありますが、ここでは無視しています。

if ($type ne 'tree') {
    print $blob;
    exit;
}

while ($blob =~ m/\G([0-9]+)[ ]([^\0]+)\0(.{20})/gcmsx) {
    my($mode, $name, $sha1) = ((oct $1), $2, (unpack 'H*', $3));
    my $entrytype = $mode & oct('0400000') ? 'tree' : 'blob';
    printf "%06o %s %s\t%s\n", $mode, $entrytype, $sha1, $name;
}

tree に対する git の出力と perl の出力とを比べてみます。

$ perl get.pl  d8329fc1cc938780ffdd9f94e0d364e0ea74f579
100644 blob 83baae61804e65cc73a7201a7252750c76066a30    test.txt

tree の記述内容の読み取りもできるようになりました。

ところで、tree は一階層のディレクトリに相当し、ディレクトリ・エントリに相当するものが順に並んでいます。サブディレクトリのエントリには別の tree へのハッシュ値が入ります。ディレクトリとは異なり、親 tree と自 tree へのハッシュは格納されていません。

ハッシュを参照として含む点は commit や tag も同じです。こうしたハッシュによる git オブジェクト間の参照関係によって、有向グラフを形成します。コミットの履歴とディレクトリ構造を扱うには、上の手順でオブジェクトをリポジトリから読み取っていって、データ構造を作れば良いのですが、気が向いたら書いてみます。