12k
All articles

犯人は誰だ? git blame で真犯人を突き止める

git blameの使い方を解説。出力の読み方、-L・-w・-M・-Cでの絞り込み、大量変更の回避、行の本当の変更元の追跡を紹介。

OpenReplay Team
OpenReplay Team
犯人は誰だ? git blame で真犯人を突き止める

git blame は、ファイルの各行に対して、その行を最後に変更したコミットと、そのコミットの作者・日付を注釈として付与します。

しかし、そこで示されるコミットは、多くの場合「求めているコミット」ではありません。自分が書いた覚えのない奇妙な行を調べてみると、blame が返してくるのは 18 ヶ月前の 4,000 ファイルにおよぶ「apply prettier」コミットだった、というわけです。

この行き止まりは、素の git blame を実行した際のごく当たり前の結果であり、そこから先へ進むことこそが本当のスキルです。本記事では、デフォルト出力の読み方、-L-w-M-C による絞り込みとノイズ除去、親コミットを遡って本当に意味のある変更にたどり着く方法、そして最近追加された 2 つの機能である --diff-algorithm(Git 2.53 以降)と git last-modified(Git 2.52 以降)を取り上げます。

要点

  • git blame がある行に対して表示するコミットは、その行に最後に触れたコミットです。それはしばしば、その行に意味を与えた変更ではなく、再フォーマット・リネーム・移動であったりします。
  • 報告されたコミットの親で blame を再実行し(git blame <hash>^ -- file)、それを繰り返すのが、元となった変更にたどり着く確実な方法です。--ignore-rev--ignore-revs-file を使えば、既知のノイズコミットを自動的にスキップできます。
  • -w は空白を無視し、-M はファイル内で移動した行を追跡し(デフォルトのしきい値は英数字 20 文字)、-C は他のファイルからコピーされた行を追跡します(デフォルト 40 文字)。-C は最大 3 つまで重ねて指定でき、その分だけ検索範囲が広がります。
  • Git 2.53 で git blame に --diff-algorithm が追加され、patienceminimalhistogrammyers を指定できるようになりました(デフォルトは myers)。
  • Git 2.52 で実験的な git last-modified が追加され、ディレクトリ内の各パスに最後に触れたコミットを 1 回の走査で報告できるようになりました。

デフォルトの git blame 出力はどう読むのか?

デフォルトの git blame 出力の各行には、4 つのフィールドが順に並びます。短縮されたコミットハッシュ、作者名、作者日付、行番号、そしてその後に行の内容が続きます。man ページの default format セクション にこれらのフィールドが列挙されています。Git はデフォルトでハッシュを 16 進 7 桁に短縮し、境界コミット(blame が到達できた最も古いコミット)を示すキャレット用に、さらに 1 カラムを空けています。日付は、--date または blame.date で別途指定しない限り ISO 形式で出力されます。

git blame src/router.js
a1b2c3d4 (Jane Doe 2024-03-08 14:22:31 +0100 42)   return cache.get(key) ?? fetchRoute(key);

左から順に読むと、a1b2c3d4 がコミット、Jane Doe とタイムスタンプがそのコミットの author ident、42 が現在のファイルにおける行番号、そして閉じ括弧より後がその行そのものです。

このハッシュについて理解すべき重要な点は、「それが何ではないか」です。それは、そのロジックを導入したコミットではありません。差分がその行に触れた最も新しいコミットであり、フォーマッタ・リンタ・リファクタリングが行われるコードベースでは、それが機械的な変更であることは頻繁にあります。最初の blame の結果は、判決ではなく手がかりとして扱いましょう。

-L で git blame を行範囲に限定するには?

git blame -L 40,60 -- src/router.js は注釈を 40 行目から 60 行目に限定し、git blame -L :handleRoute -- src/router.js は、その正規表現に名前が一致する関数の本体に限定します。どちらの形式も -L オプション として文書化されており、複数回指定することもできます。

git blame -L 40,60 -- src/router.js
git blame -L :handleRoute -- src/router.js

:funcname 形式は、あなたの使う言語を構文解析するわけではありません。git diff がハンクヘッダーに何を表示するか判断するのと同じ仕組みで関数名を検出しており、gitattributes の diff 属性を通じてファイル種別ごとに調整できます。範囲の両端は /regex/ パターンも受け付け、終端は +N オフセットも受け付けるため、-L '/^function handleRoute/,+15' のような指定も有効です。

git blame で空白と移動したコードを無視するには?

-w を渡すと、git blame はバージョン比較時に空白を無視するため、インデントだけの再フォーマットが、触れた行の帰属を主張することはなくなります。-M は 1 つのファイル内で移動した行を拾い上げ、-C は同じコミットが変更した他のファイルから来た行にまで探索を広げます。man ページ では、これらのデフォルトのマッチしきい値がそれぞれ英数字 20 文字と 40 文字と記載されています。

症状フラグ
行が再インデントや行末空白の除去に帰属されている-w
行がファイル内でコードを並べ替えたコミットに帰属されている-M
行が他のファイルからのコピーまたは移動で到来した-C(重ね掛け可能)
行が既知の一括コミットに帰属されている--ignore-rev <hash>
git blame -w -- src/router.js
git blame -M -- src/router.js
git blame -C -C -C -- src/router.js

-C を追加するごとに、git blame がコピー元の行を探すファイルの範囲が広がります。

  • -C は、同じコミットが変更した他のファイルを検索します。
  • -C -C は、そのファイルを最初に追加したコミットが触れたファイルも検索します。
  • -C -C -C はさらに範囲を広げ、任意のコミットのファイルまで対象にします。

複数の -C フラグに数値のしきい値が指定されている場合は、最後のものが優先されます。ファイル全体のリネームにはフラグは一切不要です。blame は自動的にリネームをまたいで行を追跡し続けますし、現在の Git ではその挙動をオフにする手段は用意されていません。

一括再フォーマットの前のコミットを見つけるには?

機械的なコミットを乗り越えるには、そのコミットの親で blame を再実行し(git blame <hash>^ -- src/router.js)、表示されるコミットが実際にその行の挙動を変えたものになるまで繰り返します。^ サフィックスは第 1 親を指す標準的な gitrevisions 構文なので、blame はノイズコミットが入る直前のファイルの状態から開始されます。

  1. git blame -L 40,60 -- src/router.js を実行し、対象行のハッシュを控えます。
  2. git show --stat <hash> でそのコミットを確認します。再フォーマット、リネーム、移動であれば、次へ進みます。
  3. git blame -n <hash>^ -L 40,60 -- src/router.js を実行します。-n フラグは元のコミットにおける各行の行番号を表示します。これは、リビジョン間で行番号がずれるため、次のパスで -L の指定をやり直す必要があるかもしれない点で重要です。
  4. 表示されるコミットが、その行の動作を変えたものになるまで、ステップ 2 から繰り返します。
git blame -n a1b2c3d4^ -L 40,60 -- src/router.js

リポジトリに既知のノイズコミットがある場合は、手作業での遡行は省略しましょう。--ignore-rev <hash> は、指定したコミットを飛び越えて行を帰属させるよう git blame に指示します。--ignore-revs-file は、1 行に 1 つずつ完全な形で書き出したハッシュのファイル全体に対して同じことを行います。blame.markIgnoredLines を設定すると再帰属された行に ? が付き、blame.markUnblamableLines を設定すると再帰属できなかった行に * が付きます。

git blame --ignore-rev a1b2c3d4 -- src/router.js
git blame --ignore-revs-file .git-blame-ignore-revs -- src/router.js
git config blame.markIgnoredLines true

そのリストを .git-blame-ignore-revs としてコミットし、blame.ignoreRevsFile をそこに向ける方法については、5 Git Dotfiles Every Developer Should Know で解説しています。

別の差分アルゴリズムを試す(Git 2.53 以降)

Git 2.53 で git blame に --diff-algorithm が追加され、patienceminimalhistogrammyersdefaultmyers のエイリアス)を指定できるようになりました。デフォルトは myers です。この追加は Git 2.53 のリリースノート に記載されており、指定可能な値は man ページの —diff-algorithm オプション に列挙されています。

blame は、2 つのバージョンの差分を取ることで、どの親の行がどの子の行に対応するかを判断します。そしてアルゴリズムが異なれば、行の対応付けも変わります。再フォーマットでよく起こるように、あるコミットが変更された行と変更されていない行を交互に含んでいる場合、あるアルゴリズムではその行が再フォーマットに帰属される一方、別のアルゴリズムでは元々その行を書いたコミットに帰属されることがあります。

git blame -L 40,60 -- src/router.js
git blame -L 40,60 --diff-algorithm=patience -- src/router.js

いずれかのアルゴリズムが他より正確だと文書化されているわけではありません。デフォルトの帰属結果が納得しがたい場合、同じコマンドを patiencehistogram で実行するコストはコマンド 1 回分にすぎず、比較できるセカンドオピニオンが得られます。

git last-modified でディレクトリについて問い合わせる

Git 2.52 で git last-modified が追加されました。これは、ファイルごとに git log -1 を実行するのではなく、1 回の履歴走査でディレクトリ内の各パスを最後に変更したコミットを報告します。このコマンドは実験的(experimental)と位置付けられており、挙動が変わる可能性があります。git-last-modified の man ページ は NAME 行で実験的ステータスを明記しており、出力形式は <oid> TAB <path> で、パスごとに 1 行、完全なオブジェクト ID のみで、作者・日付・件名は含まれないと示しています。

git last-modified -r -- src/

-r(または 0 以外の --max-depth)を指定しない場合、pathspec 自体に一致するエントリのみが得られ、その下位のサブディレクトリまでは辿りません。リネームとモード変更は変更としてカウントされます。これが置き換えるファイルごとのループは、ファイル 1 つごとに同じコミット群を何度も走査しますが、last-modified は一度だけ走査します。これが答えるのは「このモジュールで最近何が変わったか」であり、「なぜこの行が存在するのか」とは別の問いです。個々のファイルを blame し始める前に、まず手に取る価値のあるコマンドです。

blame は問いであって判決ではない

git blame の出力が示すのは、ある行に最後に触れた人物であり、その名前はほとんどの場合、あなたが必要としている答えではありません。焦点を絞るために -L を、機械的なノイズを取り除くために -w-M/-C を付けて blame を実行し、その上で親を遡っていく(あるいは ignore ファイルを整備する)ことで、表示されるコミットがその行を説明するメッセージを持つところまで進みましょう。そのコミットさえ手に入れば、git show <hash> が差分とその理由を示してくれます。それこそがこの作業の目的です。つまり、なぜそのコードがそこにあるのかを理解し、そもそもそれを生んだ出来事を繰り返すことなく変更できるようにすることです。

FAQ

git blame は現存する行しか表示しませんが、行を削除した人はどうすれば分かりますか?

man ページが指摘しているとおり、git blame は削除された行や上書きされた行については何も教えてくれません。代わりに pickaxe を使いましょう。git log -S'some text' -- src/router.js は、その文字列を追加または削除したすべてのコミットを列挙し、-p を付ければ削除そのものを表示できます。あるいは、git blame --reverse a1b2c3d..HEAD -- src/router.js を使えば、そのコミットから履歴を前方向に辿り、各行がまだ存在していた最も新しいリビジョンを示してくれます。

なぜ git blame は一部の行に 00000000 と 'Not Committed Yet' を表示するのですか?

それらの行には未コミットの変更が含まれているからです。リビジョン引数を指定しない場合、git blame はファイルのワーキングツリー上のコピーに注釈を付けるため、HEAD と異なる行にはすべてゼロのハッシュが付き、作者名の代わりに 'Not Committed Yet' が表示されます。変更をコミットまたは stash するか、git blame HEAD -- src/router.js を実行してコミット済みバージョンに注釈を付け、ローカルの編集を完全に無視してください。

GitHub の blame ビューは .git-blame-ignore-revs ファイルを尊重しますか?

はい。GitHub はリポジトリのルートにある .git-blame-ignore-revs という名前のファイルを blame ビューに自動的に適用します。コマンドラインの --ignore-revs-file と同じ仕組みを使っており、適用時には 'Ignoring revisions' というバナーを表示します。より古いコミットに再帰属できなかった行については、無視対象のコミットがそのまま表示されます。なお、このファイルはローカルの git を設定するものではないため、各開発者は依然として git config blame.ignoreRevsFile .git-blame-ignore-revs を実行する必要があります。

git blame と git log -L の違いは何ですか?

git blame は 1 行につき 1 つのコミット、すなわちファイルのある 1 バージョンにおいてその行に最後に触れたコミットを報告します。一方 git log -L 40,60:src/router.js は、40 行目から 60 行目を履歴全体にわたって追跡し、それらを変更したすべてのコミットを、その範囲の差分とともに新しい順に表示します。容疑者を素早く特定するには blame を、行の変遷を追うには log -L を使いましょう。どちらも :funcname 形式を受け付け、git log -L :handleRoute:src/router.js のように書けます。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.