彷徨えるフジワラ

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

"internal:patch" 実装経緯のメモ

『*.rej ファイル生成』形式のマージの実装過程で得た知見のまとめ。

※ Bitbucket 上で hgext-mergebypatch として公開してます

"mdiff.unidiff()" と "patch.internalpatch()" の組み合わせで、とりあえずは所望の機能が実現出来ることを確認出来たのだが、案の定と言うか予定通りと言うか、rename/copy が絡んだマージだと、思ったように動かない。

rename/copy が絡んだケースでも、「local 側で改名済み」の場合は上手く行くが、「other 側で改名」の場合は上手く行かない。具体的には、hunk 適用処理までは辿り着くものの、*.rej ファイルが生成されないのだ。

はたと思い立って、"patch.PatchError" を捕捉するのを止めてみたところ .... ビンゴ! dirstate によって状態管理されていないファイルに対するパッチ適用は、patch.PatchError 浮揚で中断されていた!

マージ処理 (= "filemerge()" 呼び出し) って、作業領域の親コンテキスト等の設定が済んでから呼ばれるのかと思いきや:

  1. manifest 同士の比較を元に、実施する処理を確定
  2. 確定した処理を元に、作業領域中のファイル内容を改変 (作成/破棄/マージ etc...)
  3. 作業領域の親コンテキストの設定
  4. 確定した処理を元に、dirstate を更新

という順序で処理されている模様 ("update()@mercurial/merge.py") 。
改名前のファイルに関しては dirstate が生きているので、「改名前のファイルにパッチ適用」と言う考えもあるのだが、「other 側でファイルが改名」されている場合は、改名元のファイルを削除してから "filemerge()" が呼ばれるのだ。

そのため:

  1. 改名前の名前で local 側内容を書き出し
  2. 改名前ファイルに対してパッチを適用
  3. 改名後の名前に改名 (「*.rej」ファイルも)

という感じの手順が必要になる。

実は一旦この方式で実装してみたのだが、例えば:

other 側で A を B に、C を A に改名

みたいな場合だと、local 側から見て「改名前の名前 A」には、既に別な内容のファイルが存在することになるので、処理の順序 (多分名前の辞書順) 次第では、最悪マージ結果が格納されたファイルの内容を破壊する可能性が出てくる。

そういった状況を回避するには、「改名前の名前」のファイルが存在する場合、マージ実施前にそのファイル (+ 「*.rej」ファイルも) を退避しておく必要がある。

1つのリポジトリ上でマージ処理が並行して実施される心配が無い事から、退避ファイル名は ".hg/xxxxxxx" みたいな感じで固定出来るのが幸いと言えば幸いなのだけれど、実際に実装すると、何と言うか不恰好なんだよねえ(笑)。

以上のような経緯で、「改名前ファイルに対してパッチ適用」案は却下。

そうなると、「暫定的に dirstate に状態を記録 〜 マージ 〜 状態破棄」という方法しか無さそう。

そこで気になるのが以下の点:

一時的な状態作成 〜 破棄によって、余計な副作用は無いか?

ここまで来たなら腹を括るしか無いので、腰を据えて merge.py と dirstate.py を調べる事に。

まずは「マージ処理」前後で dirstate に対して何を実施しているのかを調べるために、merge.py を彷徨うことに .....

"manifestmerge()@mercurial/merge.py" によると、一口に「マージ処理」と言っても、以下の4パターンがあるらしい:

  1. バージョン間で単純に差異があるケース ('versions differ')
  2. local で複製/改名されているケース ('local copied/moved to')
  3. other で複製されているケース ('remote copied to')
  4. other で改名されているケース ('remote moved to')

# 括弧内は debug 情報を出力した際に表示されるメッセージ

同じ Mercurial のソース内でも、other/remote とか、base/ancestor とか、同義語のバリエーションが混在してるのはご愛嬌(笑)。

この4パターンに該当する場合に、"applyupdates()" 〜 "mergestate.resolve()" を経由して "filemerge()" が呼ばれているのだ。

上記のパターンのうち、(1) と (2) に関しては既に local 側に対象ファイルが存在しているので、dirstate での状態管理について配慮が必要になるのは (3) と (4) のケースのみ。

そこで、(3) と (4) についてマージ実施後の dirstate 更新で何をやっているのか、"recordupdates()@mercurial/update.py" を確認すると、マージ結果格納先ファイル ("fd") に着目した場合は、いずれのパターンでも:

  • ブランチ間マージの場合は "merge(fd)" および "copy(f, fd)"
  • リニアアップデートでのマージの場合は "normallookup(fd)"

といった具合。

「ブランチ間マージ」は、いわゆる "hg merge" によるマージの事で、「リニアアップデートでのマージ」は、CVSSubversion での update において発生している「未コミット成果ベースのマージ」の事。

※ リンク先では「未記録成果ベース」表記ながら、書籍の方では「未コミット成果ベース」と表記しているのでそちらに統一

そこで、dirstate の merge()/copy()/normallookup() 実装を確認してみると:

  • "merge()" や "normallookup()" の直接的な効果は "drop()" で破棄可能
  • "merge()" や "normallookup()" は "copy()" 結果を破棄してしまう
  • fd に対する現在の "copy()" 状況は "copied(fd)" で取得可能

という感じだったので:

  1. 対象ファイル fd が dirstate 管理下に無い事を確認
  2. fd に対する "copied(fd)" を保存
  3. "normallookup(fd)" により dirstate に管理状態を一時的に作成
  4. パッチ適用の実施
  5. "drop(fd)" により dirstate の管理状態を破棄
  6. "copied(fd)" 結果を "copy()" により復旧

という手順で処理すれば、dirstate 状態の整合性を保てそう。

マージの際に "copied(fd)" 状態の復旧が本当に必要かはイマイチ分かってないのだけれど、一応念のためということで。

Matt に突っ込まれそう (^ ^;;;)

終わってみれば、「改名前のファイル名にパッチ適用」でチマチマとファイルを退避するよりも、断然簡素な実装に。実装部分よりも、implementation notes の方が多い気もするが(笑)、まぁ、良しとしよう。

マージ挙動の場合分けが整理出来たので、テストも綺麗に実装出来たのが嬉しい副作用。

最悪、"filemerge()" のリファクタリングさえ採用されれば、"internal:patch" はエクステンション経由で追加可能になるので、patch #1 だけは何とか採用されてくれないかなぁ .... ⇒ 採用されました!

さて、本当はここで終わりの筈だったのだけれど、パッチ投函に向けてテストを書いている段階で、マージ結果に思わぬ誤算が。

元々のパッチ適用方針は以下の通り。

base と other の差分を取って、local に適用する

この方針の場合、*.rej ファイルには other 側の差分が格納されることになる。

「ブランチマージ」の場合はこれで構わないのだけれど、「リニアアップデートマージ」の場合はちょっと事情が違ってくる。

「リニアアップデートマージ」の場合、local の内容はあくまで「作業領域中の一時的な状態」であるのに対して、other の内容は「既に履歴にコミットされた状態」なので、あくまで個人的な感覚だけど、*.rej ファイルに格納されるのは local 側の差分の方が良くねぇ?と言う気が。

しかし、残念な事に "filemerge()" 側には「ブランチマージ」なのか「リニアアップデートマージ」なのかは伝わってこないのだ。

API を改変しても良いけど、呼び出し元も含めて影響範囲が結構広い ....

となると、マージの種別に関わりなく:

base と local の差分を取って、other に適用する

という方針にせざるを得ない。

まぁ、マージする場合、other よりも local の方が既知であることが殆どだろうから、local が *.rej に格納された方が衝突解消がやり易い、という発想も出来るかも?ということで、差分適用方針を変更することに。

結局、試作開始から1週間弱の間、掛かりっきりになってしまったけれど、色々分かってきたのでそれなりに楽しかったと言うことで、この話はおしまい。