実践 git rebase --onto

あるプロジェクトにおいて、メインの開発ラインと、サブプロジェクト的な、メインプロジェクトの内容も多少書き換えつつ追加の機能を実装する実験的な開発ラインがあったとしましょう。
サブプロジェクトの開発中もメインプロジェクトの開発は進むので、サブプロジェクトはキリの良いところでメインプロジェクトのバージョンアップに合わせて merge や rebase をしてメインプロジェクトの変更を反映させなければならない。
そんな時に便利なのが git rebase –onto かなと思っています。

以下、Git のバージョンは 1.8.1.3 です。

git rebase –onto とは?

「git rebase」でググればいくらでも秀逸な説明が出てくるんですが、一応簡単に説明しておきます。
グラフはドキュメントからの抜粋です。説明もドキュメントの日本語訳のような感じです。

次のような構成を考えます。topic ブランチは next ブランチから派生しており、例えば next ブランチで実装されている一部の機能に依存しているとします。

 o---o---o---o---o  master
      \
       o---o---o---o---o  next
                        \
                         o---o---o  topic

ここで、topic ブランチの依存している機能の安定版が master ブランチに merge されたとします。そうすると、次のように topic ブランチの派生元を next ブランチから master ブランチに変更したくなるでしょう。

 o---o---o---o---o  master
     |            \
     |             o'--o'--o'  topic
      \
       o---o---o---o---o  next

これを実現するのが次のコマンドです。

$ git rebase --onto master next topic

また、別のリポジトリでは次のような構成になっていたとします。

                         H---I---J topicB
                        /
               E---F---G  topicA
              /
 A---B---C---D  master

しかし、topicB は topicA に依存していないので、次のように master をベースとするように変更したいとします。1

              H'--I'--J'  topicB
             /
             | E---F---G  topicA
             |/
 A---B---C---D  master

これを実現するのが次のコマンドです。

$ git rebase --onto master topicA topicB

最後の例です。次のような構成を考えます。

 E---F---G---H---I---J  topicA

次のように F, G を取り除きたくなったとします。

 E---H'---I'---J'  topicA

次のコマンドで実現できます。

$ git rebase --onto topicA~5 topicA~3 topicA

以上の例からわかるように、git rebase –onto は、–onto に指定したコミットをベースとし、upstream に指定したブランチ(最後から2番目)と branch に指定したブランチ(最後)の差分となるコミットを適用するためのコマンドです。

実践!

冒頭で述べたようなプロジェクトがあった場合に git rebase –onto が便利ですよという説明をしてみます。

ここに架空のプロジェクト favmemo を始めるとします。git-flow ライクな運用をすることにします。

$ mkdir favmemo && cd favmemo
$ git init
$ git checkout -b develop
$ touch common.txt && git add common.txt
$ touch version.txt && git add version.txt
$ git commit -m 'add common.txt and version.txt'

favmemo に依存したサブプロジェクト、hoge も始めましょう。場合によってはサブモジュール化も考えられますが、メインプロジェクトのコードにも多少手を加えるものとして、ここではブランチを切るだけにします。

$ git checkout -b hoge/v0.0/develop
$ touch hoge.txt && git add hoge.txt
$ git commit -am 'add hoge.txt'

そんな中、バージョン 0.1 がリリースされます。

$ git checkout -b feature/foo develop
$ echo -e "foo\nfoo" >> common.txt
$ git commit -am 'append "foo"'
$ git checkout develop
$ git merge --no-ff --no-edit feature/foo
$ git checkout -b release/v0.1
$ echo v0.1 > version.txt
$ git commit -am 'release v0.1'
$ git tag -am 'v0.1' v0.1

リリースしてすぐにバグが見つかったので修正してバージョン 0.1.1 がリリースされます。
次期バージョンの 0.2 にはこの修正を適用しないものとします。理由はよくわかりませんが、まぁ古いバージョンには存在するのに新しいバージョンにはきれいさっばり存在しないコミットって実際問題ありますよね。たぶん。

$ git checkout -b hotfix/v0.1.1 release/v0.1
$ echo v0.1.1 > version.txt
$ git commit -am 'change version.txt (v0.1.1)'
$ echo bar >> common.txt
$ git commit -am 'append "bar"'
$ git checkout -b release/v0.1.1 release/v0.1
$ git merge --no-ff --no-edit hotfix/v0.1.1
$ git commit -am 'release v0.1.1'
$ git tag -am 'v0.1.1' v0.1.1

バージョン 0.1.1 のリリースをしている間も develop ブランチでは開発が進んでいます。

$ git checkout -b feature/foo.txt develop
$ touch foo.txt && git add foo.txt
$ git commit -m 'add foo.txt'
$ git checkout develop
$ git merge --no-ff --no-edit feature/foo.txt

そんな中、バージョン 0.1.1 にバグが見つかりました。

$ git checkout -b hotfix/v0.1.2 release/v0.1.1
$ echo v0.1.2 > version.txt
$ git commit -am 'change version.txt (v0.1.2)'
$ sed -i "" -e "$(echo -e "2i\\\\\nbaz\n\r")" common.txt
$ git commit -am 'insert "baz"'

バージョン 0.1.1 に hotfix を適用してバージョン 0.1.2 をリリースします。

$ git checkout -b release/v0.1.2 release/v0.1.1
$ git merge --no-ff --no-edit hotfix/v0.1.2
$ git tag -am 'v0.1.2' v0.1.2

今回は develop にも適用します。version.txt に対する変更は不要なので、merge ではなく cherry-pick を使います。

$ git checkout develop
$ git cherry-pick -x hotfix/v0.1.2

さてさて、バージョン 0.1.2 がリリースされたんで、サブプロジェクト hoge の rebase を行います。

$ git checkout -b hoge/v0.1/develop hoge/v0.0/develop
$ git rebase -p release/v0.1.2

hoge/v0.1 の開発も行います。

$ git checkout -b hoge/v0.1/feature/fuga
$ echo feature >> hoge.txt
$ git commit -am 'append "feature"'
$ git checkout hoge/v0.1/develop
$ git merge --no-ff --no-edit hoge/v0.1/feature/fuga

このタイミングでメインプロジェクトでバージョン 0.2 がリリースされます。

$ git checkout -b feature/bar.txt develop
$ touch bar.txt && git add bar.txt
$ git commit -m 'add bar.txt'
$ git checkout develop
$ git merge --no-ff --no-edit feature/bar.txt
$ git checkout -b release/v0.2
$ echo v0.2 > version.txt
$ git commit -am 'release v0.2'
$ git tag -am 'v0.2' v0.2

現在の状況を確認してみましょう。

v0.1.2

$ git checkout release/v0.1.2
$ head *
==> common.txt <==
foo
baz
foo
bar

==> version.txt <==
v0.1.2
$ git log --graph --oneline
*   21091d6 Merge branch 'hotfix/v0.1.2' into release/v0.1.2
|\  
| * 8248d12 insert "baz"
| * c4c7a76 change version.txt (v0.1.2)
|/  
*   81e75ed Merge branch 'hotfix/v0.1.1' into release/v0.1.1
|\  
| * dbe2584 append "bar"
| * ce42026 change version.txt (v0.1.1)
|/  
* 87f052e release v0.1
*   7c3f28d Merge branch 'feature/foo' into develop
|\  
| * 592e3be append "foo"
|/  
* dd4087b add common.txt and version.txt

v0.2

$ git checkout release/v0.2
$ head *
==> bar.txt <==

==> common.txt <==
foo
baz
foo

==> foo.txt <==

==> version.txt <==
v0.2
$ git log --graph --oneline
* 907ea13 release v0.2
*   3f2cc08 Merge branch 'feature/bar.txt' into develop
|\  
| * 52ed8ec add bar.txt
|/  
* f8611da insert "baz" (cherry picked from commit 8248d120ffa669781605706666c2dbc08d8592f6)
*   106e96c Merge branch 'feature/foo.txt' into develop
|\  
| * a45617b add foo.txt
|/  
*   7c3f28d Merge branch 'feature/foo' into develop
|\  
| * 592e3be append "foo"
|/  
* dd4087b add common.txt and version.txt

hoge/v0.1

$ git checkout hoge/v0.1/develop
$ head *
==> common.txt <==
foo
baz
foo
bar

==> hoge.txt <==
feature

==> version.txt <==
v0.1.2
$ git log --graph --oneline
*   6a7a5e6 Merge branch 'hoge/v0.1/feature/fuga' into hoge/v0.1/develop
|\  
| * bbe4989 append "feature"
|/  
* 061188f add hoge.txt
*   21091d6 Merge branch 'hotfix/v0.1.2' into release/v0.1.2
|\  
| * 8248d12 insert "baz"
| * c4c7a76 change version.txt (v0.1.2)
|/  
*   81e75ed Merge branch 'hotfix/v0.1.1' into release/v0.1.1
|\  
| * dbe2584 append "bar"
| * ce42026 change version.txt (v0.1.1)
|/  
* 87f052e release v0.1
*   7c3f28d Merge branch 'feature/foo' into develop
|\  
| * 592e3be append "foo"
|/  
* dd4087b add common.txt and version.txt

さて、ここからが本題です。サブプロジェクト hoge にメインプロジェクトの変更を反映させます。
ここまで単純であれば git rebase –onto を使えばいいやと思うのではないかと思いますが、内情を知らなくてエンジニアリング力も低い自分のような人であれば v0.1 のコミットは v0.2 にも含まれていると思うじゃないですか?そうすると、単純に merge や rebase でいいと考えてしまうわけです。

一応 merge, rebase だとどのような問題が生じるかも試してみます。
とりあえず別のブランチを切ってタグを打っておきます。

$ git checkout -b hoge/v0.2/develop hoge/v0.1/develop
$ git tag tmp

merge

$ git merge --no-ff --no-edit release/v0.2
Auto-merging version.txt
CONFLICT (content): Merge conflict in version.txt
Automatic merge failed; fix conflicts and then commit the result.
$ git status
# On branch hoge/v0.2/develop
# You have unmerged paths.
#   (fix conflicts and run "git commit")
#
# Changes to be committed:
#
#	new file:   bar.txt
#	new file:   foo.txt
#
# Unmerged paths:
#   (use "git add <file>..." to mark resolution)
#
#	both modified:      version.txt
#
$ echo v0.2 > version.txt && git add -u
$ git commit
$ head *
==> bar.txt <==

==> common.txt <==
foo
baz
foo
bar

==> foo.txt <==

==> hoge.txt <==
feature

==> version.txt <==
v0.2
$ git log --graph --oneline
*   b222395 Merge branch 'release/v0.2' into hoge/v0.2/develop
|\  
| * 907ea13 release v0.2
| *   3f2cc08 Merge branch 'feature/bar.txt' into develop
| |\  
| | * 52ed8ec add bar.txt
| |/  
| * f8611da insert "baz" (cherry picked from commit 8248d120ffa669781605706666c2dbc08d8592f6)
| *   106e96c Merge branch 'feature/foo.txt' into develop
| |\  
| | * a45617b add foo.txt
| |/  
* |   6a7a5e6 Merge branch 'hoge/v0.1/feature/fuga' into hoge/v0.1/develop
|\ \  
| * | bbe4989 append "feature"
|/ /  
* | 061188f add hoge.txt
* |   21091d6 Merge branch 'hotfix/v0.1.2' into release/v0.1.2
|\ \  
| * | 8248d12 insert "baz"
| * | c4c7a76 change version.txt (v0.1.2)
|/ /  
* |   81e75ed Merge branch 'hotfix/v0.1.1' into release/v0.1.1
|\ \  
| * | dbe2584 append "bar"
| * | ce42026 change version.txt (v0.1.1)
|/ /  
* | 87f052e release v0.1
|/  
*   7c3f28d Merge branch 'feature/foo' into develop
|\  
| * 592e3be append "foo"
|/  
* dd4087b add common.txt and version.txt

問題なさそうにも見えますが、common.txt に bar が入っているのは v0.1.1 の変更であって、v0.2 にはない変更なのでよろしくないです。
また、ログがかなり複雑になっていて、どれがサブプロジェクト hoge のコミットなのか把握しにくいです。
メインプロジェクトのバージョンアップの度にこのような作業が発生すると思うと気が重くなります。

元の状態に戻しておきます。

$ git reset --hard tmp

rebase

オプションとして -p を付けるかどうかで挙動が変わりますが、個人的に -p を付けないことはまずないので -p を付けた場合のみ試してみます。

$ git rebase -p release/v0.2
error: could not apply 87f052e... release v0.1

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".
Could not pick 87f052ed75639dc08e45a780f1cb8b6e27bd997e
$ git diff
diff --cc version.txt
index 60fe1f2,085135e..0000000
--- a/version.txt
+++ b/version.txt
@@@ -1,1 -1,1 +1,5 @@@
++<<<<<<< HEAD
 +v0.2
++=======
+ v0.1
++>>>>>>> 87f052e... release v0.1
$ echo v0.2 > version.txt && git add -u
$ git rebase --continue
error: could not apply ce42026... change version.txt (v0.1.1)

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".
Could not pick ce420261cf74221694a3019b3e4ab482fdeb68bc
$ git diff
diff --cc version.txt
index 60fe1f2,8308b63..0000000
--- a/version.txt
+++ b/version.txt
@@@ -1,1 -1,1 +1,5 @@@
++<<<<<<< HEAD
 +v0.2
++=======
+ v0.1.1
++>>>>>>> ce42026... change version.txt (v0.1.1)
$ echo v0.2 > version.txt && git add -u
$ git rebase --continue
error: could not apply c4c7a76... change version.txt (v0.1.2)

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".
Could not pick c4c7a761b45bc9dda9a928b92a529cad93699c62
$ git diff
diff --cc version.txt
index 60fe1f2,5366600..0000000
--- a/version.txt
+++ b/version.txt
@@@ -1,1 -1,1 +1,5 @@@
++<<<<<<< HEAD
 +v0.2
++=======
+ v0.1.2
++>>>>>>> c4c7a76... change version.txt (v0.1.2)
$ echo v0.2 > version.txt && git add -u
$ git rebase --continue
The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:

    git commit --allow-empty

Otherwise, please use 'git reset'
# Not currently on any branch.
# You are currently rebasing.
#   (all conflicts fixed: run "git rebase --continue")
#
nothing to commit (working directory clean)
Could not pick 8248d120ffa669781605706666c2dbc08d8592f6
$ git commit --allow-empty
$ git rebase --continue
Successfully rebased and updated refs/heads/hoge/v0.2/develop.
$ head *
==> bar.txt <==

==> common.txt <==
foo
baz
foo
bar

==> foo.txt <==

==> hoge.txt <==
feature

==> version.txt <==
v0.2
$ git log --graph --oneline
*   072198e Merge branch 'hoge/v0.1/feature/fuga' into hoge/v0.1/develop
|\  
| * 2a8e239 append "feature"
|/  
* da702c6 add hoge.txt
*   86ac9e3 Merge branch 'hotfix/v0.1.2' into release/v0.1.2
|\  
| * 3e91348 insert "baz"
|/  
*   57d72e3 Merge branch 'hotfix/v0.1.1' into release/v0.1.1
|\  
| * 4a219d6 append "bar"
|/  
* 907ea13 release v0.2
*   3f2cc08 Merge branch 'feature/bar.txt' into develop
|\  
| * 52ed8ec add bar.txt
|/  
* f8611da insert "baz" (cherry picked from commit 8248d120ffa669781605706666c2dbc08d8592f6)
*   106e96c Merge branch 'feature/foo.txt' into develop
|\  
| * a45617b add foo.txt
|/  
*   7c3f28d Merge branch 'feature/foo' into develop
|\  
| * 592e3be append "foo"
|/  
* dd4087b add common.txt and version.txt

ログはずいぶんすっきりしましたが、common.txt に bar が入っているのダメですね。
また、コンフリクトが多いので辛いです。サブプロジェクトを開発している身であればメインプロジェクトのコミットでコンフリクトが起きると本当に辛いでしょう。

再度、元の状態に戻しておきます。

$ git reset --hard tmp

rebase –onto

結局のところ、ベースを v0.2 にしてそこにサブプロジェクトで行ったコミットを反映したいわけなので、rebase –onto を使うのが良いでしょう。

$ git rebase -p --onto release/v0.2 HEAD~2
Successfully rebased and updated refs/heads/hoge/v0.2/develop.
$ head *
$ head *
==> bar.txt <==

==> common.txt <==
foo
baz
foo

==> foo.txt <==

==> hoge.txt <==
feature

==> version.txt <==
v0.2
$ git log --graph --oneline
*   9e93866 Merge branch 'hoge/v0.1/feature/fuga' into hoge/v0.1/develop
|\  
| * a87a016 append "feature"
|/  
* 94c43a6 add hoge.txt
* 907ea13 release v0.2
*   3f2cc08 Merge branch 'feature/bar.txt' into develop
|\  
| * 52ed8ec add bar.txt
|/  
* f8611da insert "baz" (cherry picked from commit 8248d120ffa669781605706666c2dbc08d8592f6)
*   106e96c Merge branch 'feature/foo.txt' into develop
|\  
| * a45617b add foo.txt
|/  
*   7c3f28d Merge branch 'feature/foo' into develop
|\  
| * 592e3be append "foo"
|/  
* dd4087b add common.txt and version.txt

common.txt の bar はなくなっているし、ログもすっきりしてるし、コンフリクトもないし言うことないですね!
このやり方であれば、メインプロジェクトが何度バージョンアップしても、サブプロジェクトの最初のコミットさえ覚えておけばメインプロジェクトの前のバージョンにはあるけど次のバージョンには存在しないという謎のコミットのことも考えなくて済みます。
これが、一度でも merge で変更を反映してしまうと一気にカオスになります。また、メインプロジェクトの開発途中の機能をサブプロジェクトに merge した時もカオスです。

まとめ

git rebase –onto 便利ですね!
サブプロジェクトの開発をしている身であれば hoge/develop のような形でメインとなる開発ブランチを1つにして、メインプロジェクトのバージョンアップの度にブランチを変更するというのは避けたい気もしますが、とりあえずこんな形で開発を進めるのが良いのかなぁと思います。
メインプロジェクトのコードをほとんどいじらないのであれば、サブプロジェクトの submodule にメインプロジェクトを加えるのもありかなぁという気もします。
このような境遇のサブプロジェクトの運用方法についてもっと良い方法があればご教授お願いします><

おまけ

今回の例のような状態を作りたい方は次のスクリプトを実行すると作成できます!上記のコードをひとまとめにしただけですが・・・。

#!/bin/bash

mkdir favmemo && cd favmemo
git init
git checkout -b develop
touch common.txt && git add common.txt
touch version.txt && git add version.txt
git commit -m 'add common.txt and version.txt'
git checkout -b hoge/v0.0/develop
touch hoge.txt && git add hoge.txt
git commit -am 'add hoge.txt'
git checkout -b feature/foo develop
echo -e "foo\nfoo" >> common.txt
git commit -am 'append "foo"'
git checkout develop
git merge --no-ff --no-edit feature/foo
git checkout -b release/v0.1
echo v0.1 > version.txt
git commit -am 'release v0.1'
git tag -am 'v0.1' v0.1
git checkout -b hotfix/v0.1.1 release/v0.1
echo v0.1.1 > version.txt
git commit -am 'change version.txt (v0.1.1)'
echo bar >> common.txt
git commit -am 'append "bar"'
git checkout -b release/v0.1.1 release/v0.1
git merge --no-ff --no-edit hotfix/v0.1.1
git commit -am 'release v0.1.1'
git tag -am 'v0.1.1' v0.1.1
git checkout -b feature/foo.txt develop
touch foo.txt && git add foo.txt
git commit -m 'add foo.txt'
git checkout develop
git merge --no-ff --no-edit feature/foo.txt
git checkout -b hotfix/v0.1.2 release/v0.1.1
echo v0.1.2 > version.txt
git commit -am 'change version.txt (v0.1.2)'
sed -i "" -e "$(echo -e "2i\\\\\nbaz\n\r")" common.txt
git commit -am 'insert "baz"'
git checkout -b release/v0.1.2 release/v0.1.1
git merge --no-ff --no-edit hotfix/v0.1.2
git tag -am 'v0.1.2' v0.1.2
git checkout develop
git cherry-pick -x hotfix/v0.1.2
git checkout -b hoge/v0.1/develop hoge/v0.0/develop
git rebase -p release/v0.1.2
git checkout -b hoge/v0.1/feature/fuga
echo feature >> hoge.txt
git commit -am 'append "feature"'
git checkout hoge/v0.1/develop
git merge --no-ff --no-edit hoge/v0.1/feature/fuga
git checkout -b feature/bar.txt develop
touch bar.txt && git add bar.txt
git commit -m 'add bar.txt'
git checkout develop
git merge --no-ff --no-edit feature/bar.txt
git checkout -b release/v0.2
echo v0.2 > version.txt
git commit -am 'release v0.2'
git tag -am 'v0.2' v0.2
  1. topicA の開発が進んで、G の時点と衝突するような変更が加わると master に topicA と topicB を merge する際に面倒ですからね