導入
Gitを使い始めのころはよくこんな図でブランチを理解してませんでしたか?
- 黄色いラインがmainブランチ
- 緑のラインがそこから派生したdevelopブランチ
- HEADは今自分がいる位置(現在の作業ブランチ)
イメージとしては間違いではないですが、Gitが内部でブランチやHEADをどう管理しているかを知っておくことでもう少し解像度高く理解することができます。
この記事のポイント
この記事で抑えておくべきgitの構造上の特徴は以下の3つだけです。
- コミットは一意なID(コミットハッシュ)を持つ
- コミットはその親のコミットへの参照を持つ
- ブランチやHEADは特定の1コミットを指し示すポインタである
一つ一つ見ていきます。
①コミットは一意なID(コミットハッシュ)を持つ
コミットするとそのコミットに対して40桁のハッシュ値が生成されます。
c11c16b6ad1c2228bcb38bc1e9bfe91d78485709
みたいなやつです。
このハッシュ値はIDとして機能します。つまりハッシュ値によって全コミットの中から「ある特定のコミット」を一意に特定することができます。
②コミットはその親のコミットへの参照を持つ
各コミットはその直前のコミット=親コミットに対する参照を持ってます(一番最初のコミットは親なし)。ある特定のコミットより前のコミット履歴は、親を順番に辿っていくことで求めることができます。
③ブランチやHEADは特定の1コミットを指し示すポインタである
これが一番重要ですが、ブランチとは枝分かれしたツリー全体を指すものではなく、特定の1コミット(通常はツリー構造の先端にあるコミット)への参照を示すただのポインタです。HEADも同じく特定の1コミットへの参照を示すポインタとなっています。
正しい理解
これら3つの前提を元に最初のGitのイメージ図を書き直すとこうなります。(右図)
ここでは便宜的にコミットハッシュを連番(#1〜#8)に置き換えて表現してますが、実際には前述の通り40桁の英数字です。
元の図との違いを見ていきます。
まず、コミット間の矢印の向きが逆になっています。時系列としては親⇒子ですが、Gitの内部構造的には子⇒親の方向になっているからです。
次に、mainブランチやdevelopブランチがそれぞれ先端のコミットハッシュを参照しています。これは変数のようなものだと捉えれば理解しやすいと思います。「main」という名前の変数に「#7
」という値が格納されている、というイメージです。とても単純な構造ですが、これがブランチの正体です。 ブランチとは決して枝分かれしたツリー構造全体を指すものではなく、あくまでも特定の1コミットに対する参照を意味します。
ではよく見る左側の図のようなブランチツリーは何かというと、これはあくまで概念図であり、実体としてはブランチが指し示す先端のコミットから矢印の方向に順番に親を辿っていくことで、そのブランチが持つ過去の系譜が履歴として見えているに過ぎないのです。
次にHEADですが、これもブランチと同じく変数と捉えて問題ありません。ただしHEADに格納される値は、通常コミットハッシュではなくブランチ名になります。つまり、なぜHEADが今自分のいる位置(この図でいうと#8
)を表すことになるのかというと、HEADがdevelopを参照し、developが#8
を参照しているので、実質HEADは#8
を間接的に参照することになるから、というのが理由です。
.gitの中身を見てみよう
これらのGitの内部管理の情報は隠しフォルダ.git
の中のあるファイルで管理されています。HEADの情報は.git/HEAD
に、ブランチの情報は.git/refs/heads/<ブランチ名>
にブランチごとに記録されています。それぞれのファイルの中身を見てみると・・・
ref: refs/heads/develop
c11c16b6ad1c2228bcb38bc1e9bfe91d78485709
HEADにはブランチ名(正確にはファイルパス)、ブランチにはコミットハッシュがそれぞれ記録されているのが分かると思います。
gitのコマンドを図解してみる
さて、ここからが本題です。
- コミットは一意なID(コミットハッシュ)を持つ
- コミットはその親のコミットへの参照を持つ
- ブランチやHEADは特定の1コミットを指し示すポインタである
この3点を理解した上で普段よく使うgitのコマンドを眺めてみます。
git commit
一番よく使うコマンドといえば何といってもこれでしょう。
作業ブランチdevelopで変更を新しくコミットする場合を考えます。
このコマンドは変更分を新たなコミットとして作り、ブランチの参照先をそのコミットハッシュに書き換えます(#8
⇒ #9
)。こうすることでブランチの指し示す先が先端に移動し、間接参照しているHEADの位置も自動的に先端に移動することになります。
git checkout <branch>
これもよく使うでしょう。作業ブランチを切り替えます。
作業ブランチを切り替えるというのは、HEADの参照を切り替えるということと同義です。つまりHEADが参照しているブランチ名を変更(develop ⇒ main)することで、今自分がいる位置をそのブランチが指し示すコミットの場所(#8
⇒ #7
)に移動します。
git merge
マージにはfast-fowardマージとnon-fast-fowardマージの2種類があります。
まずはnon-fast-fowardマージから見ていきます。
これは例えばdevelopブランチでコミットをしている間に、mainが別のコミットで先に進んでいる場合のマージです。この状態でmainブランチに移動しdevelopの変更分を取り込むシーンを考えます。(=developをmainにマージ)
この場合は新たにマージコミット(#9
)が作られてdevelopのコミット(#8
)が#9
と接続されます。同時にmainブランチの参照先がマージコミットである#9
に移動します。当然HEADもそれに引きずられて移動します。またこの時マージコミット(#9
)は必然的に親コミットへの参照を2つ持つことになります。(#7
と#8
)
次にfast-fowardマージです。
これはdevelopブランチでコミットしている間、mainが先に進んでいない場合のマージです。この状態で同じようにmainにdevelopの変更分を取り込みます。
この場合、マージコミットは作られません。単純にmainの参照先をdevelopと同じ位置(#5
)に移動するだけで済むからです。
git reset
場合によってはよく使うgit reset
。これは例えば特定のコミットを無かったことにしたい、ある時点にまで巻き戻したいといった場合に使うことが多いと思います。
これも仕組みは単純です。元々developブランチで作業していたとして、#6
のコミットまで戻りたい(#8
を取り消したい)という場合、git reset #6
とするとdevelopの参照先が#6
に移動します。別の言い方をするとgit reset
はブランチの参照先コミットハッシュを書き換えるコマンドです。
ちなみに、この時#8
のコミットは無くなってしまいますが、これは履歴から見えなくなっただけで実際には消滅してしまったわけではありません。あくまで「消えたように見える」だけで、Gitの内部的には#8
のコミットは残っています。(※詳細は後述します)
git checkout <hash>
もう一つ、特定のコミットの時点での状態を再現したいという場合に使えるgit checkout
。
通常、git checkout
は先に説明した通りgit checkout <ブランチ名>
でブランチの切り替えに使うことが多いと思いますが、git checkout <ハッシュ値>
とすると任意のコミットの位置にHEADを移動させることができます。
実は前述したgit reset <ハッシュ値>
も、このgit checkout <ハッシュ値>
も、結果的には同じ位置にHEADを置くことになるので、ソースコードの状態だけに着目するなら同じ状態になります。ただしこの2つにはGitの内部管理的には決定的な違いがあります。
git reset
がブランチの参照先を変えることで間接的にHEADの位置も変えていたのに対し、git checkout
はHEADが直接コミットハッシュを参照するように書き換えます。別の言い方をすれば、git reset
はブランチの位置そのものに影響を与えますが、git checkout
はブランチの位置に影響を与えずにHEADだけを変更することができます。git reset
がブランチの参照値を指定した値で書き換えるコマンドであるのに対して、git checkout
はHEADの参照値を指定した値で書き換えるコマンドであると言えます。
このことから分かるように、通常はHEADが参照するのはブランチ名なのですが、実は直接コミットハッシュを参照させることもできるのです。このことはgit checkout
がdetached HEADを引き起こす原因になることに繋がります。
detached-HEAD
detached HEADとは簡単にいうと「HEADがブランチから切り離された状態」です。
ここまでの説明で「ブランチはただのポインタである」ということと「HEADは通常ブランチを指す」ということが理解できていれば「HEADがコミットハッシュを直接参照する」という状態が何故detached HEADと呼ばれるかも何となくイメージできるのではないかと思います。
detached HEADになってしまってもすぐに問題にはなりません。問題になるのは、うっかりdetached HEADの状態のままそこから新たなコミット(#9
)を重ねた場合です。
この状態で例えば一旦作業を中断し、別の作業をするためにdevelopに切り替えたとします。
こうしてしまうと#9
のコミットは消えて無くなってしまいます。なぜなら#9
はどのブランチからも参照されていない、かつ、他のどのコミットからも親として参照されていない状態になるので、完全に宙に浮いてしまうことになり履歴から辿ることができなくなるからです。そのため、「一旦developで別の作業をしてから再び#9
に戻って続きの作業をしよう」と思った時に #9
に戻って来れなくなる事態が発生します。
ただ、実際には戻れないわけではありません。git reset
で説明した時と同じく、#9
のコミットはあくまで「消えてしまったように見える」だけでGitの内部的には残っているので、戻りたい位置のコミットハッシュをgit reflog
(参照先の変更を記録した操作ログのようなもの)から探し出し、git checkout #9
で#9
の位置に再びHEADを持ってくることはできます。
ただ、ログから特定のハッシュ値をサルベージするのも面倒なので、detached HEADの状態でコミットしてしまった場合は他のブランチに移動する前に新たなブランチを作ってポインタをその位置に置いてあげましょう。こうすればHEADが新たに作ったブランチ名を参照することになるのでその時点でdetached HEADが解消されます。
まとめ
- コミットは一意なID(コミットハッシュ)を持つ
- コミットはその親のコミットへの参照を持つ
- ブランチやHEADは特定の1コミットを指し示すポインタである
この3つを覚えておくだけでもGitの挙動を頭の中でイメージしやすくなるのではないかと思います。
参考