状況とやりたいこと

プロダクトの開発中に、一部の機能を外したバージョンを先行リリースすることになり、下記を行った。

  1. 開発ブランチ (dev) から、先行リリース用ブランチ (pre-release) を作った
  2. 先行リリース用ブランチで、バージョン番号をリリース用に変えてコミットした (V)
  3. 先行リリース用ブランチから不要な機能を除去し、コミットした (X)
  4. 先行リリース用ブランチでテストして、バグ修正をコミットした (Y、hotfixブランチは作らず直接コミット)
  5. その間に開発ブランチ側でも別の機能追加をしてコミットした (C)
A---B---C    dev
     \
      V---X---Y    pre-release (Xが機能削除)

さて、先行リリース用ブランチで直したバグは、当然ながら既存の開発ブランチにも取り込まなければならない。

だが、何も考えずに開発ブランチに先行リリース用ブランチをマージすると、「不要な機能を除去」したコミットまでマージされ、その機能が消えてしまう。

こういう場合、自分の知る限りでは4種類の対処方法があると思う。

①単純マージ後に機能削除コミットを取り消し (revert)

まず思いつくのは、いったんdevpre-releaseを単純mergeし (この時点で、Xで削除した機能がdevからもいったん消えてしまう)、その後devXrevertすることだ。これで、消えた機能は元に戻る。

git switch dev
git merge pre-release
git revert X

今後pre-releaseに別のバグ修正が入ったとしても、適宜devpre-releaseを単純mergeしていけば良い。

ただ、devの履歴にも機能削除とrevertの履歴が残ってしまうのが少し気持ち悪い。

②チェリーピック (cherry-pick)

チェリーピックで、X以外のコミット、つまりVYdevに取り込む手もある。

git switch dev
git cherry-pick V Y

この場合、devに機能削除の履歴が残らないのは良い。

しかし、今後pre-releaseに別のバグ修正が入り、うっかりdevpre-releaseを単純mergeしてしまうと、Xで消した機能が消えてしまう。最初のマージ以降、もうpre-releaseは触りませんというのならチェリーピックでも良いが、今後もpre-releaseでの修正が続くなら、「Xdevmergeしちゃだめ」をずっと覚えておかなければならず、つらい。また、バグ修正のコミットの数が多い場合、取り込み対象を個別に指定するのもつらい。

③選択的リベースで必要コミットだけ選定 (rebase -i)

pre-releaseと同じコミットを指す新しいブランチ (例えばbugfix) を作って、rebase -iXのコミットを除去したあと、devbugfixを単純mergeするという手もある。要は、作ってなかったhotfixブランチを後追いで作ってやるわけだ。

git switch -c bugfix pre-release
git rebase -i B
立ち上がったエディタでXのpickをdropに書き換えて保存
git switch dev
git merge bugfix

この場合、エディタでXの行を書き換えるだけなので、バグ修正のコミットが大量にあっても問題ない。

ただし、今後pre-releaseに別のバグ修正が入り、うっかりdevpre-releaseを単純mergeしてしまうと、Xで消した機能が消えてしまうのはチェリーピックしたときと同じだ。やはり「Xdevmergeしちゃだめ」をずっと覚えておく必要があり、つらい。

④機能削除コミットをours戦略でマージ (merge -s ours)

となると、最適なのは、コミットXdevマージしたことにすることだろう。Gitの操作としては、merge-sオプションでours戦略を指定してXをマージする。これで、実際にはdevを何も変更していないのに、Xdevにマージ済みだとGitに信じ込ませられる。こういう動きをするのがours戦略だ。(なお、似たような字面だが-Xours-s oursとは全く意味が違うので注意。-Xoursは、ort戦略 (またはその前身のrecursive戦略) のオプションで、修正がコンフリクトした箇所は自ブランチ側の修正を採用するときに使うもの)。

git switch dev
git merge V  # Xの直前までをマージ
git merge -s ours X  # Xをマージしたことにする 実際には何もしない
git merge pre-release  # Xより後をマージ

これだと、devから必要機能が削除された跡は残らないし、今後pre-releaseに別のバグ修正が入っても、適宜単純mergeしていくだけで済む。めでたしめでたし。

動作確認ログ

以下は、4種類の方法が上の説明どおりであることを確認したときの動作ログ。ご参考までに。

まず、各パターンを確認するベースとして、下図の状態を作る。

A---B---C    dev
     \
      V---X---Y    pre-release (Xが機能削除)

コマンド:

git init test-base
cd test-base
git switch -c dev
touch A.txt
git add .
git commit -m "A: add A.txt"
touch B.txt
git add .
git commit -m "B: add B.txt"
ls
git switch -c pre-release
touch V.txt
git add .
git commit -m "V: add V.txt"
git rm B.txt
git commit -m "X: remove B.txt"
touch Y.txt
git add .
git commit -m "Y: add Y.txt"
ls
git switch dev
ls
touch C.txt
git add .
git commit -m "C: add C.txt"
ls
git log --graph --oneline --all

実行結果:

$ git init test-base
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint:   git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint:   git branch -m <name>
Initialized empty Git repository in /tmp/test-base/.git/
$ cd test-base
$ git switch -c dev
Switched to a new branch 'dev'
$ touch A.txt
$ git add .
$ git commit -m "A: add A.txt"
[dev (root-commit) 3953e11] A: add A.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 A.txt
$ touch B.txt
$ git add .
$ git commit -m "B: add B.txt"
[dev 1a16b92] B: add B.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 B.txt
$ ls
A.txt  B.txt
$ git switch -c pre-release
Switched to a new branch 'pre-release'
$ touch V.txt
$ git add .
$ git commit -m "V: add V.txt"
[pre-release e00b67d] V: add V.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 V.txt
$ git rm B.txt
rm 'B.txt'
$ git commit -m "X: remove B.txt"
[pre-release 68318b5] X: remove B.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 B.txt
$ touch Y.txt
$ git add .
$ git commit -m "Y: add Y.txt"
[pre-release 7a505ef] Y: add Y.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Y.txt
$ ls
A.txt  V.txt  Y.txt
$ git switch dev
Switched to branch 'dev'
$ ls
A.txt  B.txt
$ touch C.txt
$ git add .
$ git commit -m "C: add C.txt"
[dev d90a97d] C: add C.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 C.txt
$ ls
A.txt  B.txt  C.txt
$ git log --graph --oneline --all
* d90a97d (HEAD -> dev) C: add C.txt
| * 7a505ef (pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt

①単純マージ後に機能削除コミットを取り消し (revert) の動作確認

コマンド:

cd ..
git clone test-base test-1
cd test-1
ls
git branch pre-release origin/pre-release
git merge --no-edit pre-release
ls
git log --graph --oneline
git revert --no-edit XXXXXX
ls
git log --graph --oneline --all
git switch pre-release
ls
touch Z.txt
git add .
git commit -m "Z: add Z.txt"
git switch dev
ls
git merge --no-edit pre-release
ls
git log --graph --oneline --all

実行結果:

$ cd ..
$ git clone test-base test-1
Cloning into 'test-1'...
done.
$ cd test-1
$ ls
A.txt  B.txt  C.txt
$ git branch pre-release origin/pre-release
branch 'pre-release' set up to track 'origin/pre-release'.
$ git merge --no-edit pre-release
Merge made by the 'ort' strategy.
 B.txt => V.txt | 0
 Y.txt          | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 rename B.txt => V.txt (100%)
 create mode 100644 Y.txt
$ ls
A.txt  C.txt  V.txt  Y.txt
$ git log --graph --oneline
*   a402394 (HEAD -> dev) Merge branch 'pre-release' into dev
|\
| * 7a505ef (origin/pre-release, pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git revert --no-edit 68318b5
[dev 6449502] Revert "X: remove B.txt"
 Date: Fri Oct 11 13:21:15 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 B.txt
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt
$ git log --graph --oneline --all
* 6449502 (HEAD -> dev) Revert "X: remove B.txt"
*   a402394 Merge branch 'pre-release' into dev
|\
| * 7a505ef (origin/pre-release, pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git switch pre-release
Switched to branch 'pre-release'
Your branch is up to date with 'origin/pre-release'.
$ ls
A.txt  V.txt  Y.txt
$ touch Z.txt
$ git add .
$ git commit -m "Z: add Z.txt"
[pre-release 5762632] Z: add Z.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Z.txt
$ git switch dev
Switched to branch 'dev'
Your branch is ahead of 'origin/dev' by 5 commits.
  (use "git push" to publish your local commits)
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt
$ git merge --no-edit pre-release
Merge made by the 'ort' strategy.
 Z.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Z.txt
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt  Z.txt
$ git log --graph --oneline --all
*   130aa20 (HEAD -> dev) Merge branch 'pre-release' into dev
|\
| * 5762632 (pre-release) Z: add Z.txt
* | 6449502 Revert "X: remove B.txt"
* | a402394 Merge branch 'pre-release' into dev
|\|
| * 7a505ef (origin/pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt

②チェリーピック (cherry-pick) の動作確認

コマンド:

cd ..
git clone test-base test-2
cd test-2
ls
git branch pre-release origin/pre-release
git log --graph --oneline --all
git cherry-pick --no-edit VVVVVV YYYYYY
ls
git log --graph --oneline
git switch pre-release
ls
touch Z.txt
git add .
git commit -m "Z: add Z.txt"
git switch dev
git merge --no-edit pre-release
ls
git log --graph --oneline --all

実行結果:

$ cd ..
$ git clone test-base test-2
Cloning into 'test-2'...
done.
$ cd test-2
$ ls
A.txt  B.txt  C.txt
$ git branch pre-release origin/pre-release
branch 'pre-release' set up to track 'origin/pre-release'.
$ git log --graph --oneline --all
* d90a97d (HEAD -> dev, origin/dev, origin/HEAD) C: add C.txt
| * 7a505ef (origin/pre-release, pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git cherry-pick --no-edit e00b67d 7a505ef
[dev 4982f91] V: add V.txt
 Date: Fri Oct 11 13:16:20 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 V.txt
[dev 139ba3c] Y: add Y.txt
 Date: Fri Oct 11 13:16:35 2024 +0900
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Y.txt
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt
$ git log --graph --oneline
* 139ba3c (HEAD -> dev) Y: add Y.txt
* 4982f91 V: add V.txt
* d90a97d (origin/dev, origin/HEAD) C: add C.txt
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git switch pre-release
Switched to branch 'pre-release'
Your branch is up to date with 'origin/pre-release'.
$ ls
A.txt  V.txt  Y.txt
$ touch Z.txt
$ git add .
$ git commit -m "Z: add Z.txt"
[pre-release 13bd6b5] Z: add Z.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Z.txt
$ git switch dev
Switched to branch 'dev'
Your branch is ahead of 'origin/dev' by 2 commits.
  (use "git push" to publish your local commits)
$ git merge --no-edit pre-release
Merge made by the 'ort' strategy.
 B.txt => Z.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename B.txt => Z.txt (100%)
$ ls
A.txt  C.txt  V.txt  Y.txt  Z.txt
$ git log --graph --oneline --all
*   8ce31da (HEAD -> dev) Merge branch 'pre-release' into dev
|\
| * 13bd6b5 (pre-release) Z: add Z.txt
| * 7a505ef (origin/pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
* | 139ba3c Y: add Y.txt
* | 4982f91 V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt

③選択的リベースで必要コミットだけ選定 (rebase -i) の動作確認

コマンド:

cd ..
git clone test-base test-3
cd test-3
ls
git branch pre-release origin/pre-release
git switch -c bugfix pre-release
ls
git log --graph --oneline --all
git rebase -i BBBBBB
# drop 68318b5 X: remove B.txt
ls
git log --graph --oneline
git switch dev
ls
git merge --no-edit bugfix
ls
git log --graph --oneline
git switch pre-release
ls
touch Z.txt
git add .
git commit -m "Z: add Z.txt"
git switch dev
ls
git merge --no-edit pre-release
ls
git log --graph --oneline --all

実行結果:

$ cd ..
$ git clone test-base test-3
Cloning into 'test-3'...
done.
$ cd test-3
$ ls
A.txt  B.txt  C.txt
$ git branch pre-release origin/pre-release
branch 'pre-release' set up to track 'origin/pre-release'.
$ git switch -c bugfix pre-release
Switched to a new branch 'bugfix'
$ ls
A.txt  V.txt  Y.txt
$ git log --graph --oneline --all
* d90a97d (origin/dev, origin/HEAD, dev) C: add C.txt
| * 7a505ef (HEAD -> bugfix, origin/pre-release, pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git rebase -i 1a16b92
Successfully rebased and updated refs/heads/bugfix.
$ ls
A.txt  B.txt  V.txt  Y.txt
$ git log --graph --oneline
* 0bb7978 (HEAD -> bugfix) Y: add Y.txt
* e00b67d V: add V.txt
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git switch dev
Switched to branch 'dev'
Your branch is up to date with 'origin/dev'.
$ ls
A.txt  B.txt  C.txt
$ git merge --no-edit bugfix
Merge made by the 'ort' strategy.
 V.txt | 0
 Y.txt | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 V.txt
 create mode 100644 Y.txt
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt
$ git log --graph --oneline
*   9b7470f (HEAD -> dev) Merge branch 'bugfix' into dev
|\
| * 0bb7978 (bugfix) Y: add Y.txt
| * e00b67d V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git switch pre-release
Switched to branch 'pre-release'
Your branch is up to date with 'origin/pre-release'.
$ ls
A.txt  V.txt  Y.txt
$ touch Z.txt
$ git add .
$ git commit -m "Z: add Z.txt"
[pre-release 5de109a] Z: add Z.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Z.txt
$ git switch dev
Switched to branch 'dev'
Your branch is ahead of 'origin/dev' by 3 commits.
  (use "git push" to publish your local commits)
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt
$ git merge --no-edit pre-release
Merge made by the 'ort' strategy.
 B.txt => Z.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename B.txt => Z.txt (100%)
$ ls
A.txt  C.txt  V.txt  Y.txt  Z.txt
$ git log --graph --oneline --all
*   f631adb (HEAD -> dev) Merge branch 'pre-release' into dev
|\
| * 5de109a (pre-release) Z: add Z.txt
| * 7a505ef (origin/pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
* |   9b7470f Merge branch 'bugfix' into dev
|\ \
| * | 0bb7978 (bugfix) Y: add Y.txt
| |/
| * e00b67d V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt

④機能削除コミットをours戦略でマージ (merge -s ours) の動作確認

コマンド:

cd ..
git clone test-base test-4
cd test-4
ls
git branch pre-release origin/pre-release
git log --graph --oneline --all
git merge --no-edit VVVVVV
ls
git merge --no-edit -s ours XXXXXX
ls
git merge --no-edit pre-release
ls
git log --graph --oneline
git switch pre-release
ls
touch Z.txt
git add .
git commit -m "Z: add Z.txt"
git switch dev
ls
git merge --no-edit pre-release
ls
git log --graph --oneline --all

実行結果:

$ cd ..
$ git clone test-base test-4
Cloning into 'test-4'...
done.
$ cd test-4
$ ls
A.txt  B.txt  C.txt
$ git branch pre-release origin/pre-release
branch 'pre-release' set up to track 'origin/pre-release'.
$ git log --graph --oneline --all
* d90a97d (HEAD -> dev, origin/dev, origin/HEAD) C: add C.txt
| * 7a505ef (origin/pre-release, pre-release) Y: add Y.txt
| * 68318b5 X: remove B.txt
| * e00b67d V: add V.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git merge --no-edit e00b67d
Merge made by the 'ort' strategy.
 V.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 V.txt
$ ls
A.txt  B.txt  C.txt  V.txt
$ git merge --no-edit -s ours 68318b5
Merge made by the 'ours' strategy.
$ ls
A.txt  B.txt  C.txt  V.txt
$ git merge --no-edit pre-release
Merge made by the 'ort' strategy.
 Y.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Y.txt
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt
$ git log --graph --oneline
*   0f05291 (HEAD -> dev) Merge branch 'pre-release' into dev
|\
| * 7a505ef (origin/pre-release, pre-release) Y: add Y.txt
* | ccc814a Merge commit '68318b5' into dev
|\|
| * 68318b5 X: remove B.txt
* | e2bf619 Merge commit 'e00b67d' into dev
|\|
| * e00b67d V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt
$ git switch pre-release
Switched to branch 'pre-release'
Your branch is up to date with 'origin/pre-release'.
$ ls
A.txt  V.txt  Y.txt
$ touch Z.txt
$ git add .
$ git commit -m "Z: add Z.txt"
[pre-release f2f3c72] Z: add Z.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Z.txt
$ git switch dev
Switched to branch 'dev'
Your branch is ahead of 'origin/dev' by 6 commits.
  (use "git push" to publish your local commits)
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt
$ git merge --no-edit pre-release
Merge made by the 'ort' strategy.
 Z.txt | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 Z.txt
$ ls
A.txt  B.txt  C.txt  V.txt  Y.txt  Z.txt
$ git log --graph --oneline --all
*   632ccc2 (HEAD -> dev) Merge branch 'pre-release' into dev
|\
| * f2f3c72 (pre-release) Z: add Z.txt
* | 0f05291 Merge branch 'pre-release' into dev
|\|
| * 7a505ef (origin/pre-release) Y: add Y.txt
* | ccc814a Merge commit '68318b5' into dev
|\|
| * 68318b5 X: remove B.txt
* | e2bf619 Merge commit 'e00b67d' into dev
|\|
| * e00b67d V: add V.txt
* | d90a97d (origin/dev, origin/HEAD) C: add C.txt
|/
* 1a16b92 B: add B.txt
* 3953e11 A: add A.txt

※参考文献

※バージョンメモ

  • git version 2.43.0