『*.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: