彷徨えるフジワラ

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

『*.rej ファイル生成』形式のマージ

色々ドタバタしたせいで、またまた間隔があいてしまったけれど、TokyoMercurial #1 で話題になった件の詳細シリーズ - その6。

ちなみに、結構長いです。あと、python コードもバンバン出てきます。でも Mercurial の用法に関しては全然出てきません(笑)。

事の発端は、TokyoMercurial #1 での参加者からの以下のような質問:

複雑な衝突が発生するマージの際に、3-way マージ出力だと問題が分かりづらい場合があるので、『patch 適用失敗』の時みたいに、衝突部分を *.rej ファイル保存することは出来ないか?

『複雑な衝突が発生するマージ』の説明に、簡単な例で申し訳ないが、"hg merge" で衝突が発生した場合:

6
<<<<<<< local
7 modify this line @ b.txt
8
||||||| base
7
8
=======
7
8 modify this line @ a.txt
>>>>>>> other
9

上記のように、『マージ元 (= 第1親)』(local)、『共通の祖先』(base) と『マージ対象(= 第2親)』(other) 時点の内容が列挙される。

上記程度の単純な場合なら良いのだけれど、衝突範囲が広かったり、断続的に衝突していたりすると、最終的な落とし所が判断しづらい場合が結構ある。

で、『patch 適用失敗』の場合には:

Hunk #2 FAILED at 5
1 out of 2 hunks FAILED -- saving rejects to file b.txt.rej

みたいなエラーメッセージと共に、"b.txt.rej" ファイルに以下のような内容が保存される。

--- b.txt
+++ b.txt
@@ -6,7 +6,7 @@
 5
 6
 7
-8
+8 modify this line @ a.txt
 9
 a
 b

結局のところは『差分ファイル』なので、GUI とかを使ったマージに比べれば、視認性は劣ると言えるけれど、状況次第では確かに 3-way マージ結果よりも分かり易い気がする。

で、その場では:

『base と other の差分』から作ったパッチを、local に適用するような外部スクリプトを作ってやれば、出来ると思う

と回答したのだけれど、後になって考えてみると:

  • 外部のマージツールに指定されるのは、一時的に作成されたファイルだけ
  • 作業領域に取り込まれるのは『マージ結果』ファイルのみ
  • "*.rej" ファイルを自力で作業領域に格納しようにも、リポジトリの位置情報が無い (※ かろうじて PWD 環境変数で判定可能かな?)

という按配なので、internal:* 系列のマージ機能の1つとして実装する必要がありそうだと判明。

以下は、新しいマージ方式 "internal:patch" の追加実装の過程。

とりあえず、internal:* 系列のマージ機能が実装されている場所を特定する必要が有る。

ベタな手法だけど、"internal:" で grep すれば、簡単に mercurial/filemerge.py 辿り着く。

デフォルトで実行される "internal:merge" は、simplemerge.simplemerge() に丸投げしているみたいだけれど、別に 3-way マージしたいわけではないので、これはあまり参考にならないなぁ。

    if tool == "internal:merge":
        r = simplemerge.simplemerge( ...... )

そう言えば "hg help merge-tools" の翻訳の際に気付いたのだけれど、"internal:dump" なんてのが増えてるんだよなぁ。

    elif tool == 'internal:dump':
        a = repo.wjoin(fd)
        util.copyfile(a, a + ".local")
        repo.wwrite(fd + ".other", fco.data(), fco.flags())
        repo.wwrite(fd + ".base", fca.data(), fca.flags())
        return 1 # unresolved

『other と base 時点の内容をファイルに書き出して、マージ失敗とみなす』という仕様と上記のソース、この処理が書かれている filemerge() 関数の説明コメントから、以下のような作業方針が確定。

  • other の filectx (= fco) と、base の filectx (= fca) を元に差分を生成
  • マージ対象ファイルに対して差分を適用
  • マージ失敗 (= 衝突検出) を示す終了コード 1 で終了

差分の生成に関しては、まずは "hg diff" の実装である mercurial/commands.py の diff() 関数を確認。

処理の肝は、cmdutil.diffordiffstat() 呼び出しなのだけれど:

    m = scmutil.match(repo[node2], pats, opts)    
    cmdutil.diffordiffstat( ...., m, ..... )

上記のコードにおいて、scmutil.match() が返す m というのは、ファイル名とのマッチングを判定するオブジェクト (mercurial/match で定義)。要は、"hg diff sub/dir" とやった場合に、"sub/dir" に合致するファイルの差分だけ表示するための、絞込みに使うもの。

今回の用途的には:

  • ピンポイントで特定のファイルの差分が欲しい
  • filectx まで特定出来ているのに、わざわざ match オブジェクトを作るのは無駄
  • 差分結果をメモリなりファイルなりに書き出したい
  • ui への出力をバッファする手もあるけど面倒

ということで、cmdutil.diffordiffstat() の先に踏み込む事に。

"hg diff" に --stat オプションが無い場合、cmdutil.diffordiffstat() では patch.diffui() を呼び出している。

        for chunk, label in patch.diffui( .... ):
            write(chunk, label=label)

引数 fp にファイルオブジェクトを指定すれば、ファイルへの書き出しもやってくれるみたいだけど、ファイルの絞込みに関しては相変わらずアレなので、これも直接使えそうに無い。

更に patch.diffui() の先に踏み込む ..... 実は、ここで紆余曲折。patch.diffui() ⇒ patch.difflabel() まで進んだものの、そこで流れを見失ってしまったのだ。

今になって振り返れば、patch.diffui() ⇒ patch.difflabel() での呼び出しの際に、func 引数として patch.diff() を渡していて、結果的にこれが処理の肝だと分かるのだけど ....

確か『当該リビジョン時点のファイル内容は filectx.data() で取得』を頼りに、mercurial/patch.py を彷徨った結果、patch.diff() ⇒ patch.trydiff() と来て、ここで呼ばれる mdiff.unidiff() が実際の差分生成を行っていることが判明した記憶が。

    text = mdiff.unidiff(to, date1,
                        # ctx2 date may be dynamic
                        tn, util.datestr(ctx2.date()),
                        join(a), join(b), revs, opts=opts)

このエントリを書く段階で、つらつらソースを見返していたら、changectx.diff()@mercurial/context.py でサラっと patch.diff() 呼び出していた。ここから辿れば簡単だったのに .... orz

to/tn が filectx.data() であること、join() がパス名を生成してることが分かれば、とりあえず差分を生成する処理はでっち上げられそうな感じ。

    elif tool == 'internal:patch':
        fcactx = fca.changectx()
        fcoctx = fco.changectx()
        b2opatch = mdiff.unidiff(# ancestor
                                 fca.data(), util.datestr(fcactx.date()),
                                 # otehr
                                 fco.data(), util.datestr(fcoctx.date()),
                                 fca.path(), fco.path())

上記のようなコードを filemerge() に追加して、b2opatch ("base to other patch" ね) の内容を確認するべく "hg merge --tool internal:patch" を実施してみると .... やった!差分が取れてる!

次は、出来た差分をパッチとして適用する処理の実現。

これもやっぱり、"hg import" の実装である mercurial/commands.py の import() 関数の確認から開始。

色々入り組んではいるけれど、結局のところ、patch.patch() を実行すれば良いみたい。

    patch.patch(ui, repo, tmpname, strip=strip, files=files,
                eolmode=None, similarity=sim / 100.0)

問題は、差分情報が mdiff.unidiff() の戻り値としてオンメモりな状態なのに対して、patch.patch() は『パッチファイル名』か『パッチファイルのファイルオブジェクト』しか受け付けないという点。

最終的に patch.iterhunks() ⇒ class pactch.linereader に渡るファイルオブジェクトは、現状では readline() と close() しか使われてなさそうなので、適当に誤魔化す事も出来そうだし、Python 標準の StringIO とやらを使用しても良さそうではある。

但し、オンメモリで処理する不安としては、『大量の差分が生じた場合に、メモリ空間を圧迫する』事が上げられる。一旦ファイルに書き出してしまえば、読み込みに関しては一定量以上はメモリに載らないので、メモリへの負担が減る筈。

幸い、一時ファイルへの書き出しに関しては、mercurial/filemerge.py に大量にサンプルがある事だし、とりあえず差分は一旦ファイルに書き出す事に。

厳密には、局所変数からの参照が切れないとメモリ解放されないだろうから、最終的には実装を色々調整する必要があると思われるが、とりあえず方針としては『ファイルに書き出す』という事で。

        prefix = "%s~patch." % (os.path.basename(fd))
        (tmpfd, pfname) = tempfile.mkstemp(prefix=prefix)
        ui.debug('writing diff result into %s\n' % (pfname))
        f = os.fdopen(tmpfd, "wb")
        f.write(b2opatch)
        f.close()
        patch.patch(ui, repo, pfname, strip=1, eolmode=None)

上記のコードを追加して、早速マージを! ..... おぉ! saving rejects to してる! .... あれ?『衝突したから "hg resolve" しろよ!』メッセージが出ないなぁ。.... ありゃ?! "hg parents" で親が一つしか表示されない!どーして?どーして?

.... もしや?と思い、 --traceback 付きで実行してみると .... あちゃー!例外発生で、マージ処理が中断してる! rejected hunk があると patch.patch() って例外を投げるの?

確認してみたら、MQ でも例外を捕捉してた。

    try:
        fuzz = patchmod.patch(self.ui, repo, patchfile, strip=1,
                              files=files, eolmode=None)
        return (True, list(files), fuzz)
    except Exception, inst:
        self.ui.note(str(inst) + '\n')
        if not self.ui.verbose:
            self.ui.warn(_("patch failed, ....."))
        self.ui.traceback()
        return (False, list(files), False)

改めて patch.patch() を見てみると.... :

    try:
        if patcher:
            return _externalpatch( .... )
        return internalpatch( .... )
    except PatchError, err:
        raise util.Abort(str(err))

随分強引だなぁ、おい!(笑)

MQ みたいに全ての例外を問答無用で捕捉すると、実装エラー/データ不正/環境不正(システムコール失敗とか)の区別が付かなくなるので、出来ればそれは避けたいところ。

しかし、patch.patch() 内部で PatchError を握りつぶしてしまっているしなぁ ....

まぁ、"internal:patch" 実行の際に外部パッチツールを使うことも無いだろうということで、直接 patch.internalpatch() を呼び出す事に!

実装を修正して、再度マージを実施 .... おぉ! *.rej ファイルが生成されつつ、ちゃんとマージ処理は継続している!出来た出来た!

以上の成果を元に、ソコソコ動作確認した修正差分を末尾に掲載。

本家への提案に関しては、このパッチ自体に:

  • rename/copy が絡んだ場合に、*.rej のファイル名/diff ヘッダが元の名前ベースになってしまうのをなんとかしたい
  • メモリ消費低減等の対処の実施

上記のような改善点がある他に、mercurial/filemerge.py 自身が結構アドホックな感じなので:

  • 個々の internal:* 系マージツール実装の分離/整理
  • mercurial/commands.py での @command 的な仕組みの導入
  • mercurial/help/revsets.txt での '.. predicatesmarker' 的な仕組みの導入

などと合わせて提案しようと算段中。

実のところは、任意の internal 系マージツールをエクステンション経由で追加できるようにしておけば、最悪 "internal:patch" 自体の core への取り込みが却下されても、後でどうにでもできるという深謀遠慮(笑)。

もっとも、提案が採用されたとしても default ブランチ取り込み ⇒ 次のメジャーリリース時公開だろうから、リリース版で利用可能になるのは最速でも5月頭ってことになるのかな?

diff -r e1d8218d733b mercurial/filemerge.py
--- a/mercurial/filemerge.py	Mon Feb 06 15:36:44 2012 -0600
+++ b/mercurial/filemerge.py	Wed Feb 08 22:13:50 2012 +0900
@@ -7,7 +7,7 @@
 
 from node import short
 from i18n import _
-import util, simplemerge, match, error
+import util, simplemerge, match, error, mdiff, patch
 import os, tempfile, re, filecmp
 
 def _toolstr(ui, tool, part, default=""):
@@ -20,7 +20,7 @@
     return ui.configlist("merge-tools", tool + "." + part, default)
 
 _internal = ['internal:' + s
-             for s in 'fail local other merge prompt dump'.split()]
+             for s in 'fail local other merge prompt dump patch'.split()]
 
 def _findtool(ui, tool):
     if tool in _internal:
@@ -221,6 +221,44 @@
         repo.wwrite(fd + ".other", fco.data(), fco.flags())
         repo.wwrite(fd + ".base", fca.data(), fca.flags())
         return 1 # unresolved
+    elif tool == 'internal:patch':
+        os.unlink(b)
+        os.unlink(c)
+        # os.unlink(back)
+
+        ui.debug('orig=%s, fco=%s, fca=%s, fcd=%s\n' %
+                 (orig, fco.path(), fca.path(), fcd.path()))
+        fcactx = fca.changectx()
+        fcoctx = fco.changectx()
+        b2opatch = mdiff.unidiff(# ancestor
+                                 fca.data(), util.datestr(fcactx.date()), 
+                                 # otehr
+                                 fco.data(), util.datestr(fcoctx.date()), 
+                                 # treat as diff between origs,
+                                 # for merging between src/dst of copy/rename
+                                 orig, orig,
+                                 [ str(fcactx), str(fcoctx) ]
+                                 )
+        prefix = "%s~patch." % (os.path.basename(fd))
+        (tmpfd, pfname) = tempfile.mkstemp(prefix=prefix)
+        ui.debug('writing diff result into %s\n' % (pfname))
+        f = os.fdopen(tmpfd, "wb")
+        f.write(b2opatch)
+        f.close()
+        try:
+            patch.internalpatch(ui, repo, pfname, strip=1, eolmode=None)
+        except patch.PatchError, err:
+            # EXPECTED FAILURE
+            ui.warn(str(err) + '\n')
+            if orig != fcd.path():
+                # will be renamed after merge:
+                ui.warn(_("rejected hunks for %s are saved to %s.rej, "
+                          "because of renaming on other\n") %
+                        (fcd.path(), orig))
+        finally:
+            if not ui.debugflag:
+                os.unlink(pfname)
+        return 1 # unresolved
     else:
         args = _toolstr(ui, tool, "args", '$local $base $other')
         if "$output" in args: