Subversion、MercurialとGitのセマンティクス差をどう埋めるか
ゲームのビルド環境には未だにSubversionやMercurialからインポートしているリポジトリがいくつか存在している(Perforceはついに撲滅に成功した)。しかし、細かいインフラを作っていく中でVCSはできれば統一したい。VCS毎にUIとかいくつも作っていられないからね。。
目標
"あるexecutableのあるバイトを生成する要因になったコードの作者をO(logN)で特定する"
当然ゲームには複数のオープンソースなりNDAしたミドルウェアが含まれるため、
- クロスリポジトリで複数プロトコルのVCSで構成される履歴を処理でき
- ソースコードの行とexecutableのバイトを関連付けることができるビルドシステムを持ち
- 外部プロジェクトの取り込みについて履歴をアノテーションできる
必要がある。全てをGitに突っ込めば(= monorepoを生成すれば)それなりに簡単だが、プロジェクトの規模が大きくなると非現実的になってくるので、在野のリポジトリをそのまま扱うために既存のVCSをそのまま扱えるフレームワークを考察している。
Subversion
Subversionはだいぶ考察が進んできていて、いくつかのFOSSリポジトリを使って実験を進めているところ。
SubversionがMercurialやGitと最も違うのは、そのリポジトリが 再帰的構造を取る 点と言える。普通、つまりGitのようなVCSであれば、リポジトリのルートが厳密に決定でき "リポジトリの履歴 == プロジェクトの履歴" と言える(プロジェクトが複数のリポジトリで構成されることは考えない)。
しかし、Subversionのリポジトリは再帰的であるため、"ツリーのどこを取り出してもSubversionリポジトリとして成立する"。つまり、:
- 任意のサブディレクトリをチェックアウトしてリポジトリとして使用できる
- 無関係なプロジェクトを単一のリポジトリに格納できる
- あるプロジェクトのブランチを自由な場所に作成できる
- 単一のコミットで複数のブランチを同時に修正できる
という特徴がある。どれを取っても比較的最悪で、例えば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ができなくなるので避けるべきという意見も有るかもしれない。
DB実装のnmosh→MongoDB置き換えがやっと終ったので試しにRubyのGitミラー投入。最大fanoutは5でr35619 https://t.co/Lkwc15tVU3 https://t.co/uQ1rm1ADdI https://t.co/TSYstB0Fu3 https://t.co/7HokYAgkRu https://t.co/qu3oBhC9Dl https://t.co/rNbyAp6fa6 とr17460 の2つ。r66699 = 68303 commits. pic.twitter.com/tlE9RLLLht
— okuoku (@okuoku) 2019年1月3日
例えば、Rubyは1つの修正を複数のブランチに単一のコミットで導入していて、
- https://svn.ruby-lang.org/cgi-bin/viewvc.cgi?view=revision&revision=35619
- https://github.com/ruby/ruby/commit/af310963abb1ef737d52b29ec77f6598ac505472
- https://github.com/ruby/ruby/commit/9c3bf9bec4927510c881ee807de5d37026fa89a8
- https://github.com/ruby/ruby/commit/67166228c63983d98805846edcce4897f3eb9068
- https://github.com/ruby/ruby/commit/8acdd955f98cf6515da37f54b9f8003f224f9cb2
- https://github.com/ruby/ruby/commit/a4e76099bf44ae7d75bd24c3c8233d710e590fac
Subversionでは1つのコミットになっているものが、そのGitミラーでは複数のコミットに分散しているのがわかる。
文化的差異
Subversionリポジトリの文化的差異として挙げられるのは:
- 無関係なヒストリを単一リポジトリで扱う - Gitで言うと
subtree
マージのように、複数の無関係なツリーを単一のリポジトリで扱う傾向にある。例えば、OSのような大きなプロジェクトにはvendor
ブランチがあり、他所のプロジェクトのリリースをブランチ経由で取り込むために使用されることがある。 - コミットログにサブジェクトが無い - これはRCSやCVSから由来した文化と言えるが、コミットログに1行の要約を付ける文化が無い。このためUIの方で適当にコンテキストを抽出して表示する必要がある。
- 巨大ファイルをコミットしがちである - これはゲーム固有かもしれないがSubversionやPerforceのようなVCSを採用したプロジェクトはアセットの管理にもVCSを使う傾向がある。
- 空ディレクトリを扱える - ...CVSの反省だろうか。。ただしこの特徴はブランチを扱うために重要になる(後述)
- 独特の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
をディレクトリではなくファイル単位で実行してブランチを作っている事例は発見できなかった。
gccのリポジトリをGitに変換するのにRAMが64GiBでも足りないみたいな話 https://t.co/7MSbukyhnj が有って、そんなに食うのかよと思ったけど、手元のツール(ChezScheme製)に履歴データ読ませただけで知らない間に10GiBくらい喰ってて、やっぱりでかいリポジトリのオンメモリ処理は難しい。 pic.twitter.com/WtvxdYfd9Z
— okuoku (@okuoku) 2018年7月16日
他のアプローチとしては、ファイル単位の履歴を全て追跡してツリー内の全ファイルで比較を行う手がある。ただ、これが必要なケースは皆無で、個人的には ディレクトリのコピーさえ追えばブランチは発見できる と結論している。そもそもディレクトリ以外も追跡するアプローチだと各パス毎に履歴情報を記録する必要があって、あまり現実的なコストで実現できない気がしている。
もちろん、 svn copy
コマンドを抽出するだけではブランチの推論にはならない。COPYが発生するのは
- ブランチの作成
- ブランチ内でのファイルコピー
git-svn
のようなツールが生成するブランチを超えたファイルコピー- vendorブランチからのコピー
といった要因があり、適切なヒューリスティックを使ってブランチの作成イベントを抽出する必要がある。簡単には "trunkのコピー、または、そのコピーのコピーはブランチと見做す" というものが考えるが、
といった例外的なワークフローが有るため一筋縄では行かない。
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では割合スマートに実現できる。
- 0。 Bareリポジトリの生成
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を確保している。)
簡単な方法は、RubyのGitHubミラーのようにe-mailアドレスを git-svn
形式( <SVNUSER>@<SVN-UUID>
)のままにすることが考えられる。GitHub(Enterprise)ではこの形式のメールアドレスもe-mailアドレスとして設定できるため、PGP署名のような機能を使わないならこれで十分と言える。
ミラーによってはe-mailを真面目に設定していることがあるため、この方法は万能ではない。
Mercurialについては、コミットオブジェクトの表現力がほぼ同じため、各コミットオブジェクトとMercurialのChangesetは1対1対応させることができる。
未解決の問題
現状の構成、つまり、 Gitミラー + メタデータを外部リポジトリで持つ という構造はそれなりの表現力が確保でき、かつ、実際にビルドシステムを廻す上でも運用上十分なことが多いというのが結論になりつつある。(もちろんプロジェクトによっては自身がSubversionのキーワード置換に依存していたり等100%の互換は無いが。。)
現時点で未解決の問題は、
- マージアノテーション 。blameを実現する上でマージ情報を良く処理するのは重要なことだが、同時に、マージ情報そのものが正しいかどうかを検証したり、不正なマージ情報を打ち消すといったアノテーションの付与が必要になるのではないかと考えている。
- 巨大ファイル 。
git-lfs
に開くのが簡単な気もしているが、これを行ったリポジトリでgit-svn
を使えるのかが未調査。 - Rename表現のアノテーション 。Subversionはファイルの移動/コピーをアノテーションできる。Gitはtreeという形でディレクトリ単位の抽象化を選択しているが、ファイル単位での処理にした方が良いかもしれない(1つのオブジェクトが複数のファイル名を持つモデル)
- リポジトリ間のマージ 。いわゆるVendorブランチのマージのような操作を表現するためのメタデータをどのように持つべきかは考察し切れていない。
あたりがある。