Git
目次 ▾ 第2版

7.7 Gitツール - リセット徹底解説

リセット徹底解説

より専門的なツールに移る前に、Gitの`reset`コマンドと`checkout`コマンドについて説明しましょう。これらのコマンドは、Gitを初めて使用する際に最も混乱しやすい部分の2つです。非常に多くの機能を持つため、実際に理解して適切に使用するのは不可能に思えるかもしれません。そのため、簡単な比喩を用いて説明します。

3つのツリー

`reset`と`checkout`をより簡単に理解するには、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の一般的なワークフロー

このプロセスを視覚化してみましょう。1つのファイルがある新しいディレクトリに入るとします。このファイルをファイルの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を実行すると、インデックスと作業ディレクトリの間でエントリが異なるため、ファイルが赤で「コミットのためにステージングされていない変更」として表示されます。次に、git addを実行してインデックスにステージングします。

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

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

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

これでgit statusを実行しても出力されなくなります。3つのツリーすべてが再び同じになったためです。

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

リセットの役割

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 addコマンドとgit commitコマンドを実行する前までロールバックされました。

ステップ3:作業ディレクトリの更新(--hard

resetが実行する3番目のことは、作業ディレクトリをインデックスと同じようにすることです。--hardオプションを使用すると、この段階まで続行します。

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

では、何が起こったかを考えてみましょう。最後のコミット、git addコマンドとgit commitコマンド、そして作業ディレクトリで行ったすべての作業が元に戻されました。

このフラグ(--hard)は、resetコマンドを危険にする唯一の方法であり、Gitが実際にデータを破壊する非常にまれなケースの1つであることに注意することが重要です。resetの他の呼び出しは簡単に元に戻すことができますが、--hardオプションは、作業ディレクトリのファイルを強制的に上書きするため、元に戻すことができません。この特定のケースでは、Git DBのコミットにファイルのv3バージョンはまだあり、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と同じにする(ここで停止)

基本的に、HEADからインデックスにfile.txtをコピーするだけです。

Mixed reset with a path
図148. パスを使用したミックスリセット

これは、ファイルをステージング解除するという実際的な効果があります。そのコマンドの図を見て、git addが実行することを考えると、それらは正反対です。

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

そのため、git statusコマンドの出力が、ファイルをステージング解除するためにこれを実行することを示唆しています(詳細については、ステージングされたファイルのステージング解除を参照)。

特定のコミットからそのファイルのバージョンを取得する必要があるとGitが推測するのを防ぐために、特定のコミットを指定することもできます。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」、「このファイルは忘れていました」のようなメッセージを含む一連のコミットがあるとします。resetを使用して、それらを簡単に1つのコミットに圧縮し、非常にスマートに見せることができます。コミットの圧縮では別の方法を示していますが、この例では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を含む1つのコミットがあり、次にfile-a.txtv3に変更し、file-b.txtを追加した2番目のコミットがあるように見えます。ファイルのv2バージョンのコミットは履歴に存在しなくなりました。

確認してみましょう

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

パスなしの場合

git checkout [ブランチ]を実行することは、git reset --hard [ブランチ]を実行することと非常によく似ており、[ブランチ]のように見えるように3つのツリーすべてを更新しますが、2つの重要な違いがあります。

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

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

例えば、異なるコミットを指すmasterブランチとdevelopブランチがあり、現在developブランチにいる(つまりHEADがそれを指している)とします。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 [ブランチ] ファイルと同じですが、作業ディレクトリのファイルも上書きします。これはgit reset --hard [ブランチ] ファイル(もしresetがそれを実行できたとしたら)と全く同じです。作業ディレクトリは安全ではなく、HEADは移動しません。

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

概要

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

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

HEAD インデックス 作業ディレクトリ 作業ディレクトリ安全?

コミットレベル

reset --soft [コミット]

参照

いいえ

いいえ

はい

reset [コミット]

参照

はい

いいえ

はい

reset --hard [コミット]

参照

はい

はい

いいえ

checkout <コミット>

HEAD

はい

はい

はい

ファイルレベル

reset [コミット] <パス>

いいえ

はい

いいえ

はい

checkout [コミット] <パス>

いいえ

はい

はい

いいえ

scroll-to-top