🎭

Gitはファイルのリネームをどう扱うか

2021/10/13に公開

概要

Git管理下にあるファイルをリネームした場合、 git log や git diff はそれをいい感じに処理してくれます。具体的にどういう動作をするか見てみましょう。

確認環境:

/tmp/repo$ git version
git version 2.33.0

動作確認

git mv してコミットした後 git log でコミットを確認すると、リネームと表示されています。

/tmp/repo$ git init
Initialized empty Git repository in /private/tmp/repo/.git/
/tmp/repo$ seq 100 > seq.1
/tmp/repo$ git add seq.1
/tmp/repo$ git commit -m 1
[master (root-commit) f9b660f] 1
 1 file changed, 100 insertions(+)
 create mode 100644 seq.1
/tmp/repo$ git mv seq.1 seq.2
/tmp/repo$ git commit -m 2
[master 2bf38ed] 2
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename seq.1 => seq.2 (100%)
/tmp/repo$ git log -1 --summary
commit 2bf38ede2913518ff7f7a43b5ef318a0a412b66a (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 16:32:08 2021 +0900

    2

 rename seq.1 => seq.2 (100%)

git mv を使わずに、 mv した後に git add しても同様に、リネームと表示されます。

/tmp/repo$ mv seq.2 seq.3
/tmp/repo$ git add seq.2
/tmp/repo$ git add seq.3
/tmp/repo$ git commit -m 3
[master 8ff082d] 3
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename seq.2 => seq.3 (100%)
/tmp/repo$ git log -1 --summary
commit 8ff082d181985abf585ce5b5f787dfddcb77e72e (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 16:35:12 2021 +0900

    3

 rename seq.2 => seq.3 (100%)

単純なリネームではなく、ファイルの内容を少し変更するとどうなるでしょうか?
リネームしてから末尾に1行追加します。

/tmp/repo$ mv seq.3 seq.4
/tmp/repo$ echo "additional line" >> seq.4
/tmp/repo$ git add seq.3 seq.4
/tmp/repo$ git commit -m 4
[master 481f348] 4
 1 file changed, 1 insertion(+)
 rename seq.3 => seq.4 (94%)
/tmp/repo$ git log -1 --summary
commit 481f348d1596b8bfad9da33eff5f97a408f951ea (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 16:39:02 2021 +0900

    4

 rename seq.3 => seq.4 (94%)

この場合も git log の出力でリネームと表示されています。

記録せず、検出する

Git のコミットオブジェクトはその時点のソースツリーと親コミットの参照を保持しています[1]が、ソースツリー内のファイルがどうリネームされたかの情報は記録していません。Git はリネームを記録するのではなく、 git log や git diff の処理においてリネームを検出しています。手前のコミットで存在していたファイルが消えて、存在しなかったファイルが現れている場合に、それらのマッチングを行い、類似度が高ければリネームと判定します。

例えば以下のようにファイルの内容を2倍すると、50%の類似度でリネームと判定されます。

/tmp/repo$ cat seq.4 seq.4 > seq.5
/tmp/repo$ rm seq.4
/tmp/repo$ git add seq.4 seq.5
/tmp/repo$ git commit -m double
[master 2dff09c] double
 1 file changed, 101 insertions(+)
 rename seq.4 => seq.5 (50%)
/tmp/repo$ git log -1 --summary
commit 2dff09cf7fc382308b4b5cceb5ae7a51f6cc54fe (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 21:13:12 2021 +0900

    double

 rename seq.4 => seq.5 (50%)

3倍だとどうでしょうか?この場合、類似度が50%に満たないため、リネームと判定されません。

/tmp/repo$ cat seq.5 seq.5 seq.5 > seq.6
/tmp/repo$ rm seq.5
/tmp/repo$ git add seq.5 seq.6
/tmp/repo$ git commit -m triple
[master 54a9835] triple
 2 files changed, 606 insertions(+), 202 deletions(-)
 delete mode 100644 seq.5
 create mode 100644 seq.6
/tmp/repo$ git log -1 --summary
commit 54a9835ad699b301346f669799091b39f7c3fbc9 (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 21:16:32 2021 +0900

    triple

 delete mode 100644 seq.5
 create mode 100644 seq.6

この場合も、-M オプションを使って類似度の閾値をデフォルトの50%から変えれば、リネームと判定させられます。

/tmp/repo$ git log -1 --summary -M30%
commit 54a9835ad699b301346f669799091b39f7c3fbc9 (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 21:16:32 2021 +0900

    triple

 rename seq.5 => seq.6 (33%)

リネーム検出

リネームを検出するには、候補となるファイルの組み合わせに対して、ファイルの中身を比較して類似度を算出し、類似度の高い組み合わせを選ぶ必要があり、処理コストがかかります。そのため Git では処理コストを抑える工夫と、コストが大きくなりすぎる場合の対処が実装されています。具体的には

  • 削除されたファイルをリネーム元の候補、追加されたファイルをリネーム先の候補とする
  • ファイルのハッシュ値が一致するペアをリネームと判定(類似度100%とする)
  • ファイル名(basename)が同じもの同士の類似度が高ければリネームと判定
  • 残りのファイル数が多すぎるなら次の処理をスキップして終了(閾値は -l オプションで変更可能)
  • 残りの組み合わせで類似度が高いものをリネームと判定

という順で処理しています。

類似度

類似度の求め方ですが、まず最初にファイルサイズが大きく異なる場合は類似度0%とします。次にファイルの中身を改行もしくは64バイト単位で分割して各々のハッシュ値を計算して、ハッシュ値の分布を求めます。このハッシュ値の分布の近さを類似度とします。完全に一致していれば類似度100%、分布の差が大きいほど類似度は小さくなります。

行の追加削除、順序の入れ替えといった、テキストファイルの典型的な編集パターンを適用した場合に、類似度がどうなるかを見ましょう。

末尾に行を追加した場合は既に見ていたので、ここでは先頭に行を追加してみます。追加した行は不一致、元からあった行は一致として類似度が算出されます。

/tmp/repo$ echo "add a line" > seq.7
/tmp/repo$ cat seq.6 >> seq.7
/tmp/repo$ rm seq.6
/tmp/repo$ git add seq.6 seq.7
/tmp/repo$ git commit -m "add a line"
[master 713afc4] add a line
 1 file changed, 1 insertion(+)
 rename seq.6 => seq.7 (99%)
/tmp/repo$ git log -1 --summary
commit 713afc47bbc51baba268be9dde8b066f3ba4b3ab (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 22:02:42 2021 +0900

    add a line

 rename seq.6 => seq.7 (99%)

一方で、行を削除した場合には、削除した行が不一致、残りの行は一致として類似度が算出されます。

/tmp/repo$ wc -l seq.7
     607 seq.7
/tmp/repo$ tail -n 500 seq.7 > seq.8
/tmp/repo$ rm seq.7
/tmp/repo$ git add seq.7 seq.8
/tmp/repo$ git commit -m tail
[master 242e79f] tail
 1 file changed, 107 deletions(-)
 rename seq.7 => seq.8 (82%)
/tmp/repo$ git log -1 --summary
commit 242e79fb455ddf18c4d154e5cdda12740f654851 (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 22:06:28 2021 +0900

    tail

 rename seq.7 => seq.8 (82%)

このように、大半の行が一致していれば、類似度は大き目の値になります。

また、類似度はハッシュ値の分布を比較して求めているので、行の順序を入れ替えただけであれば分布は変化せず、類似度は100%になります。

/tmp/repo$ shuf seq.8 > seq.9
/tmp/repo$ rm seq.8
/tmp/repo$ git add seq.8 seq.9
/tmp/repo$ git commit -m shuf
[master aeac3cd] shuf
 1 file changed, 418 insertions(+), 418 deletions(-)
 rename seq.8 => seq.9 (100%)
/tmp/repo$ git log -1 --summary
commit aeac3cd8a8120eb6a6ffa03747546fb2a63ab5ee (HEAD -> master)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 22:14:08 2021 +0900

    shuf

 rename seq.8 => seq.9 (100%)

このようにランダムな並び替えをすることはそうそうないとは思いますが、1行1レコードのファイルをソートする場合や、関数定義の順序を入れ替えるといった変更では類似度は下がらないことがわかります。

ファイルの履歴

ファイルパスを指定して git log するとそのファイルに関するコミット履歴のみを抽出して見れますが、デフォルトではリネームに追従しません。

/tmp/repo$ git log --summary seq.9
commit aeac3cd8a8120eb6a6ffa03747546fb2a63ab5ee (HEAD)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 22:14:08 2021 +0900

    shuf

 create mode 100644 seq.9

リネームに追従してコミット履歴を見るには、--follow オプションを指定します。

/tmp/repo$ git log --follow --summary seq.9
commit aeac3cd8a8120eb6a6ffa03747546fb2a63ab5ee (HEAD)
Author: Foo <foo@example.com>
Date:   Sun Oct 10 22:14:08 2021 +0900

    shuf

 rename seq.8 => seq.9 (100%)

commit 242e79fb455ddf18c4d154e5cdda12740f654851
Author: Foo <foo@example.com>
Date:   Sun Oct 10 22:06:28 2021 +0900

    tail

 rename seq.7 => seq.8 (82%)

commit 713afc47bbc51baba268be9dde8b066f3ba4b3ab
Author: Foo <foo@example.com>
Date:   Sun Oct 10 22:02:42 2021 +0900

    add a line

 rename seq.6 => seq.7 (99%)

commit 54a9835ad699b301346f669799091b39f7c3fbc9
Author: Foo <foo@example.com>
Date:   Sun Oct 10 21:16:32 2021 +0900

    triple

 create mode 100644 seq.6

-M オプションによる類似度の閾値の指定も可能です。

/tmp/repo$ git log --follow --summary -M30% seq.9

まとめ

Gitはファイルのリネームを記録しておらず、履歴参照時にリネームを検出しています。
この記事では、Gitのリネーム検出の挙動について調べました。

より詳細を知りたい方は実装を見てください:

https://github.com/git/git/blob/master/diffcore-rename.c

脚注
  1. Pro Git / 10.2 Gitの内側 - Gitオブジェクト ↩︎

Discussion