git-flowでのフレキシブルなリリースについて

問題意識

git-flowでリポジトリを運用しているプロジェクトで、開発済み機能の、リリース時期、リリース対象機能をフレキシブルに選択できるようにしたい。

たとえば、機能Aと機能Bを開発中で、機能Aは来週リリース、機能Bは他社連携先機能のリリースに合わせたいとする。他社のスケジュールによって、機能Bが機能のリリースに前後したり、一緒になったりする、といったケースに対応したい。

いまの弊社プロジェクトのgit-flowの運用だと、すべての開発済みコミットをdevelopブランチに集約しているので、リリース時に対象機能のコミットを、人力でピックアップする羽目になっている。これはダサい。

注釈含め、以下と同じ気持ちだ。

追記 なぜdevelopブランチが複数あるのかという疑問が散見されたので,我々のケースについて書いておきます ざっくり言ってしまえばリリースサイクルの違いです.例えば大きめの新機能を実装しつつ既存コードの保守もするという時,その時点で稼働しているコード (master) から派生したdevelop/A (便宜上Aとします) の上で新機能の実装を入れてしまうと,保守のコードと新機能コードが混ざってしまい,保守のためのデプロイのタイミングで出て欲しくない新機能のコードまで露出してしまうことになるので,それを防ぐ (分離させる) という目的でdevelopを複数に分ける (例えばdevelop/Bで新機能の開発は行なう) というような形にしています.保守の為のdevelopブランチでも,新機能の為のdevelopでも,どちらも独立したステージング環境では見ておきたいので……

(中略)

*1:git-flowの解説ではdevelopブランチが複数存在している場合についてあまり言及されてない気がする……

moznion.hatenadiary.com

ググってみる

上の「問題意識」で引用した記事では、developを機能別に複数作ればいいという意見だ。以降、「複数developブランチパターン」と呼ぶことにする。

他にも、featureからfeatureを切ればいいという意見がある。「featureブランチネストパターン」と呼ぶことにする。

stackoverflow.com

比較検討

上の2つのやりかたに加え、弊社の現状の運用方法(「cherry-pickパターン」と呼ぶことにする)を加えた、3つの運用方法を比較検討する。

複数developブランチパターン

詳細

develop/x.x.x という命名規則でdevelopブランチを切る(x.x.xはバージョン番号)。リリース時は、対応するバージョンのdevelpブランチをベースに、featureブランチを切る。

感想

どのdevelopブランチになんの機能変更が入ったか、わからなくなりそう?

自社のプロジェクトの場合、リリースの順番が定まらないまま開発が始まったり、開発開始してから順番が変更になったりすることが多い。リリースごとに、バージョン番号が増加するとは限らない。わかりづらいので、バージョン番号を命名規約にすることは避けたい。追加内容別にブランチを切ればいいか。

複数のdevelopブランチをまとめてリリースしたいとき、とりまとめはどのブランチで行えばいいか?releaseブランチで行うのが適当か?

featureブランチネストパターン

詳細

大きな機能を開発開始するとき、featureブランチを切る。大きな機能のサブタスクを開発開始するとき、featureブランチからさらにfeatureブランチを切る。

感想

featureブランチはリリース直前までdevelopブランチにマージしない、ということになるのかな?大きな機能追加/変更時は、まずdevelopからfeatureブランチを切り、そのfeatureブランチからサブタスクごとにfeatureブランチをネストして切る。一段目のfeatureブランチが、従来のdevelopブランチになるイメージ。

一段目のfeatureブランチを切る粒度は、リリースの選択対象となるように。 二段目のfeatureブランチは、従来通りで良い。弊社ならRedmineのチケット単位。

リリース時、リリースに含まれる変更内容がわかりやすくなりそう。リリースノートが作りやすそう。 しかし、孫featureの親featureがどれか、わからなくなりそうか?命名規約で対処する?いや、切った本人は覚えているか。孫featureは基本的に開発担当者のローカルPCで完結するので、本人が覚えていれば十分。

hotfixするほどでないちょっとしたバグ修正はdevelopに入れてしまう?もしくは,feature/bugfixブランチを切る?後者のほうが、統一されてわかりやいかな。

cherry-pickパターン

現状。開発はRedmineのチケット単位(1日〜2,3日で終わるくらい)の粒度でdevelopからfeatureブランチを切り、都度developにマージする。 リリース時は、releaseブランチをmasterから切って、リリース対象機能と開発の順番がたまたま合っていれば、まだリリースしない開発の開始コミット直前までマージする。そうでなければ、リリース対象機能のコミットをdevelopブランチからcherry-pickする。

感想

masterとdevelopとの差分が把握しづらい。 マージした場合、リリース内容が把握しづらい。cherry-pickする場合、ピックするかどうかの判断が大変。

まとめ

機能を二段階の粒度で捉えることにする。 リリース時に取捨選択の対象になるような、大きな粒度の機能を「大きな機能」と呼ぶ。(例: ブログ機能の追加) 大きな機能のサブタスクを「小さな機能」と呼ぶ。(例: ブログへのいいね機能の追加)

ようするに、大きな機能単位でコミットを管理したい。 また、リリース時に含める大きな機能を、フレキシブルに選択したい。

cherry-pickパターンはどちらもできないのでボツ。 複数ブランチパターンは、リリース時の対象選択や、他developブランチへのバックポートの手順が煩雑そう。つまり、git-flowプラグインのコマンドひとつではできなさそう。 featureブランチネストパターンなら、コミット管理も、対象選択も簡単そうだ。採用。

結論

featureブランチネストパターンを採用する。

大きな機能を開発開始するときは、developブランチからfeatureブランチを切る。 今後、大きな機能をのfeatureブランチは、開発完了しても、リリース前までdevelopにはマージしないようにする。 リリース時に、大きな機能のfeatureブランチから含める機能をマージする。

git flow feature start <大きな機能>
git flow feature start <小さな機能> feature/<大きな機能>

でブランチを切れば、ネストしたfeatureブランチが切れる。

git flow feature finish <小さな機能>

で、うまく分岐元のfeature/<大きな機能>に取り込まれる。

ちょっとしたバグフィックスなども、直接developに入れるのではなく、feature/bugfix などのブランチを切ってい、そこにコミットしたほうが管理しやすいかと思う。

ネストしたfeatureブランチの動作確認

git-flowプラグインはnvie版とavh版がある。以下,avh版では期待通り動作することを確認した。

github.com

nvie版はプルリクエストはあるが、放置されている模様。

github.com

Welcome to fish, the friendly interactive shell
~ $ mkdir nested-features
~ $ cd nested-features/
~/nested-features $ git init
Initialized empty Git repository in /home/matsui/nested-features/.git/
~/nested-features (master|✔) $ git flow version
1.11.0 (AVH Edition)
~/nested-features (master|✔) $ git --version
git version 2.18.0
~/nested-features (master|✔) $ git flow init
No branches exist yet. Base branches must be created now.
Branch name for production releases: [master]
Branch name for "next release" development: [develop]

How to name your supporting branch prefixes?
Feature branches? [feature/]
Bugfix branches? [bugfix/]
Release branches? [release/]
Hotfix branches? [hotfix/]
Support branches? [support/]
Version tag prefix? []
Hooks and filters directory? [/home/matsui/nested-features/.git/hooks]
~/nested-features (develop|✔) $ echo base change > base.txt
~/nested-features (develop|…) $ git add .
~/nested-features (develop|●1) $ git commit -m "[add] base.txt"
[develop 16e1e67] [add] base.txt
 1 file changed, 1 insertion(+)
 create mode 100644 base.txt
~/nested-features (develop|✔) $ git flow feature start big-feature
Switched to a new branch 'feature/big-feature'

Summary of actions:
- A new branch 'feature/big-feature' was created, based on 'develop'
- You are now on branch 'feature/big-feature'

Now, start committing on your feature. When done, use:

     git flow feature finish big-feature

~/nested-features (feature/big-feature|✔) $
echo new small feature > small-feature.txt
~/nested-features (feature/big-feature|…) $ git add .
~/nested-features (feature/big-feature|●1) $
git commit -m "[add] small-feature.txt"
[feature/big-feature f1b0032] [add] small-feature.txt
 1 file changed, 1 insertion(+)
 create mode 100644 small-feature.txt
~/nested-features (feature/big-feature|✔) $
git flow feature start another-small-feature feature/big-feature
Switched to a new branch 'feature/another-small-feature'

Summary of actions:
- A new branch 'feature/another-small-feature' was created, based on 'feature/big-feature'
- You are now on branch 'feature/another-small-feature'

Now, start committing on your feature. When done, use:

     git flow feature finish another-small-feature

~/nested-features (feature/another-small-feature|✔) $
git log --graph --one-line
fatal: unrecognized argument: --one-line
~/nested-features (feature/another-small-feature|✔) $
git log --graph --all --oneline
* f1b0032 (HEAD -> feature/another-small-feature, feature/big-feature) [add] small-feature.txt
* 16e1e67 (develop) [add] base.txt
* 7527443 (master) Initial commit
~/nested-features (feature/another-small-feature|✔) $ git log --graph --all --oneline
* f1b0032 (HEAD -> feature/another-small-feature, feature/big-feature) [add] small-feature.txt
* 16e1e67 (develop) [add] base.txt
* 7527443 (master) Initial commit
~/nested-features (feature/another-small-feature|✔) $
echo another small feature > another-small-feature.txt
~/nested-features (feature/another-small-feature|…) $ git add .
~/nested-features (feature/another-small-feature|●1) $ git commit
Aborting commit due to empty commit message.
~/nested-features (feature/another-small-feature|●1) $ git commit -m "[add] another-small-feature.txt"
[feature/another-small-feature fe5c067] [add] another-small-feature.txt
 1 file changed, 1 insertion(+)
 create mode 100644 another-small-feature.txt
~/nested-features (feature/another-small-feature|✔) $ git log --graph --all --oneline
* fe5c067 (HEAD -> feature/another-small-feature) [add] another-small-feature.txt
* f1b0032 (feature/big-feature) [add] small-feature.txt
* 16e1e67 (develop) [add] base.txt
* 7527443 (master) Initial commit
~/nested-features (feature/another-small-feature|✔) $ git flow feature finish
Switched to branch 'feature/big-feature'
Updating f1b0032..fe5c067
Fast-forward
 another-small-feature.txt | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 another-small-feature.txt
Deleted branch feature/another-small-feature (was fe5c067).

Summary of actions:
- The feature branch 'feature/another-small-feature' was merged into 'feature/big-feature'
- Feature branch 'feature/another-small-feature' has been locally deleted
- You are now on branch 'feature/big-feature'

~/nested-features (feature/big-feature|✔) $ git log --graph --all --oneline
* fe5c067 (HEAD -> feature/big-feature) [add] another-small-feature.txt
* f1b0032 [add] small-feature.txt
* 16e1e67 (develop) [add] base.txt
* 7527443 (master) Initial commit
~/nested-features (feature/big-feature|✔) $ # 期待通り
~/nested-features (feature/big-feature|✔) $ # developにはマージされていない。
~/nested-features (feature/big-feature|✔) $ git checkout develop
Switched to branch 'develop'
~/nested-features (develop|✔) $ git flow feature start anohter-big-feature
Switched to a new branch 'feature/anohter-big-feature'

Summary of actions:
- A new branch 'feature/anohter-big-feature' was created, based on 'develop'
- You are now on branch 'feature/anohter-big-feature'

Now, start committing on your feature. When done, use:

     git flow feature finish anohter-big-feature

~/nested-features (feature/anohter-big-feature|✔) $ echo another big feature > another-big-feature.txt
~/nested-features (feature/anohter-big-feature|…) $ git add .
~/nested-features (feature/anohter-big-feature|●1) $ git commit -m "[add] another-big-feature.txt"
[feature/anohter-big-feature b47cd48] [add] another-big-feature.txt
 1 file changed, 1 insertion(+)
 create mode 100644 another-big-feature.txt
~/nested-features (feature/anohter-big-feature|✔) $ git log --graph --all --oneline
* b47cd48 (HEAD -> feature/anohter-big-feature) [add] another-big-feature.txt
| * fe5c067 (feature/big-feature) [add] another-small-feature.txt
| * f1b0032 [add] small-feature.txt
|/
* 16e1e67 (develop) [add] base.txt
* 7527443 (master) Initial commit
~/nested-features (feature/anohter-big-feature|✔) $ git checkout feature/big-feature
Switched to branch 'feature/big-feature'
~/nested-features (feature/big-feature|✔) $ git branch
  develop
  feature/anohter-big-feature
* feature/big-feature
  master
~/nested-features (feature/big-feature|✔) $ git flow feature finish
Switched to branch 'develop'
Merge made by the 'recursive' strategy.
 another-small-feature.txt | 1 +
 small-feature.txt         | 1 +
 2 files changed, 2 insertions(+)
 create mode 100644 another-small-feature.txt
 create mode 100644 small-feature.txt
Deleted branch feature/big-feature (was fe5c067).

Summary of actions:
- The feature branch 'feature/big-feature' was merged into 'develop'
- Feature branch 'feature/big-feature' has been locally deleted
- You are now on branch 'develop'

~/nested-features (develop|✔) $ git log --graph --all --oneline
*   ef90ebe (HEAD -> develop) Merge branch 'feature/big-feature' into develop
|\
| * fe5c067 [add] another-small-feature.txt
| * f1b0032 [add] small-feature.txt
|/
| * b47cd48 (feature/anohter-big-feature) [add] another-big-feature.txt
|/
* 16e1e67 [add] base.txt
* 7527443 (master) Initial commit
~/nested-features (develop|✔) $ git checkout feature/anohter-big-feature
Switched to branch 'feature/anohter-big-feature'
~/nested-features (feature/anohter-big-feature|✔) $ git checkout develop
Switched to branch 'develop'
~/nested-features (develop|✔) $ ls
another-small-feature.txt  base.txt  small-feature.txt
~/nested-features (develop|✔) $ git checkout feature/anohter-big-feature
Switched to branch 'feature/anohter-big-feature'
~/nested-features (feature/anohter-big-feature|✔) $ git flow feature finish
Switched to branch 'develop'
Merge made by the 'recursive' strategy.
 another-big-feature.txt | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 another-big-feature.txt
Deleted branch feature/anohter-big-feature (was b47cd48).

Summary of actions:
- The feature branch 'feature/anohter-big-feature' was merged into 'develop'
- Feature branch 'feature/anohter-big-feature' has been locally deleted
- You are now on branch 'develop'

~/nested-features (develop|✔) $ git log --graph --all --oneline
*   d581427 (HEAD -> develop) Merge branch 'feature/anohter-big-feature' into develop
|\
| * b47cd48 [add] another-big-feature.txt
* |   ef90ebe Merge branch 'feature/big-feature' into develop
|\ \
| |/
|/|
| * fe5c067 [add] another-small-feature.txt
| * f1b0032 [add] small-feature.txt
|/
* 16e1e67 [add] base.txt
* 7527443 (master) Initial commit
~/nested-features (develop|✔) $