👌

Gitの初級者から中級者になる

2021/06/07に公開

この記事の対象読者はもちろんGit初心者である。
あなたがGit初心者かどうかは自分で判断してもらえればいいが、ある程度明確には以下のようになる。

Git初心者・・・主要なコマンドを覚え使っているが、しばしば直観と挙動の差に悩まされる。
Git中級者・・・おおむね意図した通りにGitを使える。
Git上級者・・・Git内部の挙動まで理解している。

重要なところは太字の部分だ。
個人的に、Gitを難しいと思う理由はここだと思っている。
私自身、業務で使えてはいてもコマンドの結果に不安や恐怖を覚えることが多かった。

しかしある時、私の直観とGitの挙動はたいてい一致するようになった。
Gitをシンプルで一貫性のある構造物として認識できるようになったからだ。
コマンドはあくまで構造を変えるためのインターフェースでしかなく、コマンドをいくら暗記してもGitはいつまでもブラックボックスのままだったのだ。

Gitのコマンドは素晴らしい抽象化が施されていて、初学者に非常にやさしいと思う。
ある意味、ブラックボックスでも使えることがGitの利点なのだ。
そして、この記事は「ある程度使いこなすようになった人」に向けていて、人によってはもう少し先に進めるかもしれない。
同じような内容のサイトは星の数ほどあると思うが、過去の私と同じような点で困っている人にはもしかしたら響く内容かもしれない。
そんな人に読後に「Gitをより理解できた」と思っていただけたら、この記事の目的は十分達成である。

コミットグラフ

まず最初に説明しなければいけないのは「コミットグラフ」だ。

コミットグラフにおけるノードは当然コミットである。
リポジトリが git init で作られたとき、コミットグラフにはノードが存在しない。
”Initial Commit”というメッセージと共に、最初のコミットが行われると以下のような状態になる。

318d4f12d1a5d17dc306f8918cff1ea4a39a3fe6というのはコミットについたハッシュ値である。
前のコミットのハッシュ値や、著者・コミット者などの情報を基に一意に計算されるものだ。
コミットのハッシュ値さえわかれば、一意にコミットを特定できる。

HEADは現在、自分が作業しているコミットを参照している。

ここにもう一度コミットを行うとこうなる。

新しいコミットから矢印が出ている。
この記法はおそらく、後のコミットが前のコミットへの参照を持っていることを表している。
つまり、後(child) → 前(parent)に矢印が向いていて、コミットされた順序だと思っていると間違う。(そんな間違いをするのは私だけかもしれないが)

そして、HEADが自動的に新しいコミットに移ったことがわかる。

HEADを移動して、過去のコミットを参照させることもできる。
例えば、git checkoutにハッシュ値を指定すると、そのコミットのときの状態に戻ることができる。

git checkout 318d4f12d1a5d17dc306f8918cff1ea4a39a3fe6

これで、Initial Commitの状態に戻った。

このときに、新しくコミットを作成するとこうなる。

2回目にしたコミット bccebcda[1] はもちろん消えない。
最初のコミットを参照する別のコミットが作られ、HEADがそこに移動する。
結果的に、コミットグラフは分岐することになる。

2回目のコミット bccebcda と3回目のコミット 630a198f を併せることもできる。
つまり、分岐を一つにまとめることができる。

git merge bccebcda

現在のHEADは、3回目のコミット 630a198f を指しているので、

新しいコミット= 630a198f + bccebcda

ができる。図にするとこうだ。

新しいコミット=マージコミットは、元になった2つコミットを親コミットとして参照することになる。

このように分岐が合流する場合は、'recursive'というマージ戦略が取られる。

この時、お互いの分岐の最新コミットで、同一パスのファイルの内容が違うとコンフリクトとなる
手動で修正する必要があり、修正後にコミットを行い、マージコミットが作成される流れだ。


ここで少しどうでもいい話

たまにコミットツリーという人がいるが、細かいことを言うとコミットグラフだ。
ツリーは以下のようなもので、2つ以上のノードが共通の子を持つことがない

Gitにおいては、マージコミットが複数の親を持つのでグラフになる。


ブランチ

次はブランチについて説明する。
ブランチは特定のコミットへの参照を持っている。

先ほどまで作っていたグラフにブランチを書き加える。

← master がついている位置を確認してほしい。
なぜ master ブランチが2番目のコミット bccebcda を参照しているを理解できるよう、順を追って説明していこう。

これまでの説明ではブランチを省略していたので、再度 Initial Commit から見ていく。
最初のコミットが行われた後の状態はこうだ。

何をすることもなく master ブランチが存在している。
これは、master[2] がデフォルトブランチに設定されているからだ。

← master ← HEAD という記法が気になるかもしれないが、理由はもう少し後で説明する。

では、この状態でもう一度コミットを行うとどうなるか。

新しいコミットにブランチの参照とHEADの参照が移動する。

さて、次は最初のコミット 318d4f12 にHEADを移動させる。

git checkout 318d4f12

を行うのだ。すると、

HEAD が master を指していないのがわかる。
この状態のHEADを detached HEAD という。
detached HEADの状態ではコミットを行っても、関連する(attached)ブランチがないのでブランチの参照が移動しない。

つまり、この状態で新しくコミットを行うとこうなる。

ここで、HEADを起点に分岐を合流させてもブランチが移動しないので、

git merge bccebcda

を行うとこうなる。

かくして、master は2番目のコミット bccebcda を参照する。

では現在のHEADに対して、ブランチを作成してみよう。

git branch branch-a

branch-a が作成され、現在 HEAD が向いているコミットが参照される。

あとは、HEADを branch-a に向ければ detached HEAD の状態から抜け出すことができる。

git checkout branch-a

ではここで HEAD を master に移動してみよう。
git checkout はブランチ名を取って、HEADを特定のブランチの位置まで変えることができる。

git checkout master

くどいようだが、このようになる。

HEADはmasterにアタッチされる。
ここで、master を branch-aの状態にまで進めるにはどうすればいいか。
それは、git mergeによっておこなうことができる。

git merge branch-a

現在居る master に branch-a をマージするというコマンドの実態は、単純にブランチとHEADの参照の移動しかしていないことがわかる。

このようなマージの戦略を 'fast-forward' といい高速に処理が終わる。
fast-forward が可能かどうかは単純にブランチの移動先がコミットグラフにおいて自身の子孫にあたるかに依る。
そうでない場合は recursive になり、合流地点となるマージコミットが新しくできることになる。

ところで、ブランチに似た概念としてタグがある。
タグはHEADによってattachされない、正真正銘ただのコミットのエイリアスだ。

ブランチ名・タグ名・ハッシュ値・HEADなどはコミットを特定できる情報として commit-ish と呼ばれる。
git checkoutなどは commit-ish な値なら何でも受け付けることができる。


練習問題1

ここまでの知識の理解度を図るため、練習問題を作った。

内容は単純で、今の状態のリポジトリから、以下のような図の状態のコミットグラフを作成してくれればいい。

いくつもやりようはあると思うが、私の解答を記事の最後に載せておくので、必要に応じて参照してほしい。


git rebase

ここまでの知識があると、リベースを理解しやすい。

以下のようなコミットグラフがあったとする。

このとき、branch-a を master でリベースするという操作は下記のコマンドになる

git rebase master

これを実行するとどうなるかというと、まず branch-a と master の共通の祖先が特定される。

そして、ベースとする master のコミットに対して、共通の祖先から branch-a が参照しているコミットまでのすべてのコミットの内容が順々に再コミットされていく。

ここで、再コミットはもともとのコミットとは全く別のハッシュ値がふられる。
また、著者はそのままだが、コミット者はリベースを行った者になる。

リベースの再コミットは1つずつ行われていき、途中でコンフリクトがあると解消を求められる。
よって、同じファイルを何度も更新していると、そのファイルでコンフリクトがあった時に地獄のような作業が待っている。(これが、私がリベースをおすすめしない理由)

リベース以前の状態に戻したいときは簡単で、branch-a と HEADの位置を元あった場所に戻せばいい。(なので、リベース前のHEADのハッシュ値を保存しておくといい)

git reset --hard <リベース前のHEADのコミットハッシュ>

ちなみに、リベース後にぽつんと取り残されてしまった左の分岐はどのブランチにも接続されておらず、ハッシュ値がないと見つけるのは困難になり、実質失われた状態になることに注意したい。

git push

git push は更新したいリモートブランチをpushするローカルブランチの参照するコミットまで fast-forward させようとする。

この図のような場合は fast-forward が可能だが、別の人がリモートブランチを更新している可能性もある。

その場合は、git pull をする。

git pullgit fetch + git merge だといわれる。
git fetchでローカルのリモートコピーを更新して、リモートコピーの対象ブランチからローカルのブランチにgit mergeをするのだ。

この場合は、recursive 戦略にてマージを試みることになる。
つまり、マージに成功するとこうなる。

このような状態になれば、リモートはfast-forwardができるようになる。

fast-forward したあとのリモートの状態はこうなる。

最後に

Gitの公式サイトを隅々まで理解しているわけではないし、内部実装を把握しているわけでもない。
あくまで実際に使ってきた経験から、挙動に矛盾しない直観をリバースエンジニアリングしただけだ。
つまり、これは私のモデルでしかなく、Gitの公式モデルと一致していたとしても偶然でしかない。

なので、これを正しいものだと思い込む必要もないし、細かい相違点を指摘される必要もない。
ただ自分の思考を記事にして、公開したかっただけなのだ。

では全く誰の役にも立たないかといえば、そうでもないと思っている。
Gitに対して、私と同じような苦しみ方をして、同じような思考の出口を探している人にとっては有益かもしれない。

練習問題1の解答

git checkout branch-a
# 何か変更を入れる
git add *
git commit -m "comment"

git checkout master
# 何か変更を入れる
git add *
git commit -m "comment"

# これで detached HEAD となる
echo git checkout $(git rev-parse HEAD) | bash

git merge branch-a
脚注
  1. ハッシュ値の頭8文字だけでもコミットは指定できる ↩︎

  2. master/slave は奴隷制を意識させるため、最近は main に変える動きもあるようだ ↩︎

Discussion