Git基礎 #3 ブランチとマージ — fast-forwardと3-way

「Git基礎 #2 add・commit・status — ステージングエリアの意味」では、変更を選んでコミットを積む方法を扱いました。コミットが1本の線として積み上がり始めると、すぐに次の悩みが生まれます。動いているコードはそのままにして、実験的な変更を別に進めたい状況です。この問題を解く道具がブランチです。

全6回で構成します。

  • #1 Gitとは — スナップショットモデルと3つの領域
  • #2 add・commit・status — ステージングエリアの意味
  • #3 ブランチとマージ — fast-forwardと3-way ← この記事
  • #4 リモートリポジトリ — clone・fetch・pull・pushとoriginの正体
  • #5 取り消し — restore・reset・revertの使い分け
  • #6 GitHubと初めてのPull Request

この記事では、ブランチが実際には何なのかをまず確認し、分岐した2本の線を再び合流させる2つのマージ方式、そしてマージでコンフリクトが起きたときに解決する流れまで、実際に実行しながら整理します。

ブランチの正体 — コミットを指すポインタ #

ブランチに初めて触れるとき、最もよくある誤解があります。ブランチを作るとプロジェクトフォルダが丸ごとコピーされると考えがちですが、Gitのブランチはコミット1つを指す軽量なポインタです。実体を直接確認してみます。

ブランチの実体を確認
$ cat .git/refs/heads/main
1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b

mainというブランチの実体は、コミットハッシュ40文字が書かれた41バイトのファイル1つです。ファイルがコピーされることはありません。だからGitではブランチを作るコストが実質0で、1日に何個作って消しても負担になりません。

ポインタはもう1つあります。HEADいま自分がどのブランチの上にいるかを指すポインタです。コミットを作ると、HEADが指しているブランチが新しいコミットへ前進します。

ブランチの作成と切り替え #

ブランチの操作はいくつかのコマンドで十分です。

ブランチの作成と切り替え
$ git branch                 # ブランチ一覧を確認 (* が現在のブランチ)
* main

$ git switch -c feature-login   # 作成と同時に切り替え
Switched to a new branch 'feature-login'

$ git branch
* feature-login
  main

git branch feature-loginで作成だけしてgit switch feature-loginで切り替えることもできますが、実務では作成と同時に切り替えるgit switch -cを圧倒的によく使います。

注記
古い資料にはgit checkout -b feature-loginが多く登場します。checkoutはブランチの切り替えとファイルの復元という別々の2つの仕事を1つのコマンドに詰め込んでいて混乱が大きく、Git 2.23からブランチの切り替えはswitch、ファイルの復元はrestoreに分かれました。このシリーズは新しいコマンドを基準にします。

コミットするとポインタだけが動く #

feature-loginの上でコミットを2回作るとどうなるでしょうか。動くのはブランチポインタだけです。

コミットによるポインタの移動
[ブランチ作成直後]
A --- B --- C   ← main, feature-login (HEAD)

[feature-login でコミットを2つ追加]
A --- B --- C            ← main
              \
                D --- E   ← feature-login (HEAD)

mainはコミットCに立ち止まったまま、feature-loginだけがD、Eへ前進しました。この状態でgit switch mainで戻ると、作業ディレクトリのファイルはコミットC時点のスナップショットに切り替わります。実験中のコードが本流の履歴に触れない理由がこの構造にあります。

fast-forwardマージ — ポインタの移動だけ #

実験が成功したので、feature-loginの作業をmainに合流させます。マージは受け入れる側のブランチへ移動してから実行します。

fast-forwardマージ
$ git switch main
$ git merge feature-login
Updating 1a2b3c4..9f8e7d6
Fast-forward
 login.py | 42 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)

出力にFast-forwardと書かれています。上の図をもう一度見ると、ブランチが分岐したあとmainには新しいコミットが1つもありませんでした。この場合Gitは新しいコミットを作る必要がなく、mainポインタをEまで押し進めるだけです。これがfast-forwardマージです。

fast-forward後
A --- B --- C --- D --- E   ← main (HEAD), feature-login

履歴は一直線のまま残り、マージコミットは生まれません。ポインタの移動だけを許可したいときはgit merge --ff-only feature-loginのようにオプションを付けます。fast-forwardが不可能な状況ではマージを中断してくれるため、意図しないマージコミットを防げます。

3-wayマージ — コミット3つで合流する #

今度は、分岐している間にmainにも新しいコミットができた場合です。

両側が前進した状態
A --- B --- C --- F        ← main (HEAD)
              \
                D --- E    ← feature-login

もうポインタを動かすだけでは合流できません。Gitは3つのコミット、すなわち共通祖先のCmainの先端Ffeature-loginの先端Eを比較して、両側の変更をすべて含む新しいコミットを作ります。コミット3つを材料にするので3-wayマージと呼びます。

3-wayマージ
$ git switch main
$ git merge feature-login
Merge made by the 'ort' strategy.
 login.py | 42 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)
3-wayマージ後
A --- B --- C --- F --- M   ← main (HEAD)
              \       /
                D --- E     ← feature-login

新しくできたMがマージコミットです。親コミットを2つ(FとE)持つ点が普通のコミットと違います。分岐して合流した痕跡がそのまま履歴に残ります。

コンフリクト — 同じ箇所を両側が変更したとき #

3-wayマージでGitはほとんどの変更を自動で合流させます。別々のファイルを変更した場合や、同じファイルでも別の箇所を変更した場合は問題ありません。自動で合流できないケースは1つだけです。同じファイルの同じ箇所を両側が別々に変更したときです。

コンフリクト発生
$ git merge feature-login
Auto-merging greeting.py
CONFLICT (content): Merge conflict in greeting.py
Automatic merge failed; fix conflicts and then commit the result.

このときGitはマージを止めて、コンフリクトしたファイルにマーカーを残します。

greeting.py の中のコンフリクトマーカー
<<<<<<< HEAD
print("こんにちは、スクールオブウェブです")
=======
print("ようこそ、スクールオブウェブです")
>>>>>>> feature-login

読み方は単純です。<<<<<<< HEADから=======までが現在のブランチ(main)の内容で、=======から>>>>>>> feature-loginまでがマージしてくるブランチの内容です。解決は、エディタでマーカーを含むこのブロック全体を消し、最終的に残すコードを直接書くことです。片方を選んでも、両方を混ぜて新しく書いても構いません。

コンフリクト解決後の仕上げ
$ git add greeting.py     # 解決したと印を付ける
$ git commit              # マージコミットの完成 (メッセージは自動で提案されます)

コンフリクトはエラーではなく、人の判断が必要なので決めてほしいというGitからの質問です。マーカーを整理してadd、commitで答えればマージが完成します。途中でマージ自体をやめたい場合は、git merge --abortでマージ前の状態に戻れます。

終わったブランチは消す #

マージが終わったブランチポインタには、もう仕事がありません。残してもコミットが消えることはありませんが、一覧が散らかるので消す習慣をおすすめします。

マージ済みブランチの削除
$ git branch -d feature-login
Deleted branch feature-login (was 9f8e7d6).

-dはマージが終わったブランチだけを消してくれる安全なオプションです。マージされていないブランチを強制的に消す-Dもありますが、コミットを失う恐れがあるため、意味を理解して使うときだけにします。

ヒント
分岐した履歴をマージコミットなしで一直線に整理するrebaseという方法もあります。判断基準と禁止事項が一緒に付いてくるテーマなので基礎シリーズでは扱わず、実務ワークフローを扱う次のシリーズで正面から取り上げます。

まとめ #

この記事のポイントは3つです。

  • ブランチはコミットを指す41バイトのポインタで、作成と削除のコストは実質ありません。
  • マージは2種類です。片側だけが前進していればポインタを動かすだけのfast-forward、両側が前進していれば共通祖先を含むコミット3つからマージコミットを作る3-wayです。
  • コンフリクトは同じ箇所を両側が変更したときに人の決定を求める手続きで、マーカーの整理後にadd、commitで終わります。

ここまでは、すべての作業が自分のコンピュータの中で完結していました。次の記事「Git基礎 #4 リモートリポジトリ — clone・fetch・pull・pushとoriginの正体」では、リポジトリをほかのコンピュータとやり取りする方法、そして多くの人が正体を知らないまま使っているoriginが実際には何なのかを確認します。

X