Kernel (中略) 話 - mirrorrepo-git とかいうツールをわざわざ作った理由

本エントリは、カーネル/VM Advent Calendar 2012 : ATND のために寄稿されました。

序章: git ヤバイ

突然ですが、git って便利ですよね。分散バージョン管理って素敵です。git を使う Linux カーネルも、Linux カーネルを使う Android も素敵です。
ですが似たような git リポジトリを幾つもクローンするのは非効率的だとは思いませんか? 例えば Linux カーネルのツリーは git clone すると約 700MB もの量になります。これに "カスタマイズ版 Linux カーネル" が少なくとも数十個あることを考えると、とても面倒なことになります。しかし… git は、重複するオブジェクト、重複する歴史を重複して格納することはありません。つまり git 自身は、オブジェクトや歴史の重複排除の機能を持っているのです。普通の方なら、git remote と fetch の設定変更を使うことで、なんとかなるはずです。

なんとかならない話

しかし、なんとかならないときもあります。私が数十個の Android / Linux カーネルリポジトリをまとめて remote でアーカイブ、管理していたとき、幾つもの問題に遭遇しました。例えば、次のような具合です。

  1. 削除されたブランチ、タグを追跡しきれない
  2. タグやブランチが移動したりすると、アーカイブしてあるタグや参照が消えるおそれがある
  3. fetch すると、何故か 1 コミット巻き戻っている

1 つめと 2 つめの話は、どちらも git fetch の仕様に関わるものです。特に 2 つめの、fast-forward のできない参照の付け替えが成された後で gc によってコミットが削除されてしまうのは、(リポジトリをただ使うのではなく) リポジトリデータ全体のアーカイブを行いたい私としては困ります*1。3 つめの話は、…マジです。とある SoC メーカーの Android カーネルリポジトリを fetch していたら、あるとき突然 1 コミットだけ巻き戻ってしまい大慌てしてしまいました。
というわけで、自分としてはこんなツールが欲しい。

  1. 複数のリポジトリをまとめる (重複排除)
  2. すべてのコミットやオブジェクトなどをそのまま保存 (正確なアーカイブ)
  3. git の alternates (特定リポジトリのオブジェクトを借りることで、重複するオブジェクトを排除する機能) に使える信頼性のあるオブジェクトストアの提供 (アーカイブを使うことによる HDD 使用量の削減)
  4. 参照の歴史を管理することで、特定の日のリポジトリを再現する (アーカイブに基づく正確なエクスポート)

というわけで、独自ツールを作ることになっちゃったわけです。mirrorrepo-git*2

mirrorrepo-git を実装する

GitPython ですべてを自動化

私には、好きな言語があります。シェルスクリプトです。ワンライナーで書いた複雑なコマンドがシェル上で実行されるのは大好きですし、ちょっとした自動化をシェルスクリプトで行うのも大好きです。しかし、ここにはひとつだけ問題があります。プログラムが複雑になると (例えば配列のようなものが絡んでくると)、シェルスクリプトの記述は容易に非効率的に、そしてわかりづらくなるのです。
そこで私が頼ることにしたのは、スクリプト的な言語の中でもシェルスクリプトの次に好きな、Python です。ライブラリが色々整っていますし、まぁ頑張れば色々できると目論んでのことです。私が使ったのは Index of Packages : Python Package Index という git を Python から自動で弄るライブラリです。面倒なことの多くをやってくれるのですが、実際には問題も発生したりしたので、ハックで回避しています。

mirrorrepo-git の構造

mirrorrepo-git は、次のような構造をした特別な git bare レポジトリを用います。メタデータ用とデータ格納用の 2 つのリポジトリがあるのが特徴です。重要なところから見て行きましょう。

+ repository.git
  + mirrorrepo.metadata
  | fetch したリポジトリのメタデータ (refs) を格納
  + objects
  | 共有オブジェクトが配置される (git)
  + refs
  | 参照
    + tags
    | コミット削除を防ぐためのタグを自動で追加
    + mirrorrepo
    | 一時的にミラーを行う場所

基本は git remote

私が git の共有リポジトリを作るのに重宝した機能は git remote (複数のリモートサイトを管理する) ですが、mirror-repo も例に漏れずこの機能を活用しています。具体的には、複数の git リポジトリに対して別々のリモート名を与え、そして、ミラーのために特別な場所に fetch する設定を保存しておきます*3

repo.create_remote(name, url).config_writer.
    set('fetch', '+*:refs/mirrorrepo/{}/*'.format(name))
repo.remote(name).config_writer.set('tagopt', '--no-tags')

fetch してから保存

mirrorrepo-git は、次の順番で参照を保存します。

  • refs/mirrorrepo/(ミラー名) で始まる参照をすべて削除
  • refs/mirrorrepo/(ミラー名) のディレクトリ全体を削除
    • 例えば a/b を fetch した後で a を fetch しようとするとエラーになるため。
  • git fetch を実行
  • refs/mirrorrepo/(ミラー名) で始まる参照を保存
  • refs/mirrorrepo/(ミラー名) で始まる参照をタグの形で保存する
    • GC による不用意な削除を防止

fetch した参照は一時的に用いるだけです。これによって、複数回 fetch するときに出がちな問題を回避することができます。

が…ハックは必要

実は、GitPython をこのままで fetch に用いると、fetch 後に例外で終了してしまいます。これは、fetch の特別な設定 ("+*:" の部分) が GitPython の実装 (戻り値を返す部分) と相性が悪いのです。
原因は HEAD です。mirrorrepo-git では色々な問題を回避するために HEAD や notes といった (通常は fetch の対象にならない) 参照を保存するのですが、この中でも HEAD は、branch でも tag でもない特別な参照として扱われます。ですが GitPython はこのような参照を扱えず、例外が出てしまうのです。とはいえこの時点で fetch は済んでいるので…
というわけで、簡単なハックをすることにしました。この種類の例外は特別なメッセージを持っているので、単純に…例外を握りつぶします。戻り値は使いません。

try:
    repo.remote(name).fetch()
except Exception as e:
    # hack to ignore FETCH__HEAD errors (due to special handling)
    if e.message.startswith('Failed to parse FETCH__HEAD line: '):
        break

実はこの辺で時間切れになってしまい、まだ実装が完成していません。データを取得するまではできているので、それを共有リポジトリとしてエクスポートするというのが最大の目標になります。

これで、幾つリポジトリがあっても大丈夫…なはず!

自分にとっての必要性からこういうものを作ることになりましたが、git のハックというのは面白いですよ。また結果等を追記します。

*1:たとえ gc を無効にしたとしても、どこからも参照されない宙に浮いた参照というのはアーカイブを使う上で望ましくありません。

*2:git という名前を後ろにつけたのは、リポジトリクローンツールの Subversion 版を mirrorrepo-svn として作っているから。開発中のこちらには重複排除機能はないが、基本となるツールである svnsync の取得する情報に加えてメタデータの歴史を同時管理することを目標にしている。

*3:デフォルト設定のままだとタグがローカルリポジトリのタグとして保存されたりと都合が悪い。