Subversion、MercurialとGitのセマンティクス差をどう埋めるか

ゲームのビルド環境には未だにSubversionMercurialからインポートしているリポジトリがいくつか存在している(Perforceはついに撲滅に成功した)。しかし、細かいインフラを作っていく中でVCSはできれば統一したい。VCS毎にUIとかいくつも作っていられないからね。。

目標

"あるexecutableのあるバイトを生成する要因になったコードの作者をO(logN)で特定する"

当然ゲームには複数のオープンソースなりNDAしたミドルウェアが含まれるため、

必要がある。全てをGitに突っ込めば(= monorepoを生成すれば)それなりに簡単だが、プロジェクトの規模が大きくなると非現実的になってくるので、在野のリポジトリをそのまま扱うために既存のVCSをそのまま扱えるフレームワークを考察している。

Subversion

Subversionはだいぶ考察が進んできていて、いくつかのFOSSリポジトリを使って実験を進めているところ。

SubversionMercurialやGitと最も違うのは、そのリポジトリ再帰的構造を取る 点と言える。普通、つまりGitのようなVCSであれば、リポジトリのルートが厳密に決定でき "リポジトリの履歴 == プロジェクトの履歴" と言える(プロジェクトが複数のリポジトリで構成されることは考えない)。

しかし、Subversionリポジトリ再帰的であるため、"ツリーのどこを取り出してもSubversionリポジトリとして成立する"。つまり、:

  1. 任意のサブディレクトリをチェックアウトしてリポジトリとして使用できる
  2. 無関係なプロジェクトを単一のリポジトリに格納できる
  3. あるプロジェクトのブランチを自由な場所に作成できる
  4. 単一のコミットで複数のブランチを同時に修正できる

という特徴がある。どれを取っても比較的最悪で、例えばSubversion自身のリポジトリは、ASF財団の 100万リビジョンを超える Subversionリポジトリ https://svn.apache.org/repos/asf/ の一部を占めていて、Subversion自身の trunk (Gitで言うところの master )は subversion/trunk/ に位置している。 ...Subversionでは、こういうリポジトリの構造を人間が決めることができ、機械で自動的に検出するには、ある程度inferが必要になる。

(実際には、Subversionリポジトリには単一のUUIDが有り、そのUUIDに紐付く静的なrootも1つに決定できる。ただし、このroot自体にはプロジェクト履歴という意味ではあんまり意味が無い。リポジトリの操作履歴では有るんだけど。)

1つのリビジョンで複数のブランチを変更するというのが妥当かどうかは何とも言えない。履歴をconciseに保つためにそのようなスタイルを好む人が居るかもしれないし、コミット単位のrevertができなくなるので避けるべきという意見も有るかもしれない。

例えば、Rubyは1つの修正を複数のブランチに単一のコミットで導入していて、

Subversionでは1つのコミットになっているものが、そのGitミラーでは複数のコミットに分散しているのがわかる。

文化的差異

Subversionリポジトリの文化的差異として挙げられるのは:

  1. 無関係なヒストリを単一リポジトリで扱う - Gitで言うと subtree マージのように、複数の無関係なツリーを単一のリポジトリで扱う傾向にある。例えば、OSのような大きなプロジェクトには vendor ブランチがあり、他所のプロジェクトのリリースをブランチ経由で取り込むために使用されることがある。
  2. コミットログにサブジェクトが無い - これはRCSCVSから由来した文化と言えるが、コミットログに1行の要約を付ける文化が無い。このためUIの方で適当にコンテキストを抽出して表示する必要がある。
  3. 巨大ファイルをコミットしがちである - これはゲーム固有かもしれないがSubversionやPerforceのようなVCSを採用したプロジェクトはアセットの管理にもVCSを使う傾向がある。
  4. ディレクトリを扱える - ...CVSの反省だろうか。。ただしこの特徴はブランチを扱うために重要になる(後述)
  5. 独特のmergeinfo属性を使用する - Subversionにはリポジトリのマージを構造として持たず(= リポジトリの履歴は厳密に木構造となる )、UIヒントとしてmergeinfo属性を使用する。実際にはサーバ側でもmergeinfoを使用するがリビジョンが明確な親を複数持てるGitやMercurialとは異なっている。

vendorブランチの処理はまだ上手いソリューションが浮かんでいない。この問題はGitでもSubtreeマージや、外部プロジェクトの取り込みが課題として有りVCS非依存の問題として解くことになるんじゃないかと思っている。

コミットログの問題は、コミットのコンテキストを抽出するためにビルドリプレイを使用したアーティファクトの抽出(ソースコードとC関数の対応表をメタデータとして持つ)と、コミットログへの静的なタグ付けをサポートする方向で考えている。例えば、手元のRubyミラーでは

            - ruby-redmine0:
                type: tag
                id: ruby-redmine
                url: https://bugs.ruby-lang.org/issues/NNNNN
                pattern: "[Bug #NNNNN]"
            - ruby-redmine1:
                type: tag
                id: ruby-redmine
                url: https://bugs.ruby-lang.org/issues/NNNNN
                pattern: "[#NNNNN]"

のように、コミットログに付与されるバグトラッカへのリンクを抽出してタグを打つことにしている。

巨大ファイルと空ディレクトリはどうしようもないので外部にメタデータリポジトリを持つことになる。

mergeinfoは単にヒントとして使うのが良いと考えている。Subversionの文化として、mergeはGitで言うところの cherry-pick に近く、それまでの履歴を全て包含することは保証していないことが多い印象がある。リリースブランチを持つプロジェクトでは、上記のようにcherry-pickやマージではなく別立てのコミットを実施することが多い。

ブランチ作成を追え!

SubversionリポジトリをGit風のセマンティクスで扱う上で最大の障害になるのは、 人間が自由に決められるリポジトリ構造を自動的に認識しGitリポジトリの構造に反映する 点となる。

もっとも、Rubyのような単一プロジェクトで単一のSubverionリポジトリを使っている場合は、標準レイアウトをそのまま使用できるので大きな問題にはならない( git-svn もそれを想定している)。問題になるケースは、例えば、個人がサブディレクトリを掘ってそれ以下にブランチを作ったりしているような大きなリポジトリの構造を良く認識することにある。

ブランチを自動認識する、とは、 ユーザが実行した svn copy コマンドをリポジトリの履歴から推論する ことと等価と言える。未だきちんと纏められていないが、いわゆるFOSSのSubversionリポジトリsvn copyディレクトリではなくファイル単位で実行してブランチを作っている事例は発見できなかった。

他のアプローチとしては、ファイル単位の履歴を全て追跡してツリー内の全ファイルで比較を行う手がある。ただ、これが必要なケースは皆無で、個人的には ディレクトリのコピーさえ追えばブランチは発見できる と結論している。そもそもディレクトリ以外も追跡するアプローチだと各パス毎に履歴情報を記録する必要があって、あまり現実的なコストで実現できない気がしている。

もちろん、 svn copy コマンドを抽出するだけではブランチの推論にはならない。COPYが発生するのは

  1. ブランチの作成
  2. ブランチ内でのファイルコピー
  3. git-svn のようなツールが生成するブランチを超えたファイルコピー
  4. vendorブランチからのコピー

といった要因があり、適切なヒューリスティックを使ってブランチの作成イベントを抽出する必要がある。簡単には "trunkのコピー、または、そのコピーのコピーはブランチと見做す" というものが考えるが、

  1. 複数のブランチが含まれるディレクトリをCOPYする
  2. サブディレクトリのみをブランチしてマージで戻す

といった例外的なワークフローが有るため一筋縄では行かない。

git-svn はブランチ内のファイルコピーを積極的にCOPYに展開するが、適切に設定されていない場合ブランチを超えたコピーをinferしてしまうことがある。

Mercurial

Mercurialの考察は始まったばかりの状態に有るが、SDLやUnityのような重要なミドルウェアMercurialなので避けては通れないと思っている。

もっとも、MercurialとGitの表現力はかなり近く、Mercurial固有の機能(タグや名前付きブランチの履歴管理)はメタデータを別のリポジトリで管理することで代替できるためSubversionよりはシンプルでコンパクトに表現できるのではないかと考えている。

MercurialからGitへのミラーツールはHgGitやgit-cinnabar( https://github.com/glandium/git-cinnabar )が既にあり、これらは今回の目的に十分な機能性を持っている。よって、今回実装する必要があるのはメタデータの抽出部分だけになる。

メタデータの抽出

Subversionと同じく、メタデータを一旦テキストファイルに変換し専用のGitリポジトリに格納する形で管理したい。このためには、リポジトリに対してメタデータ列挙 を行う。 Subversionでは svndump の出力をパースする形で行ったが、Mercurialでは割合スマートに実現できる。

hg clone <REPOSITORY>
hg checkout null
hg pull

MercurialにはBareリポジトリの概念が無いが、単に hg checkout null することでworking directoryを空にすることができる。

  • 1。タグ、ブランチのHEADの取得
hg tags --template "{node}\t{tag}\n"
hg branches --template "{node}\t{branch}\n" -c

Gitで言うところのコミットハッシュは {node} で表現される。Mercurialの名前付きブランチにはcloseの概念があり、 -c オプションで表示を有効化できる。

  • 2。Changeset(リビジョン)の列挙
hg log -r "all()" --template "{node}\t{rev}\t{p1node}\t{p2node}\t{branch}\n"

Mercurialにはrevsetsと呼ばれる専用のクエリ言語があり、これで表示対象のリビジョンを選択できる。MercurialでのChangesetには親は高々2つまでであるため、p1nodeとp2nodeの指定で必要十分な情報を出力できる。

このコマンドは

8db358c7a09ac8827c384030ce80b4abb864465d        12532   af47ff0de5ab1ddd8dbf0e30dd982a23fa45ab5f        0000000000000000000000000000000000000000        default
eb22f5f6d5a54b8f3d441fc1874e0e5ab0b09d7a        12533   8db358c7a09ac8827c384030ce80b4abb864465d        0000000000000000000000000000000000000000        default
682d9b5ecbedab90f0ddf647cd8124fdd39607c2        12534   eb22f5f6d5a54b8f3d441fc1874e0e5ab0b09d7a        0000000000000000000000000000000000000000        default
edb58d9516563ac8a01d8a04a5c3ef1d19babf8f        12535   2560bdcf3130485c44409fe6bb04e87961a9af4b        0000000000000000000000000000000000000000        SDL-1.2
6472da23f3ef931c9694ee4602867bf25a69d5b8        12536   edb58d9516563ac8a01d8a04a5c3ef1d19babf8f        0000000000000000000000000000000000000000        SDL-1.2
8586f153eedec4c4e07066d6248ebdf67f10a229        12537   6472da23f3ef931c9694ee4602867bf25a69d5b8        0000000000000000000000000000000000000000        SDL-1.2

のように、ブランチを跨いだ履歴を表示できる。 12537 のような番号はリポジトリローカルのリビジョン番号だが、ローカルのコミットが一切無ければリモートのものと一致する。

  • 3。Changesetのダンプ
hg debugdata -c 8586f153eedec4c4e07066d6248ebdf67f10a229

Changeset内容のダンプは、 debugdata コマンドで行える。(FIXME: logはmanifest(Gitのtreeオブジェクトに相当する)が出ない .,.?)

e6e2f259e0fb09741aa4c393d9aff7bcc35ee9a7
Patrice Mandin <patmandin@gmail.com>
1547389670 -3600 branch:SDL-1.2
src/audio/mint/SDL_mintaudio.c
src/audio/mint/SDL_mintaudio.h
src/audio/mint/SDL_mintaudio_it.S

atari: Fill audio buffer with silence when too many interrupts triggered on same buffer

(manifest以外の部分はlogコマンドにテンプレートを与えることで出力させられる。)

$ hg log -r 8586f153eedec4c4e07066d6248ebdf67f10a229 --template "{author}\n{date}\n{desc}"
Patrice Mandin <patmandin@gmail.com>
1547389670.0-3600
atari: Fill audio buffer with silence when too many interrupts triggered on same buffer

リビジョンのマッチング

リビジョンのマッチングは、トポロジを考慮した方式やら色々試したが、殆どのケースで authorと時刻のペア によるマッチングが期待通り動作する。もちろんトポロジの一致をチェックする必要が有るが、 殆どのケースで人間は常識的な方法でコミットを実施する と裏付けられたと言える。

ただ、Subversionでは Authorのマッチ自体が一仕事 になる。というのも、Subversionは履歴にauthorのメールアドレスを一般に持っておらず、Gitミラー側に付与するメールアドレスとのマッチングを考察する必要がある。(SubversionはDVCSではないため、単一のユーザ名名前空間を持つ。対して、MercurialやGitはDVCSであるためメールアドレスを使用することでユーザのuniquenessを確保している。)

簡単な方法は、RubyGitHubミラーのようにe-mailアドレスを git-svn 形式( <SVNUSER>@<SVN-UUID> )のままにすることが考えられる。GitHub(Enterprise)ではこの形式のメールアドレスもe-mailアドレスとして設定できるため、PGP署名のような機能を使わないならこれで十分と言える。

ミラーによってはe-mailを真面目に設定していることがあるため、この方法は万能ではない。

Mercurialについては、コミットオブジェクトの表現力がほぼ同じため、各コミットオブジェクトとMercurialのChangesetは1対1対応させることができる。

未解決の問題

現状の構成、つまり、 Gitミラー + メタデータを外部リポジトリで持つ という構造はそれなりの表現力が確保でき、かつ、実際にビルドシステムを廻す上でも運用上十分なことが多いというのが結論になりつつある。(もちろんプロジェクトによっては自身がSubversionのキーワード置換に依存していたり等100%の互換は無いが。。)

現時点で未解決の問題は、

  1. マージアノテーション 。blameを実現する上でマージ情報を良く処理するのは重要なことだが、同時に、マージ情報そのものが正しいかどうかを検証したり、不正なマージ情報を打ち消すといったアノテーションの付与が必要になるのではないかと考えている。
  2. 巨大ファイルgit-lfs に開くのが簡単な気もしているが、これを行ったリポジトリgit-svn を使えるのかが未調査。
  3. Rename表現のアノテーションSubversionはファイルの移動/コピーをアノテーションできる。Gitはtreeという形でディレクトリ単位の抽象化を選択しているが、ファイル単位での処理にした方が良いかもしれない(1つのオブジェクトが複数のファイル名を持つモデル)
  4. リポジトリ間のマージ 。いわゆるVendorブランチのマージのような操作を表現するためのメタデータをどのように持つべきかは考察し切れていない。

あたりがある。