git rebase でヒストリを直線的にする方法と使う時の注意点


Git の強力なコマンド rebase を使ってヒストリを直線的にして見やすくする方法と, そのコマンドを使うときに知っておきたい, 知らないと他のコントリビュータの人たちから一斉に非難を浴びてしまう可能性がある注意点を紹介します.

git rebase の使い方

git rebase はいくつかに分岐したブランチを直線的にして git log などでヒストリを見たときに見やすくしてくれるとても便利なコマンドです.

ただ使用時には注意点もあるので, それも後々紹介させていただきたいと思います.

まず git rebase の基本的な使い方を紹介します.

次のようなヒストリで:

             A --- B --- C <- development
            /
           /
    D --- E --- F --- G <- master < HEAD

次のように git rebase を使うと:

git checkout development
git rebase master

このようにヒストリを直線的に修正することができます:

    D --- E --- F --- G --- A --- B --- C <- development <- HEAD
                      ^
                      |
                     master

ポイントはまず上乗せしたいブランチ (development) をチェックアウトして, git rebase <branch><branch> に乗っける先のブランチ (master) を指定することです.

もしくは git rebase <newbase> <branch> という構文を使って development ブランチをチェックアウトしなくても development ブランチを master ブランチに乗っけることができます:

git rebase master development

このコマンドは最初のコマンドのショートハンドになります.

このようにリベースした時にもしかしたらマージコンフリクトが発生する場合があります.

例えば, 乗っける development ブランチの全てのコミットでマージコンフリクトが発生した場合, 発生する順番は A, B, C となります.

その場合, まずマージコンフリクトが発生しているコミット A の特定のファイルのマージコンフリクトを手作業で解決します.

解決し終わったらそれぞれのファイルを git add もしくは git rm でインデックスにステージして解決したというマークをします.

そして git rebase --continue で, 次のマージコンフリクトが発生しているコミット B に移り, 同じようにマージコンフリクトを解決します.

もしくは git rebase --skip で, コミット A のマージコンフリクトをスキップして, コミット A によってもたらされる変更を破棄して, 次のマージコンフリクトが発生しているコミット B に移ることもできます.

そのサイクルをコミット C まで続けて, master をベースにした development のリベースが完了します.

もし git rebase コマンドでリベースを開始してから, やっぱりそのリベースを中止したいという場合, git rebase --abort で現在進行中のリベースを中止にでき, git rebase を実行する前の状態に戻れます.

リベースが完了したら次のコマンドで masterHEAD をファストフォワードなので C にそのまま持っていくことができます.

git checkout master
git merge development

するとヒストリは次のようになります:

    D --- E --- F --- G --- A --- B --- C <- (HEAD -> master, development)

そして development ブランチはマージで取り込みもう必要ないということで, 次のコマンドで削除します:

git branch -d development

このようにリベースされたブランチ (development) をリベースの基になったブランチ (master) にマージする場合, ファストフォワードとなるのでスムーズにマージを完了させることができます.

なので誰かのレポジトリにコントリビュートする際, そのレポジトリの origin/master を基に自身のコミットをリベースしてパッチをそのレポジトリの管理者に送ると, 管理者はスムーズに自身のパッチであるコミットをフォストフォワードマージできるので, 管理者自身がマージコンフリクトを解決したりする手間がかからなくなります.

git rebase の注意点

git rebase は分岐したブランチのヒストリを直線的なものにして, ヒストリを綺麗にしてくれますが, 注意すべき点もあります.

ありのままの開発の経緯が失われる

一つ目の注意点として, もし先ほどのヒストリで:

             A --- B --- C <- development
            /
           /
    D --- E --- F --- G <- master < HEAD

リベースする代わりに次のコマンドで development ブランチを master ブランチにマージすると:

git merge development

このようなヒストリになります:

             A --- B --- C
            /             \
           /               \
    D --- E --- F --- G --- H (HEAD -> master, development)

このように E から分岐して CG がマージして H というコミットが作られたと開発のありのままの経緯を知ることができます.

もし先ほどのように次のコマンドでリベースしてマージすると:

git checkout development
git rebase master
git checkout master
git merge development

このようになるので:

    D --- E --- F --- G --- A --- B --- C <- (HEAD -> master, development)

もともと A, B, CE から分岐させたコミットという経緯がわからなくなってしまいます.

なのでマージを使ってヒストリを本来のままの姿にして開発の経緯をありありと残して, こういった経緯で開発されてきたのねということをそのまま他の人たちにもわかるようにするのか, それともリベースを使って他の人たちがヒストリを見たときに開発の経緯を見やすくするのかという, どちらを選ぶかというなかなか悩ましい問題があります.

リモートのヒストリをリベースするとカオスになる

二つ目の注意点として, 既に公開されているリモートレポジトリのヒストリをリベースしてそれを git push -f で強制的にプッシュしてしまうと, 他のコントリビュータの人たちがそのリモートレポジトリのヒストリを git pull などで自分のローカルレボジトリにマージしてしまうと, 混乱を生みカオスになってしまうというものがあります.

リモートのヒストリをリベースして強制プッシュしてしまう A さんと, その A さんがリベースしてプッシュしたヒストリをプルしてしまった B さんを例に, どんな混乱が生じるのかということを説明させていただきたいと思います.

まず A さんが次のようなヒストリをリモートにプッシュしたとします:

  Mr. A

            A --- B --- C
           /             \
          /               \
    D -- E --- F --- G --- H <- master <- HEAD

そしてそのヒストリを B さんがプルして, I, J, K というコミットを H から作ったとします:

  Ms. B

            A --- B --- C
           /             \
          /               \
    D -- E --- F --- G --- H --- I --- J --- K <- master <- HEAD
                           ^
                           |
                          origin/master

そして A さんがやっぱりマージしたコミット H を取り消して, G をベースに A, B, C をリベースして git push -f で強制的にプッシュしたとします:

  Mr. A

    D -- E --- F --- G -- A --- B --- C <- master <- HEAD

そしてそれを B さんが git pull すると KC' の共通の祖先として EG があるので, 再帰的マージとなり, EG がマージされた仮想共通祖先と KC' の 3 way マージによって L が作られます.

  Ms. B


            A --- B --- C
           /             \
          /               \
    D -- E --- F --- G --- H --- I --- J --- K --- L <- master <- HEAD
                      \                           /
                       \                         /
                        A' -------- B' -------- C' <- origin/master

このように残念ながら B さんののヒストリは混乱に満ちたものになってしまいました.

A, B, C という既に H の時点で取り込まれている 3 つのコミットを, それと同じ 3 つのコミット A', B', C'L で再び取り込み直すという重複が起きてしまっています.

また A, B, C のそれぞれのコミット情報 (名前, 日時, メッセージ) はマージして取り込んだ A', B', C' のそれぞれと全く同一のもので, 混乱に拍車をかけます.

そしてもしこのようなヒストリを B さんが git push したとしたらさらに状況が悪くなります.

A さんがリベースして綺麗にしたつもりのリモートヒストリが B さんのヒストリによって, A さんがリベースする前のヒストリが復元されてしまいます.

なのでそれを A さんが git pull したとしたら, A さんのローカルレボジトリのヒストリは次のようになってしまいます:

  Mr. A

            A --- B --- C
           /             \
          /               \
    D -- E --- F --- G --- H --- I --- J --- K --- L <- (HEAD -> master, origin/master)
                      \                           /
                       \                         /
                        A' -------- B' -------- C'

B さんのヒストリを再現してしまいました.

このようなことを防ぐためには, どうすればよかったのでしょうか.

そもそもとして A さんがリモートヒストリをリベースして git push -f で強制的なプッシュをしてしていなければ, このようなことにはならずに済んだのですが, B さんが A さんによってリベースされたヒストリを git pull する代わりに, git fetch でリモートヒストリがリベースされていると気づくことができた場合, git push --rebase コマンドを使って B さんは自身のヒストリを, 先ほどのような混乱に満ちたものから一転, 次のような直線的なヒストリにすることができます:

  Ms. B

    D -- E --- F --- G --- A --- B --- C --- I --- J --- K <- master <- HEAD
                                       ^
                                       |
                                      origin/master

リモートヒストリの origin/master の先端である C をベースに B さんのヒストリをリベースすることができるからです.

このようなヒストリの場合, A さんがリモートヒストリをリベースして強制プッシュしてから, B さんがそれを git pull して C から I, J, K とコミットを続けていった場合と同じ結果になります.

つまりは A さんがリモートヒストリをリベースして強制プッシュしてしまったという事実を結果的になかったもの同然にすることができます.

ただこのように綺麗にリベースされるためには, リモートヒストリの A, B, C と B さんのヒストリの A, B, C が同一のコミットである必要があります.

もしこのような --rebase オプションを git pull するときのデフォルトとする場合, pull.rebase 設定の値を true にすると可能です.

コマンドでその値を設定する一例として git config --global pull.rebase true で設定できます.

このように万が一誰かがリモートヒストリをリベースして強制プッシュしてしまっても, このように git pull --rebase で綺麗にリベースすることができる可能性もありますが, そもそもとしてリモートヒストリはリベースしないよう気をつけていれば, このような自体を避けることができます.

このようにリモースヒストリをリベースして強制プッシュしてしまうと色々と大変なことになってしまうということがお分かりになっていただけるかと思います.

もし万が一 A さんのようなことを, パブリックプロジェクトで行ってしまうと, 他のコントリビュータからの非難の対象になりかねませんので, 本当に気をつけたいものです.

といっても書き込みアクセスがないと git push -f コマンドは使えませんので, 書き込みアクセスが与えられていない場合はいらぬ心配かもしれません.

まぁでも気をつけたことに越したことはないでしょう.

まとめ

git rebase は分岐したブランチを直線的にして見やすくしてくれるありがたいコマンドですが, それまでのコミットの経緯がありのままでなくなってしまうというデメリットもあります.

またリモートヒストリをリベースして, それを git push -f で強制的にプッシュしてしまうと, 他のコントリビュータがいる場合, 混乱を生じさせてしまうので, git rebase を使う場合はまだプッシュしていない自身のヒストリの修正や, コントリビュータが自分一人だけといった限られた状況でのみ使うようにするべきかと思います.

git rebase の使い方に関するより詳しい説明は, 次の URL より参照できます:

https://git-scm.com/book/en/v2/Git-Branching-Rebasing

あと今回の git rebase コマンドは基本的な使い方のみしか紹介しておりませんが, -i (--interactive) など強力なオプションがあったりしますので, そのコマンドの網羅的な情報は次の URL より参照できます:

https://git-scm.com/docs/git-rebase