「さっきのコミット、やっぱり無かったことにしたい」と思ったとき、Git には git revert と git reset という2つのコマンドがあります。どちらも結果としては「コミットを取り消す」のですが、履歴の扱いがまったく違うため、場面を間違えると同僚のリポジトリを壊してしまうこともあります。この記事では、それぞれが何をするコマンドなのか、--soft や --hard といったオプションの違い、そして「どちらを使うべきか」の判断基準を順番に解説します。
目次
取り消し方には「積む」と「戻す」の2方向がある
git revert は、取り消したいコミットと逆の変更をする新しいコミットを作って積むコマンドです。「追加した行を削除する」「削除した行を書き戻す」という打ち消しのコミットが1つ増えるので、元のコミットも、それを取り消したという事実も、両方が履歴に残ります。履歴は前に進むだけで、過去は一切書き換わりません。
一方 git reset は、ブランチの先端(HEAD)を過去のコミットに移動させるコマンドです。移動した結果、それより先にあったコミットはどのブランチからも参照されなくなり、git log には表示されなくなります。つまり「無かったこと」にできるかわりに、履歴が書き換わります。
この違いがそのまま使い分けの基準になります。共有済みのものは revert、自分の手元にしかないものは reset、というのが基本です。それぞれのコマンドを具体的に見ていきましょう。
git revert:打ち消すコミットを新しく積む
使い方はシンプルで、取り消したいコミットのハッシュを渡すだけです。
git revert <hash>
実行するとコミットメッセージを編集するエディタが開き、既定では Revert "元のメッセージ" という内容が用意されています。そのまま保存して閉じれば、打ち消しのコミットが作られます。履歴がどう変わるか、git log --oneline で見てみましょう。
# revert する前
$ git log --oneline
4d8e0f1 (HEAD -> main) 検索フォームを追加
b93c517 ヘッダーのリンク先を修正
7c21ab9 トップページの見出しを調整
# 検索フォームの追加を取り消す
$ git revert 4d8e0f1
# revert したあと
$ git log --oneline
e57b1a2 (HEAD -> main) Revert "検索フォームを追加"
4d8e0f1 検索フォームを追加
b93c517 ヘッダーのリンク先を修正
7c21ab9 トップページの見出しを調整
元の 4d8e0f1 はそのまま残り、その上に打ち消しのコミット e57b1a2 が積まれています。ファイルの中身は「検索フォームを追加する前」の状態に戻っていますが、履歴としては何も失われていません。この状態はそのまま git push できます。履歴を書き換えていないので、force push は必要ありません。
-n でコミットせずに変更だけ取り込む
複数のコミットを取り消したいとき、既定では取り消した数だけ「Revert …」というコミットが並んでしまい、履歴が読みにくくなります。-n(--no-commit)を付けると、打ち消しの変更を作業ツリーとステージに適用するだけでコミットは作られません。最後に自分でまとめてコミットすれば、1つの取り消しコミットにできます。
# 2つのコミットをコミットせずに打ち消す
git revert -n 4d8e0f1
git revert -n b93c517
# 内容を確認してから、自分でメッセージを付けて1コミットにまとめる
git status
git commit -m "検索フォームとヘッダーの変更を取り消し"
-n を付けた状態で「やっぱりやめる」と思ったときは、git revert --abort で作業を始める前の状態に戻せます。
範囲を指定してまとめて取り消す
連続したコミットをまとめて取り消したいときは、A..B という範囲の書き方が使えます。たとえば直近3つのコミットを取り消すなら次のように書きます。
# 直近3つのコミットを取り消す(HEAD~3 自身は含まれない)
git revert HEAD~3..HEAD
# 打ち消しをまとめて1コミットにしたい場合
git revert -n HEAD~3..HEAD
git commit -m "直近3件の変更を取り消し"
ここで注意したいのは、HEAD~3..HEAD という範囲には始点の HEAD~3 は含まれないという点です。含まれるのはその次のコミットから HEAD までの3つになります。Git の範囲指定に共通するルールなので、意図したコミットが対象になっているか git log --oneline HEAD~3..HEAD で先に確認しておくと安心です。
マージコミットを revert するときは -m が必要
マージコミットは親コミットを2つ持っているため、そのまま revert しようとすると「どちらの親を基準に打ち消すのか分からない」というエラーになります。そこで -m(--mainline)で、残したいほうの親の番号を指定します。
# マージを取り消す(1 = マージ先ブランチ側を残す)
git revert -m 1 <merge-hash>
親の番号は、マージコミットの1つ目の親が 1、2つ目の親が 2 です。main ブランチにいる状態で git merge feature を実行した場合、1つ目の親がマージ前の main、2つ目の親が feature の先端になります。したがって「取り込んだ feature の内容を無かったことにして main 側だけ残したい」という通常のケースでは -m 1 を指定します。
なお、マージを revert したあとに同じブランチを再度マージしても、Git から見れば「マージ済み」なので打ち消した変更は戻ってきません。改めて取り込みたい場合は、打ち消しコミットのほうをさらに revert します。
git reset:HEAD を過去に戻す
git reset は、今いるブランチの先端を指定したコミットまで巻き戻します。新しいコミットは作られず、巻き戻した先より後ろのコミットは履歴から消えたように見えます。
# reset する前
$ git log --oneline
4d8e0f1 (HEAD -> main) 検索フォームを追加
b93c517 ヘッダーのリンク先を修正
7c21ab9 トップページの見出しを調整
$ git reset --hard HEAD~1
# reset したあと(4d8e0f1 が履歴から消える)
$ git log --oneline
b93c517 (HEAD -> main) ヘッダーのリンク先を修正
7c21ab9 トップページの見出しを調整
revert と違い、取り消した形跡は残りません。履歴をきれいに保てる反面、他の人が持つ履歴と食い違うという副作用があります。
–soft / –mixed / –hard の違い
git reset でいちばん分かりにくいのが、この3つのオプションです。Git には「HEAD(コミットの履歴)」「ステージ(インデックス=次のコミットに含める内容)」「作業ツリー(実際のファイル)」という3つの領域があり、オプションによってどこまで巻き戻すかが変わります。
| オプション | HEAD | ステージ | 作業ツリー | 結果として起きること |
|---|---|---|---|---|
--soft | 戻す | そのまま | そのまま | コミットだけ取り消され、変更はステージに残る |
--mixed(既定) | 戻す | 戻す | そのまま | コミットとステージが取り消され、変更は未ステージで残る |
--hard | 戻す | 戻す | 戻す | ファイルの中身も巻き戻り、変更は失われる |
上から下に行くほど「巻き戻す範囲」が広がると考えると分かりやすくなります。--soft はコミットという記録だけを消し、変更内容はすべて手元に残ります。--mixed はオプションを省略したときの既定で、コミットに加えて git add した状態も取り消されますが、ファイル自体は編集したままです。--hard だけがファイルの中身まで書き換えるため、コミットしていない変更は消えてしまいます。
git reset –soft HEAD~1 でコミットだけやり直す
実務でもっとも使うのがこの形です。「コミットメッセージを間違えた」「関係ないファイルまで一緒にコミットしてしまった」「2つに分けるべきだった」というときに、内容はそのまま残したままコミットだけをやり直せます。
# 直前のコミットを取り消す(変更はステージに残る)
$ git reset --soft HEAD~1
$ git status
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: src/search-form.php
modified: src/style.css
# メッセージを直してコミットし直す
$ git commit -m "検索フォームを追加"
ステージの状態がコミット直前とまったく同じなので、そのまま git commit すればメッセージだけを付け直せます。ファイルを分けて別々のコミットにしたい場合は、いったん git reset(オプション無し=--mixed)でステージも解除し、必要なファイルだけ git add し直してから2回に分けてコミットします。
ファイル単位で取り消したいときは git restore
git reset はブランチ全体を巻き戻すコマンドなので、「このファイルの編集だけ取り消したい」「このファイルのステージだけ解除したい」という用途には向きません。その場合は git restore を使います。作業ツリーの変更を捨てるなら git restore <ファイル>、ステージから下ろすだけなら git restore --staged <ファイル> です。コミットの取り消しは reset / revert、ファイル単位の取り消しは restore、と役割で覚えておくと迷いません。
push 済みなら revert、手元だけなら reset
2つのコマンドの違いを、判断のポイントごとに整理すると次のようになります。
| 観点 | git revert | git reset |
|---|---|---|
| 履歴の扱い | 打ち消すコミットを新しく積む(残る) | HEAD を過去に戻す(書き換わる) |
| コミット数 | 増える | 減る |
| push 済みのコミット | そのまま push できる | force push が必要になる |
| 共有ブランチでの安全性 | 安全 | 他の人の履歴を壊す恐れがある |
| 主な用途 | 公開済みの変更を取り消す | 手元のコミットをやり直す |
判断の基準はひとつだけで、そのコミットをすでに push したかどうかです。push 済み、つまり他の人が取得できる状態になっているコミットは revert で取り消します。新しいコミットが1つ増えるだけなので、相手は普通に git pull するだけで同じ状態になれます。
これに対して、共有ブランチで git reset をして force push すると、リモートの履歴が自分のローカルの履歴で上書きされます。すでに古い履歴を取得していた人は、次に pull したときに存在しないはずのコミットを持った状態になり、無理にマージしようとして消したはずのコミットが復活したり、その人がまだ push していなかった作業が行方不明になったりします。main や develop のような共有ブランチで reset と force push を組み合わせるのは避けてください。
逆に、まだ push していない自分だけのコミットであれば reset で自由にやり直して構いません。試行錯誤のコミットを整理してから push したほうが、レビューする側にも読みやすい履歴になります。
取り消したあとに困ったときの対処
revert がコンフリクトして止まる
取り消したいコミットより後で同じ場所を編集していると、打ち消しの変更を適用できずにコンフリクトが起きます。この場合は通常のコンフリクト解消と同じで、git status で対象ファイルを確認し、ファイルを開いてコンフリクトマーカーを取り除き、git add したうえで git revert --continue を実行します。
# コンフリクトしたファイルを確認する
git status
# 手で直してからステージに上げる
git add src/style.css
# revert を再開する
git revert --continue
# やめて元の状態に戻すとき
git revert --abort
reset したあとに push が拒否される
push 済みのコミットを reset で取り消すと、ローカルよりリモートのほうが先に進んでいる状態になるため、通常の git push は拒否されます。そのブランチが自分専用だと確認できているなら、--force-with-lease を付けて上書きします。
git push --force-with-lease origin feature/search
--force がリモートの状態を確かめずに無条件で上書きするのに対し、--force-with-lease は「自分が最後に確認したときからリモートが進んでいないこと」を条件に上書きします。自分が知らないうちに誰かが push していた場合は拒否されるので、他人の作業を消してしまう事故を防げます。force push が必要になったときは、--force ではなく --force-with-lease を使う習慣にしておきましょう。
–hard で消した変更を取り戻したい
git reset --hard で切り捨てたコミットは git log には出てきませんが、コミットのオブジェクト自体はしばらくリポジトリに残っています。git reflog で HEAD が動いた履歴をたどればハッシュが分かるので、そこへ戻せば復元できます。
$ git reflog
b93c517 (HEAD -> main) HEAD@{0}: reset: moving to HEAD~1
4d8e0f1 HEAD@{1}: commit: 検索フォームを追加
# reset する直前の位置に戻す
$ git reset --hard 4d8e0f1
ただし、救えるのはコミット済みの内容だけです。一度もコミットしていない編集や、git add すらしていないファイルは reflog に記録されないため、--hard で消えたら戻せません。--hard を打つ前に git status で本当に捨ててよい変更か確認し、残したいものがあれば git stash で退避しておくと安全です。
まとめ
git revert は打ち消すコミットを新しく積むコマンドで、履歴が残り、そのまま push できます。複数まとめて取り消すときは -n でコミットを作らずに変更だけ適用し、最後に自分でコミットすると履歴がすっきりします。マージコミットを対象にするときは残したい親を -m 1 のように指定します。一方 git reset は HEAD を過去に戻すコマンドで、--soft はコミットだけ、--mixed(既定)はコミットとステージ、--hard は作業ツリーまで巻き戻します。定番の使い方は git reset --soft HEAD~1 で、内容をステージに残したままコミットをやり直せます。選ぶ基準は「すでに push したか」だけです。共有ブランチで reset して force push すると他の人の履歴を壊してしまうので、どうしても必要なときは --force-with-lease を使い、うっかり --hard で消してしまったときは git reflog で救出を試みてください。