あらゆるバージョン管理システムの最も便利な機能の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コマンドをもっと詳しく知りたいなら、関連するドキュメントを参照しましょう。