Git入門:バージョン管理のきほん

公開済みの履歴を安全に戻す(revert)

生田 陸人
LuaGate エンジニア / 現役エンジニア
編集 LuaGate編集部

このレッスンで分かること

  • git revert が履歴を消さずに変更を打ち消す仕組み
  • resetrevert の使い分けの基準が「共有済みかどうか」であること
  • revert の途中でコンフリクトが起きたときの進め方

消すのではなく、打ち消す

前のレッスンで、git resetHEAD を動かして、そこから先のコミットを辿れなくするコマンドだと学びました。つまり履歴そのものを書き換えます。

これは手元だけで作業しているうちは便利ですが、git push して他の人と共有したあとでは使えません。自分の履歴だけ短くなり、相手の履歴と食い違うからです。

そこで登場するのが git revert です。git revert は履歴を消しません。代わりに「そのコミットと逆の変更をする新しいコミット」を積みます。

プレーンテキスト

reset の場合 A --- B --- C → A --- B revert の場合 A --- B --- C → A --- B --- C --- C'

C' が、C で追加した行を削除し、C で削除した行を追加し直すコミットです。結果としてファイルの中身は B の時点と同じになりますが、履歴には C も C' も残ります。

「C というミスがあり、C' で戻した」という経緯が記録として残るわけです。共同作業では、この痕跡が残ることのほうがむしろ利点になります。

利点は 2 つあります。1 つは、他の人の手元と食い違わないことです。履歴は増えるだけなので、誰かがすでに C を取り込んでいても矛盾しません。相手は普通に git pull すれば C' も受け取り、同じ状態になります。

もう 1 つは、あとから経緯を追えることです。障害が起きて機能を止めた場合、「いつ入れて、いつ止めたのか」が履歴に残っていれば、原因の調査も、直したうえでの再投入もやりやすくなります。履歴を消してしまうと、この情報ごと失われます。

resetrevert は「戻す」という言葉が同じなので混同しがちですが、やっていることは正反対です。reset は記録を無かったことにし、revert は記録を足します。

使ってみる

打ち消したいコミットのハッシュを git log --oneline で調べます。

ターミナル

$ git log --oneline

プレーンテキスト

9f1c0a4 検索フォームの並び順を変更 3b7d21e トップページの見出しを修正 c8e4f60 README に開発環境の手順を追加

9f1c0a4 の変更が原因で不具合が出たので、これを打ち消します。

ターミナル

$ git revert 9f1c0a4

実行するとエディタが開き、コミットメッセージの下書きが表示されます。

プレーンテキスト

Revert "検索フォームの並び順を変更" This reverts commit 9f1c0a4c6b3b2a1e5d8f7c6b5a4938271605f4e3.

そのまま保存して閉じれば確定します。なぜ戻すのかを 1 行足しておくと、あとから読む人に親切です。 エディタの保存と終了の仕方は設定によって違いますが、既定の Vim なら Esc を押してから :wq と入力します。

結果を確認します。

ターミナル

$ git log --oneline

プレーンテキスト

7a5e3d1 Revert "検索フォームの並び順を変更" 9f1c0a4 検索フォームの並び順を変更 3b7d21e トップページの見出しを修正 c8e4f60 README に開発環境の手順を追加

9f1c0a4 は消えていません。その上に打ち消しのコミットが積まれています。ファイルの中身は 3b7d21e の時点と同じ状態に戻っています。

あとは push するだけです。履歴を書き換えていないので、--force は要りません。

ターミナル

$ git push

エディタを開かずに済ませたいときは git revert --no-edit 9f1c0a4 と書きます。既定のメッセージがそのまま使われます。ただし理由を書き残す機会が減るので、重要な打ち消しでは省略しないほうがよいです。

複数のコミットをまとめて打ち消す

範囲で指定することもできます。連続した何件かを一気に戻したいときに使います。

ターミナル

$ git revert c8e4f60..9f1c0a4

この書き方は c8e4f60含まず、その次から 9f1c0a4 までを打ち消します。始点を含めたいときは c8e4f60^..9f1c0a4 のように ^ を付けます。新しいものから順に打ち消していくので、間の依存関係で崩れにくくなっています。

既定では 1 コミットごとに打ち消しコミットが積まれますが、1 つにまとめたいときは --no-commit を付けます。

ターミナル

$ git revert --no-commit c8e4f60..9f1c0a4 $ git commit -m "検索まわりの変更を一括で戻す"

--no-commit はコミットせずステージに載せるだけなので、最後に自分でコミットします。履歴が読みやすくなる一方、あとから個別に取り込み直しにくくなるので、関連する変更をまとめて戻すときに限って使ってください。

reset と revert の使い分け

判断の基準は 1 つだけです。そのコミットを他の人と共有したかどうか です。

状況使うコマンド履歴
まだ push していない、手元だけの履歴git reset消える
すでに push して共有した履歴git revert残る

前のレッスンで示した境界と同じです。reset は手元だけ、revert は共有済み。この対応を崩さない限り、事故は起きません。

なぜ共有済みで reset を使ってはいけないのか、具体的に見ておきます。

自分が git reset --hard HEAD~1 でコミットを消し、git push --force でリモートを上書きしたとします。同僚がすでにそのコミットを git pull で取り込んでいた場合、同僚の手元にはまだそのコミットがあります。そこで同僚が push すると、消したはずのコミットが復活します。あるいは、同僚が force push を受け取ったあとに git pull すると、進めていた作業と衝突して混乱します。

つまり、共有済みの履歴の書き換えは、自分では完結しません。周りを巻き込みます。revert なら履歴を足すだけなので、誰の手元とも矛盾しません。

判断に迷ったら git log --oneline --all を見て、そのコミットが origin/main にも含まれているか確認してください。origin はリモートリポジトリに付けられる既定の名前で、origin/main はリモート側の main がどこまで進んでいるかを表す目印です。そこに含まれていれば、共有済みです。

確認は次のコマンドでもできます。

ターミナル

$ git log --oneline origin/main..HEAD

プレーンテキスト

9f1c0a4 検索フォームの並び順を変更

ここに出てくるのは「手元にはあるが、まだリモートに送っていないコミット」です。この一覧に入っていれば reset を使ってよく、入っていなければ共有済みなので revert を選びます。打つ前にこの 1 行を確認するだけで、履歴を壊す事故はほぼ防げます。

なお、自分ひとりしか触っていないブランチであれば、push 済みでも reset と force push で整理してよい、という運用のチームもあります。ただしそれはチームの合意があってこその話です。判断がつかないうちは revert を選んでおけば、誰にも迷惑をかけません。

マージコミットを revert するとき

ブランチを合流させたコミット(マージコミット)は、親が 2 つあります。どちらの側に戻すのかを Git は自分では判断できないので、そのまま revert すると次のエラーになります。

ターミナル

$ git revert 7d4e1a2

プレーンテキスト

error: commit 7d4e1a2 is a merge but no -m option was given. fatal: revert failed

-m で、どちらの親を「戻り先」とするか番号で指定します。多くの場合、取り込んだ側の本流が 1 番です。

ターミナル

$ git revert -m 1 7d4e1a2

ブランチとマージはまだ学んでいないので、いまは「マージコミットには -m 1 が要る」とだけ覚えておけば十分です。仕組みは マージの仕組み(fast-forward と マージコミット) で扱います。

親の番号は git show で確かめられます。

ターミナル

$ git show --no-patch 7d4e1a2

プレーンテキスト

commit 7d4e1a2f3b8c9d0e1a2b3c4d5e6f708192a3b4c5 Merge: 3b7d21e 9f1c0a4

Merge: の行に並ぶ 2 つのハッシュが親で、左が 1 番、右が 2 番です。取り込まれた側ではなく、取り込んだ本流のほうを選んでください。

コンフリクトが起きたときの進め方

打ち消したい行が、そのあとのコミットでさらに編集されていると、Git は自動で戻せません。

ターミナル

$ git revert 9f1c0a4

プレーンテキスト

CONFLICT (content): Merge conflict in src/search.js error: could not revert 9f1c0a4... 検索フォームの並び順を変更 hint: After resolving the conflicts, mark them with hint: "git add/rm <pathspec>", then run "git revert --continue".

慌てる必要はありません。ヒントに手順が書かれています。

該当ファイルを開くと、次のような印が入っています。

プレーンテキスト

<<<<<<< HEAD const sorted = items.sort(byUpdatedAt); ======= const sorted = items.sort(byName); >>>>>>> parent of 9f1c0a4 (検索フォームの並び順を変更)

上が現在の内容、下が戻したい内容です。残すべき形に手で書き直し、<<<<<<< ======= >>>>>>> の 3 行を削除します。そのうえで次を実行します。

ターミナル

$ git add src/search.js $ git revert --continue

途中でやめたくなったら、次のコマンドで revert を始める前の状態に戻せます。

ターミナル

$ git revert --abort

この --abort があるので、revert は試してみて大丈夫です。 失敗しても取り消せます。コンフリクトの詳しい読み方は コンフリクトを解決する で扱います。

やりがちな失敗と、その戻し方

多いのは、打ち消すコミットを間違える ことです。git log --oneline の並びを見誤って、隣のコミットを revert してしまう、という取り違えです。

これは慌てなくても直せます。revert のコミットもただのコミットなので、それをさらに revert すれば元に戻ります。

ターミナル

$ git log --oneline

プレーンテキスト

2f8c6b3 Revert "トップページの見出しを修正" 9f1c0a4 検索フォームの並び順を変更 3b7d21e トップページの見出しを修正

間違えて 3b7d21e を打ち消してしまったので、その打ち消しコミット 2f8c6b3 を打ち消します。

ターミナル

$ git revert 2f8c6b3

履歴には往復した記録が全部残りますが、それでいいのです。履歴が汚れることを恐れて reset --force push に走るほうが、はるかに危険です。 共有済みの履歴では、正しさより安全性を優先してください。

なお、間違いに気づいたのが revert をコミットする前、つまりエディタが開いている段階なら、メッセージを空にして保存すると中止できます。

もう 1 つ多いのが、作業中の変更を残したまま revert を始めてしまう ことです。

ターミナル

$ git revert 9f1c0a4

プレーンテキスト

error: your local changes would be overwritten by revert. hint: commit your changes or stash them to proceed. fatal: revert failed

Git が手前で止めてくれるので、作業内容が消えることはありません。ヒントのとおり、先にコミットするか 作業を一時退避する(stash) で退避してから、もう一度実行してください。

revert に限らず、履歴を触るコマンドは作業フォルダが片付いている状態で実行するのが原則です。git statusnothing to commit, working tree clean になっていることを確認してから始めると、余計なつまずきが減ります。

この章のポイント
  • git revert は履歴を消さず、逆の変更をする新しいコミットを積んで打ち消す
  • 使い分けの基準は「共有済みかどうか」。push 前は reset、push 後は revert
  • マージコミットには -m 1 が要る。コンフリクトしたら git add して --continue、やめるなら --abort
  • revert を間違えたら、その revert をさらに revert すればよい。force push で履歴を書き換えない