🎋

「push済のブランチだけどリベースしたい!」というときの対処方法

2023/11/27に公開

gitのrebaseの説明には「pushしたブランチはrebaseしてはいけない」という注意書きが良くでてきます。
ですが、やっぱりrebaseしたいときがあります。
この記事では、そうしたときの対処方法の一つを紹介します。

rebaseは破壊的な影響を与えることがあるのですが、ここで紹介するのはできるだけ安全に作業できる手順です。

また、rebaseはgitを使いこなすポイントの一つだと思いますので、rebaseとは何かについてもわかるように書いたつもりです。
rebaseを理解したいという方も読んで参考にしてみてください。

要約

push済ブランチをrebaseしたいとき、以下のようにすると安全に目的を実現できます。

  • rebaseしたいpush済ブランチから新たなブランチをローカルで作成してそれをrebaseする
  • コンフリクトすることがあるからコミットをまとめておくと良い
  • 元にしたpush済ブランチの掃除も忘れずに

上記の説明でわかる人はそれでOK。

ここからは、gitやrebaseに不慣れな方もわかりやすいようにrebaseの役割なども含め、少々細かく説明します。
また、説明の中では「リベース」はすべて「rebase」と表記します。
(書いているときに混ぜて書いてしまいがちなので統一する、という理由です)

説明用の例

以下のような状況を想定します。

  • mainブランチのver1.0.0からfeature/Aを作成して開発作業中
  • 他の人にコードレビューしてもらうためにfeature/Aはpush済
  • その後mainブランチのコミットが進んだ

※ git-flowを使っている人は、この記事全体で「mainブランチ」を「developブランチ」と読み替えるとわかりやすいと思います。
※ 図の〇はコミットを示しています。◎は作図上書いてありますが実際には矢印元のコミットと同じです(下記図の場合だと◎はコミットID:abc1234と同じコミット)。

この状況でfeature/Aをmainブランチの最新コミット(コミットID:def5678)にrebaseしてfeature/Aの開発を継続したい、というシチュエーションです。

rebaseではなくマージでver1.1.0の内容を取り込む方法もあります。この場合、2本のブランチが合流する形になるのですが、ソース修正がどちらのブランチ由来なのかを追跡しづらいなどの不都合があり、rebaseの方が見やすいブランチの履歴になります。

具体的には以下のようにrebaseして、

こんな形になるのが実現したいイメージです。

分岐元(=ベース)の場所を変更するから「rebase」ですね。

問題点

今回、feature/Aはすでにpush済なので、rebaseしようとすると以下のような問題があります。

  • rebaseしてpushしようとすると「強制push(-fオプションを付けたpush)でないとpushできない」というエラーになる
  • 強制pushをするとコミットID:abc1234からブランチングしたfeature/Aはリモートに存在しなくなる
    • 他の人がfeature/Aをチェックアウトして作業していた場合はその人のブランチはどこにも紐づかない浮いたブランチになってしまう

特に最後の「他の人に影響してしまう」というのが一番の問題で、この問題があるから「push済のブランチはrebaseしてはいけない」と言われるわけですね。

「他の人はfeature/Aを使っていない」と断言できるときは強制pushでも問題ないということでもあります。ただ、gitに不慣れなうちはその判断が難しい場合もあるので、慣れるまでは「gitに警告されるようなことはしない」と思っていて良いと思います。

安全にrebaseする手順

ここからはこの状況で目的を実現する手順を説明します。
それぞれの手順の意味はその後に記載します。

と、その前に、説明する上ではリモート(origin)とローカルを区別する必要があるのでその確認です。
今回の例であれば以下のようになっているはずです。

現時点ではリモートとローカルがまったく一緒ですね。

この後、操作を示す図の左上にどちらの環境なのかを示します。
といっても操作はすべてローカルです。

これ以降の操作はgit bashのコマンドで示します。GUIツールでgit操作している人は、対応する操作がありますのでそのツールの記事を探して参考にしてください。
(SourceTreeだと2の手順は「対話形式でリベース」といったコマンドなのでその単語でググるなど)

1. 最新のfeature/Aでfeature/A-1を作成

最新のfeature/Aを元にしてfeature/A-1を作成します。
feature/Aをチェックアウトしている状態で以下のコマンドを実行します。

git checkout -b feature/A-1

feature/A-1がローカルに作成され、feature/A-1をチェックアウトした状態になります。

2. feature/A-1のコミットを1つにまとめる

feature/A-1にコミットが複数ある場合、コミットを1つにまとめます。
これには「対話形式のリベース(rebase -i)」を使います。

rebase対象のコミットIDを指定するのですが、そのコミットは「ブランチの分岐元」です。
今回であれば指定するのは「abc1234」です。

git rebase -i abc1234

コマンドを実行するとvimが編集モードで開きます。
編集の考え方としては「一番上だけpickのまま」「それより下のコミットはsquash(sの1文字でOK)」です。

まず、最初の画面。

今回はコミットが2つだけ(1,2行目)なので一番上はそのままにして2行目の「pick」を「s」に変更します。
(私がvimで操作するなら「jcws」と入力してからESC)
3行目以降にコミットがある場合も3行目以降の先頭の「pick」を「s」にすれば良いです。

ちなみにこの画面の4行目以降の#で始まる行はここで指定できるコマンドの説明なので無視してOKです。(もちろん読んで理解を深めてもOKです)

vimなので「:wq」と上書き保存して編集終了します。

すると、次にコメント編集画面が表示されます。

今回、コミットをまとめているので、まとめたコミット全体を表現するようなコメントを記載します。
この画面にcommitAとcommitBのコミットコメントが表示されているので、その内容を参考にして考える感じです。
今回はとりあえずまとめたことを示すコメントにしました。
(なお、#で始まる行は説明だったり参考情報だったりなので、これらは全削除でOKです。)

英語を読めば、ここで何をすればよいかとか、まとめたコミットの詳細とかが丁寧に書いてあるのですが、こうして英語がうわっと表示されると圧倒されがち。なので、慣れないうちは欲しい情報だけ拾い読みして全部消してしまいましょう。

これを実行すると下記のようなブランチ状態になっています。

3. mainの最新コミットにrebaseする

feature/A-1をmainの最新コミットにrebaseします。
最新コミットに対するrebaseなので、ここで指定するコミットIDは「def5678」です。

git rebase def5678

ここでコンフリクトが発生する可能性があります。
これはmainブランチとfeature/A(=feature/A-1)で同一ファイルの同一箇所に対し違う修正を行っている場合です(実際には別の箇所の変更でもgitがコンフリクトと判断する場合もあります)。
これは個別に対処するしかないので、Web記事を探すなどして個別対処してください。
コンフリクトを解消してrebaseを完了したら作業は完了です。

結果

ここまで実行すると以下のようになります。

元のfeature/Aに対しては何もしていないのでそのまま残っており、今はfeature/Aにver.1.1.0の内容を取り込んだfeature/A-1が開発対象ですね。
この後は、feature/A-1で開発を継続すればよいです。

もし、pushが必要になったらfeature/A-1をpushすることで以下のような形になります。

実施した内容

ポイントは「push済featureブランチをmainブランチの最新コミットにrebaseしたい」という要望は何のためなのか、ということです。
その目的は「開発しているfeature/Aに、新しいmainブランチの内容を取り込んで開発を継続したい」ことです。
この目的を果たせるのであればfeature/Aを直接操作しなくてもよい、というのが今回の手順です。

今回、feature/Aはpush済みなので、このブランチはそのままにしています。
その代わりとして、rebaseしたいブランチ(=feature/A)と同じブランチ(=feature/A-1)をローカルに作成して、ローカルブランチに対してrebaseを行います。
こうすることですべてローカルブランチに対しての操作になり、rebaseもpushも問題なく作業できます。

後は目的を実現した「feature/A-1」で開発を継続すればよい、ということになります。

この手順のポイント

feature/Aが残っているのでやり直しができる

feature/Aではなくfeature/A-1に対してrebaseしているので、feature/Aはそのまま残っています。
このため「rebaseをやり直せる」というのが大きなメリットです。

もし、feature/Aに対して直接rebaseした場合はコミットID:abc1234からブランチングされたfeature/Aは存在しなくなってしまい、操作をやり直すことができません。
これが怖くてrebaseに手を出しにくい状況でした。

この手順ではfeature/Aはそのまま残っているので、feature/A-1の作業をやり直したいとき(たとえばコンフリクトの解消の仕方を間違ってしまった、など)にすぐにやり直すことができます。

コミットをまとめる理由

3. mainの最新コミットにrebaseするにも記載しましたが、rebase時にコンフリクトが発生する可能性があります。
このとき、feature/A-1に複数コミットがある状態のままrebaseを行うと、各コミットごとにコンフリクトを解消する必要があります
今回でいうとコンフリクトがコミットAとコミットBのそれぞれで発生する可能性があり、そうなるとそれぞれでコンフリクトを解消する必要があります。
ここで間違ってしまうこともよくあります。

今回のようにrebase前にコミットをまとめておくと、コンフリクトが発生しても最新のファイルの内容に対して1回だけ対応すればよいようになります。

注意点

ここはポイントの裏返しなのですが、通常のrebaseと比べて、以下の点に注意する必要があります。

feature/Aが放置されがち

feature/Aは結果としてバックアップのような位置づけなので、feature/A-1で開発が進みだせば不要になります。
結果、feature/Aは放置されがちです。
なので、この手順で運用する場合には、作業者が責任をもって削除するとか、定期的に不要なブランチをリストアップして削除するとか、ルールを作って対応すると良いです。

コミットが1つにまとまってしまう

状況によっては1つ1つのコミット単位に意味があって、そのまま残しておきたい場合があります。
途中の手順でコミットをまとめるのはコンフリクト対策ですので、gitに慣れてコミットごとのコンフリクト対応もできるようになった場合は2番目の手順をスキップすればコミットを残したままrebase可能です。

終わりに

rebaseは使うのがちょっと怖いコマンドで、でも使わないとなかなか理解できないものでもあります。
今回の手順はその怖さを感じずに安心して作業できますので、どんどん使ってrebaseを使いこなせるようになってください。

また、ここで紹介した手順、gitに不慣れな人がいるときには私は実際の開発で運用ルールとして使っています。
この手順をルールとすることでrebaseでの事故を防げますので、開発メンバーのgit操作に心配がある場合には運用ルールとするのも良いと思います。

Discussion