Gitで管理されたPJにおいて、1つの大きなファイルを2つ以上に分割するリファクタをする時、
- 片方だけが「rename」として扱われる
- もう片方は「新規ファイル」と見なされて、
git blameが全て「分割した自分のコミット」になってしまう
という経験はないでしょうか。
リファクタリングでファイルを分割しつつも、以前のコミットメッセージをgit blameで参照出来るようにしておきたいという需要はあるかと思います。
今回「できるだけシンプルな手順で 1つのファイルを2つに分割しても、両方のファイルから元の履歴を辿れるか?」
を実験した結果をまとめます。
このエントリのまとめ(先に読みたい人向け)
- Git は「リネームされた」という情報をコミットに保存しておらず、削除されたファイルと追加されたファイルの類似度から毎回推測している(Git 公式ドキュメントの
gitdiffcore(7)の diffcore-rename セクション に「削除されたfileXと追加されたfile0の内容が十分似ていればR100 fileX file0として扱う」と言う旨の記述があります。※ここでのR100はR<類似度%>という形式です。Rが rename、100` が類似度 100% を意味します)。 - 1コミットの中では、「削除」と「追加」のペア付けは 類似度が一番高い 1組だけ が選ばれる。
- ところが
git log --followやgit blameの様に 1ファイルずつ履歴を遡る処理 では、毎回そのファイル単体から類似度判定が行われる。 - その結果、差分 (
git show --stat) では「1つだけ rename、もう1つは新規」、一方で履歴追跡 (git log --follow/git blame) では「2つとも同じ元ファイルに辿り着く」という現象が起こりうる。 - 参考にした Old New Thing の記事 では、複数ブランチとマージを使って 3 つの分割先ファイル全てで blame を維持する手法が紹介されている。
- 本記事では、元ファイルのコピーを2つ作成し、元ファイルを削除してからコミットすると、両方のファイルで元のファイルの履歴が引き継がれることを検証している。
背景:Git のリネーム検出は「保存」ではなく「推測」
まず前提として、Git はコミットの中に
「このファイルは A から B にリネームされました」
という情報を直接保存していません。
実際には、コミットの内容から
- どのファイルが削除されたか
- どのファイルが追加されたか
- それぞれの中身がどれくらい似ているか(類似度)
を計算して、「たぶんこれはリネームだろう」と判断しています。
この挙動は、Git の内部フィルタである diffcore の解説ドキュメント gitdiffcore(7) の「diffcore-rename」セクションに、削除されたエントリと新規追加されたエントリの内容が「十分似ている(similar enough)」場合に R100 old new のような rename として扱われる、という形で記述されています。
この「類似度による推測」が行われるタイミングは大きく2つあります。
- コミット差分を表示する時(
git show,git diff,git log --statなど) - 特定のファイルの履歴を遡る時(
git log --follow,git blameなど)
ポイントは、
-
- の時は「そのコミットに含まれる 全削除ファイル × 全追加ファイル の組み合わせ」から、類似度が最大の 1組だけを rename と見なす
-
- の時は「そのファイル 1つだけ に注目して、親コミットのファイルと似ているかどうかを判定する」
つまり、同じ「rename 検出」でも前提がかなり違うということです。
How do I split a file into two while preserving git line history?の紹介
今回の検証のきっかけになったのが、Raymond Chen 氏によるこちらの記事です:
- How do I split a file into two while preserving git line history?(The Old New Thing)
この記事では、foods という 1つのファイルに
- 果物の一覧
- 野菜の一覧
- 乳製品の一覧
が順に書かれている、というシンプルなリポジトリを例にしています。
素直に分割すると何が起こるか
foods を
fruitsveggiesdairy
の 3つに一気に分割するコミットを作ると、Git は
- 行数が一番多いファイルだけを「
foodsからの rename」と判定 - 他の 2つは「新規ファイル」と見なす
- 結果として、小さい2ファイルの
git blameは、行の大部分が「分割した人のコミット」になってしまう
という問題が起こる。というのが記事の出発点です。
記事で紹介されている解決策
この問題に対して、記事では次のような解決策を提案しています(ざっくり要約です)。
- 分割先ごとにブランチを切る(
fruitsブランチ、veggiesブランチ、dairyブランチ)。 - 各ブランチで
- まず
foodsをそのブランチのファイル名(例:fruits)に リネームだけ するコミットを作る。 - 次のコミットで、そのファイル内から不要な行を削除する。
- まず
- 最後に、3つのブランチを octopus マージ(
git merge fruits veggies dairyのように、3 本以上のブランチを一度に統合する多元マージ)で統合する。
こうすることで、3つ全てのファイルが「元の foods からの rename」と認識され、
git blame でも元の作者(記事では Alice / Bob / Carol)の行単位の履歴がきちんと残ることが示されています。
つまり履歴は記録を保存している訳ではなく、推定しているということが分かります。
この内容でも十分にリファクタリング時に履歴を引き継ぎたいという要望には対応することが出来ますが、
推定という点を考慮し、より簡単な方法で履歴の引き継ぎが出来ないかを検証します。
実験:1ファイルを2つにコピーして元を削除する
ここからは、実際に行った実験のログをベースに手順を紹介します。
今回は、前述の記事との差分として、ブランチを切らず 1 回のコミットで再現出来るかを検証します。
1. 元ファイルを作る
まずは適当なリポジトリを作り、original.txt をコミットします。
git init
cat > original.txt <<'EOF'
Line 01: alpha
Line 02: bravo
EOF
git add original.txt
git commit -m "Add original file"
2. ファイルをコピーして、元を別の名前にリネーム
次に、original.txt をそのまま part1.txt と part2.txt にコピーし(うち 1 つはリネーム)、元を削除します。
cp original.txt part1.txt
mv original.txt part2.txt
git add -A
git commit -m "Split original into part1 and part2"
3. コミット差分を確認する
この状態で git show --stat を見ると、次のようになります。
git show --stat
出力のイメージ:
original.txt => part1.txt | 0
part2.txt | 2 ++
-
original.txt => part1.txtは rename と判定されている -
part2.txtは単なる新規追加ファイル扱いになっている -
part2.txtを元のファイル名に戻したい場合は次のコミットで実行。
つまり、「1コミットの中では 1つだけ rename 扱いになる」という、Git の内部フィルタである diffcore の解説ドキュメント gitdiffcore(7) に記載された挙動そのものになっていることが分かります。
4. 各ファイルから履歴を辿ってみる
では、part1.txt と part2.txt からそれぞれ履歴を辿るとどうなるでしょうか。
git log --oneline --follow part1.txt
git log --oneline --follow part2.txt
どちらも
- 直近のコミット: 「Split original into part1 and part2」
- その一つ前のコミット: 「Add original file」
と表示され、両方のファイルが original.txt の履歴まで遡れていることが分かります。
更に git blame してみると、
git blame part1.txt
git blame part2.txt
出力のイメージは次のようになります。
^<hash> original.txt (... 1) Line 01: alpha
^<hash> original.txt (... 2) Line 02: bravo
part1.txt、part2.txt のどちらに対して git blame を実行しても、全ての行が original.txt を作成した同じコミットに紐付いて表示されます。
つまり、
- コミット差分では「1つだけ rename、もう1つは新規追加」
- しかし blame の結果は「どちらも元の
original.txtの行として扱われる」
という状態になっているわけです。
仕組みの整理:なぜ両方から履歴が遡れるのか
ここまでの結果を、仕組みの観点から整理してみます。
コミット差分の場合(git show / git diff)
コミット差分を表示する時、Git はそのコミットに含まれる
- 全ての削除ファイル
- 全ての追加ファイル
の組み合わせについて類似度を計算し、一番似ている 1組だけ を rename と見なします。
今回の例では、
- 削除:
original.txt - 追加:
part1.txt,part2.txt
という組み合わせがあり、どちらも中身は 100% 同じです。
その為 Git から見れば
-
original.txt → part1.txt(類似度 100%) -
original.txt → part2.txt(類似度 100%)
と、同点の候補が 2つある状態になりますが、どちらか一方だけが rename として選ばれ、残りは新規ファイル扱い になります。
これが git show --stat で「片方だけ rename」と表示された理由です。
履歴追跡の場合(git log --follow / git blame)
一方で git log --follow や git blame は、「特定のファイルから親コミットに遡る」処理を繰り返しています。
例えば git log --follow part2.txt の場合、
- まず「
part2.txtが存在する最新コミット」を見つける - そのコミットの親を見に行き、「親側のどのファイルが
part2.txtに一番似ているか」を判定する - 一番似ているファイルがあれば、「そのファイルの過去」として更に親を辿る
という流れになります。
今回のコミットでは、
- 親コミットには
original.txtしか存在しない - 且つ
part2.txtとoriginal.txtは中身が完全に一致
という条件なので、
- 「
part2.txtと最も似ているファイル」=original.txt
と判断され、original.txt の履歴へと素直に繋がります。
part1.txt についてもまったく同じ理由で original.txt に繋がる為、
- 両方のファイルが同じ祖先(
original.txt)を持つ
という結果になるわけです。
※補足
コミット差分では「そのコミット内の削除ファイルと追加ファイルの全組合せの中から、一組だけを rename として結び付ける」のに対して、
履歴追跡では「各ファイルごとに、親コミットのツリーの中から最も似ているファイルを独立に探す」という違いがあります。
今回の例では part1.txt / part2.txt ともに original.txt と類似度 100% ですが、差分表示ではそのうち一組だけが rename として選ばれます。
なお、「どちらが rename と判定され、どちらが新規扱いになるか」は Git の内部実装が持つファイル列挙順や候補リストの並び順に依存しています。
重要なのは「削除された 1 ファイルに対して差分表示上は 1 つの追加ファイルしか対応付けられない」という制約と、履歴追跡では各ファイルが個別に親ツリーを探索する、という二点です。
実務で使うときの注意点
実際のリファクタリングでは、分割後の各ファイル(手順上は一度コピーとして作ったファイル)に対して
- 行を追加・削除する
- 変数名や関数名を変える
- ついでに少しリライトする
といった変更が同時に入ることが多く、1コミットの中で上記手法とリファクタリングを行うと類似度にズレが生じる為、判定が変わり、より類似度が高い方のみがリネーム先と見なされます。
その為、「分割の為のコピーだけを行うコミット」と「分割後のリファクタをするコミット」を分ける 必要があります。
- まず本記事の手法で「元ファイル → 複製×N」にするコミットを 1 つ作る(このコミットでは中身はいじらない)。
- その後、別コミットでリファクタリングを行っていく(ここで本来の意味での分割が行われる)。
こうしておけば、「分割用の中間コミット」の時点では元ファイルと分割先ファイルの類似度がほぼ 100% の為、Git にとって rename 判定が安定します。
後続コミットでどれだけ書き換えても、「分割用コミットより前」の履歴はこの rename を経由して辿れるので、元の実装の作者情報は残ります。
今回の実験手法自体は正式に機能として提供されているものではない為、ツールによっては正常に履歴が追えないなどの可能性がある為、あくまで挙動理解の為のものです。
実際に運用しているGitレポジトリで試す際は自己責任の上、入念な検証を検討頂ければと思います。
おまけ:スクリプトでこの手順を自動化する
毎回 cp や git rm を手で打つのは地味に面倒なので、簡単なシェルスクリプトにまとめておくと便利です。
例えば、リポジトリのルートに git-split-with-history.sh という名前で次のようなスクリプトを置きます。
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 3 ]; then
echo "Usage: $0 <original> <new1> <new2> [<new3> ...]" >&2
exit 1
fi
orig=$1
shift
# Git リポジトリ内かどうかを確認
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Error: not inside a git repository." >&2
exit 1
fi
# 元ファイルの存在と追跡状態を確認
if [ ! -f "$orig" ]; then
echo "Error: original file '$orig' not found." >&2
exit 1
fi
if ! git ls-files --error-unmatch "$orig" >/dev/null 2>&1; then
echo "Error: original file '$orig' is not tracked by git." >&2
exit 1
fi
# 上書きしないように新ファイル名をチェック
for new in "$@"; do
if [ -e "$new" ]; then
echo "Error: target '$new' already exists." >&2
exit 1
fi
done
# 複製を作成
for new in "$@"; do
dir=$(dirname "$new")
[ "$dir" = "." ] || mkdir -p "$dir"
cp "$orig" "$new"
done
# 元ファイルを削除し、変更をステージ
git rm "$orig"
git add "$@"
echo "Split '$orig' into:"
for new in "$@"; do
echo " - $new"
done
echo
echo "Now review and commit, for example:"
echo " git status"
echo " git diff"
echo " git commit -m \"Split $orig into $*\""
使い方の例は次の通りです。
./git-split-with-history.sh src/old.ts src/new-part-a.ts src/new-part-b.ts
これで、本記事で紹介したのと同じ「複製→元を削除→新ファイルを追加」という状態まで一気に進められます。
あとは各ファイルに対して必要なリファクタリングを行い、テストを流してからコミットすれば完了です。
FYI: git blame -C を使う場合
-
git blame -C file/git blame -C -C fileは、「このファイルの行が別ファイルからコピーされてきた可能性」を探しにいき、コピー元ファイルのコミットまで辿ろうとします(類似度しきい値に基づく推測なので、その分処理は重くなります)。 - 「
git blame -Cを前提にしてよい」チームであれば、「単純に内容だけを分割するコミット」でも、かなりの範囲まで元ファイルの行履歴を辿ることが出来ます。 - 一方で、「オプション無しの
blame/log --followでも素直に履歴を追いたい」「類似度しきい値にあまり依存したくない」場合は、本記事のようにmvやコピー→元削除といった形で明示的に系譜を作っておく方が安全です。
既存手法との比較
ここまでに出てきた手法を、「diff 上の rename の扱い」と「--follow / blame での行単位履歴」という 2 つの観点で比較すると、次のようになります。
| 手法 | 手順の複雑さ | rename の扱い(差分) | 行単位履歴(--follow / blame) |
|---|---|---|---|
| 単純一括分割(何も工夫しない) | もっとも簡単(1コミットで分割) | 元ファイルからの rename として扱われるのは最大 1 ファイルで、それ以外は新規扱い。 | 元ファイルの行履歴を十分に辿れないことが多い。 |
| Old New Thing方式(ブランチ+octopusマージ※) | ブランチ運用・octopus マージが必要 | 分割後の全ファイルが、元ファイルからの rename として扱われる。 | 各分割ファイルで、元ファイルの行履歴をほぼ完全に辿れる。 |
| 本記事のミニマル手法(複製→削除) | コマンド数は少ない(1コミット) | 単一コミット内では 1 ファイルだけが rename と判定され、もう 1 ファイルは新規扱い。 | 分割直後の複製ファイルは、どちらからでも共通の元ファイルの行履歴に辿れる。 |
実務で「ちゃんと履歴を残したい」なら Old New Thing 方式のような丁寧なコミット設計が有力候補になります。
本記事の手法は「どういう時に履歴が残る/残らないのか」を理解する為の 最小実験用途 として位置づけるのが良さそうです。
まとめ
- Git は rename 情報をそのまま保存しておらず、「削除ファイル」と「追加ファイル」の類似度から推測している。
- コミット差分では、類似度が一番高い 1組だけが rename と見なされる為、「1つだけ rename、もう1つは新規」という表示になりやすい。
- しかし
git log --followやgit blameのような履歴追跡では、ファイル単位で類似度判定をやり直す為、元のファイルから同じ内容のファイルをコピーして作成し、元のファイルを削除する場合、2つのファイルから同じ元ファイルの履歴に辿り着くことが確認出来た。
参考リンク
- Raymond Chen, How do I split a file into two while preserving git line history?(The Old New Thing, 2019)
gitdiffcore(7)- Tweaking diff output(diffcore-rename によるリネーム検出)- Git の
-M/-Cオプションや rename 判定の挙動を解説した日本語ブログ記事(Hatena Blog) - 大阪大学による Git のリネーム・コピー追跡に関する研究報告(PDF)
- Stack Overflow: Move parts of file to other files and keep git history