main が進んだので rebase したら 15 ファイルで競合する。競合なしで通ったのに、マージ後にテストが落ちる。上流に入った同じ変更、ディレクトリの改名、並行ブランチの改名で踏んだ 3 つと、取り込む前に確かめる方法を解説する。

本記事はプロモーションを含みます。
作業ブランチを main に追従させようとして git rebase main を打ったら、15 ファイルで競合した。逆に、競合は 1 ファイルだけで済んだのに、マージした後でテストが 10 件落ちたこともある。
どちらも、取り込む前に確かめれば防げた。筆者が先週踏んだ 3 つの場面から、結論は次のとおりだ。
git range-diff で見つけて git rebase --onto で外すCONFLICT (file location) で止まる。パスで差分を取るツールは、移動と同時の内容変更を取りこぼすgit merge-tree で作業ツリーを触らずに測れる。ただし衝突しないファイルは旧名のまま入るので、マージ後の木でテストを流す以下、どれも git 2.55.0 で、何を比べてどう決めたかを書く。
作業ブランチのコミットの一部が、別の PR や cherry-pick で先に main に入っていることがある。このとき rebase の動きは、上流側のコミットが「同じ差分」かどうかで分かれる。
フォーマッタの hook などで、同じ整形が両方のブランチに別のコミットとして入ることがある。この場合、素の git rebase main でよい。rebase はこちらのコミットを事前に除く。
man git-rebase の --reapply-cherry-picks の節には次のようにある。
In the absence of –keep-base (or if –no-reapply-cherry-picks is given), these commits will be automatically dropped.
使い捨てのリポジトリで、1 つ目のコミットを main に cherry-pick してから rebase すると、次の警告を出してそのコミットを飛ばした。
$ git rev-list --count main..feat
2
$ git rebase main
warning: skipped previously applied commit 58987cd
hint: use --reapply-cherry-picks to include skipped commits
$ git rev-list --count main..feat
1
rebase の後にコミット数が減る。git log --oneline main.. で並びを見て、消えたのが意図したコミットかを確かめる。残したいときは --reapply-cherry-picks を付ける。
困ったのは、ドキュメントを足すブランチを main に追従させたときだ。先頭の 5 コミットは、別の経路ですでに main に入っていた。それでも素の rebase では 15 ファイルで競合する状態だった。自動で落ちるのは差分が同じコミットだけで、上流側で手直しやまとめ直しを経て差分が変わった変更は、内容が同じでも落ちない。
選択肢は 2 つあった。
5 コミットが本当に main と同じ内容かは、git range-diff で確かめた。range-diff は 2 つの範囲のコミットを、差分が十分近いもの同士で対応付けて並べる。
git range-diff main...HEAD
対応したコミットには = (同じ) か ! (少し違う) が付き、片方にしか無いコミットには < か > が付く。先頭 5 つが main 側と対応し、残る 2 つだけが独自のコミットだと分かったので、載せ替えを選んだ。
# <5 つ目のコミット> より後ろ (= 独自の 2 コミット) だけを main の上に載せる
git rebase --onto main <5 つ目のコミット>
--onto main X は「X より後ろのコミットを main の上に載せ直す」という意味だ。main と同じ変更のコミットは載せ直さないので、それらが起こしていた競合は出ない。
! が付いた組は、内容が少し違う。外す前に range-diff の差分を読み、こちらにしか無い変更が混ざっていないかを見る。混ざっていれば、その分は別のコミットにして残す。
原本の隣に翻訳ファイルを置くブランチを、上流に追従させたときに踏んだ。上流は 21 件の変更の中で、ディレクトリの移動やファイルの改名をしていた。こちらは旧ディレクトリに新規ファイルを足していた。
この形だと、rebase は新規ファイルを改名後のディレクトリへ移したうえで、競合として止まる。
$ git rebase main
CONFLICT (file location): old/y.md added in 7797a45 (add y) inside a directory that was renamed in HEAD, suggesting it should perhaps be moved to new/y.md.
$ git status --short
UA new/y.md
止まるのは、merge.directoryRenames の既定値が conflict だからだ。man git-config では、true なら新規ファイルを新しいディレクトリへ移し、conflict なら競合として報告する、とある。git は移し先の推測を自動では確定させず、人に確かめさせる。
移し先が正しければ、そのまま add して続ける。
git add new/y.md
git rebase --continue
毎回確かめる必要が無いリポジトリなら、git config merge.directoryRenames true で自動にできる。
このブランチでは、上流で原本が変わった分を翻訳に反映するスクリプトを使っていた。原本の差分をパスで取る作りで、移動したファイルのうち 3 件の変更を拾えなかった。移動と同時に中身も変わっていたからだ。
パスが変わったファイルは、旧版のパスと新版のパスを指定して比べる。
git diff <旧版>:old/x.md <新版>:new/x.md
拾えなかった 3 件は、この形で原本の差分を出し、翻訳に手で当てた。rebase の競合が解けても、追従したことにはならない。改名があった回は、移った原本に変更が無いかを別に確かめる。
シェルの共通関数を改名するブランチ (error => die など) を出したとき、並行して進んでいた別のブランチが旧名を 16 か所で新しく使っていた。どちらを先にマージしても、もう片方の追従が要る。どれだけ壊れるかを、マージする前に知りたかった。
git merge-tree --write-tree は、作業ツリーもインデックスも触らずにマージを計算する。終了コードは衝突なしで 0、衝突ありで 1 だ。--name-only を付けると、衝突したファイル名だけを出す。
$ git merge-tree --write-tree --name-only rename other
c3490869016a91536c2ed0ffc2e643d60663c06b
up.sh
Auto-merging up.sh
CONFLICT (content): Merge conflict in up.sh
$ echo $?
1
1 行目がマージ結果の木、次が衝突したファイルだ。実際の 2 本でも、衝突は up.sh だけだった。
衝突が 1 ファイルなら、追従は軽いと読みたくなる。しかし相手のブランチが旧名を新しく使った 16 か所のうち、こちらの改名が触った行と重ならない所は、衝突せずに旧名のまま自動でマージされる。
そこで、衝突の有無ではなくマージ後の木でテストを見た。結果は 10 件落ちると分かった。衝突の数だけ見ていれば、マージした後に気付くところだった。
マージ後の木は merge-tree の出力からコミットにできる。man git-merge-tree の USAGE NOTES にある手順だ。
tree=$(git merge-tree --write-tree rename other) || { echo "衝突あり" >&2; exit 1; }
commit=$(git commit-tree "$tree" -m "merge preview" -p rename -p other)
git worktree add --detach ../merge-preview "$commit" # ここでテストを流す
衝突があるときは出力に木以外の行も混ざるので、この手順は衝突が無い組み合わせで使う。衝突がある組み合わせは、使い捨ての worktree で実際に git merge して、競合を解いてから流す。
筆者はこの結果から、相手のブランチ側で旧名を新しい名前に直す追従をタスクに積んだ。どれだけ直せばよいかを、マージする前に把握できた。
git log を見てコミット数を確かめるgit range-diff main...HEAD で対応を確かめ、git rebase --onto main <最後の重複コミット> で独自のコミットだけ載せるCONFLICT (file location) で止まる。移し先を確かめて add する。移動と同時の内容変更は git diff 旧:旧パス 新:新パス で比べるgit merge-tree で衝突を測る。衝突しないファイルは旧名のまま入るので、マージ後の木でテストを流すrebase の基準にする「既定ブランチ」をスクリプトで決めるときの落とし穴は Gitの既定ブランチと派生元をスクリプトで判定する落とし穴 に、取り込みが済んだブランチの片付けは Gitのマージ済みブランチを一括削除する方法 に書いた。
rebase や merge が内部でコミットと木をどう扱っているかを手を動かして確かめるなら、次の本が手元にあると引きやすい。
独習Git (リック・ウマリ 著、吉川邦夫 訳)
--onto、--reapply-cherry-picks)merge.directoryRenames)--write-tree、--name-only、USAGE NOTES)