git history で古いコミットを修正する
Git 2.55のgit historyは、fixup、reword、splitで古いコミットを書き換え、積み重なったブランチを更新し、dry-runで安全に確認できます。
実験的な git history コマンドは、単一の古いコミットを書き換え、ステージされた変更を取り込んだり、メッセージを差し替えたり、コミットを 2 つに分割したりできます。しかも、その上に積み上げられたすべてのローカルブランチを移動させながら、インタラクティブ rebase を一度も開始しません。
複数のブランチを積み重ねて運用している人なら、このコマンドが置き換えようとしている失敗パターンに心当たりがあるはずです。3 つ下のコミットにタイプミスを見つけ、rebase -i を始め、まったく触るつもりのなかったコミットでコンフリクトに遭遇し、気づけば detached HEAD のまま rebase の途中で判断を迫られている、という状況です。git history は、そこに陥らないように作られています。そのサブコマンドはいずれも 1 つの保証を共有しています。すなわち、操作は完了するか、エラーで中断して何も変更しないかのどちらかです。
本記事では、Git 2.55 の 3 つのサブコマンドを出力例とともに解説し、何かが動く前に書き換えをプレビューできる --dry-run フラグを紹介し、制約(マージ履歴は不可、コンフリクトする操作は不可、hooks は実行されない)についても触れます。
要点
- Git 2.55 の
git historyには 3 つのサブコマンドがあります。fixupはステージされた変更を古いコミットに取り込み、rewordはそのメッセージを差し替え、splitはコミットを 2 つに分割します。 - すべての操作はアトミックです。リプレイされる履歴のどこかでコンフリクトが発生するような書き換えであれば、コマンドは中断し、何も変更しません。
- デフォルトでは、書き換えられたコミットの子孫にあたるすべてのローカルブランチが更新されます。
--update-refs=headを指定すると、現在の HEAD のみが移動します。 --dry-runは何も移動させず、代わりに後からgit update-refで適用できる ref の更新内容を出力します。これは書き換えを安全にプレビューする方法です。- このコマンドは実験的であり、hooks を一切実行せず、マージを含む履歴は拒否します。その場合は
git rebase --rebase-mergesを使ってください。
なぜ git history コマンドが存在するのか?
git history は、インタラクティブ rebase の煩雑さとリスクを伴わずに、古いコミットを 1 つだけ修正するために存在します。公式マニュアル では git rebase と対比して説明されています。選択肢が少なく、編集の範囲が狭いときに手を伸ばしやすいコマンドだ、というわけです。また実験的という位置づけであり、フラグや挙動はリリース間で変わる可能性があります。
学ぶ価値がある理由は 2 つあります。1 つ目はアトミック性です。マージコンフリクトで終わる可能性のある操作は、そもそも提供されません。これは意図的な設計判断で、このコマンドは書き換えを、ステップを踏んでいくセッションではなく一発勝負として扱うからです。--continue も --abort もなく、復旧すべき中途半端な状態も存在しません。2 つ目は影響範囲です。デフォルトで、書き換えられたコミットの子孫を指すすべてのローカルブランチを更新します。これはまさにスタックブランチのワークフローが必要としているものです。
このコマンドは 2 段階で登場しました。reword と split は Git 2.54 で導入され、Git 2.55 で fixup が追加されました。したがって、本記事が対象とする 2.55 時点では 3 つのサブコマンドがあります。
| サブコマンド (Git 2.55) | 機能 | bare リポジトリでの実行 |
|---|---|---|
git history fixup | ステージされた変更を古いコミットに取り込む | 不可(index を読むため) |
git history reword | 古いコミットのメッセージを差し替える | 可 |
git history split | 1 つのコミットを 2 つに分割する | 可 |
このリストは今後増えると考えてよいでしょう。開発中の git history のマニュアルには、すでに 4 つ目のサブコマンドとして drop が記載されています。これはコミットを削除し、その子孫を親の上にリプレイするものです。3 つで全部だと決めつける前に、自分の環境で git history -h を確認してください。
fixup: ステージされた変更を古いコミットに取り込む
git history fixup <commit> は、index にステージされている内容を対象コミットに織り込みます。内部的には 3-way マージであり、HEAD、対象コミット、そしてステージされた変更から構築したツリーの 3 つを入力とします。--reedit-message でエディタを要求しない限り、対象コミットは元のメッセージと author を保持します。これは実質的に git commit --fixup に続けて git rebase --autosquash を実行するのと同じことを、行き詰まる余地のない 1 ステップに圧縮したものです。
たとえば、2 つ前のコミットで入った config ローダーにデフォルト値が欠けていて、その上に feature ブランチが積まれているとします。
$ git log --oneline --branches
c41f9e2 (feature/retries) add retry logic
7b2d8a0 (HEAD -> main) add http client
3e59c11 add config loader
$ git add src/config.js
$ git history fixup 3e59c11
$ git log --oneline --branches
f8a01d3 (feature/retries) add retry logic
92c6b7e (HEAD -> main) add http client
5d40e19 add config loader
3 つのハッシュがすべて変わり、main と feature/retries の両方が書き換え後の履歴を指すようになりました。これはデフォルトの --update-refs=branches が働いた結果です。チェックアウトしているブランチだけでなく、対象コミットの子孫にあたるすべてのローカルブランチが移動します。HEAD だけを移動させたい場合は --update-refs=head を渡します。これは、rebase 対象の範囲内のコミットを指す ref しか更新しない git rebase --update-refs よりも広い範囲に及びます。
大事なスタックに対して実行する前には、--dry-run を付けてください。参照は一切移動しません。代わりに、実行されたであろう移動の一覧が、後で git update-ref に渡せる形式で出力されます。Git は必要となった新しいオブジェクト自体は書き込むため、その一覧を後からリプレイしても通常は問題ありません。書き換えを確定する前に、どのブランチが影響を受けるのかを正確に確認する、誠実な方法です。
amend との適用範囲の違いにも注意してください。git commit --amend は HEAD にしか届きませんが、fixup は線形履歴上の任意のコミットに届きます。また、index を必要とする唯一のサブコマンドでもあり、そのため他の 2 つと違って bare リポジトリでは実行できません。
reword: 古いコミットメッセージを差し替える
git history reword <commit> は、対象コミットについて 1 つのこと、つまりメッセージだけを変更します。それ以外はすべて引き継がれ、各子孫がその上にリプレイされ、ブランチの ref も追随します。
$ git history reword 7b2d8a0
エディタが現在のメッセージ add http client を入力済みの状態で開きます。より良いメッセージを保存すると、スタックが再構築されます。
$ git log --oneline --branches
3ba90cf (feature/retries) add retry logic
a1d27e8 (HEAD -> main) add http client with timeout handling
5d40e19 add config loader
reword は index にもワーキングツリーにも手を付けないため、bare リポジトリでも問題なく動作します。また、チェックアウトしていないブランチ上のメッセージを、作業中の内容を乱すことなく修正できます。
split: 1 つのコミットを 2 つに分ける
git history split <commit> は、そのコミットが導入した diff をハンク単位で順に確認していきます。選択したものが新しいコミットにまとめられ、元のコミットの新しい親としてその下に挿入されます。元のコミットには、選ばなかったハンクが残ります。すべてのハンクを選ぶ、あるいは 1 つも選ばない、という選択は拒否されます。どちらの場合も 2 つのコミットの一方が空になってしまうためです。
レートリミッターと無関係なメトリクスのコードが混ざったコミットがあるとします。
$ git history split 91b04c7
メトリクスのハンクには y、リミッターのハンクには n と答えます。エディタで両方のコミットメッセージの入力を求められ、authorship は元のコミットのまま維持され、結果としてきれいな 2 つのコミットが得られます。
$ git log --oneline
e7d3f21 (HEAD -> main) add rate limiter
b19c8a4 add request metrics
末尾に pathspec を付けると (git history split 91b04c7 -- src/metrics.js)、指定したファイルに分割対象を絞り込めます。そのリストに含まれないものは元のコミットにそのまま残ります。reword と同様、split はコミットグラフのみを操作するため、bare リポジトリでも動作します。
git history にできないことは?
マニュアルの LIMITATIONS セクション は短く、そのまま額面どおりに受け取る価値があります。マージコミットは対象外です。書き換えたい履歴にマージが含まれている場合、マニュアルは代わりに git rebase --rebase-merges を使うよう案内しています。コンフリクトで終わる可能性のある操作はすべて拒否されます。この制約は、Git がいわゆる first-class conflicts を獲得すれば緩和される可能性があります。現時点では hooks も実行されませんが、マニュアルはこれが変わる余地を残しています。
fixup にはもう 1 つ、--empty=(drop|keep|abort) で扱われるケースがあります。ここで空コミットが生じる経路は 2 つあります。ステージした修正が対象コミットを完全に打ち消してしまう場合と、祖先に押し込んだのと同じ変更をすでに後続のコミットが持っている場合です。drop がデフォルトで、履歴の再構築時にそうしたコミットを破棄します。keep はそのまま残し、abort は判断を委ねずにエラーでコマンドを停止します。root コミットの削除はまだサポートされていません。
rebase を使うべきなのはどんなときか?
git history は 1 つのコミットを編集します。それより広い範囲については、依然として git rebase が適切なツールです。コミットの範囲全体に新しいベースが必要なときは rebase、複数のコミットを一度にまとめて処理したいときはインタラクティブ rebase が答えになります。そして、コンフリクト状態を rebase を通じて持ち回り、後から解決したいのであれば、それは jj の first-class conflicts モデル の領域であり、git history は意図的にそこを目指していません。
まとめ
もっとも頻繁に発生する履歴編集、つまり複数のブランチが積み上がっている 1 つのコミットを修正するという場面において、git history はリスクのあるインタラクティブ rebase を、完全に成功するか実行を拒否するかのどちらかである操作に置き換えます。実際のスタックを 1 つ選び、git history fixup <commit> --dry-run を実行して、行われるはずだった ref の更新内容を読んでみてください。この 5 分の実験で、このコマンドが日々のワークフローに入るかどうかが分かるはずです。判断する際には、実験的という位置づけを念頭に置いておいてください。フラグも挙動も、リリースごとに変わる可能性があります。
FAQ
git history fixup と、autosquash を伴う git commit --fixup の違いは?
git history fixup は、ステージされた変更を単一のアトミックなステップで対象コミットに取り込みます。git commit --fixup は fixup! コミットを記録するだけで、後から git rebase --autosquash で squash する必要があり、そのインタラクティブ rebase はコンフリクトで途中停止する可能性があります。また git history fixup はデフォルトで子孫のローカルブランチをすべて移動させ、書き換えがコンフリクトする場合は何も変更せずに中断します。
git history はリモートブランチや、すでに push 済みのコミットを更新しますか?
いいえ。git history が更新するのはローカルブランチのみで、リモート追跡 ref には触れません。書き換えられたコミットには新しいハッシュが付くため、push 済みのコミットについては force-push (git push --force-with-lease) と、古い履歴を pull した人との調整が必要になります。これは他の書き換えと同じです。まだ push していないコミットや、自分だけが作業しているブランチで使うのがもっとも安全です。
git history による書き換えを取り消すには?
reflog を使います。git history はブランチの ref を新しいコミットに移動させますが、元のコミットはリポジトリ内に残り、通常の non-bare リポジトリでは移動した各ブランチがその更新を reflog に記録します。git reflog show にブランチ名を指定して書き換え前のハッシュを探し、チェックアウト中のブランチなら git reset --hard で、そうでなければ git update-ref で復元してください。
git history でコミットを完全に削除できますか?
Git 2.55 ではできません。3 つのサブコマンド (fixup、reword、split) はコミットを変更したり分割したりできますが、削除はできません。現時点でコミットを削除するには、git rebase -i でそのコミットを 'drop' とマークするか、git rebase --onto でスキップします。git history の開発中ドキュメントには drop サブコマンドが登場しているため、将来のリリースでネイティブな選択肢が加わる可能性があります。