過去バージョンをメンテナンスするGitのブランチ運用

ソフトウェア開発の仕事では 、過去バージョンのメンテナンス(バグ修正)も行いながら最新版を開発していかなければならないことが多い。これを効率的に行えるような Git のブランチ運用方法を、マジメに調べたことが無かったなーと思ったので調べてみた。もう少し要件を具体的に書くと:

  • 複数の過去バージョンの変更履歴を完全に追跡できる
  • どの過去バージョンに対してもバグ修正を簡単に追加できる
  • ある過去バージョンに適用したバグ修正を、他のバージョンへ簡単に適用できる
  • どのバグ修正が、どの過去バージョンに適用されているかを簡単に追跡できる

なお、ここでの「バグ修正」は 1 コミット単位ではなく 1 マージの単位とする。多くの現場ではバグ修正を GitHub の Pull Request に相当する単位で管理しているはずで、したがってそれらに対応するトピックブランチのマージ履歴を追跡できれば良いと考えている。

インターネット上の情報を調べたり試してたりした結果、一番良さそうな運用方法を以下にまとめておく。まあ、とは言っても YaST(SuSE Linux のパッケージマネージャー)のメンテナンスブランチ運用方針そのものと言っても差し支えない内容なのだけれども。

メンテナンスブランチの運用

簡単にまとめると、以下のように運用するのが良さそうだ:

  1. メンテナンス対象となるバージョン系列1それぞれに、専用のブランチを用意する
    • これを「メンテナンスブランチ (maintenance branch)」と呼ぶ
    • 最新版をメンテナンスするブランチという意味で広義には main を含む
    • 例: 1.3 など
  2. 過去バージョンにも適用すべきバグ修正は以下のように実施する
    1. 修正適用対象の中で一番古いバージョンのメンテナンスブランチから修正用 のトピックブランチを作る
    2. 修正のコミットを加える
    3. レビュー後、分岐元のメンテナンスブランチにマージする
    4. 続いて、修正がマージされたメンテナンスブランチを、 その次に古いバージョンのメンテナンスブランチにマージする
    5. これを main にマージするまで繰り返す
  3. バグ修正をバックポート2するときは、以下のように実施する
    1. バックポート先のメンテナンスブランチからバグ修正のトピックブランチを作成
    2. より新しいメンテナンスブランチの修正コミットを cherry-pick
    3. レビューしてマージ

バグ修正をバックポートする際にはマージを使えない(たとえば 1.7.0 を開発中の main を 1.3 系のメンテナンスブランチにマージしたら大変なことになる)ため cherry-pick を使う。こういう事情もあるため、やはり古いバージョンから修正する方が良いみたいだ。

あるバグ修正が、あるバージョン系列に取り込まれているかを確認する

あるトピックブランチで行ったバグ修正が特定のバージョン系列に取り込まれているかを調べるには、そのトピックブランチのマージコミットを git log に --merges--grep オプションを使って探せば良い。たとえば 1.0 系に fix/dot-instead-of-bang というトピックブランチで行ったバグ修正が取り込まれているか確認したければ:

% git log --pretty=oneline --grep 'fix/dot-instead-of-bang' --merges 1.0
674df130a96da3d6b71933b14cc0cf5d62cb93d7 Merge branch 'fix/dot-instead-of-bang' into 1.0

検索がヒットしたということで、1.0 系に適用されていることが分かる。なお当然だけれどマージコミットを作成しなかった場合、この方法は使えない。

ブランチ固有のコミットの扱い

ところで、一般的にはメンテナンスブランチごとに固有のコミットというものが存在する。たとえば package.jsonversion フィールドを 1.3.2 から 1.3.3 に更新するコミットは 1.3 系のメンテナンスブランチにだけ適用されるべきもの、といったように。こうしたコミットは、メンテナンスブランチ間でのマージ操作時に(理想的には)除外したい内容なので少し気をつける必要がある。

まず、そのようなコミットがメンテナンスブランチ間のマージ時に衝突を起こした場合は、普通に解決すれば良い。もし衝突を起こさなかった場合、そのコミットを revert するコミットを追加すると良い。revert コミットの追加は簡単だし、将来別のバグ修正をマージするときにも再適用されるので後顧の憂いも無い。

cherry-pick でトピックブランチを再現する(イマイチな)方法

実は以前、前節で触れたマージ時の衝突を避けようと考えて、バグ修正用トピックブランチを再現するトピックブランチを cherry-pick の連発で再現してマージする方法を使ったこともある。例として「感嘆符ではなくドットを使う」というバグ修正を v1.0 系と main (v1.1系) に適用する場合を挙げると、まず fix/dot-instead-of-bang-1.0 というトピックブランチを v1.0 のメンテナンスブランチから作り、修正コミットを加え、マージする。続いて main から fix/dot-instead-of-bang-main というトピックブランチを作り、今度は v1.0 系の修正コミットを cherry-pick で追加して、main にマージする。…こんな方法だった。

これ、Git 的には「(a)トピックブランチ作成、(b)コミット追加、(c)マージ」という一連の処理を各メンテナンスブランチに対してバラバラに実行しているので、(たまたま内容が同一の)まったく無関係な修正が適用されていることになる。人間的には同じものと扱ってほしい変更が別物として記録されているわけなので、履歴が妙な感じになる。具体例で見た方が分かりやすいので出しておこう。メンテナンスブランチを古いものから順にマージしていく運用であれば次のようになる修正履歴が:

% git log --oneline --graph --no-decorate main 1.0
* c9719ac Bump version number to 1.1.1
*   91011ae Merge branch '1.0'
|\
| * bc379f5 Bump version number 1.0.1
| *   674df13 Merge branch 'fix/dot-instead-of-bang' into 1.0
| |\
| | * 99f64d7 Use dot instead of bang
| |/
* | a5de4c6 Bump version number 1.1.0
* | 83ffa39 Put main logic into a function
|/
* 36fca5e Bump version number to 1.0.0
gitGraph
    checkout main
    commit id:"36fca5e" tag:"v1.0.0"
    branch 1.0
    checkout main
    commit id:"83ffa39"
    commit id:"a5de4c6" tag:"v1.1.0"
    checkout 1.0
    branch fix/dot-instead-of-bang
    checkout fix/dot-instead-of-bang
    commit id:"99f64d7"
    checkout 1.0
    merge fix/dot-instead-of-bang id:"674df13"
    commit id:"bc379f5" tag:"v1.0.1"
    checkout main
    merge 1.0 id:"91011ae"
    commit id:"c9719ac" tag:"v1.1.1"

cherry-pick 連発で同じ変更を再現すると次のようになってしまう:

% git log --oneline --graph --no-decorate main 1.0
* c9719ac Bump version number to 1.1.1
*   58d85e0 Merge branch 'fix/dot-instead-of-bang-main'
|\
| * 4337ca6 Use dot instead of bang
|/
* a5de4c6 Bump version number to 1.1.0
* 83ffa39 Put main logic into a function
| * f14b322 Bump version number to 1.0.1
| * e1c1c8a Merge branch 'fix/dot-instead-of-bang-1.0' into 1.0
|/|
| * 99f64d7 Use dot instead of bang
|/
* 36fca5e Bump version number to 1.0.0
gitGraph
    checkout main
    commit id:"36fca5e" tag:"v1.0.0"
    branch 1.0
    branch fix/dot-instead-of-bang-1.0
    checkout fix/dot-instead-of-bang-1.0
    commit id:"99f64d7"
    checkout 1.0
    merge fix/dot-instead-of-bang-1.0 id:"e1c1c8a"
    commit id:"f14b322" tag:"v1.0.1"
    checkout main
    commit id:"83ffa39"
    commit id:"a5de4c6" tag:"v1.1.0"
    branch fix/dot-instead-of-bang-main
    checkout fix/dot-instead-of-bang-main
    commit id:"4337ca6"
    checkout main
    merge fix/dot-instead-of-bang-main id:"58d85e0"
    commit id:"c9719ac" tag:"v1.1.1"

同じ内容のバグ修正を行うトピックブランチが 2 つ現れ、さらにバグ修正コミット ("Use dot instead of bang") が異なるコミットハッシュで 2 回登場している。このように、パッと見ただけでは本当に同じ修正が入っているのか不安になる内容だし、この例では分かりにくいけれど複数のコミットで構成されるバグ修正だった場合「同じ順序で同じ数だけ適用されているか?」といった心配も出てくる。その点、ブランチのマージでは「漏れ」が絶対に起こらない。…といったように、衝突を回避しやすいというメリットに対してデメリットが大きいと考えられるため、この方法はイマイチだと今は考えている。

参考情報

yastgithubio.readthedocs.io

stackoverflow.com


  1. たとえばマイナーバージョン単位で直近 2 つをサポート対象とする、 といったソフトウェアの場合は「1.3 系」などという呼び方でバージョン 1.3.x を総称すると思う。 この例でいう「1.3 系」を指して「バージョン系列」と書いている。
  2. バグ修正のバックポートとは、 より新しいバージョンに適用した修正を、古いバージョンに適用すること。