git reset は変更を元に戻すための、複雑で汎用性のあるツールです。--soft、--mixed、--hard のコマンドライン引数を使った、主に 3 つの形式で呼び出します。これら 3 つの引数はそれぞれ、Git にある 3 つの内部状態管理メカニズムであるコミットツリー (HEAD)、ステージングインデックス、作業ディレクトリに対応しています。

git reset と 3 つの Git ツリー

git reset の使い方を適切に理解するためには、まず Git の内部状態管理システムを理解しておく必要があります。これらのメカニズムは Git の「3 つのツリー」と呼ばれることがあります。従来のツリー型データ構造とは厳密には異なるため、ツリーという呼び方は必ずしも正確ではありません。しかし、編集のタイムラインを追跡する、ノードとポインターをベースにしたデータ構造になっているため、これらのメカニズムをわかりやすく説明するには、リポジトリにチェンジセットを作成して 3 つのツリーから辿っていくのが最適です。

まず初めに以下のコマンドで新しいリポジトリを作成します。

 $ mkdir git_reset_test $ cd git_reset_test/ $ git init . Initialized empty Git repository in /git_reset_test/.git/ $ touch reset_lifecycle_file $ git add reset_lifecycle_file $ git commit -m"initial commit" [master (root-commit) d386d86] initial commit 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 reset_lifecycle_file

上のサンプルコードでは空のファイル reset_lifecycle_file が 1 つ入った新しい Git リポジトリを作成しています。この時点で、サンプルリポジトリには reset_lifecycle_file から追加した 1 つのコミット (d386d86) があります。

作業ディレクトリ

最初に調べるツリーは「作業ディレクトリ」です。このツリーはローカルファイルシステムと同期しており、ファイルとディレクトリへの変更はこのツリーに即座に反映されます。


$ echo 'hello git reset' > reset_lifecycle_file
$ git status 
On branch master 
Changes not staged for commit: 
(use "git add ..." to update what will be committed) 
(use "git checkout -- ..." to discard changes in working directory) 
modified: reset_lifecycle_file

デモリポジトリでは reset_lifecycle_file を修正してコンテンツを追加しています。git status を呼び出すと、ファイルへの変更を Git が認識していることがわかります。これらの変更は現在、1 つ目のツリーである「作業ディレクトリ」に属しています。git status を使って作業ディレクトリへの変更を表示することができます。変更には「modified」のプレフィックスが付いた赤字で表示されます。

ステージングインデックス

次に「ステージングインデックス」ツリーを見ていきます。このツリーは、git add でプロモートされて後続のコミットに保存される作業ディレクトリの変更を追跡します。ステージングインデックスは複雑な内部キャッシュメカニズムです。Git では通常、ステージングインデックスの実装の詳細をユーザーから隠そうとします。

ステージングインデックスの状態を正確に見るためには、あまり知られていない Git コマンド git ls-files を使用する必要があります。git ls-files は本来、ステージングインデックスツリーの状態を調べるデバッグユーティリティです。

 git ls-files -s 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 reset_lifecycle_file

ここでは -s または --stage オプションを指定して git ls-files を実行しました。-s オプションを指定しない場合、git ls-files 出力にはインデックスに現在属しているファイル名とパスのリストだけが表示されます。-s オプションではステージングインデックスのファイルの追加メタデータが表示されます。このメタデータはステージされたコンテンツのモードビット、オブジェクト名、ステージ番号です。ここでは 2 番目の値 (d7d77c1b04b5edd5acfc85de0b592449e5303770) であるオブジェクト名に注目します。これは標準の Git オブジェクト SHA-1 ハッシュで、ファイルのコンテンツのハッシュです。コミット履歴にはコミットと ref へのポインターを識別するための独自のオブジェクト SHA ハッシュが保存されています。ステージングインデックスにはインデックス内のファイルのバージョンを追跡するための独自のオブジェクト SHA ハッシュがあります。

次に、修正した reset_lifecycle_file をステージングインデックスにプロモートします。


$ git add reset_lifecycle_file 

$ git status 

On branch master Changes to be committed: 

(use "git reset HEAD ..." to unstage) 

modified: reset_lifecycle_file

ここではステージングインデックスにファイルを追加する git add reset_lifecycle_file を呼び出しました。ここで git status を呼び出すと「コミットされる変更」の下に緑で reset_lifecycle_file が表示されます。git status は実際にはステージングインデックスを表していないため、注意が必要です。git status コマンドの出力には、コミット履歴とステージングインデックスの間の変更が表示されています。この時点のステージングインデックスのコンテンツを調べてみましょう。

 $ git ls-files -s 100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

reset_lifecycle_file のオブジェクト SHAが e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 から d7d77c1b04b5edd5acfc85de0b592449e5303770 に更新されているのがわかります。

コミット履歴

最後のツリーはコミット履歴です。git commit コマンドを実行すると、コミット履歴にある永続スナップショットに変更が追加されます。このスナップショットにはコミット時のステージングインデックスの状態も含まれています。

 $ git commit -am"update content of reset_lifecycle_file" [master dc67808] update content of reset_lifecycle_file 1 file changed, 1 insertion(+) $ git status On branch master nothing to commit, working tree clean

ここでは「update content of resetlifecyclefile」というメッセージを含む新しいコミットを作成しました。コミット履歴にチェンジセットが追加されました。この時点で git status を呼び出すと、どのツリーにも保留中の変更がないことがわかります。git log を実行するとコミット履歴が表示されます。これで 3 つのツリーからこのチェンジセットをたどったので、ここからは git reset を使ってみましょう。

仕組み

表面上は、git resetgit checkout と挙動が似ています。git checkoutHEAD ref ポインターのみを操作しますが、git resetHEAD ref ポインターと現在のブランチの ref ポインターを移動します。この挙動をわかりやすく説明するために、以下のサンプルを見てみてください。

このサンプルでは master ブランチの連続するコミットを詳しく見ていきました。HEAD ref と master ブランチ ref はコミット d をポイントしています。それでは git checkout bgit reset b. を実行して結果を比較してみましょう。

git checkout b

git checkout を実行しても、master ref は d をポイントしたままです。HEAD ref が移動し、コミット b をポイントしています。リポジトリは現在、「detached HEAD」状態になっています。

git reset b

対照的に、git resetHEAD とブランチ ref の両方を指定したコミットに移動します。

git reset はコミットの ref ポインターを更新するだけでなく、3 つのツリーの状態を修正します。ref ポインターの修正は常に発生していて、3 番目のツリーであるコミットツリーが更新されます。コマンドライン引数 --soft、--mixed--hard でステージングインデックスツリーと作業ディレクトリツリーの修正方法を指定します。

主なオプション

git reset のデフォルトの呼び出しには --mixedHEAD の暗黙の引数があります。つまり、git reset を実行するいうことは、git reset --mixed HEAD を実行しているのと同じことになります。このコマンドラインで、HEAD は指定したコミットです。HEAD の代わりに任意の Git SHA-1 コミットハッシュを使うこともできます。

--hard

これは最も直接的かつ "危険" で使用頻度の高いオプションです。--hard を渡すと、コミット履歴の ref ポインターが指定したコミットに更新されます。その後、ステージングインデックスと作業ディレクトリは、指定したコミットの状態と同じになるようにリセットされます。ステージングインデックスと作業ディレクトリでこれまで保留されていたすべての変更は、コミットツリーの状態と同じになるようにリセットされます。つまり、ステージングインデックスと作業ディレクトリで確定していないすべての保留中の変更が失われることになります。

これをわかりやすく説明するために、前の手順で作成した、3 つのツリーがあるサンプルリポジトリを引き続き使っていきます。まずはリポジトリにいくつか修正を加えます。サンプルリポジトリで以下のコマンドを実行します。

$ echo 'new file content' > new_file $ git add new_file $ echo 'changed content' >> reset_lifecycle_file

これらのコマンドによって new_file という名前の新しいファイルが作成され、リポジトリに追加されます。また reset_lifecycle_file のコンテンツに修正が入ります。これらの変更を加えたところで、git status を使ってリポジトリの状態を調べてみましょう。

$ git status On branch master Changes to be committed: (use "git reset HEAD ..." to unstage) new file: new_file Changes not staged for commit: (use "git add ..." to update what will be committed) (use "git checkout -- ..." to discard changes in working directory) modified: reset_lifecycle_file

リポジトリに保留中の変更があることがわかります。ステージングインデックスツリーには new_file の追加に関する保留中の変更があり、作業ディレクトリには reset_lifecycle_file の修正に関する保留中の変更があります。

次に進む前に、ステージングインデックスの状態を調べてみましょう。

$ git ls-files -s 100644 8e66654a5477b1bf4765946147c49509a431f963 0 new_file 100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

インデックスに new_file が追加されたのがわかります。reset_lifecycle_file を更新しましたが、ステージングインデックス SHA (d7d77c1b04b5edd5acfc85de0b592449e5303770) に変化はありません。git add を使ってこれらの変更をステージングインデックスにプロモートしていないため、これは想定していた挙動です。これらの変更は作業ディレクトリにあります。

では git reset --hard を実行してリポジトリの新しい状態を調べてみましょう。

$ git reset --hard HEAD is now at dc67808 update content of reset_lifecycle_file $ git status On branch master nothing to commit, working tree clean $ git ls-files -s 100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

ここでは --hard オプションを使って「ハードリセット」を実行しました。Git には、HEAD が最新のコミット dc67808 をポイントしていることを示す出力が表示されています。次は、git status を実行してリポジトリの状態を確認してみましょう。Git には保留中の変更がないことがわかります。ステージングインデックスの状態も調べると、new_file が追加される前の時点にリセットされていることがわかります。reset_lifecycle_file に加えた変更と new_file の追加は失われたことになります。このようなデータの消失は元に戻すことができないという点を忘れないでください。

--mixed

デフォルトの操作モードで、ref ポインターが更新されます。ステージングインデックスは、指定したコミットの状態にリセットされます。ステージングインデックスで元に戻したすべての変更は作業ディレクトリに移動します。それでは実際にやってみましょう。

$ echo 'new file content' > new_file $ git add new_file $ echo 'append content' >> reset_lifecycle_file $ git add reset_lifecycle_file $ git status On branch master Changes to be committed: (use "git reset HEAD ..." to unstage) new file: new_file modified: reset_lifecycle_file $ git ls-files -s 100644 8e66654a5477b1bf4765946147c49509a431f963 0 new_file 100644 7ab362db063f9e9426901092c00a3394b4bec53d 0 reset_lifecycle_file

上のサンプルではリポジトリにいくつか修正を加えました。ここでも new_file を追加して reset_lifecycle_file のコンテンツを修正しています。これらの変更はその後、git add を使ってステージングインデックスに適用されます。リポジトリがこの状態になったので、リセットを実行してみましょう。

$ git reset --mixed $ git status On branch master Changes not staged for commit: (use "git add ..." to update what will be committed) (use "git checkout -- ..." to discard changes in working directory) modified: reset_lifecycle_file Untracked files: (use "git add ..." to include in what will be committed) new_file no changes added to commit (use "git add" and/or "git commit -a") $ git ls-files -s 100644 d7d77c1b04b5edd5acfc85de0b592449e5303770 0 reset_lifecycle_file

ここでは「混合リセット」を実行しました。繰り返しになりますが、--mixed はデフォルトのモードで、git reset 実行時と同じ結果が得られます。git statusgit ls-files の出力を調べてみると、インデックス内にreset_lifecycle_file のファイルしかない状態に、ステージングインデックスがリセットされていることがわかります。reset_lifecycle_file のオブジェクト SHA が以前のバージョンにリセットされています。

ここで重要なのは、git status によって、reset_lifecycle_file に修正が入っていることと、追跡対象外ファイル new_file があることがわかったことです。これは --mixed の明示的な挙動です。ステージングインデックスがリセットされて、保留中の変更が作業ディレクトリに移動されました。対照的に、--hard を使ったリセットのケースではステージングインデックスと作業ディレクトリの両方がリセットされて更新が失われていました。

--soft

--soft 引数が渡されると、ref ポインターが更新されてそこでリセットが停止します。ステージングインデックスと作業ディレクトリは変更されません。この挙動をわかりやすく説明するのは簡単ではありません。引き続きデモ用リポジトリを使ってソフトリセットの準備を行いましょう。


$ git add reset_lifecycle_file 

$ git ls-files -s 

100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file 

$ git status 

On branch master 

Changes to be committed: 

(use "git reset HEAD ..." to unstage) 

modified: reset_lifecycle_file 

Untracked files: 

(use "git add ..." to include in what will be committed) 

new_file

ここでも git add を使って、修正した reset_lifecycle_file をステージングインデックスにプロモートしました。インデックスが git ls-files の出力で更新されているのがわかります。git status の出力には「コミットされる変更」が緑で表示されています。前のサンプルの new_file が、追跡対象外ファイルとして作業ディレクトリで未確定状態になっています。以降のサンプルでは必要ないため、rm new_file を実行してこのファイルを削除しましょう。

リポジトリがこの状態になったらソフトリセットを実行します。

 $ git reset --soft $ git status On branch master Changes to be committed: (use "git reset HEAD ..." to unstage) modified: reset_lifecycle_file $ git ls-files -s 100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

「ソフトリセット」を実行しました。git statusgit ls-files を実行してリポジトリの状態を確認すると、何も変更されていないことがわかります。これは想定された挙動です。ソフトリセットではコミット履歴だけがリセットされます。デフォルトでは、git resetHEAD とともにターゲットコミットとして呼び出されます。今回のコミット履歴はすでに HEAD にあるため、HEAD に暗黙的にリセットしても実際には何も起こりません。

--soft の理解を深めてうまく使えるようになるために、HEAD ではないターゲットコミットを用意する必要があります。ステージングインデックスには reset_lifecycle_file があるので、新しいコミットを作成してみましょう。

$ git commit -m"prepend content to reset_lifecycle_file"

この時点で、リポジトリには 3 つのコミットがあるはずです。1 つ目のコミットまで時間をさかのぼりましょう。これを行うには 1 つ目のコミットの ID が必要です。git log の出力を見ると ID がわかります。

$ git log commit 62e793f6941c7e0d4ad9a1345a175fe8f45cb9df Author: bitbucket  Date: Fri Dec 1 15:03:07 2017 -0800 prepend content to reset_lifecycle_file commit dc67808a6da9f0dec51ed16d3d8823f28e1a72a Author: bitbucket  Date: Fri Dec 1 10:21:57 2017 -0800 update content of reset_lifecycle_file commit 780411da3b47117270c0e3a8d5dcfd11d28d04a4 Author: bitbucket  Date: Thu Nov 30 16:50:39 2017 -0800 initial commit

コミット履歴 ID は各システムで固有だということを覚えておきましょう。つまり、このサンプルのコミット ID と、ご自身のコンピューターに表示されているコミット ID は異なっているということです。このサンプルで使うコミット ID は 780411da3b47117270c0e3a8d5dcfd11d28d04a4 です。これが「最初のコミット」に対応する ID です。ID がわかったらそれを対象として指定してソフトリセットを実行します。

時間をさかのぼる前に、まずはリポジトリの現在の状態を確認しておきましょう。

 $ git status && git ls-files -s On branch master nothing to commit, working tree clean 100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

ここでは git statusgit ls-files -s の連携コマンドを実行しました。これで、リポジトリに保留中の変更があり、ステージングインデックスの reset_lifecycle_file のバージョンが 67cc52710639e5da6b515416fd779d0741e3762e であることがわかりました。これを忘れないようにしながら 1 つ目のコミットに戻ってソフトリセットを実行しましょう。

$git reset --soft 780411da3b47117270c0e3a8d5dcfd11d28d04a4 $ git status && git ls-files -s On branch master Changes to be committed: (use "git reset HEAD ..." to unstage) modified: reset_lifecycle_file 100644 67cc52710639e5da6b515416fd779d0741e3762e 0 reset_lifecycle_file

上のコードでは「ソフトリセット」を実行するとともに、リポジトリの状態を出力する git statusgit ls-files の連携コマンドも呼び出しています。リポジトリの状態の出力を調べるといくつか注目すべき点があります。まず、git status を実行したことで、reset_lifecycle_file に修正が加えられて、それらの修正が後続のコミットにステージされた変更であることが強調表示されていることがわかります。次に git ls-files の入力を見ると、ステージングインデックスは変更されておらず、前からあった SHA 67cc52710639e5da6b515416fd779d0741e3762e がそのままになっていることがわかります。

このリセットによって何が起こったのかをさらに詳しく見るために、git log を調べてみましょう。

$ git log commit 780411da3b47117270c0e3a8d5dcfd11d28d04a4 Author: bitbucket  Date: Thu Nov 30 16:50:39 2017 -0800 initial commit

ログの出力を見ると、コミット履歴に 1 つだけコミットがあることがわかります。これによって、--soft で何が起きたかがわかりやすくなります。すべての git reset 呼び出し同様、リセットで行われる最初の処理はコミットツリーのリセットです。--hard--mixed の前のサンプルでは両方とも HEAD をポイントしておらず、コミットツリーを過去の状態に戻していませんでした。ソフトリセット中に起こるのはこれがすべてです。

そうなると、git status の結果で修正されたファイルがあった理由がよくわからなくなります。 --soft ではステージングインデックスを操作しないため、コミット履歴をたどってステージングインデックスが更新されたことになります。git ls-files -s の出力を見ると reset_lifecycle_file の SHA が更新されていないため、これは間違いありません。本来はツリー間の差分を表示するためのコマンドである git status は「3 つのツリー」の状態を表示しない点をここで繰り返しておきます。このサンプルでは、ステージングインデックスがコミット履歴よりも先の変更になっていてあたかもすでにそれらをステージしたかのような状態になっています。

取消しと打消し

git revert が変更を元に戻す「安全」な手段だとすると、git reset は危険な手法という考え方もできます。git reset には作業を失ってしまうという現実的なリスクがあります。git reset でコミットが削除されることはありませんが、コミットが孤立して ref からそのコミットに直接アクセスするパスがなくなることになります。孤立したコミットは通常、git reflog を使って特定、復元することができます。Git では内部ガベージコレクターが実行されると孤立したコミットが完全に削除されます。Git のデフォルトでは、30 日ごとにガベージコレクターが実行されるように設定されています。コミット履歴は「Git の 3 つのツリー」の 1 つです。残りの 2 つであるステージングインデックスと作業ディレクトリはコミットとは違って永続的なものではありません。作業を失ってしまう可能性のある唯一の Git コマンドであるため、git reset の使用には細心の注意が必要です。

一方、打消しは公開済みコミットを元に戻すための安全な方法です。git reset ではステージングインデックスと作業ディレクトリへのローカルの変更を元に戻します。これら 2 つのコマンドの目的には明確な差があり、実装のされ方も異なっています。取消しではチェンジセットが完全に削除されるのに対して、打消しでは元のチェンジセットが保持されたまま、新しいコミットを作成して元に戻す操作が行われます。

公開済み履歴の取り消しは厳禁

の実行後に一度でもスナップショットを公開リポジトリにプッシュした場合は、git reset コマンドを絶対に使用しないでください。コミットを公開したら、他の開発者がそれを前提として作業を行っていると考える必要があります。

他のチームメンバーが開発中に行ったコミットを取り消すとコラボレーション上の深刻な問題が生じます。彼らがあなたのリポジトリとの同期を行おうとすると、プロジェクト履歴の一部が欠落したように見えます。公開済みのコミットを取り消すと何が起こるかを以下の一連の図に示します。ここで origin/master ブランチは、ローカルな master ブランチに対応する中央リポジトリのブランチです。

取り消し後に新規コミットを行うと、Git ではローカルな履歴が origin/master から分岐したと扱われ、ローカルリポジトリを同期するために作成されるマージコミットがチームの他の開発者に対する混乱と作業妨害を引き起こします。

重要なことは、git reset コマンドの実行目的は、結果が思わしくなかったローカルの実験的開発作業の取り消しであって、公開済みの変更の取消しではないと認識することです。公開済みのコミットを訂正する必要がある場合は、その目的の専用コマンド git revert が用意されています。

 git reset 

作業ディレクトリに何の変更も加えずに、指定したファイルをステージングエリアから削除するコマンドです。このコマンドを実行すると、変更を書き込むことなく指定したファイルをアンステージします。

 git reset

作業ディレクトリに何の変更も加えることなくステージングエリアをリセットして直前のコミット時の状態と一致させるコマンドです。このコマンドを実行すると、変更を書き込むことなくすべてのファイルをアンステージし、一度ステージされたスナップショットを初めから再構築することができるようになります。

 git reset --hard

ステージングエリアと作業ディレクトリをリセットして直前のコミット時の状態と一致させるコマンドです。--hard は、変更をアンステージしたうえでさらに作業ディレクトリ内のすべての変更を元に戻すことを Git に指示するフラグです。言い換えると、これはコミット前のすべての変更をまったくなかったものとするコマンドであり、これを使用する場合はローカルマシーンで行った開発作業を本当に破棄していいのか否かを確認する必要があります。

 git reset  

現在のブランチの先端を commit の位置に戻したうえでステージングエリアをその状態と一致するように元に戻しますが、作業ディレクトリのみそのままにしておきます。 の実行後に行われた変更はすべて作業ディレクトリに保存されており、より変更規模が小さくて整理されたスナップショットを使ってプロジェクト履歴への再コミットを行うことができます。

 git reset --hard  

現在のブランチの先端を の位置に戻したうえでステージングエリアおよび作業ディレクトリをその状態と一致するように元に戻します。このコマンドを実行すると、コミット前の変更に加えて後に行われたすべてのコミットもまったくなかったものとなります。

ファイルのアンステージ

git reset コマンドはステージされたスナップショットを作成する際によく使われます。次の例では、hello.pymain.py の2 個のファイルをリポジトリにすでに追加しているものと仮定しています。

# Edit both hello.py and main.py # Stage everything in the current directory git add . # Realize that the changes in hello.py and main.py # should be committed in different snapshots # Unstage main.py git reset main.py # Commit only hello.py git commit -m "Make some changes to hello.py" # Commit main.py in a separate snapshot git add main.py git commit -m "Edit main.py"

ご覧のように、git reset を使用することにより次回のコミットと無関係な変更をアンステージし、そのコミットの目的を明確化することができます。

ローカルなコミットの削除

次の例はより高度なユースケースを示します。ここでは実験的開発をしばらく行っていると仮定し、いくつかのスナップショットをコミットした後でそれらをすべて破棄する場合にどうするべきかを示します。

# Create a new file called `foo.py` and add some code to it # Commit it to the project history git add foo.py git commit -m "Start developing a crazy feature" # Edit `foo.py` again and change some other tracked files, too # Commit another snapshot git commit -a -m "Continue my crazy feature" # Decide to scrap the feature and remove the associated commits git reset --hard HEAD~2

git reset HEAD~2 は、現在のブランチをコミット 2 回分だけ前に戻すコマンドであり、実質的には最近作成した 2 つのスナップショットをプロジェクト履歴から削除する働きをします。以前に説明したように、このような reset コマンドの使用対象は未公開のコミットに限ってください。コミットを公開リポジトリにプッシュしている場合には、決して上記の操作を行わないでください。

概要

git reset は Git リポジトリの状態に対するローカルの変更を元に戻すための強力なコマンドであることがわかりました。git reset を「Git の 3 つのツリー」で操作しました。これらのツリーはコミット履歴 (HEAD)、ステージングインデックス、作業ディレクトリです。これら 3 つのツリーに対応する 3 つのコマンドラインオプションがあります。オプション --soft、--mixed、--hardgit reset に渡すことができます。

この記事では、他にもいくつかの Git コマンドを活用して取消しプロセスをわかりやすく説明しました。各コマンドの詳細については、git statusgit loggit addgit checkoutgit refloggit revert の各ページを参照してください。

git reset を学ぶ準備はできていますか?

この対話式のチュートリアルをお試しください。

今すぐ始める