彷徨えるフジワラ

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

『ファイルの最終更新日一覧取得』を最適化してみる

※ 2012/10/11 追記あり

Python から Mercurial を使うエントリがあったので、Mercurial ハッカーの増加を願って、Mercurial の内部機能の使い方について、補足説明してみることに。

以下の実装で使用しているオブジェクトの詳細等に関しては、適宜『How to write extension for Mercurial』や『コードウォーキングのための準備運動』も参照のこと。

さて、元エントリでは『複数ヘッドは考慮しない』となっているけれど、『最終更新日時』を得るなら、むしろ『履歴のヘッドを意識した方が得策』なので、まずは以下の様なループを組むことから始めてみる。

for head in repo.heads(): # 履歴ヘッドのリビジョン番号の取得
    ctx = repo[head] # 対応リビジョンの changectx の取得

    # リビジョン毎の処理

"head" は単なる「リビジョン番号」の整数値なので、"repo[head]" で changectx オブジェクトを取得する。

もし本当に『複数ヘッドは考慮しない』で構わないのなら、for 文は無しにして:

    ctx = repo['tip']

から始めても OK。
次は、各リビジョン時点で Mercurial 管理下にあるファイルに対して、個別に更新日時の取得を行うために、更にループを入れ子にする:

for head in repo.heads():
    ctx = repo[head]

    for f in ctx: # 管理下にあるファイル一覧の取得
        # ファイル毎の処理

元エントリでは、"ctx.files()" で得られる『各リビジョンにおける更新対象ファイル』のみを更新日時取得対象にしているけれど、その方法の場合だと、例えば:

あるファイルは、最初のリビジョンで追加されて以来、更新されていない

といった状況に対応するためには、記録された全ての履歴を走査しなければならず、あまり効率がよろしくない。

一方で、更新日時情報の必要なファイルを『ヘッドリビジョン時点で管理下にあるファイル』(= 『削除済み/改名元ファイル』の更新日時は不要) に限定できるならば、ここで示した方法のように、『ヘッドリビジョン時点で管理下にあるファイル』に対してのみ処理を行う方が効率が良い。

当該リビジョンにおいて管理下にある各ファイルに対して、『そのリビジョン時点の内容に相当する変更が、どのリビジョンで実施されたのか?』を知る必要があるので:

for head in repo.heads():
    ctx = repo[head]
    for f in ctx

        fctx = ctx[f] # リビジョンに対応する filectx の取得
        changedrev = fctx.linkrev() # 実際に変更があったリビジョンの取得
        changedctx = repo[changedrev] # 対応する changectx の取得

ちなみに、情報取得対象のファイルが最初から確定している場合には:

  1. repo.file() 経由で、対象ファイルの filelog オブジェクトを取得
  2. filelog.heads() でヘッドリビジョン番号(flrev)を取得
  3. 各 flrev 毎に filelog.linkrev(lfrev) で、履歴上のリビジョン番号(clrev)を取得
  4. 各 clrev 毎に repo[clrev] で changectx オブジェクトを取得
  5. 各 changectx 毎に changectx.date() で更新日時を取得
  6. 取得した更新日時から、最新のものを選択

という手順の方が (実行性能/資源消費的に) 効率的な筈。途中の手順は少々違うけど、parent コマンドの実装が上記の手順に近い。

Mercurial では『これまでに管理対象になった全ファイルの一覧』みたいなものが、一発では取り出せない (.hg/store/data 配下の全走査で、入手できなくも無いけど……) ので、この方法は、まぁ、抜け道的な実現方式。

>>>> 2012/10/11 追記: ここから

"hg manifest --all" が『これまでに管理対象になった全ファイルの一覧』に相当する。っていうか、最近 store 実装まわりでパッチを投函するまで、"--all" の存在をすっかり忘れてた…… orz

コマンド実装の肝の部分を抽出すると、以下の様な感じ。

prefix = "data/"
plen = len(prefix)
suffix = ".i"
slen = len(suffix)

lock = repo.lock() # 管理領域走査の間、改変操作を抑止
try:
    for fn, b, size in repo.store.datafiles():
        # システム予約ファイル等の除外
        if size != 0 and fn[-slen:] == suffix and fn[:plen] == prefix:
            # fn には、.hg/store を起点とするパスが
            # 格納されているので余計な部位を除外
            filename = fn[plen:-slen]

            # ファイル毎処理:
            # filelog = repo.file(filename) など
finally:
    lock.release() # 排他解放

結局のところ、"repo.store.datafiles()" の実装は、.hg/store/data 配下の全走査なので:

最新リビジョンのファイル一覧には登場しない、削除済みファイルも含めた、『全ファイル』の情報が欲しい

というようなニッチなニーズが無いなら、『リポジトリサイズが大きくない』とか『実行性能には拘らない』といった条件付きの場合でもない限り、あまりお勧めできる方法ではない。

<<<< 2012/10/11 追記: ここまで

閑話休題

ここまで来たなら、後は当該リビジョンから更新日時を取得すれば良いので:

for head in repo.heads():
    ctx = repo[head]
    for f in ctx:
        fctx = ctx[f]
        changedrev = fctx.linkrev()
        changedctx = repo[changedrev]

        date = changedctx.date() # 更新日時の取得

で、各ファイル毎の最終更新実施が、どのヘッドに該当するのかは、全てのヘッドに関して走査し終わらないと判定できないので:

fmap = {} # ファイル毎の更新日時リスト保存用途

for head in repo.heads():
    ctx = repo[head]
    for f in ctx:
        fctx = ctx[f]
        changedrev = fctx.linkrev()
        changedctx = repo[changedrev]
        date = changedctx.date()

        fmap.setdefault(f, []).append(date) # 更新日付をリストで保存

こんな感じで、ファイル毎に、更新日時情報を一旦リストで保持しておく。

ヘッドリビジョンを一通り走査し終えたなら、各ファイル毎に最新の更新日時を取り出せばよいので:

fmap = {}
for head in repo.heads():
    ctx = repo[head]
    for f in ctx:
        fctx = ctx[f]
        changedrev = fctx.linkrev()
        changedctx = repo[changedrev]
        date = changedctx.date()
        fmap.setdefault(f, []).append(date)

for f in sorted(fmap): # ファイル名でソート
    latest = sorted(fmap[f])[-1] # 昇順整列の末尾 = 最新の日時

この時点での latest は、"(unixtime, offset)" 形式の整数値の tuple になっているので、人が読める形式の最終出力を得るには、以下の様な感じにしてやれば良い。

# from mercurial import util

fmap = {}
for head in repo.heads():
    ctx = repo[head]
    for f in ctx:
        fctx = ctx[f]
        changedrev = fctx.linkrev()
        changedctx = repo[changedrev]
        date = changedctx.date()
        fmap.setdefault(f, []).append(date)
for f in sorted(fmap):
    latest = sorted(fmap[f])[-1]

    datestr = util.datestr(latest,
                           '%Y-%m-%d %H:%M:%S') # 日時情報の文字列化

また、特定のパターンに合致するファイルの情報のみを抽出する場合は:

# from mercurial import match as matchmod

match = matchmod.match(repo.root, repo.getcwd(),
                       pats,
                       opts.get('include'),
                       opts.get('exclude'))

上記の要領で作成した match オブジェクトを使用して、"match(f)" 結果の真偽で指定パターンとの合致が判定できるので:

for head in repo.heads():
    ctx = repo[head]
    for f in ctx:

        if not match(f): # パターン合致しないなら
            continue # 以後の更新日時情報取得をスキップ

としてやればよい。

以下の様にエクステンション定義してやることで、これまでに実装した機能を "hg lastupdate" コマンドとして実行することができる:

from mercurial import util, match as matchmod, commands
from mercurial.i18n import _

def lastupdate(ui, repo, *pats, **opts):
    match = matchmod.match(repo.root, repo.getcwd(),
                           pats,
                           opts.get('include'),
                           opts.get('exclude'))

    fmap = {}
    for head in repo.heads():
        ctx = repo[head]
        for f in ctx:
            if not match(f):
                continue
            fctx = ctx[f]
            date = repo[fctx.linkrev()].date()
            fmap.setdefault(f, []).append(date)
    for f in sorted(fmap):
        latest = sorted(fmap[f])[-1]
        datestr = util.datestr(latest, '%Y-%m-%d %H:%M:%S')
        ui.write('%10s %s\n' % (datestr, f))

cmdtable = {
    "lastupdate":
        (lastupdate,
         commands.walkopts,
         _("lastupdate [OPTION]... [PATTERN]...")),
}

色々な初期化周りの処理を省略できるので、コマンド追加用のエクステンションとして実装するのが断然お勧め。

なお、『特定の名前付きブランチに限定』したりしたい場合には、mercurial/commands.py の heads コマンド実装冒頭での、heads 配列の絞込みが参考になる筈。