Gitの取り消し: reset, revert, restore

1. 概要

Gitを使っていると、誰でも何度か戻したくなることがあります。ファイルを誤って編集したり、不要なgit addをしたり、本来はコミットすべきではないものをコミットしたり。Gitには、これらの状況に対応する3つの主要なコマンドがあります:restore、reset、revertです。これらのコマンドの名前は似ていますが、それぞれ異なる領域に作用します。

この記事では、同じサンプルリポジトリを使って、各コマンドを順番に説明します。記事を読み終わる頃には、どのような状況でどのコマンドを使うべきかがわかるようになります。また、誤ってreset --hardを実行した場合、コミットを復旧する方法も学べます。

2. サンプルリポジトリの準備

まず、Gitには3つの領域があることを思い出しましょう。

  • Working tree:ディスク上で現在編集しているファイル。
  • Staging area(インデックス):次のコミットに含まれるもの。
  • Commit history:保存されているコミット。HEADは現在のコミットを指しています。

では、4つのコミットを含む小さなリポジトリを作成しましょう。

mkdir order-service && cd order-service
git init -b main

echo "class Order {}" > Order.java
git add Order.java && git commit -m "Orderクラスを追加"

echo "class Customer {}" > Customer.java
git add Customer.java && git commit -m "Customerクラスを追加"

echo "class Order { int total; }" > Order.java
git commit -am "Orderにtotalを追加"

echo "class PaymentService {}" > PaymentService.java
git add PaymentService.java && git commit -m "PaymentServiceを追加"

git log --oneline

git commit -amコマンドは、追跡されているすべてのファイルをステージし、すぐにコミットします。結果としてgit logの出力は次のようになります(お使いのマシンではハッシュが異なります):

d4e5f60 (HEAD -> main) PaymentServiceを追加
c3d4e5f Orderにtotalを追加
b2c3d4e Customerクラスを追加
a1b2c3d Orderクラスを追加

3. git restore:未コミットの変更を取り消す

git restoreはGit 2.23から追加されました。このコマンドはファイルに対してのみ機能します。HEADを移動することも、コミット履歴を修正することもありません。古いバージョンのGitでは、この操作はgit checkout -- <file>で実行されていました。

3.1. Working treeの変更を破棄する

例えば、Order.javaを編集した後、気が変わったとします:

echo "class Order { int total; String note; }" > Order.java
git status --short
git restore Order.java
cat Order.java
 M Order.java
class Order { int total; }

git status --shortの出力に Mがあるのは、ファイルが編集されているがステージされていないことを意味します。git restore Order.javaは、ステージング領域からワーキングツリーに内容をコピーします。ステージされていないので、この内容はHEADと同じです。

注意: git restoreで破棄された変更は完全に失われます。Gitはそれらを保存したことがないため、reflogでも復元できません。確信がない場合は、restoreの代わりにgit stashを実行してください。

3.2. ファイルをステージング領域から削除する

誤ってコミットしたくないファイルをgit addしてしまった場合:

echo "class Order { int total; String note; }" > Order.java
git add Order.java
git restore --staged Order.java
git status --short
 M Order.java

--stagedオプションは、HEADからステージング領域に内容をコピーします。その結果、ファイルが“unstage”され、編集した内容はワーキングツリーに残ります。古いコマンドでの同等の操作はgit reset HEAD Order.javaです。

ステージング領域とワーキングツリーの両方を一度に破棄したい場合は、以下を使用します:

git restore --staged --worktree Order.java

3.3. 古いコミットからファイルを取得する

--sourceオプションを使用すると、任意のコミットからファイルの内容を取得できます:

git restore --source=HEAD~2 Order.java
cat Order.java
git status --short
class Order {}
 M Order.java

HEAD~2は“Customerクラスを追加”というコミットです。そのコミットではOrder.javaにまだtotalがありません。デフォルトではコマンドはワーキングツリーにのみ書き込むため、ファイルは M(ステージされていない)の状態になります。確認してから、保持したい場合は自分でgit addしてコミットします。そうでない場合は、git restore Order.javaを実行して元に戻します。

4. git reset:ブランチポインタを移動する

git reset <commit>は、現在のブランチ(とHEAD)を指定されたコミットに移動させます。3つのモード--soft、--mixed、--hardは、ステージング領域とワーキングツリーが上書きされるかどうかで異なります。

モード Branch/HEAD ステージング領域 ワーキングツリー
--soft 対象のコミットに移動 そのまま そのまま
--mixed(デフォルト) 対象のコミットに移動 対象のコミットにリセット そのまま
--hard 対象のコミットに移動 対象のコミットにリセット 対象のコミットにリセット

以下のすべての例は、コミットd4e5f60の清潔な状態から始まります。

4.1. –soft:コミットを削除しつつ、ステージされた変更を保持

git reset --soft HEAD~1
git log --oneline -1
git status --short
c3d4e5f (HEAD -> main) Orderにtotalを追加
A  PaymentService.java

“PaymentServiceを追加”というコミットがブランチから削除されます。ファイルPaymentService.javaは引き続き存在し、A(ステージされた)状態にあります。これはメッセージを修正したり、複数のコミットを1つにまとめたい場合に便利です:

git commit -m "PaymentServiceを追加して支払い処理を実装"

4.2. –mixed:コミットを削除してunstageする

git reset HEAD~1
git status --short
?? PaymentService.java

これがデフォルトモードです。ブランチが1つのコミット戻り、ステージング領域がリセットされます。PaymentService.javaは新しいファイルなので、現在untracked(??)になっています。コミットを再構成したい場合に--mixedを使用します。つまり、部分的にステージしてから、複数の小さなコミットに分割します。

4.3. –hard:すべてを破棄

git add PaymentService.java && git commit -m "PaymentServiceを追加"
git reset --hard HEAD~1
ls
HEAD is now at c3d4e5f Orderにtotalを追加
Customer.java  Order.java

コミット、ステージング領域、および追跡されたファイルのワーキングツリーのすべてがc3d4e5fにリセットされます。PaymentService.javaは削除されます。

注意: reset --hardは、追跡されたファイルの未コミットの変更も削除します。これらの変更は復復できません。反対に、untrackedファイルは削除されません。untrackedファイルを削除するには、git clean -fdを使用する必要があり、このコマンドも同様に危険です。

4.4. reflogを使用してコミットを復旧する

resetされたコミットはすぐには失われません。Gitは、reflogでHEADが移動するたびにそれを記録します:

git reflog -3
c3d4e5f HEAD@{0}: reset: moving to HEAD~1
9f8e7d6 HEAD@{1}: commit: PaymentServiceを追加
c3d4e5f HEAD@{2}: commit: PaymentServiceを追加して支払い処理を実装

復元したいコミットにリセットするだけで済みます:

git reset --hard 9f8e7d6
git log --oneline -1
9f8e7d6 (HEAD -> main) PaymentServiceを追加

Reflogはお使いのマシンにのみ存在します。デフォルトでは、参照されなくなった項目は30日後に期限切れとなり、ガベージコレクターによってクリーンアップされます。したがって、早急に復旧することが重要です。

4.5. resetを使用してはいけない場合

共有ブランチにプッシュ済みのコミットに対してresetを実行してはいけません。リセット後、ローカル履歴とリモート履歴が異なるため、git push --forceを実行する必要があります。それらのコミットを既にプルしている同僚は、履歴が混乱し、誤ってそれらを再度プッシュする可能性があります。

個人用ブランチなど、上書きが必要な場合は、より安全なコマンドを使用してください:

git push --force-with-lease

--force-with-leaseは、リモートに新しいコミットがあって、それをまだフェッチしていない場合、プッシュを拒否します。これにより、他の人の作業を上書きすることはありません。

5. git revert:新しいコミットで取り消す

git revertは履歴を削除しません。このコマンドは、取り消すコミットの逆の内容を持つ新しいコミットを作成します。履歴は長くなるだけで上書きされないため、これはプッシュ済みブランチに対する安全な方法です。

5.1. 単一コミットをrevertする

git revert --no-edit HEAD
git log --oneline -3
ls
1a2b3c4 (HEAD -> main) Revert "PaymentServiceを追加"
9f8e7d6 PaymentServiceを追加
c3d4e5f Orderにtotalを追加
Customer.java  Order.java

--no-editは、エディタを開かずにデフォルトメッセージを使用します。元のコミット9f8e7d6は履歴に残ります。新しいコミット1a2b3c4はPaymentService.javaを削除します。新しいコミットの本文にはThis reverts commit 9f8e7d6...という行があり、他の人が出所を特定するのに役立ちます。

履歴の中間にある古いコミットもrevertできます。必ずしもHEADである必要はありません:

git revert --no-edit c3d4e5f
cat Order.java
class Order {}

後続のコミットが同じコード行を変更している場合、Gitはコンフリクトを報告します。コンフリクトを解決して、git addを実行し、その後git revert --continueを実行します。途中で中止したい場合は、git revert --abortを使用します。

5.2. 複数のコミットを1つのコミットにrevertする

デフォルトでは、revertされた各コミットは別々のコミットを生成します。それらを統合するには、--no-commitを使用します:

git revert --no-commit HEAD~3..HEAD~1
git commit -m "Customerとsorderの変更を取り消す"

構文A..Bは、Aの後のBまでのコミットを選択します。Gitは各コミットをステージング領域に逆方向に適用し、その後単一のコミットを実行します。

5.3. マージコミットをrevertする

マージコミットには2つの親があります。したがって、Gitはどのブランチを“メインライン”として保つかを知る必要があります:

git revert -m 1 <hash-merge-commit>

-m 1は第一の親を選択します。これは通常、マージ先ブランチ(例えばmain)です。その結果、もう一方のブランチによってもたらされたすべての変更が取り消されます。

注意: マージをrevertした後、同じブランチを再度マージすると、Gitは古いコミットを“既にマージ済み”と見なします。これらの変更は戻りません。それらを復元するには、revertコミット自体をrevertする必要があります。

6. どのコマンドを選ぶか?

状況 使用するコマンド
ファイルの未ステージの変更を破棄する git restore <file>
ファイルをunstageする git restore --staged <file>
ファイルの古いバージョンを取得する git restore --source=<commit> <file>
最後のコミットを編集/統合する、未プッシュ git reset --soft HEAD~1
コミットを再構成する、未プッシュ git reset HEAD~1
ローカルコミットを完全に破棄する git reset --hard <commit>
プッシュ済みのコミットを取り消す git revert <commit>

覚えやすいようにまとめると:

  • restoreはファイルで機能します。
  • resetはブランチで機能し、ローカルで使用されます。
  • revertは共有履歴で機能します。

7. よくあるエラー

  • 共有ブランチでresetとrevertを混同する。 これが最も一般的なエラーです。プッシュ済みの場合はrevertを使用してください。
  • 未コミットの変更があるときにreset --hardを実行する。 実行する前に、git statusを確認してください。変更がある場合は、まずgit stashを実行してください。
  • reset --hardがディレクトリをクリーンアップすると思う。 Untrackedファイルはそのまま残ります。事前に確認するにはgit clean -nを、削除するにはgit clean -fdを使用する必要があります。
  • マージコミットでHEAD~1とHEAD^が異なることを忘れる。 HEAD^2は第二の親で、HEAD~2は祖父、つまり親の親です。線形履歴では、HEAD~1とHEAD^は同じです。
  • 癖でgit checkout <file>を使用する。 このコマンドは依然として機能しますが、checkoutは多くの役割を果たしており、ブランチ切り替えと誤入力しやすいです。最新のGitでは、restoreとswitchの方が明確です。

8. まとめ

この記事では、Gitでの3つの取り消し方法を学びました:

  • git restoreは履歴に触れることなく、ワーキングツリーまたはステージング領域のファイルコンテンツを復元します。
  • git resetはブランチを別のコミットに移動します。3つのレベル--soft、--mixed、--hardがあります。このコマンドは未プッシュのコミットにのみ使用すべきです。
  • git revertは逆コミットを作成し、共有ブランチの安全な選択です。

誤って何かをした場合は、git reflogを思い出してください。ほとんどの“失われた”コミットはそこに残っており、復旧を待っています。

gitversion-controlresetrevertrestore