gitで本文中のメタ情報を無視して原稿をマージしたい
書籍の自動組版では「原稿の本文中に挿入するしかないメタ情報」を扱います。「原稿の本文中に挿入」というのが曲者で、往々にしてメタ情報は人間にとっては「ノイズ」です。人間だけでなく、grepのようなテキスト処理プログラムにとってもノイズです。なので、本文の執筆や編集をする環境からは隠しておきたい。
隠す方法はいくつかあります。リッチなテキストエディタにはそのための拡張を作る機能もあるでしょう。しかし、個人的には「メモリ上にあるデータが常に漏れなく文字として表示されている」ことがテキストエディタを使いたい理由なので、表示をオンオフするのは心情的にあまりやりたくありません。
そこで便利なのがgitのブランチです。具体的には以下のようにブランチを使い分けます。
- mainブランチは「メタ情報なし」で、人間が本文の編集に専念する
- それとは別のブランチで、自動組版に必要な「メタ情報あり」の原稿を管理する
- mainでの編集内容は、「メタ情報あり」ブランチに適宜マージする
- 自動組版には「メタ情報あり」ブランチを使う
ここで問題になるのが上記の3つめのマージ作業です。「メタ情報あり」ブランチは、いわば「mainから派生した編集(=メタ情報の挿入)が常に存在する」という状態です。したがって、mainにおいて「メタ情報が挿入されている行」に対する何らかの変更があると、マージが必ず衝突します。
とはいえメタ情報は、その性質上、ある程度までは機械的に「ある行のどこに挿入されているか」を割り出せます。つまり、ここで発生する衝突は、人間による判断が解消のために必要になるような複雑なものではありません。言い換えると、「たいていは機械的に解消できるはず」です。というわけで、機械的に解消する方法を考えます。
gitがファイルをマージするときに実際に何をしてるか
gitのマージは、だいたい以下のような流れで実施されます(同名のファイルにおける挙動)。
- 「現在のブランチで作業中のコンテンツ」(ours)と、「マージ対象」(theirs)との、「共通の祖先」(base)を取ってくる
- 「baseに対してoursで変更されている部分」と「baseに対してtheirsで変更されている部分」を突き合わせる
- 同じ部分でそれぞれの編集内容が違ったり矛盾があったりしたら「衝突」[1]
前後の変更だけをみてマージするのではなく、3つの状態を比べてマージするので、これを「三者マージ」と呼びます。
で、いまやりたかったのは、「メタ情報あり」ブランチに「メタ情報なし」ブランチでの編集内容をマージすることです。この状況では、「メタ情報あり」ブランチがours、「メタ情報なし」ブランチがtheirs、そしてメタ情報を付ける前がbaseという対応になっています。どちらもbaseに対して差分があるので、上記のマージにより衝突が不可避なことがわかります。
しかし、よくよく考えると、いまやりたいのは「メタ情報を取り去った状態でoursにtheirsとの差分を取り込む」ことです。つまり、oursやtheirsとの比較においてbaseであってほしいのは「baseからメタ情報を取り去ったもの」です。
また、oursにおける修正があるとしたら、それは「メタ情報に対する変更」のみです。theirsにはそもそもメタ情報がないので、メタ情報を挿入しているだけのoursにおける修正については、theirsのほうで考慮する必要がまったくありません。したがって、このマージにおいて衝突を検知するためにoursであってほしいのは「oursからメタ情報を取り去ったもの」です。
要するに、gitが三者マージを試みる際にメタ情報がない状態に揃えてあげることができれば、そもそもメタ情報に起因する衝突は起きないはずです。
gitattributesによる「ちょっと独自のマージ」の実現
問題は、「独自に実装したマージをgitに使わせることは可能か」です。完全に独自のマージを実装するとしたらgitそのものを作り直すしかありませんが、実は「三者マージのときにours、theirs、baseをいじる」ための手段はあります。gitattributesがそれです。あらかじめ用意された「属性」に対する挙動を独自に設定できるという仕掛けなのですが、属性の1つとして「merge」があり、それに設定する値によってファイル単位での三者マージに対する独自の挙動を定義できます。実はテキストファイルをマージして衝突したときに「<<<<<<<」のような記号が挿入されるのも、このmerge属性に設定する値text
に対する組み込みの挙動として規定されています。
gitattributesを使うには、.gitattributes
というファイルに「どのファイルで、どの属性に、どんな挙動をさせるか」を指定します。「マージのときに原稿のMarkdownファイルではメタ情報を取り去ってから三者マージをする」をしたければ、「Markdownで、merge属性に、いい感じのスクリプトを実行するための値を設定する」という感じです。「いい感じのスクリプトを実行するための値」は、gitattributesではなく、予めgit configレベルで独自に「いい感じのスクリプトを実行するmerge属性用の値」として用立てておく必要があります。
というわけで、まずはgit configに「そういう値」を用意しましょう。値の名前はなんでもいいので、ここでは"skip-tags"
とします。
[merge "skip-tags"]
driver = ruby skip-tag-merge.rb "%O" "%A" "%B"
設定しているdriver
の値が「三者マージの際に実行したい、いい感じのスクリプト」です。引数の"%O" "%A" "%B"
は、merge属性が期待する固定的な文字列で、それぞれbase、ours、theirs(に対応した一時ファイルのパス)が渡されます[2]。つまり、そういう引数を取るskip-tag-merge.rb
というスクリプトをこれから書きます。
その前に、肝心の.gitattributes
ファイルを用意しておきましょう。このファイルに書くべき内容は、「すべての階層にある拡張子が.md
のファイルについて、merge属性にskip-tags
を値として指定する」というものです。下記のような書式で記述します。
**/*.md merge=skip-tags
あとはskip-tag-merge.rb
を書くだけです。こういう処理を書きます。
- メタ情報がない状態に揃えて、マージを試みる
- メタ情報があった場所に、メタ情報を書き戻す
- メタ情報があった場所がなくなってたら、衝突を表すマークを挿入する
スクリプトの実装は略。ここまで仕様をかみ砕いておけば生成AIが一発で書いてくれます。(実は「書き戻す場所」を覚えておくのが完璧にできてない)
-
実際には他にもいろいろな理由で衝突しますが、ここでは省略。 ↩︎
-
固定的な名前の引数はこれだけではなく他にもいっぱいあります。詳しくはgitattributesのドキュメントを参照。ちなみに
%O
はOursじゃなくてたぶんOriginとかなんだろう。 ↩︎
Discussion