チャプター ▾ 第2版

7.7 Git ツール - Reset の解明

Reset の解明

より特殊なツールに進む前に、Git の reset コマンドと checkout コマンドについて話しましょう。これらのコマンドは、Git を初めて使うとき、最も混乱しやすい部分の 2 つです。非常に多くのことを行うため、それらを実際に理解して適切に使用することは絶望的に思えるかもしれません。このため、簡単な比喩をお勧めします。

3つのツリー

resetcheckout を考える簡単な方法は、Git を3つの異なるツリーのコンテンツマネージャーととらえるという精神的な枠組みです。ここで言う「ツリー」とは、厳密にはデータ構造ではなく、「ファイルの集合」という意味です。インデックスがツリーのように正確に機能しないケースもいくつかありますが、当面はそう考える方が簡単です。

Git はシステムとして、通常の操作で3つのツリーを管理および操作します。

ツリー 役割

HEAD

最後のコミットスナップショット、次の親

インデックス

次に提案されるコミットスナップショット

ワーキングディレクトリ

サンドボックス

HEAD

HEAD は現在のブランチ参照へのポインタであり、これはそのブランチで最後に行われたコミットへのポインタです。つまり、HEAD は次に作成されるコミットの親になります。HEAD は、そのブランチでの最後のコミットのスナップショットと考えるのが最も簡単です。

実際、そのスナップショットがどのようなものかを見るのは非常に簡単です。HEAD スナップショット内の各ファイルの実際のディレクトリリストと SHA-1 チェックサムを取得する例を次に示します。

$ git cat-file -p HEAD
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
author Scott Chacon  1301511835 -0700
committer Scott Chacon  1301511835 -0700

initial commit

$ git ls-tree -r HEAD
100644 blob a906cb2a4a904a152...   README
100644 blob 8f94139338f9404f2...   Rakefile
040000 tree 99f1a6d12cb4b6f19...   lib

Git の cat-file コマンドと ls-tree コマンドは、より低レベルな目的に使用され、日常業務ではあまり使用されない「配管」コマンドですが、ここで何が起こっているかを見るのに役立ちます。

インデックス

インデックスは、次に提案されるコミットです。この概念を Git の「ステージングエリア」とも呼んでいますが、これは git commit を実行するときに Git が参照するものです。

Git は、最後にワーキングディレクトリにチェックアウトされたすべてのファイルの内容と、最初にチェックアウトされたときの状態のリストでこのインデックスを初期化します。その後、それらのファイルの一部を新しいバージョンに置き換え、git commit がそれを新しいコミットのツリーに変換します。

$ git ls-files -s
100644 a906cb2a4a904a152e80877d4088654daad0c859 0	README
100644 8f94139338f9404f26296befa88755fc2598c289 0	Rakefile
100644 47c6340d6459e05787f644c2447d2595f5d3a54b 0	lib/simplegit.rb

ここでも git ls-files を使用していますが、これは舞台裏のコマンドであり、現在のインデックスがどのようなものかを示します。

インデックスは厳密にはツリー構造ではありません。実際にはフラットなマニフェストとして実装されていますが、私たちの目的には十分近いです。

ワーキングディレクトリ

最後に、ワーキングディレクトリ(一般的には「作業ツリー」とも呼ばれます)があります。他の2つのツリーは、.git フォルダー内に効率的ですが不便な方法でコンテンツを保存します。ワーキングディレクトリはそれらを実際のファイルに展開するため、編集がはるかに簡単になります。ワーキングディレクトリはサンドボックスと見なしてください。変更をステージングエリア(インデックス)、そして履歴にコミットする前に、そこで変更を試すことができます。

$ tree
.
├── README
├── Rakefile
└── lib
    └── simplegit.rb

1 directory, 3 files

ワークフロー

Git の典型的なワークフローは、これらの3つのツリーを操作することで、プロジェクトのスナップショットを段階的に良い状態で記録することです。

Git’s typical workflow
図 137. Git の典型的なワークフロー

このプロセスを視覚化してみましょう。単一のファイルがある新しいディレクトリに入るとします。このファイルをファイルの**v1**と呼び、青で示します。次に、git init を実行すると、未誕生の master ブランチを指す HEAD 参照を持つ Git リポジトリが作成されます。

Newly-initialized Git repository with unstaged file in the working directory
図 138. ワーキングディレクトリにステージングされていないファイルがある新規初期化された Git リポジトリ

この時点では、ワーキングディレクトリツリーのみがコンテンツを持っています。

次にこのファイルをコミットしたいので、git add を使ってワーキングディレクトリのコンテンツをインデックスにコピーします。

File is copied to index on `git add`
図 139. git add でファイルがインデックスにコピーされる

その後、git commit を実行します。これにより、インデックスの内容が永続的なスナップショットとして保存され、そのスナップショットを指すコミットオブジェクトが作成され、master がそのコミットを指すように更新されます。

The `git commit` step
図 140. git commit ステップ

git status を実行すると、3つのツリーすべてが同じであるため、変更はありません。

次に、そのファイルを変更してコミットしたいと思います。同じプロセスを繰り返します。まず、ワーキングディレクトリでファイルを変更します。これをファイルの**v2**と呼び、赤で示します。

Git repository with changed file in the working directory
図 141. ワーキングディレクトリに変更されたファイルがある Git リポジトリ

今すぐ git status を実行すると、「Changes not staged for commit」としてファイルが赤で表示されます。これは、そのエントリがインデックスとワーキングディレクトリの間で異なるためです。次に、git add を実行してインデックスにステージングします。

Staging change to index
図 142. 変更をインデックスにステージング

この時点で git status を実行すると、インデックスと HEAD が異なるため、「Changes to be committed」の下にファイルが緑色で表示されます。つまり、次に提案されるコミットが最後のコミットと異なっているということです。最後に、git commit を実行してコミットを確定します。

The `git commit` step with changed file
図 143. 変更されたファイルでの git commit ステップ

これで git status は何も出力しなくなります。なぜなら、3つのツリーすべてが再び同じになったからです。

ブランチの切り替えやクローンも同様のプロセスで行われます。ブランチをチェックアウトすると、**HEAD** が新しいブランチ参照を指すように変更され、そのコミットのスナップショットで**インデックス**が埋められ、次に**インデックス**の内容が**ワーキングディレクトリ**にコピーされます。

Reset の役割

reset コマンドは、この文脈で見るとより理解しやすくなります。

これらの例のために、file.txt を再度変更し、3回目にコミットしたとしましょう。これで履歴は次のようになります。

Git repository with three commits
図 144. 3つのコミットがある Git リポジトリ

では、reset を呼び出したときに何が起こるか、正確に見ていきましょう。これは、これら3つのツリーをシンプルかつ予測可能な方法で直接操作します。最大3つの基本的な操作を実行します。

ステップ1: HEAD の移動

reset が最初に行うことは、HEAD が指すものを移動することです。これは HEAD 自体を変更する(checkout が行うこと)のとは異なります。reset は HEAD が指しているブランチを移動します。つまり、HEAD が master ブランチに設定されている場合(つまり、現在 master ブランチにいる場合)、git reset 9e5e6a4 を実行すると、まず master9e5e6a4 を指すように変更されます。

Soft reset
図 145. ソフトリセット

コミットを伴う reset のどの形式を呼び出しても、これは常に最初に行われることです。reset --soft を使用すると、そこで停止します。

ここで少し時間を取ってこの図を見て、何が起こったのかを理解してください。それは本質的に最後の git commit コマンドを取り消しました。git commit を実行すると、Git は新しいコミットを作成し、HEAD が指すブランチをそれに移動させます。resetHEAD~(HEAD の親)に戻ると、インデックスやワーキングディレクトリを変更せずにブランチを元の場所に戻すことになります。これでインデックスを更新し、再度 git commit を実行して、git commit --amend が行ったことを達成できます(最後のコミットの変更を参照)。

ステップ2: インデックスの更新 (--mixed)

ここで git status を実行すると、インデックスと新しい HEAD との差が緑色で表示されることに注意してください。

reset が次に行うことは、HEAD が現在指しているスナップショットの内容でインデックスを更新することです。

Mixed reset
図 146. ミックスリセット

--mixed オプションを指定すると、reset はこの時点で停止します。これはデフォルトでもあり、オプションをまったく指定しない場合(この場合は単に git reset HEAD~)、コマンドはここで停止します。

ここで少し時間を取ってこの図を見て、何が起こったのかを理解してください。まだ最後の commit は取り消されましたが、すべてが**アンステージ**されました。すべての git add および git commit コマンドを実行する前に戻ったことになります。

ステップ3: ワーキングディレクトリの更新 (--hard)

reset が行う3つ目のことは、ワーキングディレクトリをインデックスのようにすることです。--hard オプションを使用すると、この段階まで続行されます。

Hard reset
図 147. ハードリセット

では、何が起こったのか考えてみましょう。最後のコミット、git addgit commit コマンド、**そして**ワーキングディレクトリで行った作業すべてを取り消しました。

このフラグ (--hard) が reset コマンドを危険にする唯一の方法であり、Git が実際にデータを破壊する非常に数少ないケースの1つであることに注意することが重要です。他の reset の呼び出しは非常に簡単に元に戻すことができますが、--hard オプションはワーキングディレクトリ内のファイルを強制的に上書きするため、元に戻すことはできません。この特定のケースでは、ファイルの**v3**バージョンがまだ Git DB のコミットに存在しており、reflog を見てそれを取り戻すことができますが、コミットしていなかった場合は、Git がファイルを上書きし、回復不能になっていたでしょう。

まとめ

reset コマンドは、指定された順序でこれら3つのツリーを上書きし、指定した時点で停止します。

  1. HEAD が指すブランチを移動する (--soft の場合はここで停止)。

  2. インデックスを HEAD のようにする (--hard でない場合はここで停止)。

  3. ワーキングディレクトリをインデックスのようにする。

パス付きリセット

これは reset の基本的な動作をカバーしていますが、作用するパスを指定することもできます。パスを指定すると、reset はステップ1をスキップし、残りのアクションを特定のファイルまたはファイルのセットに限定します。これは実際にはある程度理にかなっています。HEAD は単なるポインタであり、あるコミットの一部と別のコミットの一部を指すことはできません。しかし、インデックスとワーキングディレクトリは部分的に更新できるため、reset はステップ2と3に進みます。

したがって、git reset file.txt を実行したと仮定します。この形式は(コミット SHA-1 やブランチを指定せず、--soft--hard も指定しなかったため)git reset --mixed HEAD file.txt の短縮形であり、次のことを行います。

  1. HEAD が指すブランチを移動する (スキップ)。

  2. インデックスを HEAD のようにする (ここで停止)。

つまり、実質的に file.txt を HEAD からインデックスにコピーするだけです。

Mixed reset with a path
図 148. パス付きミックスリセット

これにより、ファイルを**アンステージ**するという実用的な効果が得られます。このコマンドの図を見て、git add が行うことを考えると、それらはまったく逆です。

Staging file to index
図 149. ファイルをインデックスにステージング

これが git status コマンドの出力がファイルをアンステージするためにこれを実行するように示唆する理由です(これについてはステージングされたファイルのアンステージを参照)。

Git に「HEAD からデータを取得する」と仮定させないで、特定のコミットからそのファイルバージョンを取得するように指定することも簡単にできます。git reset eb43bf file.txt のように実行するだけです。

Soft reset with a path to a specific commit
図 150. 特定のコミットへのパスを持つソフトリセット

これは、ワーキングディレクトリでファイルのコンテンツを**v1**に戻し、それに git add を実行し、その後再び**v3**に戻したかのように、実質的に同じことを行います(ただし、これらのすべての手順を実際に経るわけではありません)。今 git commit を実行すると、ワーキングディレクトリに再度ファイルがなくても、そのファイルを**v1**に戻す変更が記録されます。

git add と同様に、reset コマンドも --patch オプションを受け入れ、ハンクごとにコンテンツを選択的にアンステージまたは元に戻すことができる点も興味深いことです。

スカッシュ

この新しい力を使って、コミットをスカッシュするという興味深い方法を見てみましょう。

「oops.」、「WIP」、「forgot this file」のようなメッセージを含む一連のコミットがあるとします。reset を使用すると、それらを単一のコミットにすばやく簡単にスカッシュして、非常に賢いように見せることができます。コミットのスカッシュでは、これを行う別の方法が示されていますが、この例では reset を使用する方が簡単です。

最初のコミットに1つのファイルがあり、2番目のコミットで新しいファイルが追加され、最初のファイルが変更され、3番目のコミットで最初のファイルが再度変更されたプロジェクトがあるとします。2番目のコミットは作業中のものであり、それをスカッシュしたいと考えています。

Git repository
図 151. Git リポジトリ

git reset --soft HEAD~2 を実行して、HEAD ブランチを古いコミット(保持したい最新のコミット)に戻すことができます。

Moving HEAD with soft reset
図 152. ソフトリセットで HEAD を移動

そして、もう一度 git commit を実行するだけです。

Git repository with squashed commit
図 153. スカッシュされたコミットを持つ Git リポジトリ

これで、到達可能な履歴、つまりプッシュする履歴は、file-a.txt **v1** を持つ1つのコミットがあり、次に file-a.txt を**v3**に修正し、file-b.txt を追加した2番目のコミットがあるように見えることがわかります。ファイルの**v2**バージョンを持つコミットは履歴にはもうありません。

チェックアウト

最後に、checkoutreset の違いについて疑問に思うかもしれません。reset と同様に、checkout も3つのツリーを操作しますが、コマンドにファイルパスを指定するかしないかで少し異なります。

パスなし

git checkout [branch] を実行することは git reset --hard [branch] を実行することとかなり似ており、3つのツリーすべてを [branch] のように更新しますが、2つの重要な違いがあります。

まず、reset --hard とは異なり、checkout はワーキングディレクトリに安全です。変更されたファイルが失われないことを確認します。実際には、それよりも少し賢く、ワーキングディレクトリで自明なマージを試みるため、変更していないファイルはすべて更新されます。一方、reset --hard は、チェックせずにすべてを全面的に置き換えます。

2つ目の重要な違いは、checkout が HEAD を更新する方法です。reset は HEAD が指すブランチを移動しますが、checkout は HEAD 自体を移動して別のブランチを指すようにします。

たとえば、異なるコミットを指す masterdevelop ブランチがあり、現在 develop にいるとします(そのため HEAD は develop を指しています)。git reset master を実行すると、develop 自体は master と同じコミットを指すようになります。代わりに git checkout master を実行すると、develop は移動せず、HEAD 自体が移動します。HEAD は master を指すようになります。

したがって、どちらの場合も HEAD をコミット A を指すように移動しますが、その方法は大きく異なります。reset は HEAD が指すブランチを移動し、checkout は HEAD 自体を移動します。

`git checkout` and `git reset`
図 154. git checkoutgit reset

パスあり

checkout を実行するもう1つの方法はファイルパスを使用することです。これは reset と同様に HEAD を移動しません。git reset [branch] file と同じように、そのコミットにあるそのファイルでインデックスを更新するだけでなく、ワーキングディレクトリ内のファイルも上書きします。これは git reset --hard [branch] file とまったく同じです(もし reset でそれを実行できるなら) — ワーキングディレクトリに安全ではなく、HEAD も移動しません。

また、git resetgit add と同様に、checkout--patch オプションを受け入れ、ハンクごとにファイルコンテンツを選択的に元に戻すことができます。

まとめ

これで reset コマンドを理解し、より快適に使えるようになったと思いますが、checkout との違いが正確にどのようなもので、異なる呼び出しのすべてのルールを覚えることは不可能だとまだ少し混乱しているかもしれません。

どのコマンドがどのツリーに影響するかを示すチートシートを次に示します。「HEAD」列は、そのコマンドが HEAD が指す参照(ブランチ)を移動する場合は「REF」と表示され、HEAD 自体を移動する場合は「HEAD」と表示されます。「WD Safe?」列には特に注意してください。**NO** と表示されている場合は、そのコマンドを実行する前に少し考えてください。

HEAD インデックス 作業ディレクトリ WD セーフ?

コミットレベル

reset --soft [commit]

REF

NO

NO

YES

reset [commit]

REF

YES

NO

YES

reset --hard [commit]

REF

YES

YES

NO

checkout <commit>

HEAD

YES

YES

YES

ファイルレベル

reset [commit] <paths>

NO

YES

NO

YES

checkout [commit] <paths>

NO

YES

YES

NO

scroll-to-top