git checkout解説

以下は社内向け勉強会のLT枠で話した内容をベースにして編集増補したものである。増補しただけでなくそもそも1回分を記事にしたものではなかったりもするので、この内容を5分で話したわけではない。

## git checkoutコマンドの概要

* 「ブランチの切り替え」と「ファイルの復元」の2つの機能を持つコマンド
* 2019-08-16にリリースされたGit 2.23にて「ブランチの切り替え」についてはgit switch、「ファイルの復元」についてはgit restoreという、git checkoutの機能を分割した同じことができるコマンドが**実験的に**追加された

当記事ではgit checkoutのサンプルコマンドを記載する際は、同一の処理を行うgit switchあるいはgit restoreのサンプルコマンドも記載する。

## 「ブランチの切り替え」に関係する機能の説明

### 単純なブランチの切り替え

ブランチの切り替えを行うコマンドは以下である。

`git checkout <ブランチ名>`

`git switch <ブランチ名>`

また、ブランチ名に「-」を指定すると、1つ前にいたブランチに切り替えられる。

### 新しいブランチを作ってそのブランチへ切り替え

指定したブランチが存在しなければ新しくブランチを作り、そのブランチへの切り替えを行うコマンドは以下である。

`git checkout -b <新しいブランチ名> [<新しいブランチの開始位置>]`

`git switch -c <新しいブランチ名> [<新しいブランチの開始位置>]`

「新しいブランチの開始位置」は"commit-ish(コミットっぽいもの)"を指定する。省略時は切り替え前のブランチが指している最新のコミットである。commit-ishについては独立した項目にて説明する。

また、以下のようにすると指定したブランチ名がすでに存在する場合でも、一旦そのブランチを削除してから作成し直し、切り替える。

`git checkout -B <新しいブランチ名> [<新しいブランチの開始位置>]`

`git switch -C <新しいブランチ名> [<新しいブランチの開始位置>]`

#### オプションなしで新しいブランチを作ってそのブランチへ切り替えが行われる場合

ただのブランチ切り替えコマンド`git checkout <ブランチ名>`あるいは`git switch <ブランチ名>`でも新しいブランチが作られてから切り替えが行われるパターンもある。ブランチ名にローカルに存在しないが対応するリモート追跡ブランチ（例：origin/dev）が存在するものを指定した場合である。つまり`git checkout dev`はorigin/devブランチが存在しdevブランチが存在しない場合に限り、`git checkout -b dev origin/dev`として扱われる。

### commit-ish

commit-ishはコミットを表せそうなもの色々のことである。最もそれっぽいのはコミットに付いているSHA-1でハッシュした16進数40桁のID（git logとかで見られる`commit 2678bf2479b9ac52898d00c86c1712262a338501`とか書かれた16進数部分。決まった呼び名はないが、当記事では「コミットID」と呼称する）だが、その他にも例えば以下のようなものを指定することもできる。

* ブランチ名（この場合、「ブランチが指している最新のコミット」になる）
* タグ名（この場合、「タグが指しているコミット」になる）
* その他ブランチやコミットを指すもの（"HEAD"など。ブランチやタグと合わせてGitの世界では「リファレンス」と呼ばれる。この場合、「指し先を辿っていって最終的に辿り着いたコミット」になる）
* reflog（「リファレンス」を操作したログ）にある各ログ（この場合、「そのログが指しているコミット」になる）
* 他のcommit-ishから^や~を使っていくつか遡った先のコミット

などなど。詳しくは`man gitrevisions`で見ることができるもののうち、ファイルやディレクトリを指しているもの以外となるが、それはもう、色々ある。

なお、コミットIDを入力値とする際には40文字全部を指定しなければならないわけではなく、先頭から4文字以上指定していれば、リポジトリ内で一意に定まっている場合は自動で特定してくれる。

### リファレンス

上記で「リファレンス」という概念を紹介した。Gitの世界において「リファレンス」とはコミットあるいは別のリファレンスを参照しているものである。

リポジトリのルートディレクトリに.gitというディレクトリがあり、ここにリポジトリの内部構成情報が配置される。リファレンスも1つにつき1ファイル、.gitの中に存在する。

例えばローカルのブランチの情報は.git/refs/headsの中に存在する。わかりやすいことにそれぞれのブランチ名がファイル名となっている。このファイルを開けてみると内容は例として以下のような、16進数40桁だけが書かれたものになっている。

```text
26df12c3b8432ac4a38cdc9183d8d7ffce7612b4
```

取りも直さずこの値はコミットIDである。この例だと、このリポジトリには26df12c3b8432ac4a38cdc9183d8d7ffce7612b4というコミットIDを持つコミットがあって、ブランチはそのコミットを指している、ということになる。これでわかるのは、ブランチというのは任意の1つのコミットを指すものである、ということである。で、この後に作業者がこのブランチでコミットしたりすると、このファイルが書き換わって新しいコミットを指すようになる、というわけだ。

一方、「別のリファレンスを参照しているリファレンス」の例は、作業者が現在作業しているブランチを表す"HEAD"である。このリファレンスの情報は.git/HEADにある。内容は例として以下のようなものである。

```text
ref: refs/heads/dev
```

つまりこの例だとrefs/heads/devを参照している、ということである。.gitディレクトリをルートとして考えればrefs/heads/devは要するにローカルブランチdevのリファレンスの情報が書かれたファイルである。作業者が「ブランチの切り替え」を行うと、このファイルが書き換えられて切り替え後のブランチのリファレンスを指すようになる、というわけだ。

.git/HEADは「別のリファレンスを参照しているリファレンス」なので、commit-ishとして"HEAD"を指定した場合、「HEADの指すブランチ（つまり作業者が作業中のブランチ）の指すコミット」に内部的に変換されることになる。

と、ここまで書いておいて何だが、.git/HEADはブランチではなくコミットを直接参照することもある。つまり、.git/HEADの中身が16進数40桁のコミットIDだけが書かれたファイルになることがある。detached HEADと呼ばれる状態であるが、次項で説明する。

### detached HEAD

ブランチの切り替えを行うコマンド`git checkout <ブランチ名>`について、ここでブランチ名の代わりにブランチ以外のcommit-ishを指定した場合はどうなるだろうか。

この場合でも切り替えは行われるが、以下のようなメッセージが同時に表示されるだろう。

```text
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.

If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:

  git switch -c <new-branch-name>

Or undo this operation with:

  git switch -

Turn off this advice by setting config variable advice.detachedHead to false
```

この状態を「detached HEAD」と呼ぶ。この場合において"HEAD"はいつもの「現在作業者が作業中のブランチを指す」リファレンスであることを止め、コミットを直接指すリファレンスになっている。

つまり、作業者から見ると作業中のブランチが何もなく所在地は指定したコミット、ということになる。detached HEAD状態が何のために用意されているかと言うと、上記のメッセージにある通り「ブランチに影響させずに実験的な変更をコミットした後、そのコミットを捨ててブランチに戻るという作業を可能とするため」であるのだが、`git checkout <commit-ish>`なんてブランチ切り替えのつもりで簡単に実行され得るため、大抵は誤ってdetached HEAD状態に入る羽目になる。

もっとも、detached HEAD状態から脱出するのは簡単である。メッセージにあるように`git checkout -`で前にいたブランチに戻れるし、`git checkout <ブランチ名>`で任意のブランチに切り替えることもできる。

これはdetached HEAD状態になってから新たなコミットを行った場合でも同じである。さらにこの新たなコミットを使用して`git checkout -b <新たなコミット>`で新しいブランチを作っても良い。

一方、新しいコマンドgit switchにおいてはdetached HEAD状態に入るのに--detachオプションが必要である。つまり、

`git switch --detach <commit-ish>`

となるので、余程でなければ誤ってdetached HEAD状態になることはない。git checkoutにも--detachオプションはあるがcommit-ishがブランチ名である時を除き省略可能であるため、簡単にdetached HEAD状態になり得るのだ。

## 「ファイルの復元」に関係する機能の説明

### インデックスとワークツリー

ここからは「ファイルの復元」に関するコマンドの説明となるが、まず先にこの後頻出するGit用語であるインデックスとワークツリーについて説明する。

Gitにおいてはコミットする前にgit addコマンドでコミットするファイルを一旦登録する必要がある。このファイルの登録先を「インデックス」と呼ぶ。別名として「ステージ」と呼ばれることもあり、git addすることを「ステージする」と言ったりするが「インデックスする」という言い方はあまり聞かない。またインデックスを昔は「キャッシュ」と言ったようだ。

一方、インデックスに登録される対象のファイル、つまり作業者がリポジトリにコミットすべく編集作業を行うファイル全般は「ワークツリー」あるいは「ワーキングツリー」と呼ばれる領域に置かれている、という扱いになっている。

Git用語を使ってコミットする流れを書くと、作業者はワークツリー上でファイルを編集し、git addコマンドで編集したファイルをインデックスに上げた後、git commitコマンドでコミットする、ということになっているわけである。

なお、git commitの-aオプションでgit addコマンドは省略できる場合があるが、この場合でもコマンド実行直後はインデックスと新しいコミットが同じファイル内容を持つので、見た目としては一旦インデックスを経由しているようなものである。

### ファイルをどこかのコミットのものと同じ内容にする

ファイルをどこかのコミットを復元元として、それと同じ内容にするコマンドは以下となる。

`git checkout <復元元tree-ish> [--] <ファイル名>...`

ファイル名は複数指定、ワイルドカード、「.」（そのディレクトリとサブディレクトリにあるすべてのファイル、の意味）も指定可能である。「.」を使って「dir/.」のような指定も可能であり、この場合dirとそのサブディレクトリのすべてのファイルが対象になる。ファイル名を指定してあるかどうかがgit checkoutが「ファイルの復元」をするのか、「ブランチの切り替え」をするのかの違いとなる。

ファイル名の直前の「--」はこの後の引数をオプションとして使用しない、という意味であり、ファイルパスがマイナスで始まるものを指定する場合に有用であるが、特にそういうのがなければ省略しても良い。習慣として付ける人もいるし、バッチの中で不特定のファイルを処理する場合は付けるべきだろう。念のため書いておくが「[--]」ではなく「--」である。ここからコピペ等する際は気を付けること。[]は「省略されることがある」という意味である。

復元元はこれまでに説明したcommit-ishではなくtree-ishである。tree-ishはcommit-ishを含み、ここでは余程のことがなければtree-ishにもcommit-ishと同じ形式で指定すれば良い。tree-ishについては独立した項目で説明する。

一方、復元先については、git checkoutやgit restoreにおいて復元先となり得る箇所は2つしかない。これが上で説明したインデックスとワークツリーである。このコマンドの場合、復元先は「インデックスとワークツリーの両方」である。なので、上記のコマンドでは作業中のファイルはどこかのコミットの内容にされるし、そのファイルはgit addされていない状態になる。

git checkoutでは復元先は細かく制御はできないが、git restoreではインデックスを表す-S（あるいは--staged）やワークツリーを表す-W（あるいは--worktree）という2つのオプションによって復元先の制御が可能である。またgit restoreでは復元元は-s（あるいは--source）というオプションを必要とするので、上記コマンドと同等のgit restoreによるコマンドは以下となる。

`git restore -s <復元元tree-ish> -S -W [--] <ファイル名>...`

-Sと-Wの両方を指定することによって復元先をインデックスとワークツリーの両方としているわけである。もし-Sしか指定しないなら復元先はインデックスだけになるし、-Wしか指定しないなら復元先はワークツリーだけになる。-Sも-Wも両方指定していなければ-Wが指定されているものと扱われ、ワークツリーにだけ復元される。

なお、git checkoutやgit restoreによるファイル復元によって、git管理外のファイル（これにはインデックスにもいずれのコミットにも含まれていないファイル、つまり`git status`で"Untracked files"に表示されるものも含む）が変更、削除されることはない。

### tree-ish

commit-ishが「辿った先がコミット」であるのに対してtree-ishは「辿った先がツリー」であるものである。

「ツリー」というのはGit用語で「ディレクトリ」のことである。言い換えれば「ファイルやサブディレクトリのリスト」である。ワークツリーの「ツリー」もこれであり、つまりワークツリーは「作業中のディレクトリ」というわけである。ワークツリーと同様にインデックスもツリーである。

コミットは「含まれるファイルのリスト」として1つのツリーへの参照を持っているため（これは1ファイルも含まれなかったとしても、である）、commit-ishは常にtree-ishである。

commit-ish以外に作業者が一応指定可能なtree-ishとしては、Gitはコミットから参照されたツリーの中でサブディレクトリをまた別のツリーとして保持しているため、`<commit-ish>:<リポジトリルートからの相対ディレクトリ>`という形式が許容される。なのでどこかのコミットのどこか別のディレクトリからファイルを復元する、ということも可能である。ただし復元元からの相対ディレクトリと復元先のリポジトリルートからの相対ディレクトリが合ってないと復元できないので、大したことはできないと思われる。

### ワークツリーのファイルをインデックスと同じ内容にする

git checkoutにおいて復元元を省略して、

`git checkout [--] <ファイル名>...`

とした場合、復元元はインデックス、復元先はワークツリーとなる。

git restoreにおいても復元元を省略して復元先をワークツリーだけに限定して、

`git restore -W [--] <ファイル名>...`

あるいは-Wは省略できるので、

`git restore [--] <ファイル名>...`

とした場合は復元元はインデックスである。

ややこしいので復元元を指定した場合も含めて表にすると、

* git checkout

|オプション|復元元|復元先|
|---|---|---|
|復元元を指定している|指定したtree-ish|インデックスとワークツリー両方|
|復元元を指定していない|インデックス|ワークツリーのみ|

* git restore

|オプション|復元元|復元先|
|---|---|---|
|復元元を指定している|指定したtree-ish|-Sと-Wによって制御可能|
|復元元を指定しておらず、-Sが指定されている|HEAD|制御可能だが、-Sの指定がこの項の条件なので少なくともインデックスを含む|
|復元元を指定しておらず、-Sも指定されていない|インデックス|-Sが指定されていないのがこの項の条件なのでワークツリーだけで確定|

となる。

ちなみにgit restoreの真ん中のパターン、`git restore -S [--] <ファイル名>...`は現在のgitのバージョンにおいてgit statusを実行した時にgit addの取り消しコマンドとして表示されるものである（-Sでなくてロングバージョンの--stagedの方で表示されるが）。復元元がHEAD、復元先がインデックスであるから、実行するとインデックスの指定したファイルがHEADの指すコミットの内容と同じになるので、効果としてはgit addの取り消しとなるというわけである。

### ファイルの一部だけ復元する

git checkoutでもgit restoreでも-p（あるいは--patch）というオプションがあり、`git checkout -p [--] <ファイル名>...`あるいは`git restore -p [--] <ファイル名>...`等と実行すると以下のようなメッセージが表示され、処理が一旦停止する。（ただし、[]内に表示される選択肢の種類は状況によって増減する。sが選択肢に出てくるのは変更された行とまた別の変更された行との途中に変更されてない行があるときだけである。）

```text
<ここにdiffされた内容>
(1/1) Discard this hunk from worktree [y,n,q,a,d,s,e,?]?
```

ここで?を押すとヘルプメッセージが表示される。

```text
y - discard this hunk from worktree
n - do not discard this hunk from worktree
q - quit; do not discard this hunk or any of the remaining ones
a - discard this hunk and all later hunks in the file
d - do not discard this hunk or any of the later hunks in the file
s - split the current hunk into smaller hunks
e - manually edit the current hunk
? - print help
```

ここでeを押すとエディタが立ち上がりdiffを編集するモードになるので、行頭の「+」や「-」を「 」（スペース）に修正する等でdiffを一部だけに適用される形に編集することで、一部だけを復元するようにすることができる。わかりづらいが行頭「+」の方が復元されると消える方である。

例えば復元元の内容が、

```text
a
b
```

で復元先の内容が、

```text
a
1
b
2
```

であり、最終的に、

```text
a
b
2
```

としたい場合、eで編集に入った先で以下のような表示になるが、

```text
# Manual hunk edit mode -- see bottom for a quick guide.
@@ -1,2 +1,4 @@
 a
+1
 b
+2
# ---
# To remove '+' lines, make them ' ' lines (context).
# To remove '-' lines, delete them.
# Lines starting with # will be removed.
#
# If the patch applies cleanly, the edited hunk will immediately be
# marked for discarding.
# If it does not apply cleanly, you will be given an opportunity to
# edit again.  If all lines of the hunk are removed, then the edit is
# aborted and the hunk is left unchanged.
```

これを次のように変更し、保存すれば良い（コメント行省略）。

```text
@@ -1,2 +1,4 @@
 a
+1
 b
 2
```

これをファイルごとに繰り返すことになるが、ファイル全体を復元したいファイルならeでなくてaを押せば良いし、ファイル全体を復元したくないファイルならdを押せば良い。上のヘルプメッセージにおける「hunk」はsを押さない限りファイルと同じなので、aとy、dとnは同じ効果になる。sを押すとファイルはいくつかのhunkに分割され、作業者が細かい単位で作業することを可能とする。