チャプター ▾ 第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コマンドは、低レベルな用途に使われる「配管(plumbing)」コマンドであり、日常的な作業ではあまり使われませんが、ここで何が起こっているかを見るのに役立ちます。

インデックス

インデックスはあなたの提案された次のコミットです。私たちはこの概念を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ステップ

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

ブランチの切り替えやクローンも同様のプロセスを経ます。ブランチをチェックアウトすると、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が指すブランチをそのコミットまで移動させます。HEAD~(HEADの親)までresetすると、インデックスやワーキングディレクトリを変更せずに、ブランチを元の場所に戻すことになります。これでインデックスを更新し、再びgit commitを実行することで、git commit --amendが達成したであろうことを実現できます(詳細については最後のコミットの変更を参照してください)。

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

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

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

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

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

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

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

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

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

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

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

要約

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

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

  2. インデックスをHEADと同じにする (--hardでない限り、ここで停止)

  3. ワーキングディレクトリをインデックスと同じにする。

パスを指定したReset

これは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オプションを受け入れ、ハンク単位でコンテンツをアンステージすることができます。したがって、コンテンツを選択的にアンステージまたは元に戻すことができます。

スカッシュ

この新たに得た能力を使って面白いことをする方法を見てみましょう。それはコミットのスカッシュです。

「おっと。」、「WIP」、「このファイルを忘れた」のようなメッセージを持つ一連のコミットがあるとしましょう。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.txtv1を含む最初のコミットがあり、次にfile-a.txtv3に修正し、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を実行するもう一つの方法はファイルパスを指定する方法で、これはresetと同様にHEADを移動させません。特定のコミットのファイルでインデックスを更新するという点でgit reset [branch] fileと似ていますが、ワーキングディレクトリのファイルも上書きします。これはまさにgit reset --hard [branch] file(もしresetがそれを実行させてくれるなら)と同じです。ワーキングディレクトリに対して安全ではなく、HEADも移動させません。

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

まとめ

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

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

HEAD インデックス ワーキングディレクトリ WD安全?

コミットレベル

reset --soft [コミット]

参照

いいえ

いいえ

はい

reset [コミット]

参照

はい

いいえ

はい

reset --hard [コミット]

参照

はい

はい

いいえ

checkout <コミット>

HEAD

はい

はい

はい

ファイルレベル

reset [コミット] <パス>

いいえ

はい

いいえ

はい

checkout [コミット] <パス>

いいえ

はい

はい

いいえ

scroll-to-top