彷徨えるフジワラ

年がら年中さまよってます

Mercurialエクステンションで、指定パターンに合致する管理対象ファイルを取得する

このエントリは、Mercurial Advent Calendar 2013 の14日目です。

先日、下記のツイートを契機に、Mercurial の内部実装に関する一連のやりとりがありました。

これから Mercurial の機能拡張を Python で実装してみようという人にとって、比較的有用と思われる内容でしたので、改めて内容を整理したものを公開しようと思います。

なお、現行仕様の背景的なものを説明する上で、実行性能に関する踏み込んだ話も含まれますが、その辺は興味の度合いに応じて適宜読み飛ばしてもらっても結構です。

Python 標準の glob モジュール

Python には、指定したワイルドカードに合致するファイル名一覧を取得するための glob モジュールが、標準で同梱されています。

例えば、以下のようなコードを実行した場合、カレントディレクトリ配下で "doc/*.txt" に合致するファイルの一覧が表示されます。

import glob
for f in glob.glob('doc/*.txt'):
    print f

ソースコードの字面だけ見ると:

パターン文字列 "doc/*.txt" を展開して、合致するファイル(一覧)を取得

といった印象を受けるかもしれませんが、実際には:

  1. 確定している部分のディレクトリ要素を抽出(上記例なら doc 部分)
  2. ディレクトリ配下の要素一覧を取得(Python で言えば os.listdir()
  3. 指定パターンとの合致を判定(上記例なら *.txt 部分)

上記のように、「要素一覧」に対する「パターン合致判定」によるフィルタリングで実現されています。

ソースコード上には現れませんが、ファイルシステムから読み込まれる、暗黙の「候補要素一覧」が存在するわけです。

ちなみに、コマンドの引数としてコマンドラインで直接 doc/*.txt を指定した場合、ワイルドカードを含む引数は、実際にコマンドが実行される前にシェルによって展開されます。そのため、パターン指定を展開するような処理は、コマンド側では保持しないのが一般的です。

Mercurial 固有の事情

さて、Mercurial のような履歴管理ツールの場合:

$ hg log glob:*.txt

上記のコマンド実行の際には、現時点において管理対象となっている(=ファイルシステム上に存在する) *.txt ファイル以外に、hg remove によって既にファイルシステム上から破棄されたファイルの履歴も表示しなくてはなりません。

このような場合は、現時点で存在するファイルしか引き当てられない glob モジュール方式は使えません。

ちなみに、上記実行例で glob: 接頭辞(詳細は [http://mercurial-users.jp/manual/hg.1.html#patterns:title=hg help patterns] 参照)を明示的に指定しているのは、*.txt 単独の指定だと、hg log 実行の前の段階で、シェルによってワイルドカードが展開されてしまうためです。

パターンに合致するファイルが1つでも存在する場合、合致するファイルの一覧がコマンド実行時の引数になります。その場合、既に存在しないファイルに関する履歴は、表示されなくなってしまいます。

また、接頭辞無しのパターンはデフォルトの挙動では relpath: 扱い(詳細は後述)になるので、引用符等を使って文字列 *.txt をそのまま引数に渡せたとしても、想定するようにはパターンの展開が行われなかったりします。

閑話休題

glob モジュール方式のもう一つの問題は、ファイルシステムアクセスが頻発することによる実行性能の劣化です。

ディレクトリ配下の要素一覧取得や、要素の種別判定(ファイル/ディレクトリ/シンボリックリンク....)には、システムコール呼び出しや、それを契機とするディスク I/O が伴います。

ファイル数が1000を超えるような作業領域の場合、そのような実行コストは体感できるレベルでの性能劣化を引き起こしてしまうのです。

先日、Python Developers Festa 2013.11 に参加した際に、この辺の実行性能に関する発表を行ってきましたので、興味のある方は以下のスライドを参照してみてください。

hg status において、unknown("?") や ignored("I") 込みで表示するような場合であれば、上記のようなコストも止む無しと言えますが、それ以外のケースでは極力このような性能劣化は回避したいところです。

以上ようなことから、指定パターンに合致するファイルの抽出を、Mercurial では以下の方法で行うようになっています。

  1. 指定パターンから、合致判定を行うオブジェクトを生成(match オブジェクト)
  2. 対象ファイル一覧を入手(一覧中のパスはリポジトリルートからの相対パス
  3. 一覧中の要素に対して、match オブジェクトによる合致判定を実施
  4. 合致するファイルのみをリスト化

各リビジョン毎の「対象ファイル一覧」は、履歴情報として記録されていますし、作業領域中の「対象ファイル一覧」は、.hg/dirstate という管理ファイルに保持されていますので、通常の処理ではそこから取得したファイル名を判定対象にしますが、独自に生成した文字列に対して判定を行っても構いません。

hg status において、unknown("?") や ignored("I") 込みで表示するような場合だけは、管理対象外のファイルの確認が必要なので、性能劣化しないような工夫を盛り込んだ処理になっていますが、それ以外の場合は条件判定付きの単純なループ処理で済みます。

Mercurial での手順

まずは、何らかの方法で [http://selenic.com/repo/hg/file/stable/mercurial/localrepo.py:title=localrepository] クラスのオブジェクト repo が入手できているものとします。

エクステンション等でコマンドのラッパーを実装するようなケースでは、引数で repo が指定されますので特に問題は無いでしょう。

それ以外の場合は、Mercurial の Wiki ページや、私が以前作成した発表資料を参照してください。

次に、合致判定の対象となるリビジョンに対応する ctx オブジェクトを取得します。

ctx = repo[None]

None が指定された場合、ctx オブジェクトは「作業領域」に対応するものになります。

リビジョン番号(数値/文字列)や、ハッシュ値(文字列)、タグ名/ブランチ名といった None 以外の識別子が指定された場合は、対応するリビジョンの ctx オブジェクトが取得できます。

次に、パターン glob:*.txt との合致判定を行うための match オブジェクトを取得します。

from mercurial import scmutil

match = scmutil.match(ctx, ['glob:*.txt'])

複数のパターンを指定した場合は、各パターンとの合致の論理和扱いになります。

パターンと合致するファイルの一覧は、以下の要領で抽出できます。

files = [f for f in ctx.manifest() if match(f)]

ディレクトリ相対性

パターン指定のデフォルトは「現ディレクトリを起点とする相対パス」での合致判定になります。

例えば、管理対象ファイル foo/file.txt に対する、ディレクトリ位置と指定パターンの組み合わせによる合致の有無は、以下のようになります。

ディレクトリ位置パターン指定合致
REPOROOT foo/file.txt 合致する
REPOROOT file.txt 合致しない
REPOROOT/foo foo/file.txt 合致しない
REPOROOT/foo file.txt 合致する
REPOROOT/foo ../foo/file.txt 合致する

指定パターンに対して、ディレクトリ相対での合致判定を抑止したい場合は、scmutil.match() 呼び出しの際に default='relglob'default='path' を指定してください。

これらの指定により、glob:re: が明示されないパターンは、全て relglob: または path: 扱いになります。

relglob:[http://mercurial-users.jp/manual/hg.1.html#patterns:title=hg help patterns] 等で言及されていない接頭辞ですが、glob: が「現ディレクトリ配下」のファイルのみが合致対象になるのに対して、relglob:リポジトリ配下の全てのファイルが合致対象になる、という違いがあります。

ちなみに、scmutil.match()default 引数のデフォルト値である relpath は、これまた hg help patterns での言及の無い接頭辞ですが、「パターン展開なしの glob」だと考えれば良いでしょう。

glob ではなく relpath がデフォルトになっているのは、以下のような判断によるものと思われます。

コマンドラインで指定されたワイルドカード込みのパターンは、先述したようにシェルによって事前に展開される。
そのため、接頭辞無しのファイル名指定の多くは、展開すべきパターンを持たない筈。
そうであれば、展開の必要が無い relpath の方がコストが低い。

なお、パターンに合致するファイル名を表示する際に、リポジトリルートからの相対パスではなく、現ディレクトリからの相対パスで表示したいかもしれません。

このような場合は、scmutil.match() 経由で取得した match オブジェクトを使うことで、以下のようにして相対パスを取得します。

files = [match.rel(f) for f in ctx.manifest() if match(f)]

match.rel() は、scmutil.match() に指定した ctx オブジェクト経由で repo オブジェクトの持つ「現ディレクトリ」を取得し、それを元に「リポジトリルートからの相対パス」を「現ディレクトリからの相対パス」に変換します。

リポジトリルートからの相対パス」であれば、「現ディレクトリからの相対パス」への変換は、指定パターンとの合致の有無とは無関係に実施されます。