Gitのステージング領域の正体を探る

ソフトウェアエンジニアの @DQNEO です。こんにちは。

Gitの内部構造を深掘りするシリーズ3回目です。

前回までのお話はこちら

今日はみんなだいすき「ステージング領域」の中身について解説してみます。

ステージング領域とは何か?

簡単に説明すると「次にコミットしたときにコンテンツとして登録されるもの」リストです。(別名「インデックス」ともいいます。) このリストは、 git addgit rmしたときに書き換わります。
(古くはcacheと呼ばれていました。内部実装やgit diff --cachedに今もその名残があります。)

git addのマニュアルに説明があります。

Git – git-add Documentation

The “index” holds a snapshot of the content of the working tree, and it is this snapshot that is taken as the contents of the next commit.

(訳) 「インデックス」は、ワーキングツリーの内容のスナップショットを保持する。次回コミットしたときにコンテンツとして保存されるのがこのスナップショットである。

スナップショットとはもともと写真用語で「ある瞬間を切り取ったもの」という意味です。
コミット行為を「写真を撮る」行為にたとえると、ステージングするとは「物を撮影台(stage)の上に載せる」ようなイメージです。

差分ではなくスナップショット

「スナップショット」という言葉に違和感を覚えた人がいるかもしれません。
git diff --cachedgit add -p などステージングを扱うコマンドは差分を見せるものが多いので、「ステージング=差分」という感覚を持ってしまうのはある意味当然ではあります。
ですがステージング領域に登録されているのは実は差分ではなく、(差分適用後の)コンテンツそのものです。

ステージング領域をのぞいてみよう

では実際に、git add するとどこにどういう形で保存されるのかを見てみましょう。

例として弊社のOSSである Dietcube というレポジトリをとりあげます。(レポジトリは何でもよいです)

$ git clone https://github.com/mercari/dietcube /tmp/dietcube
$ cd /tmp/dietcube
$ git tag
1.0.0
1.0.1
1.0.2
1.0.3
$ git checkout 1.0.0

ここで、README.md ファイルに蛇足のようなHello Worldをつけたして、git add してみましょう。

$ echo '蛇足のようなHello World' >> README.md
$ git add README.md

差分を確認します。

$ git diff --cached
diff --git a/README.md b/README.md
index c5162f0..99040e7 100644
--- a/README.md
+++ b/README.md
@@ -42,3 +42,4 @@ Authors
* @YuiSakamoto
* @kajiken
* @DQNEO
+蛇足のようなHello World

さて、この1行追記された README.md はどこにどのように格納されているでしょうか?

実は

  • .git/index (インデックス)
  • .git/objects/ (レポジトリのデータベース)

という2種類の場所に書き込みが行われます。

.git/index を見てみましょう。

$ cat .git/index
DIRC/Xې:Xې:�����y[�����(��Y��M�+!a

おっとバイナリファイルでした。hexdumpしてみます。

$ hexdump -C .git/index | head -n 25
00000000  44 49 52 43 00 00 00 02  00 00 00 2f 58 db b5 09  |DIRC......./X...|
00000010  00 00 00 00 58 db b5 09  00 00 00 00 01 00 00 04  |....X...........|
00000020  01 fc b1 14 00 00 81 a4  00 00 01 f5 00 00 00 00  |................|
00000030  00 00 00 79 5b aa 86 d3  f2 b4 e6 28 b9 19 8f 0c  |...y[......(....|
00000040  59 b0 a2 4d a5 2b 21 61  00 0a 2e 67 69 74 69 67  |Y..M.+!a...gitig|
00000050  6e 6f 72 65 00 00 00 00  00 00 00 00 58 db b5 09  |nore........X...|
00000060  00 00 00 00 58 db b5 09  00 00 00 00 01 00 00 04  |....X...........|
00000070  01 fc b1 15 00 00 81 a4  00 00 01 f5 00 00 00 00  |................|
00000080  00 00 00 de 4e b8 68 35  fe 5c 93 24 77 6d 26 ca  |....N.h5.\.$wm&.|
00000090  e1 08 4c 0c aa b9 fe e7  00 07 2e 70 68 70 5f 63  |..L........php_c|
000000a0  73 00 00 00 58 db b5 15  00 00 00 00 58 db b5 15  |s...X.......X...|
000000b0  00 00 00 00 01 00 00 04  01 fc b1 5b 00 00 81 a4  |...........[....|
000000c0  00 00 01 f5 00 00 00 00  00 00 00 e2 33 c0 f6 1b  |............3...|
000000d0  5a 46 b1 8e 37 c2 b5 24  22 93 bb 2a dc d1 d1 47  |ZF..7..$"..*...G|
000000e0  00 0b 2e 74 72 61 76 69  73 2e 79 6d 6c 00 00 00  |...travis.yml...|
000000f0  00 00 00 00 58 db b5 09  00 00 00 00 58 db b5 09  |....X.......X...|
00000100  00 00 00 00 01 00 00 04  01 fc b1 18 00 00 81 a4  |................|
00000110  00 00 01 f5 00 00 00 00  00 00 04 32 d0 bf fb f4  |...........2....|
00000120  b4 a4 d1 f0 23 5b 58 d3  6b 1a 38 07 c9 c0 9d 7f  |....#[X.k.8.....|
00000130  00 07 4c 49 43 45 4e 53  45 00 00 00 58 db b5 1c  |..LICENSE...X...|
00000140  00 00 00 00 58 db b5 1c  00 00 00 00 01 00 00 04  |....X...........|
00000150  01 fc b1 5c 00 00 81 a4  00 00 01 f5 00 00 00 00  |...\............|
00000160  00 00 02 62 99 04 0e 76  09 95 c5 4a 51 87 f4 13  |...b...v...JQ...|
00000170  8d d1 46 78 81 bf 87 32  00 09 52 45 41 44 4d 45  |..Fx...2..README|
00000180  2e 6d 64 00 58 db b5 15  00 00 00 00 58 db b5 15  |.md.X.......X...|

人間に読めないバイナリ列の中にところどころファイル名のようなものが見えます。
24行目あたりに README.md というのが見えるでしょうか。これはバイナリでいうと 52 45 41 44 4d 45 2e 6d 64 で、左のバイナリ表示部に確かにそれがあります。

読めない部分にはいったい何の情報が書かれているのでしょうか?
ここは考えてもわからないので公式ドキュメントを漁ってみましょう。

git/index-format.txt at v2.12.0 · git/git · GitHub

ここにindexファイルのバイナリ仕様が書かれています。

簡単に説明すると、まず大きく「ヘッダ部」と「ボディ部」に別れます。

「ヘッダ部」は固定文字列”DIRC”、インデックスのバージョン番号、インデックス内のエントリの数が格納されています。

$ hexdump -C .git/index | head -n 1
00000000  44 49 52 43 00 00 00 02  00 00 00 2f 58 db b5 09  |DIRC......./X...|

hexdumpの結果の1行目を見ると、たしかに DIRC (44 49 52 43)で始まっています。
次の00 00 00 02 はこのindexファイルのフォーマットがバージョン2であることを示しています。(バージョンには2,3,4の3種類あります。今回は2に限定して解説します。)

「ボディ部」はエントリのリストで成り立っています。エントリというのはインデックスにあるファイルの情報のことで、具体的にはstat(2)の結果、SHA-1(後述)、パス名などを格納しています。

先程のhexdumpの出力の20-25行目あたりをよく見てみましょう。

00000130  00 07 4c 49 43 45 4e 53  45 00 00 00 58 db b5 1c  |..LICENSE...X...|
00000140  00 00 00 00 58 db b5 1c  00 00 00 00 01 00 00 04  |....X...........|
00000150  01 fc b1 5c 00 00 81 a4  00 00 01 f5 00 00 00 00  |...\............|
00000160  00 00 02 62 99 04 0e 76  09 95 c5 4a 51 87 f4 13  |...b...v...JQ...|
00000170  8d d1 46 78 81 bf 87 32  00 09 52 45 41 44 4d 45  |..Fx...2..README|
00000180  2e 6d 64 00 58 db b5 15  00 00 00 00 58 db b5 15  |.md.X.......X...|

LICENSEREADME.mdが、エントリの名前(パス名)です。 LICENSE(4c 49 43 53 45 4e 53 45)の直後にゼロパディング(00 00 00)があります。その後ろからREAMDE.mdまでが 「README.mdのエントリ」になります。

エントリ部をとりだして見やすく改行してみます。

58 db b5 1c  - ctime sec (2017/03/29 22:22:36)
00 00 00 00  - ctime nano sec
58 db b5 1c  - mtime sec (2017/03/29 22:22:36)
00 00 00 00  - mtime nano sec
01 00 00 04  - dev
01 fc b1 5c  - inode
00 00 81 a4  - mode
00 00 01 f5  - uid
00 00 00 00  - gid
00 00 02 62  - size (=610 bytes)
99 04 0e 76  09 95 c5 4a 51 87 f4 13 8d d1 46 78 81 bf 87 32  - SHA-1
00 09 - flag
52 45 41 44 4d 45 2e 6d 64 - path name (README.md)

ここに出てくるSHA-1とは何でしょうか?
実はこれは、エントリに割り振られた固有のIDです。ここではエントリの実体はファイルなので、これはBlob ObjectのIDということになります。(Blob Objectについては以前書いた下記記事をご参照ください。)

tech.mercari.com

レポジトリデータベースである.git/objectの中をのぞくと、このSHA-1と同じ名前のファイルが実在することを確認できます。
これが、先ほどの git add で保存されたコンテンツの中身です。

$ ls .git/objects/99/ -l
total 4
-r--r--r-- 1 DQNEO wheel 393  3 29 22:22 040e760995c54a5187f4138dd1467881bf8732

ディレクトリ名 99と ファイル名040e760.... をくっつけると 99040e760995c54a5187f4138dd1467881bf8732 という40文字の文字列になります。
先程のステージング情報(.git/index)に書かれていたSHA-1と同じであることがわかります。

このBlob Objectの中身を表示してみましょう。git cat-file -p を使います。

$  git cat-file -p 99040e760995c54a5187f4138dd1467881bf8732
Dietcube
=========
Dietcube is the world super fly weight & flexible PHP framework.
[中略]
* @sotarok
* @YuiSakamoto
* @kajiken
* @DQNEO
蛇足のようなHello World

README.mdファイルの全体が表示されました。

とまあこんな感じで、git addすると差分ではなくてファイルが丸ごとレポジトリに格納されていることがわかりました。

.git/indexのパーサを書いてみよう

先程は .git/index バイナリをhexdumpと気合で解読しましたが、もうちょっと賢い方法はないものでしょうか。
もちろんあります。git ls-files --stageというコマンドを使うと .git/indexを解析して人の目に優しい形で表示してくれます。

が、それではつまらないのでプログラマたるもの自力でパーサを書いてみましょう。

Gitの実装を読んでみると、 indexファイルの解析処理はread-cache.cというファイルのdo_read_index()関数で行われています。

https://github.com/git/git/blob/v2.12.0/read-cache.c#L1568-L1629

一見複雑ですが、やってることの本質はシンプルで、

  • ファイルをopen(2)
  • mmap(2)でメモリ上に読み込み
  • バイナリデータのヘッダー部分を解析
  • ボディ部分(格納されているファイルエントリのリスト)をエントリ単位でループしつつ解析

という感じです。
C言語で130行ほど書けば自作パーサが作れてしまいます。
ソースコードをgistに置いておきました。

git index parser by C · GitHub

(低レイヤのビット演算処理はGit実装からコピーしてきたので全部自作ではないですが…汗)

これを使ってパースしてみましょう。

$ gcc -g -Wall -O0 -std=c99 -lz -o parse_git_index parse_git_index.c
$ ./parse_git_index .git/index
100644 5baa86d3f2b4e628b9198f0c59b0a24da52b2161 0       .gitignore
100644 4eb86835fe5c9324776d26cae1084c0caab9fee7 0       .php_cs
100644 33c0f61b5a46b18e37c2b5242293bb2adcd1d147 0       .travis.yml
100644 d0bffbf4b4a4d1f0235b58d36b1a3807c9c09d7f 0       LICENSE
100644 99040e760995c54a5187f4138dd1467881bf8732 0       README.md
100644 f552a39a97905cf34932bcf9462b57b4451a161f 0       composer.json
[中略]
100644 8d3ea9040dccf7bedd7e4a722e22465a0968df00 0       tests/RouterTest.php
100644 ebf2d4f1b31939a2a0142a9a7df40430e13b8a6e 0       tests/bootstrap.php

と、自作パーサでインデックスを解析してきれいに表示することができました。

いちおう自作パーサとgit ls-files --stageの出力が同じであることを確認しておきます。

$ diff <(./parse_git_index .git/index) <(git ls-files --stage)
$ echo $?
0

同じでした!

まとめ

  • git add した時点でコンテンツがレポジトリに格納されることを確認しました。
  • ステージング領域を格納している.git/indexファイルのバイナリの読み方を解説しました。
  • C言語でパーサを書いてみました。

Gitの中身を深掘りすると、C言語やバイナリ解析のよい練習になると思います。
みなさんも興味があったらぜひやってみてください!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加