本文へ移動
myowchick

git log --followが別ファイルの初出コミットを返す

ツール一覧の各ページにpublishedAtを入れるとき、初出日を手で調べるのが面倒だったのでgit log --follow --diff-filter=Aで機械的に取った。

5本のうち3本が同じコミットを初出だと答えた。

そのコミットは、5本の中でいちばん最初に作ったページの移植コミットだった。つまり2本が誤爆している。

--followはリネームを追うオプションのはずなのに、リネームしていないファイルで乗り換えが起きていた。

5本中2本が別ファイルの日付を返す

移植したツールページはsrc/pages/tools/<slug>.astroに1本ずつ置いてあった。--followありとなしで初出コミットを比べるとこうなる。

ページ--followあり--followなし
string-length.astro9ee78eb9ee78eb
encoding-converter.astro34e931234e9312
random-string.astro9ee78eb143acbd
qr-code.astro9ee78eb25713f0
pair-randomizer.astrob5a97b6b5a97b6

9ee78ebはstring-length.astroを追加したコミット。random-string.astroとqr-code.astroはそこには影も形も無い。

このときは5本とも同じ日に作ったので、日付だけ見ていれば気づかないまま通っていたと思う。コミットが違うので、たまたま助かっただけ。

--followは「消えたファイル」以外もリネーム元にする

qr-code.astroを追加した25713f0を、リネーム検出だけで見てみる。

$ git diff-tree -r -M --raw 25713f0^ 25713f0 -- src/pages/tools/
:000000 100644 0000... d2969b2... A	src/pages/tools/qr-code.astro

A、つまり新規追加。ここは期待どおり。コピー検出-Cを足してもAのままになる。25713f0では何も消えていないので、リネーム元の候補が無い。

ところが--find-copies-harderを足すと結果が変わる。

$ git diff-tree -r -C --find-copies-harder --raw 25713f0^ 25713f0 -- src/pages/tools/
:100644 100644 71dd625... d2969b2... C050	src/pages/tools/random-string.astro	src/pages/tools/qr-code.astro

C050。類似度50%のコピーとして、その時点で存在していたrandom-string.astroが元だと言っている。--find-copies-harderは「消えたファイル」だけでなく親ツリーの全ファイルをコピー元の候補にするので、消えていないファイルでも元になれる。

--followが返した経路もこれと同じ形をしている。--name-statusを足すと出てくる。

$ git log --follow --name-status --format='%h %ad %s' --date=short -- src/pages/tools/qr-code.astro
(略)
25713f0 2026-08-15 QR コード生成を移植する
C050	src/pages/tools/string-length.astro	src/pages/tools/qr-code.astro

9ee78eb 2026-08-15 文字数カウンターを移植する
A	src/pages/tools/string-length.astro

C050でstring-length.astroに乗り換えて、そのAを初出として返している。--followは履歴をさかのぼる途中でコピー検出を有効にしていて、それを切る手段が無い。--no-find-copies-harderを明示しても結果は変わらなかった。

ちなみにdiff-treeが選んだ元はrandom-string.astro、--followが選んだ元はstring-length.astroで、スコアはどちらも050。骨格の同じファイルが並んでいると、どれが元に選ばれるかも決まらない。

類似度50%は思ったより低い

string-length.astroとqr-code.astroは、追加された時点でそれぞれ37行と38行。うち29行が完全に一致していた。

中身はレイアウトのimportと<h1>と本体コンポーネントの差し込みで、ツールごとに違うのは名前と説明の段落だけ。実装をコピーして中身を差し替えたのだから当然そうなる。

閾値を変えて--followを掛け直すと、境目がよく見える。

-Cの閾値qr-code.astrorandom-string.astro
既定(50%)9ee78eb(誤)9ee78eb(誤)
51%25713f0(正)9ee78eb(誤)
52%25713f0(正)143acbd(正)

スコアはqr-code.astroが050、random-string.astroが051。既定の50%をぎりぎり超えたところで誤爆している。

encoding-converter.astroとpair-randomizer.astroが無事だったのは、説明の段落が長かったり構造が少し違ったりして50%に届かなかったからで、書き方しだいでどちらに転んでもおかしくない。

--diff-filter=Aと組むと乗り換えが見えなくなる

いちばん厄介なのはここだと思う。初出日が欲しいときに書くのはこういう形になる。

git log --follow --diff-filter=A --format='%ad' --date=short -- <path> | tail -1

--diff-filter=Aが追加コミットだけに絞るので、途中のC050の行は出力から落ちる。残るのは日付1行で、それがどのファイルの追加日なのかは分からない。

--name-statusを足せば、Aのあとに実際のパスが出る。ここで違うファイル名が出たら乗り換えている。

$ git log --follow --diff-filter=A --name-status --format='%h %s' -- src/pages/tools/qr-code.astro
9ee78eb 文字数カウンターを移植する
A	src/pages/tools/string-length.astro

最小の再現

空のリポジトリでも簡単に起きる。8行のファイルを作り、1行だけ書き換えたコピーを別名で追加する。

git init && printf 'line %s\n' 1 2 3 4 5 6 7 8 > a.txt
git add a.txt && git commit -m 'add a' --date=2020-01-01
sed 's/line 8/line eight/' a.txt > b.txt
git add b.txt && git commit -m 'add b' --date=2020-06-01

b.txtの初出を聞くと、--followだけ5か月ずれる。

$ git log --diff-filter=A --format='%h %ad %s' --date=short -- b.txt
746efa1 2020-06-01 add b

$ git log --follow --diff-filter=A --format='%h %ad %s' --date=short -- b.txt
abe12a8 2020-01-01 add a

このときa.txtは消えていない。b.txtと並んで今もそこにある。

どう取るか

初出日が欲しいだけなら--followを付けないほうが安全だった。

git log --diff-filter=A --format='%ad' --date=short -- <path> | tail -1

代わりに、本物のリネームがあると初出ではなくリネームしたコミットの日付が返る。さきほどの再現リポジトリでb.txtをc.txtにリネームすると、--followなしはrename b to cのコミットを、--followありはadd aを返した。正解のadd bはどちらも答えない。

だから、リネームの有無が分からないなら両方走らせて突き合わせるのがいちばん確実だと思う。答えが割れたら--name-statusで経路を見る。

  • R100のような高スコアのリネームは、たいてい本物
  • C0xxが出ていたら、そこは--find-copies-harderが拾った候補なので疑う
  • スコアが50前後なら、まず疑う

閾値を上げる手(--follow -C90%など)もあるけれど、本物のリネームと同時に中身をそれなりに書き換えていると今度は追えなくなる。誤爆を消す代わりに取りこぼしが増えるだけなので、どのみち目視は要る。

--followは「1ファイルの履歴をリネーム越しに追う」ための便利オプションで、そこは期待どおり動く。ただし、追う先を類似度で当てているという事実は表に出てこない。骨格の同じファイルを量産するリポジトリでは、その推測が普通に外れる。

← 一覧へ戻る