1,000+のファイル移動の履歴をgitに正しく残す

NOTE

この記事はsquash mergeを採用したリポジトリを念頭においています。 それ以外を採用している場合は、ここで取り上げる問題に遭遇することはあまりないと思われます。

HQでは、プロダクトを長く健全な状態で保つため、技術的負債の返済にも継続的に取り組んでいます。 その一環で最近取り組んでいる課題の一つが巨大Goパッケージによるビルド時間の増加です。

Go はパッケージ単位でビルドを行います。 Go のコンパイルは非常に高速なので初めのうちは問題になりませんが、パッケージの肥大化はビルド時間の増加だけでなく、依存関係の広がりによる再ビルドの頻度増加という形で徐々に開発体験を悪化させます。

私たちのコードベースには論理的凝集で境界が設定されたパッケージがあり、関連する処理が増えるたびにそのパッケージにコードを追加してきました。 その結果、歯止めなく肥大化し、気づけば大量のファイルが1つのパッケージに集まる状態になっていました。

対応は単純で、大きなパッケージを複数の小さなパッケージに分割することです。 AI の力も借りて、その作業自体はつつがなく進みました。 分割単位でコミットも分かれ、適切に移動の履歴が残っています。

しかし、PRを作って開いた瞬間、そこに表示されていたのは膨大なdiffでした:

+153,310 -146,531

いくら 1,000 件を超えるファイルを移動し、それに伴う修正を行ったとはいえさすがに多すぎます。 どうやらファイルの移動が Git に正しく検出されず、削除と作成として扱われてしまったようです。 HQ では squash merge を使用しているため、このままマージすると履歴が分断されてしまいます。 今後の開発のためにも、履歴を正しく残さなければなりません。

なぜ移動が「削除と作成」になるのか

そもそも Git には、あるコミットが「これは移動です」と明示的に記録する仕組みがありません。 gitが保存しているのは各時点でのファイルの中身とパスの集合であり、「移動」という情報はコミットの後から、パスの変化と中身の類似度をもとに都度推測されているだけです。 この推測には二つの壁があります。

一つ目は類似度の壁です。 移動前後のファイルの中身の類似度が一定の閾値を下回ると、gitはその2つを同一の系譜とみなさず、別々の削除と作成として扱います。 デフォルトの閾値は50%で、移動と同時に大きく書き換えたファイルほどこの壁に触れやすくなります。

二つ目は件数の壁です。 gitは移動を検出するため、削除されたファイルと作成されたファイルのすべての組み合わせについて類似度を計算します。 この計算量はファイル数の掛け算で増えるため、対象ファイル数が diff.renameLimit という上限(Git 2.33以降のデフォルトは1,000)を超えると、gitは計算そのものをスキップし、すべてを削除と作成として処理します。 実際に手元でも次の警告が出ました。

warning: exhaustive rename detection was skipped due to too many files.
warning: you may want to set your diff.renameLimit variable to at least 1335 and retry the command.

gitがこのようなアルゴリズムで移動を検出する以上、一定規模を超えた移動と編集を同時に行うと履歴が失われることは避けられません。

一つの対応策は、PR を細かく分割して PR あたりの移動件数を 1,000 以内に抑えることです。 もし作業を始める前にこの問題に気がついていれば、この方法を選んだかも知れません。 しかし、今回はすでにファイル移動をやり終えており、しかも移動によって影響を受けたファイルの修正も同時に行ってしまっています。 今更 PR を分割するのは現実的ではありません。

実はファイルの中身を一切変更することなく移動だけさせた場合、Git は類似度の計算を行わずに移動をそのまま記録するため diff.renameLimit の影響を受けずに 1,000+ のファイル移動を Git に記録させることができます。 今回はこの性質を利用して、移動だけのコミットと修正だけのコミットに分け直すことで、移動の履歴を正しく残すことにしました。

squash merge という壁

移動だけを行うコミットを切り出せば、そこは確実に移動として記録できます。 実際に PR 内で「移動だけのコミット」と「修正だけのコミット」の2つに分け直しました。 これでレビューもしやすくなり、移動の検出も揺るがないはずでした。

ところがこのリポジトリはs quash mergeを 運用ルールにしていました。 squash merge では、PR 内のコミットがどれだけ分かれていても、マージの瞬間にすべてが 1 つのコミットへ圧縮されます。 main ブランチから見ると移動コミットと修正コミットの区別は失われ、結局「移動と修正が同時に行われた巨大な1コミット」に戻ってしまいます。 git loggit blame が実際に参照するのは main 上の履歴であり、PR 内でどれだけコミットを分けていても、そこに反映されなければ意味がありません。

この PR だけ例外的に merge commit を選んだ

運用ルールを崩さずに解決するなら、PR をファイル数十件から数百件程度の単位に分割し、何度にも分けてマージしていくしかありません。 何度もレビューを往復する手間に加え一度作った PR を分割し直す手間を考え、今回はチームに相談し、この PR に限って例外的に squash merge ではなく merge commit を選ぶことにしました。 マージコミット自体はブランチの内容を圧縮しないため、ブランチに積んだ移動コミットと修正コミットは、そのままの形でmainの履歴に残ります。

mainfeaturemain の既存コミットmove: ファイルの移動edit: 移動に伴う修正merge commit

移動コミットの時点では中身が完全に一致しているため、この一手だけで 1,000+ ファイルすべてが diff.renameLimit に関係なく移動として記録されます。 レビュアーも、修正コミットの差分だけを見れば、移動によって生じたコードの変更点に集中できます。

長大なブランチを移動コミットと修正コミットに分け直す

作業をした際にファイルごとに移動と修正をコミットしていたため、数百コミットのブランチを育ててしまっていました。 いまさら1コミットずつ移動用と修正用に分け直すのは現実的ではありません。 そこで、育っているブランチを退避させ、mainから作り直したクリーンなブランチの上で、移動だけを機械的に再現することにしました。

# 育ててしまったブランチを退避
git branch -m topic topic-old

# main から作り直す
git checkout main
git checkout -b topic

移動先のパスは、退避させた topic-old ブランチと main との差分から機械的に取り出せます。 git diff-z オプションを付けて NUL 区切りで出力させれば、ファイル名にスペースや日本語が含まれていても壊れずにパースできます。

git -c diff.renameLimit=5000 diff -z --name-status -M10% main topic-old

-M10% のように類似度の閾値を下げているのは、移動と同時に多少中身が変わっているファイルも拾いたいためです。 出力のうち rename と判定されたペアだけを取り出し、それぞれに対して git mv を実行すれば、移動だけを再現した状態になります。 中身は一切変えていないため、この時点でコミットすれば、それは完全一致移動として diff.renameLimit の影響を受けずに記録されます。

git commit -m "chore: ファイルの移動"

残るは修正コミットです。 ワーキングツリーの中身はtopic-oldの最終状態に置き換えつつ、コミットとしては移動コミットからの差分だけを取り出したいので、次の手順を踏みました。

# 移動だけが終わった位置に目印をつける
git branch temp-marker

# ワーキングツリーとインデックスを topic-old の最終状態にする
git reset --hard topic-old

# インデックスだけを移動直後の状態に戻す(ワーキングツリーは topic-old のまま)
git reset --soft temp-marker

git branch -d temp-marker

git reset --soft は HEAD とインデックスだけを移動させ、ワーキングツリーには触れません。 そのため、この時点で git diff --cached を見ると、移動コミットの中身と topic-old の中身との差分、つまり移動に伴う修正だけがステージされています。

git commit -m "feat: 移動に伴う修正"

これで、移動だけのコミットと修正だけのコミットの2つに整理し直されたブランチができあがりました。 あとは force push すれば、PR の差分も整理されます。

まとめ

1,000+ ファイル規模の移動と編集を同時に行う PR を作る前に、次の点を思い出したいところです。

  • 妥当な単位があれば PR を分割する
  • 一度に全てを対処する必要があってもなんとかなる

長く開発をしていると、どうしてもこうしたファイル構造の再構成が必要になる場面は出てきます。 そうした負債を後回しにせず、今後も積極的にリファクタリングをしていきたいですね。