この記事では、Gitの「普通のpull」と「rebase」について、「普通のpullではなくrebaseした方が良いと言われるけれども、実際にrebaseを使ってみたら、衝突が発生した時に色々よくわからない事が起こって辛い思いをした」という人を対象として、rebaseの正しい使い方を解説します。 GitそのものやGitHubそのものの説明については省略していますのでご注意下さい。 また、GitはLinuxやmacOS(OS X)の端末エミュレータ上のシェルでコマンド列を実行して利用する形態を想定しています。
Gitでの「手元のコードをremoteの最新の状態と同期する」場面
-
GitHub上にあるリポジトリを複数人で手元にcloneして、各人が作業の成果をcommitしてpushしている
-
自宅と会社のPCそれぞれにcloneして、各環境で変更をcommitしてpushしている
このような状況では、pushしようとしてできないという事が度々起こります。
-
自分が作業をしている間に他の誰かが変更をpushしたせいでremoteのmasterの内容が変わってしまっているにも関わらず、そのまま自分の変更をpushしようとした
-
自宅PCで作業した内容をpushし忘れたまま出勤し、会社のPCで改めて同じような変更をpushした後、帰宅後に自宅PCで作業を再開し、変更をpushしようとした
言うまでもなくこれはpullのし忘れによって起こることで、こういう時は一旦remoteの変更を手元にpullして、それから改めてpushするという操作が必要になります。
このときpullの仕方には、ざっくり言って*「普通のpull」と「rebase」*の2通りがあります。
ブランチの運用スタイルによっては、「普通のpullではなくrebaseするほうが良い」とアドバイスされたり、あるいはルールやマナーとして「rebaseするように」と求められる場合があります。 筆者の印象では、Git FlowやGitHub Flowのようなトピックブランチを使う開発スタイルではなく、masterブランチに関係者それぞれが直接変更をpushするスタイルの時に、rebaseが推奨される印象があります。
普通のpullもrebaseも、どちらも衝突(conflict)がなければ問題ないのですが、衝突が発生してしまった場合、rebaseはその後でとる対応が普通のpullに比べると分かりにくくて面倒です。 このときの対応を誤ると、せっかくの作業の成果を失うことにもなりかねません。
作業の成果が衝突する状況の例
もう少し具体的に、衝突が発生する状況を示しましょう。
共有リポジトリにmasterというブランチがあり、変更は直接このブランチにpushしていく運用だと仮定します。
また、2人の作業者がいて、それぞれの手元で変更を行った結果以下の図のような状態になっているとします。
(図の左が過去、右が未来を指しています。========
より上は共有リポジトリのブランチ、下は作業者の手元のリポジトリのブランチです。)
A-+ [remote master]
==|================================
|
+-B---C---D [作業者1 local master]
|
+-E---F---G [作業者2 local master]
ブランチの内容は、Aの段階では以下のような内容のファイル「sample.js」が1つだけあります。
"OK"
作業者1は、「OKをNGに書き換える」という意図の元で、Aから以下のように作業を行いました。
-
「NG」に書き換える。(コミットB)
"NG"
-
「NG」を増やす。(コミットC)
"NG-NG"
-
「NG」をさらに増やす。(コミットD)
"NG-NG-NG"
一方、作業者2は「ダブルクオートからシングルクオートにする」という意図の元で、Aから以下のように作業を行いました。
-
ダブルクオートからシングルクオートに書き換える。(コミットE)
'OK'
-
「OK」を増やす。(コミットF)
'OK-OK'
-
「OK」をさらに増やす。(コミットG)
'OK-OK-OK'
はい、いかにも衝突しそうですね。
pullで変更が衝突した状況からの回復
rebaseの何が難しいのかを分かりやすく示すために、まず普通のpullの場合を説明します。
ここで作業者1が作業の成果をpushすると、ブランチの状態は以下のようになります。
A-+-B---C---D("NG-NG-NG") [remote master]
==|=============================================
|
+-E---F---G('OK-OK-OK') [作業者2 local master]
作業者2はこの時、そのままgit push
はできません(しようとしてもエラーになります)。
そこで一旦git pull
します。
すると、
A-+-B---C---D("NG-NG-NG")-+ [remote master]
==|=======================|=======================
| |
+-E---F---G('OK-OK-OK')-+-[作業者2 local master]
<<<<<<< HEAD
'OK-OK-OK'
=======
"NG-NG-NG"
>>>>>>> xxxxxxxxxxxx
このように、DとGの内容が矛盾しているので矛盾箇所を修正するように求められます。 これが「衝突(conflict)」という状態です。
衝突をどう解消するかは場合によりますが、ここでは2人の作業者の意図を尊重して「OKはNGにする」「ダブルクオートはシングルクオートにする」というそれぞれの折衷案を採る事にしました。
ファイルの内容を'NG-NG-NG'
に書き換えてgit commit -a
し、コミットします。
A-+-B---C---D("NG-NG-NG")-+ [remote master]
==|=======================|=====================================
| |
+-E---F---G('OK-OK-OK')-+-H('NG-NG-NG') [作業者2 local master]
こうして、DとGの矛盾を解消するコミットHができました。 このように、remote masterとlocal masterのような複数のブランチを統合(マージ)するために必要な、矛盾を解消するコミットを「マージコミット」と言います。
衝突が解消されてマージコミットHができたら、作業者2はようやくgit push
で変更点を共有リポジトリのブランチに反映させられる状態になります。
以下はgit push
して共有リポジトリに作業者2の成果が反映された状態です。
A-+-B---C---D-+--H[remote master ('NG-NG-NG')]
| |
+-E---F---G-+
==============================================
(作業者1、2の手元に固有の変更は無い)
不要なマージコミットの問題とrebase
先程、「複数のブランチを統合するために必要な、矛盾を解消するコミットをマージコミットと言う」と述べました。 しかし場合によっては各ブランチは矛盾無く統合できることもあります。 それぞれの作業者で違うファイルを編集していたり、単にファイルを追加しただけだったりする場合、矛盾を解消するための手作業での修正は必要なく、Gitは単に「2つのブランチを統合した」という事を示すだけのマージコミットを自動的に作成してくれます。
これは言い換えると、自動的に統合できた場面でのマージコミットには、「2つのブランチを無事に統合できた」という事以上のさしたる情報は含まれないという事です。 この事から、プロジェクト関係者の判断によって、「こういった無益なマージコミットが履歴の上に何度も現れるのは目障りなので、マージコミットが作成されないrebaseを使うように」という通達がなされる場合があります。
rebaseは、作業の起点になったコミットを別のコミット、特にリモートブランチの最新のコミットに切り替えるという操作です。 先の説明の途中の「作業者1と作業者2の作業の成果がそれぞれあって、作業者2の成果をpushできない状態」に戻ってrebaseの様子を示しましょう。
A-+-B-C-D [remote master]
==|=============================
|
+-E-F-G [作業者2 local master]
ここで*git pull --rebase
*とすると、作業者2の作業の起点がリモートブランチのAからDに切り替わります。
つまり、「Aから派生して作業していたのではなく、Dから派生して作業していた事になる」という小規模な歴史改変が行われます。
A-B-C-D-+ [remote master]
========|=============================
|
+-E-F-G [作業者2 local master]
この状態からであれば、作業者2は問題なく成果をpushできます。
A-B-C-D-E-F-G [remote master]
====================================
(作業者1、2の手元に固有の変更は無い)
今度は先程の通常のpullの場合と異なり、マージコミットが作成されていません。 大した意味のないコミットがコミット履歴の中に現れる事がないため、履歴を見るのが楽になっています。
この例では減ったマージコミットは1つだけなので、あまり差が分からないかもしれません。 しかし、1日の間にマージコミットが10個も20個も発生するような状況だと、履歴はマージコミットだらけになってしまい、本当に重要なコミットが埋もれて分からなくなってしまいます。 そのような状況下では、「普通のpullではなくrebaseするようにする」という方針が大きな意味を持ってきます。
rebaseで変更が衝突した状況からの回復の、間違ったケース
本題はここからです。
上記の図ではつつがなくrebaseできた事にしていますが、実際には、リモートブランチの先頭であるDの状態と、作業者の手元のE、F、Gの状態は矛盾しています。 そのため、「Aから派生して作業していたのではなく、Dから派生して作業していた事になる」という歴史改変は行えません。 これも「衝突(conflict)」で、通常のpullでマージコミットを自分で作成するのと同様に、rebaseの場合も衝突を解消する必要があります。
ただ、rebaseでの衝突の解消は、通常のpullでの衝突の解消とは勝手が違います。 通常のpullではたった1つのマージコミットHで衝突を解消する事になるので、解消の手間は1回で済みますが、rebaseでは場合によっては何度も解消を繰り返さなくてはなりません。 通常のpullしか知らない人にとっては、ここがrebaseの分かりにくい点です。
作業者1が変更をpushした直後の状態から順番に見ていきましょう。 筆者は当初、以下のような誤解をしていました。
A-+-B-C-D("NG-NG-NG") [remote master]
==|===================================
|
+-E-F-G('OK-OK-OK') [作業者2 local master]
ここでgit pull --rebase
を実行すると、以下のように衝突した事を示すメッセージが表示されます。
$ git pull --rebase
...
From XXXXXXX
* branch HEAD -> FETCH_HEAD
First, rewinding head to replay your work on top of it...
Applying: E
Using index info to reconstruct a base tree...
M sample.js
Falling back to patching base and 3-way merge...
Auto-merging sample.js
CONFLICT (content): Merge conflict in sample.js
error: Failed to merge in the changes.
Patch failed at 0001 E
The copy of the patch that failed is found in: .git/rebase-apply/patch
When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".
通常のpullの時と同じ要領で衝突を手作業で修正しようとするのですが、どうも先程のマージコミットの作成時とは様子が違います。
手元では'OK-OK-OK'
が最新の状態だったはずなのに、ずいぶん前の状態との差分が出ています。
<<<<<<< xxxxxxxxxxxx
"NG-NG-NG"
=======
'OK'
>>>>>>> E
まあいいでしょう、先程のマージの時のように2人の作業者の意図を汲んで「OKはNGにする」「ダブルクオートはシングルクオートにする」という変更を統合することにします。
'NG-NG-NG'
そして、先程のようにマージコミットにするべくgit commit -a
します。
rebaseでは作業をリモートのブランチの最新の状態から行ったように歴史改変してくれるということなので、こういう状態になっているはずです。
A-B-C-D("NG-NG-NG")-+ [remote master]
====================|===================================
|
+-E?-F?-G?('OK-OK-OK') [作業者2 local master?]
ところが、衝突を解消したと思ってgit push
してみても何も送信される気配がありません。
もしかしてリモート側にまた作業者1がpushしたのか?と思ってgit pull --rebase
したら、こんな事を言われます。
$ git pull --rebase
You are not currently on a branch.
Please specify which branch you want to rebase against.
See git-pull(1) for details.
git pull <remote> <branch>
どういうことか?と思ってgit branch
してみると、
$ git branch
* (no branch, rebasing master)
master
えっ、masterブランチにいたはずなのにどうして別のブランチになってるの!?
という事で慌ててgit checkout master
でmasterに切り替え直してリポジトリの内容を見ると、git pull --rebase
する前の状態に戻っています。
A-+-B-C-D("NG-NG-NG") [remote master]
==|===================================
|
+-E-F-G('OK-OK-OK') [作業者2 local master]
とりあえず元の状態に戻せたので一安心ですが、しかし衝突を直すために行った作業は電子の藻屑と消えてしまいました。 一体どうして……?
rebaseで変更が衝突した状況からの回復の、正しいケース
この時何が起こっていたのか、どうして衝突を解決したはずなのにその情報が失われてしまったのか。 これはrebaseの時に実際には何が起こっているのかを順を追って見ていかないと理解できません。
もう一度、作業者1が変更をpushした直後の状態から見ていきましょう。
A("OK")-+-B-C-D("NG-NG-NG") [remote master]
========|===================================
|
+-E('OK')-F-G [作業者2 local master]
ここでgit pull --rebase
を実行すると、何が起こるのか。
もう一度、git pull --rebase
した時のメッセージを見てみましょう。
$ git pull --rebase
...
From XXXXXXX
* branch HEAD -> FETCH_HEAD
First, rewinding head to replay your work on top of it...
Applying: E
Using index info to reconstruct a base tree...
M sample.js
Falling back to patching base and 3-way merge...
Auto-merging sample.js
CONFLICT (content): Merge conflict in sample.js
error: Failed to merge in the changes.
Patch failed at 0001 E
...
First, rewinding head to replay your work on top of it...
というメッセージは、「リモートブランチの最新の状態に移動して、そこにあなたの作業の成果を反映します」という事を言っています。
そして続けてApplying: E
(「Eを反映中」)と出た後、自動でのマージに失敗した事と、衝突したファイルの情報が出力されています。
つまり、この時Gitは「手元のmasterブランチの内容とリモートのmasterブランチの内容を完全に統合」しようとはしておらず、あくまで*「リモートのmasterから改めて作業を行った場合のコミットとしてEを反映」しようとしている*という状態なのです。
この状態を図に示しましょう。
A("OK")-+-B-C-D("NG-NG-NG")-+ [remote master]
========|===================|==================================
| |
| +-+-[作業者2 local temporary]
| | <<<<<<< xxxxxxxxxxxx
| | "NG-NG-NG"
| | =======
| | 'OK'
| | >>>>>>> E
| |
+-E('OK')-------------+-F---G [作業者2 local master]
AではなくDから作業を開始したかのように歴史を改変しようとして、DとEが衝突します。 そこで、Gitは「Aから作業を行った時のEではなく、Dから作業を行った時に妥当な内容となるE」の作成を作業者2に求めているというわけです。
また、この時手元のmasterではなく、衝突解消作業用の新しい一時的なブランチ(のようなもの)にフォーカスが移っている事にも注意が必要です。
実際に、この段階でgit branch
を実行すると、今いるブランチは(no branch, rebasing master)
と表示され、masterではないことが分かります。
-
全体のマージではなく、自分がcloneの後に行ったコミットの中で、最初にリモートのmasterと衝突してしまったEのやり直しが求められている。
-
この時参照しているのは、リモートのmasterブランチでもローカルのmasterブランチでもなく、rebaseの衝突修正用に一時的に生まれたブランチ(のようなもの)である。
rebaseではこの2点を忘れないようにくれぐれも注意して下さい。
さて、作業者2は手作業で衝突箇所を修正しますが、ここでは作業者1の成果に作業者2のEでの作業「ダブルクオートはシングルクオートにする」を反映することにしましょう。
しかし、衝突を解消してもgit commit -a
などとして普通にコミットしてはいけません。
git commit
は、実はrebaseの衝突の解消の作業の中で実行される事が想定されていない操作です。
この時点で実行してしまうと、リポジトリの状態がGitの想定する状態からずれてしまい、以後の衝突の解消の操作がすべて破綻してしまいます。
では、一体どうすればよいのか。
答えはgit pull --rebase
で衝突が発生した時のメッセージの最後に書かれています。
...
When you have resolved this problem, run "git rebase --continue".
If you prefer to skip this patch, run "git rebase --skip" instead.
To check out the original branch and stop rebasing, run "git rebase --abort".
訳すとこうなります。
-
問題を解消したら、
git rebase --continue
を実行する。 -
このコミットを反映せず飛ばしたい時は、
git rebase --skip
を実行する。 -
rebaseを取りやめて元の状態に戻したい時は、
git rebase --abort
を実行する。
つまり、先程衝突を解消したので、ここではgit rebase --continue
を実行すればよいということになります。
ただし、ここでgit rebase --continue
を実行してもまたエラーメッセージが表示されてしまいます。
$ git rebase --continue
sample.js: needs merge
You must edit all merge conflicts and then
mark them as resolved using git add
メッセージ内に説明があるのですが、衝突を解消した後は「この衝突は解消済み」という事をGitに伝えなくてはなりません。
それには、git add (ファイルのパス)
で変更をステージングに移すという操作を行います。
ステージングに移された変更は解消済みの衝突と見なされるため、これでやっとgit rebase --continue
できるようになります。
git add sample.js
で変更をステージングに移してgit rebase --continue
を実行すると、Gitは自動的に、「Dから作業した場合のE」すなわち「E'」として現在ステージング済みの状態をコミットします。
A-+-B-C-D-+ [remote master ("NG-NG-NG")]
==|=======|=========================================
| |
| +-E'('NG-NG-NG') [作業者2 local temporary]
|
+-E-F('OK-OK')-G [作業者2 local master]
これで手元のブランチのコミットEはリベースが完了しました。
するとGitは続けて、E'の後にFを行ったかのように歴史を改変しようとします。 しかしE'とFも矛盾するため、また衝突が起こります。
A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|===========================================
| |
| +-E'('NG-NG-NG')-+-[作業者2 local temporary]
| | <<<<<<< xxxxxxxxxxxx
| | 'NG-NG-NG'
| | =======
| | 'OK-OK'
| | >>>>>>> F
| |
+-E-F('OK-OK')-----------------------+-G [作業者2 local master]
今度の矛盾はどう解消しましょう。ここでは2通りのやり方があります。
-
先程のE'と同じように、Fの意図を汲んでE'に反映する新しいコミット「F'」を作る。
-
最終的に持っていきたい状態を考えるとFの作業内容は必要ないため、Fは破棄する。
F'を作る場合は、先程同様にファイルを編集してgit add
してgit rebase --continue
します。
Fを破棄する場合、衝突を解消するための手作業での修正は行わずに、git rebase --skip
を実行します。
ここではFを破棄したとしましょう。
A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|=========================================
| |
| +-E'('NG-NG-NG') [作業者2 local temporary]
|
+-E-F-G[作業者2 local master ('OK-OK-OK')]
すると、「F'を作成するための変更」がキャンセルされます。 そして、今度はGitはE'の後にFを行ったのではなく、E'の後にGを行ったかのように歴史を改変しようとします。
A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|===========================================
| |
| +-E'('NG-NG-NG')-+-[作業者2 local temporary]
| | <<<<<<< xxxxxxxxxxxx
| | 'NG-NG-NG'
| | =======
| | 'OK-OK-OK'
| | >>>>>>> G
| |
+-E-F-G('OK-OK-OK')------------------+ [作業者2 local master]
はい、また衝突しました。ここでも先程と同様に2通りの選択肢があります。
-
先程のE'と同じように、Gの意図を汲んでE'に反映する新しいコミット「G'」を作る。
-
最終的に持っていきたい状態を考えるとGの作業内容は必要ないため、Gは破棄する。
どちらにしてもいいのですが、今度もまたGを破棄してgit rebase --skip
することにしましょう。
すると、「G'を作成するための変更」がキャンセルされます。
A-+-B-C-D("NG-NG-NG")-+ [remote master]
==|===================|=========================================
| |
| +-E'('NG-NG-NG') [作業者2 local temporary]
|
+-E-F-G('OK-OK-OK') [作業者2 local master]
そして、すべての手元の変更をrebaseし終えたということで、Gitは先程までrebase作業用の一時的なブランチ(のようなもの)だった物を新しいlocalのmasterブランチに昇格させ、今までmasterだったブランチは破棄します(リポジトリ内にコミットごとのデータはまだ残っていますが、どこからも参照されなくなるので見えなくなります)。
A-B-C-D("NG-NG-NG")-+ [remote master]
====================|=========================================
|
+-E'('NG-NG-NG') [作業者2 local master]
この状態からであれば、作業者2は変更をpushできます。
ということでgit push
してしまいましょう。
A-B-C-D-E'('NG-NG-NG') [remote master]
======================================
(作業者1、2の手元に固有の変更は無い)
以上が、rebaseで実際に起こっている事の詳細と、rebaseでの衝突の正しい解消手順です。
「今自分がどのブランチで作業しているのか」を一目で分かるようにする
rebaseが分かりにくい理由の1つには、自分が作業していたはずのブランチから何の警告もなく勝手に別の一時的なブランチ(のようなもの)に移ってしまい、自分で明示的にrebase終了の操作をするまではそのブランチにいるままになってしまうから、という理由があるように筆者には思えます。
この対策としてお薦めなのが、シェルのプロンプト上に現在のブランチ名を自動的に表示するように設定しておくという方法です。 過去に紹介したおすすめzsh設定にもそのような設定が含まれていますが、ここではmacOS(OS X)や多くのLinuxディストリビューションで端末エミュレータを起動した時の既定のシェルになっているBash用の設定をご紹介します。
BashでGitリポジトリの状態をプロンプトに表示するツールとしては、bash-git-promptが有名なようです。 以下のようにするとインストールできます。
cd ~/
git clone https://github.com/magicmonty/bash-git-prompt.git .bash-git-prompt --depth=1
echo 'GIT_PROMPT_ONLY_IN_REPO=1' >> ~/.bashrc
echo 'source ~/.bash-git-prompt/gitprompt.sh' >> ~/.bashrc
後半2つのコマンド列は、Bashを起動した時点で最初からこのツールが読み込まれた状態にしておくための設定です。
以上の設定を施した状態で新しい端末を起動し、任意のGitリポジトリの中にcdすると、プロンプトがGitリポジトリの状態を示す内容に自動的に切り替わります。 プロンプトの表示が大きく変わりますので、慣れない場合はテーマを切り替えたりカスタマイズしたりしてみるとよいでしょう。 筆者の場合はUbuntuの通常のプロンプトからの変化を最小限に抑えたかったため、
override_git_prompt_colors() {
GIT_PROMPT_THEME_NAME="Single_line_Ubuntu_default"
GIT_PROMPT_START_USER="\u@\h:${PathShort}"
GIT_PROMPT_END_USER="${ResetColor}$ "
GIT_PROMPT_END_ROOT="${BoldRed}# "
}
reload_git_prompt_colors "Single_line_Ubuntu_default"
以上の内容のテーマ定義ファイルを~/.bash-git-prompt/themes/Single_line_Ubuntu_default.bgptheme
の位置に置き、~/.bashrc
の末尾にGIT_PROMPT_THEME=Single_line_Ubuntu_default
という行を追加して、この自作テーマを使うように設定しています。
まとめ
通常のpullしか知らない状態でgit pull --rebase
しようとして、衝突が発生した時にお手上げになってしまう状況について、筆者の事例を元に誤解のポイントとrebase作業の正しい流れを解説しました。
また、rebaseで失敗の元になりやすいと思われる「今いるブランチがぱっと見で分からない」問題について、Bashのプロンプトにブランチ名を表示させるための方法もご紹介しました。
rebaseは、衝突が発生しない状況では変更履歴を簡単に綺麗に保つ事ができてとても便利です。 しかし一度衝突が発生すると、途端に衝突と解消の連続の地獄に陥ります。 現在自分がどの状態にあってどのコミットまで衝突を解消したのか、この衝突の解消ではどこまでやって良くてどこから先はやらない方がよいのか、常に正確に把握しながら作業しないと、どこを目指して衝突を解消すればよいのかをすぐに見失ってしまいます。
この問題について、通常のpullでの単一のマージコミットのような、シンプルで分かりやすい解決策は残念ながらありません。 いわゆる「運用でカバー」な方法としては以下のような対策が考えられます。
-
複数人で並行して作業をしていて衝突が頻発するような状況では、rebaseを使わない。 (マージコミットの発生については、避けられない事として諦める。)
-
例えば、Git FlowやGitHub Flowのように、masterには直接コミットせず、必ずトピックブランチを作成してそれをmasterにマージする形でのみ変更を反映する、という運用を徹底する。このような状況であればrebaseはそもそも行わなくて済む。
-
自分が作業を開始する前に必ず
git pull --rebase
を実行し、また、1つ何かコミットする度にもgit pull --rebase
して、rebaseでの衝突の解消を「リモートブランチの最新の状態と、手元の先程の作業1つとの衝突の解消」に留めるようにする。 (手元のブランチの「その次の作業」との整合性まで考慮した衝突の解消は困難極まりないので、しないで済むようにする。)
以上のような対策を取りつつ、皆さんも是非rebaseを正しく活用していって下さい。