git notes をボトムアップから理解する

ruby-trunk-changesをgitから参照する - 継続にっき(2011-12-12) という記事を読んで git でコミットに note というコメントを付加できることを知りました。k-tsj さんありがとうございます。 1.7.1 あたりで追加された機能らしいのですが GitHub のコミットのページでも notes が表示されるようです*1 ([追記]その後この git notes を表示する機能は GitHub から削除されてしまいました。残念)
これは便利ですね。後から参照するぶんには ruby trunk changes 自体この形式で公開したほうがいいんじゃないかと思うくらいです。また最近 Heroku で ruby trunk pages のメモをしているツールのデータを Heroku の PostgreSQL じゃなくてどこか外部に置けないかなと思っていたので、GitHub の notes を一次データ置き場として使えないか検討しはじめました。

Git の内部モデル復習

まず Git リポジトリのデータ構造を理解するために 見えないチカラ: 【翻訳】Gitをボトムアップから理解する というのを読みます。とりあえず 3. の「コミットの美」というところまで読んでおけばだいたい足りると思います。これを読むと、Git のリポジトリには「commit」「tree」「blob」という3種類のオブジェクトと refs(参照)という概念があるということがわかります。

notes の構造を理解する

では notes はどのように表現されているのでしょうか。まずは git notes サブコマンドを使って notes を作ってその様子を観察してみます。上記の記事の例と同じような1つの commit だけを持つサンプルリポジトリを作ってみます。

$ mkdir sample
$ cd sample
$ git init
$ echo 'Hello, world!' > greeting
$ git add greeting
$ git commit -m "Added my greeting"
[master (root-commit) 34e9bf0] Added my greeting
 1 files changed, 1 insertions(+), 0 deletions(-)
 create mode 100644 greeting

そして git notes edit でコメントを付加してみます。

$ git notes add -m "Note for greeting" HEAD
$ git notes show HEAD
Note for greeting

できました。 git notes を引数なしで実行すると、 notes と commit の対応が表示されます。

$ git notes
7382ebfbc20057b1548bf4939a0108df5fe1cf9a 34e9bf07ac30d0efae8ab4f9d2b32f0376a7796a

右側の 34e9bf0... は先程 greeting を追加した最初の commit のハッシュIDです。左の 7382eb... はなんでしょうか。cat-file サブコマンドを使ってみてみます。

$ git cat-file -t 7382ebfbc20057b1548bf4939a0108df5fe1cf9a
blob
$ git cat-file -p 7382ebfbc20057b1548bf4939a0108df5fe1cf9a
Note for greeting

object の type は blob で、内容は追加した notes のものでした。ふむふむ。しかしここで2つ疑問が生じます。

  • どのように commit と notes の blob を対応付けているの?
  • この blob はどこに所属しているの? tree から参照されているはずでその tree もどこかから参照されてないと GC されてしまうはず

まず参照の問題ですが、git notes のドキュメントを読むと、デフォルトの notes は refs/notes/commits という参照名を使うそうです(設定や git notes のオプションで変更可能)。これはつまり .git/refs/notes/commits というファイルにハッシュIDが保存されているということです。この object を cat-file で内容を確認してみます。

$ git cat-file -t `cat .git/refs/notes/commits`
commit
$ git cat-file -p `cat .git/refs/notes/commits`
tree 8ab2a4e8ee1cedd07ff8b7442e4afc090fa39d45
author nagachika <nagachika@example.com> 1323960038 +0900
committer nagachika <nagachika@example.com> 1323960038 +0900

Notes added by 'git notes add'
$ git ls-tree 8ab2a4e8ee1cedd07ff8b7442e4afc090fa39d45
100644 blob 7382ebfbc20057b1548bf4939a0108df5fe1cf9a    34e9bf07ac30d0efae8ab4f9d2b32f0376a7796a

.git/refs/notes/commits に格納されているハッシュIDは commit オブジェクトのもので、tree のハッシュIDがとれました。これは最初の notes なので parent はいません。またこの tree を ls-tree サブコマンドで観察したところ、 blob を1つだけ持っているようです。ちょっとまぎらわしいですが 7382eb... のほうが blob のハッシュID で 34e9bf0... は tree 内での blob の path です。つまりファイル名に相当するものです。
ここで改めて git notes で表示される notes の blob と対象の commit の関連を見てみます。

$ git notes
7382ebfbc20057b1548bf4939a0108df5fe1cf9a 34e9bf07ac30d0efae8ab4f9d2b32f0376a7796a

同じですね! これで先の2つの疑問のこたえがわかりそうです。

  • notes の blob は refs/notes/commits (デフォルトの場合。別の参照もありえる)から commit -> tree -> blob と参照されている
  • tree 内での blob のパス名が対象の commit のハッシュIDを元にしたものになっていることで対応付けされている*2

つまり notes を持つ git repository には、通常の master などのブランチで管理されているディレクトリツリーとは全く別の、隠れたディレクトリツリーが存在していて、そのファイルパスが notes の対象のコミットを指定し、ファイルの内容が notes の内容を保持しているのですね。

notes をボトムアップに作る

ここまでわかったら、commit と同じように git notes サブコマンドを使わずに直接 notes を追加することもできます。先程のサンプルにもうひとつ commit を作って、それに notes をつけてみます。

$ echo 'Hello, world!' > greeting
$ git commit -a -m "fix a typo"
[master b15a80c] fix a typo
 1 files changed, 1 insertions(+), 1 deletions(-)
$ cat .git/refs/heads/master 
b15a80c39eb992193c69dcd6ef5aedfd0b85e3ef

ちょうど typo していたので修正する commit をしました。新しい commit のハッシュIDは b15a80c39eb992193c69dcd6ef5aedfd0b85e3ef です。つまりこの commit に notes をつけるには

  • notes の内容の blob を作る
  • tree を作って b15a80c39eb992193c69dcd6ef5aedfd0b85e3ef という path でその blob を格納する
  • その tree を持つ commit を作る
  • refs/notes/commits が新しい commit を参照するようにする

という手順を踏めばいいのです。git のサブコマンドを駆使してやってみます。

# hash-object -w で blob を作成。ハッシュIDを取得
$ echo "2nd Note for fix typo" | git hash-object -w --stdin
70595b039078803068ee2a088021c4f90745e483
# 現在の notes の tree を確認しておきます
$ git ls-tree `cat .git/refs/notes/commits`
100644 blob 7382ebfbc20057b1548bf4939a0108df5fe1cf9a    34e9bf07ac30d0efae8ab4f9d2b32f0376a7796a
# mktree に ls-tree と同じフォーマットを渡すと tree が作れます
# この時 blob のハッシュ ID と path の間はタブで区切らないといけないので注意してください
$ git mktree
100644 blob 7382ebfbc20057b1548bf4939a0108df5fe1cf9a    34e9bf07ac30d0efae8ab4f9d2b32f0376a7796a
100644 blob 70595b039078803068ee2a088021c4f90745e483    b15a80c39eb992193c69dcd6ef5aedfd0b85e3ef
# <- Ctrl-D で EOF を渡します
482c0884d0b22a4a02d011766c0f8ed9b9159b57    # ← 作成した tree のハッシュIDを取得
# tree と親 commit を指定して新しい commit を作成
$ echo "create notes by hand" | git commit-tree 482c0884d0b22a4a02d011766c0f8ed9b9159b57 -p `cat .git/refs/notes/commits`
a2ee27a63a6dec4a808a9972ec106f90cfbbcf7d    
# .git/refs/notes/commits が新しい commit を指すように更新
$ git update-ref refs/notes/commits a2ee27a63a6dec4a808a9972ec106f90cfbbcf7d

さてこれで notes が反映されているはずです。git log を見てみましょう。

commit b15a80c39eb992193c69dcd6ef5aedfd0b85e3ef
Author: nagachika <nagachika@example.com>
Date:   Fri Dec 16 00:13:20 2011 +0900

    fix a typo

Notes:
    2nd Note for fix typo

commit 34e9bf07ac30d0efae8ab4f9d2b32f0376a7796a
Author: nagachika <nagachika@example.com>
Date:   Thu Dec 15 23:40:18 2011 +0900

    Added my greeting

Notes:
    Note for greeting

できました!

notes の tree の本当の構造

これで notes についてはばっちり理解した、もう何もこわくない。そう思っていた時期がわたしにもありました。

\         /_ /     ヽ /   } レ,'           / ̄ ̄ ̄ ̄\
  |`l`ヽ    /ヽ/ <´`ヽ u  ∨ u  i レ'          /
  └l> ̄    !i´-)     |\ `、 ヽ), />/        /  地  ほ  こ
   !´ヽ、   ヽ ( _ U   !、 ヽ。ヽ/,レ,。7´/-┬―┬―┬./  獄  ん  れ
  _|_/;:;:;7ヽ-ヽ、 '')  ""'''`` ‐'"='-'" /    !   !   /   だ.  と  か
   |  |;:;:;:{  U u ̄|| u u  ,..、_ -> /`i   !   !  \   :.  う  ら
   |  |;:;:;:;i\    iヽ、   i {++-`7, /|  i   !   !  <_      の  が
  __i ヽ;:;:;ヽ `、  i   ヽ、  ̄ ̄/ =、_i_  !   !   /
   ヽ ヽ;:;:;:\ `ヽ、i   /,ゝ_/|  i   ̄ヽヽ !  ! ,, -'\
    ヽ、\;:;:;:;:`ー、`ー'´ ̄/;:;ノ  ノ      ヽ| / ,、-''´ \/ ̄ ̄ ̄ ̄
                 ̄ ̄ ̄            Y´/;:;:;\ 

実は notes 用の tree は blob が増えてくると、path の先頭の 2文字ぶん毎に sub tree を作ってネストしていきます。つまり 012346789abcdef... という commit のハッシュIDへの notes を表現する path は "0123456789abcdef...", "01/23456789abcdef...", "01/23/456789abcdef...", "01/23/45/6789abcdef..." …… といった複数の可能性があります。 git notes がこの tree 構造をどう作るかというと、 git は内部的には notes のツリーを 16分木の構造体を使って管理していて、16分木の全ての子ノードが leaf(blobまたはnull)でなくなった時に sub tree を作るようにしています。そしてこの木構造は balanced ではないので、各 tree にいくつの blob が格納された時に sub tree が作られるかは場合に依り、git の動作を完全にエミュレートしようとするととても面倒です。
幸いなことにこの sub tree 化は git notes の動作と全く同じでなくても、冗長になっていなければ*3表示には問題ないようです。また git notes add や append で notes を追加すると、一旦読み込まれた tree が git の実装に依存した sub tree 化で作り直された commit が作られるようです。多分 tree 内に blob が 64 個を越えたら sub-tree 化するというような処理にしておけばだいたい問題ないのではないかと思います*4

ここまできて GitHub API のドキュメント を読むと、「読める、読めるぞっ」とムスカ大佐の気分を味わえます。次は github_api.gem を使って GitHub 上の repository に直接 notes を作ったり更新したりする方法を調査してみたいと思います。

*1:https://github.com/blog/707-git-notes-display

*2:厳密には後述するように単にハッシュIDをファイル名にするだけではありません

*3:つまり既に sub tree が作られている階層にフラットに、しかも同じ commit を表現する blob が下の階層に存在するような blob を置いてしまうと、どちらが参照されるかが順序に依存してしまうようです

*4:実際にやってみるともうちょっと多く 70-100 くらいのところで sub tree 化されることが多いみたいです