Gitで上流の変更を取り込む前に確かめる3つのこと

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

Gitで上流の変更を取り込む前に確かめる3つのこと

本記事はプロモーションを含みます。

はじめに: 競合の数と、競合しない所の両方を見る

取り込む前に、上流に同じ変更が無いか、ディレクトリの改名が無いか、マージ後の木で壊れないかを確かめる

作業ブランチを main に追従させようとして git rebase main を打ったら、15 ファイルで競合した。逆に、競合は 1 ファイルだけで済んだのに、マージした後でテストが 10 件落ちたこともある。

どちらも、取り込む前に確かめれば防げた。筆者が先週踏んだ 3 つの場面から、結論は次のとおりだ。

  • 上流に同じ変更が入っているコミットは、差分が完全に同じなら rebase が自動で落とす。手直しされていれば落ちずに競合するので、git range-diff で見つけて git rebase --onto で外す
  • 上流がディレクトリを改名していると、こちらで足した新規ファイルは CONFLICT (file location) で止まる。パスで差分を取るツールは、移動と同時の内容変更を取りこぼす
  • 並行ブランチとの衝突は git merge-tree で作業ツリーを触らずに測れる。ただし衝突しないファイルは旧名のまま入るので、マージ後の木でテストを流す

以下、どれも git 2.55.0 で、何を比べてどう決めたかを書く。

上流に同じ変更が入っているコミット

作業ブランチのコミットの一部が、別の PR や cherry-pick で先に main に入っていることがある。このとき rebase の動きは、上流側のコミットが「同じ差分」かどうかで分かれる。

差分が同じなら、rebase は競合させずに落とす

上流に同じ差分のコミットがあると、rebase はそのコミットを載せ直さずに落とし、コミットが 1 つ減る

フォーマッタの 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 を付ける。

手直しされた変更は、range-diff で見つけて –onto で外す

range-diff で先頭 5 コミットが main と対応すると分かったので、rebase --onto で独自の 2 コミットだけ main に載せ替えた

困ったのは、ドキュメントを足すブランチを main に追従させたときだ。先頭の 5 コミットは、別の経路ですでに main に入っていた。それでも素の rebase では 15 ファイルで競合する状態だった。自動で落ちるのは差分が同じコミットだけで、上流側で手直しやまとめ直しを経て差分が変わった変更は、内容が同じでも落ちない。

選択肢は 2 つあった。

  • 素の rebase で 15 ファイルの競合を 1 つずつ解く
  • main と同じ変更のコミットを外し、このブランチにしか無いコミットだけを載せ替える

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 の差分を読み、こちらにしか無い変更が混ざっていないかを見る。混ざっていれば、その分は別のコミットにして残す。

上流がディレクトリを改名していた

足した新規ファイルは CONFLICT (file location) で止まる

上流が old/ を new/ に改名し、こちらが old/ にファイルを足していると、rebase は新しい場所へ移したうえで競合として止まる

原本の隣に翻訳ファイルを置くブランチを、上流に追従させたときに踏んだ。上流は 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 で自動にできる。

移動と同時の内容変更を、パスの比較で取りこぼす

旧パスと新パスで原本を比べるスクリプトは移動と同時の変更を拾えないので、git diff 旧:旧パス 新:新パス で手で突き合わせる

このブランチでは、上流で原本が変わった分を翻訳に反映するスクリプトを使っていた。原本の差分をパスで取る作りで、移動したファイルのうち 3 件の変更を拾えなかった。移動と同時に中身も変わっていたからだ。

パスが変わったファイルは、旧版のパスと新版のパスを指定して比べる。

git diff <旧版>:old/x.md <新版>:new/x.md

拾えなかった 3 件は、この形で原本の差分を出し、翻訳に手で当てた。rebase の競合が解けても、追従したことにはならない。改名があった回は、移った原本に変更が無いかを別に確かめる。

並行ブランチとの衝突は、マージ前に merge-tree で測る

作業ツリーを触らずに、衝突するファイルを出す

git merge-tree はマージ結果の木と衝突したファイルを出し、作業ツリーは変えない。衝突は up.sh だけだった

シェルの共通関数を改名するブランチ (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 だけだった。

衝突しないファイルは旧名のまま入る

衝突しなかったファイルは旧名の呼び出しのまま自動でマージされ、マージ後の木でテストを流すと 10 件落ちた

衝突が 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 して、競合を解いてから流す。

筆者はこの結果から、相手のブランチ側で旧名を新しい名前に直す追従をタスクに積んだ。どれだけ直せばよいかを、マージする前に把握できた。

まとめ

  • rebase は、上流に差分が同じコミットがあれば自動で落とす。後で 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 (リック・ウマリ 著、吉川邦夫 訳) 画像: 楽天市場

独習Git (リック・ウマリ 著、吉川邦夫 訳)

参考