あらゆるバージョン管理システムの最も便利な機能の1つに、間違いを「取り消す(undoする)」ことができるというのがあります。Gitにおいては、「取り消す」という言葉には少しずつ異なる様々な意味合いがあります。
新しいコミットをすると、ある時点におけるあなたのリポジトリのスナップショットをGitが保存します。それにより、後からあなたのプロジェクトを以前の状態に戻すのにGitを使うことができるわけです。
この記事では、あなたの変更を「取り消し」たくなるだろうよくあるシナリオを提示して、そのためにうまい具合にGitを使えるような方法を取り上げようと思います。
「パブリックな」変更を取り消す
シナリオ : git pushを実行してGitHubに変更を送信してから、コミットの1つに問題があることに気付きました。あなたはそのコミットを取り消したくなりました。
取り消すコマンド : git revert <SHA>
この時の動作 : git revertは、与えられたSHAと反対の(元に戻す)新しいコミットを作ります。古いコミットで「やったこと」は、新しいコミットでは「やったことの反対」になります。つまり、古いコミットで削除されたものは新しいコミットで追加され、古いコミットで追加されたものは新しいコミットで削除されます。
ヒストリを書き換えないという点で、これがGitの安全かつ最も基本的な「取り消し」のシナリオです。これで、間違ったコミットを取り消すために新しい「逆の」コミットをgit pushすることができます。
最後のコミットメッセージを修正する
シナリオ : 最後のコミットメッセージをタイポしてしまい、git commit -m "Fxies bug #42"と実行してしまいましたが、git pushする前に正しくは「Fixes bug #42」だったと気付きました。
取り消すコマンド : git commit --amend または git commit --amend -m "Fixes bug #42"
この時の動作 : git commit --amendは、前のコミットの内容のステージされた全ての変更をまとめる新しいコミットで、最新のコミットを置き換えて更新します。現時点で何もステージされていない場合は、前のコミットメッセージを置き換えるだけです。
「ローカルな」変更を取り消す
シナリオ : キーボードの上を猫が歩き回ってしまい、何かが保存された挙句、エディタがクラッシュしてしまいました。しかしまだ変更をコミットしてはいません。ファイル内の全ての変更を取り消し、前のコミットの状態まで戻してしまいたくなりました。
取り消すコマンド : git checkout -- <問題のあったファイル名>
この時の動作 : git checkoutは、ワーキングディレクトリ内をGitが管理している直前の状態に戻します。戻したいブランチ名や特定のSHAを指定することもできますが、デフォルトではGitはHEAD、つまり現在チェックアウトされているブランチの最新の状態をチェックアウトするものとして扱います。
注意点 : この方法で「取り消し」した変更は、完全に消えてしまいます。それまで行ったものはコミットされませんので、後でどうしても戻したくなってもGitは助けてくれません。何が消えてしまうのかよく理解した上で実行しましょう!(git diffで確認ができるはずです)
「ローカルな」変更をリセットする
シナリオ : あなたはローカル上で何かのコミットを行いました(まだプッシュはしていません)。しかし、あまりにもできがひどかったので、最後の3つのコミットを取り消して、何もなかったことにしたくなりました。
取り消すコマンド : git reset <戻したい最新のSHA> または git reset --hard <戻したい最新のSHA>
この時の動作 : git resetは、指定されたSHAまであなたのリポジトリの歴史を巻き戻し、その間のコミットがなかったかのようにしてしまいます。デフォルトでは、git resetはワーキングディレクトリはそのまま保持します。コミットは消えてしまいますが、コンテンツはディスク上に残ります。この方が安全ですが、コミットだけでなく変更も一発で「取り消し」してしまいたくなることもあるでしょう。その際には、--hardオプションを使います。
「ローカルな」変更を取り消した後にやり直す
シナリオ : 何らかのコミットをして、git reset --hardでそれらの変更を「取り消し」した(上の手順を参照)後、やっぱり取り消したものを戻したい!と思いました。
取り消すコマンド : git reflog の後に、git reset または git checkout
この時の動作 : git reflogを使うと、プロジェクトのヒストリを戻すのに素晴らしい情報が得られます。reflogを使えば、コミットしたものなら何でも、ほとんどのものを元に戻せます。
コミットのリストを表示してくれるgit logコマンドはよく使っているでしょう。git reflogはこれに似ていますが、こちらはHEADが変更された時を一覧表示してくれます。
以下の点には要注意
- 表示されるのは
HEADの変更のみです。HEADは、ブランチをスイッチし、git commitでコミットしたり、git resetでコミットをないことにした時だけ変更され、git checkout -- <問題のあったファイル名>`では変更されません(前のシナリオで見たように、これらの変更はコミットされないので、reflogはリカバリの際には使い物になりません)。 git reflogでは、全ての歴史を見られるわけではありません。Gitは「参照できない」オブジェクトを定期的に掃除します。月単位の日が経ったコミットが見られることは期待しない方がよいでしょう。- あなたが実行したreflogは、あなたのみのものです。他の開発者のプッシュしていないコミットを
git reflogを元にリストアすることはできません。

となると、中途半端な過去のコミットを「取り消す」のにreflogを使うにはどうしたらよいのでしょうか?これは、どこまでやりたいかによって変わってきます。
- ある時点の状態にプロジェクトのヒストリを戻したいのなら、
git reset --hard <SHA> - ヒストリを書き換えず、ある時点に存在したワーキングディレクトリ内のファイルをもう一度作成したい時は、
git checkout <SHA> -- <ファイル名> - リポジトリ内のコミットのどれかをそのままリプレイしたい場合、
git cherry-pick <SHA>
ブランチを使っている時に、同じことを繰り返す
シナリオ : コミットを行った後、masterからチェックアウトした状態でやってしまったことに気づきました。今のコミットをfeatureブランチでやり直したいと思っています。
取り消すコマンド : git branch featureし、git reset --hard origin/masterしてからgit checkout feature
この時の動作 : git checkout -b <ブランチ名>して新しいブランチを作るのには慣れているでしょう。これは、新しいブランチを作り、すぐにチェックアウトする簡単な方法として一般的ですが、まだブランチのスイッチはしたくないとしましょう。git branch featureで、最新のコミットを指したfeatureという新しいブランチを作りつつも、masterをチェックアウトした状態のままにします。
次に、git reset --hardでmasterを手元のコミットを実行する前のorigin/masterに巻き戻します。大丈夫、そのコミットはfeatureブランチに残っています。
最後に、git checkoutで新しいfeatureブランチにスイッチすれば、先ほどまでやっていた作業の内容がそのままになっているはずです。
転ばぬ先のブランチ
シナリオ : masterを元にした新しいfeatureブランチで開発を始めましたが、masterはorigin/masterからはかなり遅れていました。masterブランチはorigin/masterと同期しましたが、featureブランチも遅れた状態ではなく最新からコミットし始めたいと思いました。
取り消すコマンド : git checkout featureして、git rebase master
この時の動作 : これと同じことが、git reset(--hardはディスク上の変更を保持するため付けない)の後、git checkout -b <新しいブランチ名>し、さらに変更をコミットしても可能です。しかしこの方法だとコミット履歴が消えてしまいます。もっといい方法が上で紹介した方法です。
git rebase masterはいくつかの処理を実行します。
- まず最初に現在チェックアウトしているブランチと
masterの共通の過去のヒストリを特定します。 - 現在チェックアウトしているブランチをその過去のヒストリにリセットします。その時、その間のコミットを一時領域に退避させておきます。
- 現在チェックアウトしているブランチを
masterの最後まで進め、masterの最後のコミットより後に、一時領域に置いていたコミットをリプレイします。
一括取り消し・やり直し
シナリオ : ある方法で機能を作り始めたが、途中で他の方法がいいのではと思い始めました。既にたくさんのコミットをしていますが、その中のいくつかだけがその方法に必要です。それ以外のコミットは消してしまいたいと思っています。
取り消すコマンド : git rebase -i <以前のSHA>
この時の動作 : -iはrebaseを「インタラクティブモード」で実行します。上述のような動きでrebaseを行いますが、各コミットをリプレイする前に都度一時停止し、各コミットを変更するか確認してからリプレイします。
以下のように、rebase -iはデフォルトのテキストエディタを開いて、適用されるコミットの一覧を表示します。

始めの2つの列がカギになります。1列目は、2列目にあるSHAで特定されるコミットを選択するかどうかのコマンドです。rebase -iのデフォルトでは、各コミットを適用するという意味のpickコマンドになっています。
コミットを適用しないなら、エディタでその行を削除してしまいましょう。ダメなコミットが必要ないので、1行目と3、4行目を削除してしまうことにしましょう。
コミットの内容はそのまま保持したいけれど、コミットメッセージを変更したければ、rewordコマンドを使えます。1列目のpickをreward(あるいは単にr)で置き換えましょう。コミットメッセージをそのまま書き換えてしまいたくなりますが、rebase -iはSHAの列以降を無視してしまうので、それだとうまく動きません。SHAの後の文字列は、あくまで0835fe2が何のコミットなのかを思い出すために存在しているのです。rebase -iが実行された後に、書き換える必要のあるものについて新しいコミットメッセージの入力を求められます。
2つのコミットをまとめてしまいたい時は、下のようにsquashあるいはfixupコメントを使用できます。

squashとfixupは、「上」のコミットにまとめます。つまり、これらの「まとめる」コマンドが指定されたコミットは、直前のコミットにマージされるのです。上の例で言うと、0835fe2と6943e85が1つのコミットとしてまとめられ、それから38f5e4eとaf67f82がまた別のコミットとして一緒になります。
squashを選ぶと、Gitは新しいまとめられたコミットに新しいコミットメッセージを与えるようにプロンプトを出します。fixmeを選ぶと、リストの最初のコミットのコミットメッセージが新しいコミットのメッセージになります。この例の場合、af67f82が「やっちまった(ooops)」コミットなので38f5e4eのコミットメッセージをそのまま使えばいいのですが、0835fe2と6943e85をまとめたものには新しいコミットメッセージを付けたいということになります。
保存してエディタを終了すると、Gitは上から順にコミットを適用していきます。保存前にコミットの順番を変更すれば、その順番に変更して適用できます。必要なら、af67f82と0835fe2を以下のように変更することもできます。

前のコミットを修正する
シナリオ : 以前のコミットにファイルを含め忘れてしまいました。忘れたファイルが以前のコミットに含まれた状態になったらいいのにと思っています。幸いなことにまだプッシュはしていませんでしたが、直前のコミットではないので、commit --amendはもう使えません。
取り消すコマンド : git commit --squash <以前のコミットのSHA> してから git rebase --autosquash -i <最新のSHA>
この時の動作 : git commit --squashは、squash! 前のコミットのようなコミットメッセージ付きの新しいコミットを作ります(手動でメッセージを書いてコミットを作ることもできますが、commit --squashを使うとタイピングの手間が省けます)。
まとめたコミットに新しいコミットメッセージを書くプロンプトを出さなくてもいいなら、git commit --fixupを使うこともできます。このシナリオの場合、リベースの際に前のコミットのコミットメッセージをそのまま使うことになるでしょうから、commit --fixupを使えば良いでしょう。
rebase --autosquash -iは対話的なリベースのエディタを起動しますが、下図のようにコミットの一覧内では既にsquash!やfixup!がコミット対象と対になった状態になります。

--squashや--fixupを使う際には、修正したいコミットのSHAを覚えておく必要はなく、1つ前のコミットか5つ前のコミットかを覚えておけば十分です。Gitの^や~が便利でしょう。HEAD^はHEADの1つ前のコミットという意味ですし、HEAD~4はHEADの4つ前のコミットという意味、つまり5つ戻るという意味です。
バージョン管理しているファイルを除外する
シナリオ : application.logファイルを間違ってリポジトリに入れてしまったので、アプリケーションを起動するたびに、application.logにステージされていない変更があると言われるようになってしまいました。*.logを.gitignoreに入れましたが、まだ同じようなメッセージが出てしまいます。どうやったらGitがこのファイルを管理してしまっているのを「取り消す」ことができるのでしょうか?
取り消すコマンド : git rm --cached application.log
この時の動作 : .gitignoreは、Gitがファイルの変更をトラッキングしないようにしたり、トラッキングされたことのないファイルの変更を通知したりしないようにはできますが、一度でもaddしたりcommitしてしまうと、ファイルの変更をずっと追跡してしまうようになります。これは、git add -fで.gitignoreを無視するよう強制するようにした場合も同じことが起こります。add -fは使わない方がいいでしょう。
無視するべきファイルをGitがトラッキングするのをやめるには、git rm --cachedを使い、トラッキング対象から除外しつつディスク上にはファイルを残します。これで、そのファイルは無視され、git statusにも表示されなくなり、また間違ってファイルをコミットしてしまうこともなくなります。
まとめ
Gitで色々な操作を取り消す方法を説明しました。ここで使ってきた各種Gitコマンドをもっと詳しく知りたいなら、関連するドキュメントを参照しましょう。
