ククログ

株式会社クリアコード > ククログ > トランクベース開発での複雑な競合の解消の仕方

トランクベース開発での複雑な競合の解消の仕方

結城です。

1つ前の記事では、「トランクベース開発」のスタイルで開発する場面において、自分が中央リポジトリーにコミット権を持っているときの、競合状態の発生とその解消の流れを紹介しました。 これは、会社に所属して自社の開発プロジェクトに参加する場合や、OSS開発プロジェクトの一員として開発を行う場合によく見られる光景です。

その一方で、多くの人にとって実際に「OSS開発に関わる」場面は、プロジェクトのリポジトリーへのコミット権を持たない外部のコントリビューターとして関わる場合の方が主ではないでしょうか。

そのような状況でのトランクベース開発においては大抵、中央リポジトリから自分の管理下の場所にフォークしたリポジトリを、ローカルでの作業の成果のプッシュ先やマージリクエスト(プルリクエスト)の起点とする、という運用が必要になります。 1つ前の記事での運用と比べると、関係するリポジトリが1つ増えるため、ブランチ操作がさらに複雑になり混乱しやすいです。 この記事では前の記事の発展として、このような場面でのトランクベース開発のブランチ操作の流れを紹介します。

リポジトリのフォークとローカルへのクローン

GitHubやGitLabなどのWebサービスでリポジトリを管理しているプロジェクトに(コードやドキュメントなど、リポジトリに格納されているリソースに関する作業で)コントリビュートするには、何はともあれフォーク(fork)をしないと話が始まりません。 コントリビュートしたい先の中央リポジトリに対してコミット権が無い以上、作業の成果を中央リポジトリまで届けるための、中継点となるリポジトリが必要だからです。

コントリビュートしたいリポジトリにはコミット権が無くても、フォークしたリポジトリに行ったコミットをマージリクエスト(プルリクエスト)経由で変更を取り込んでもらう事はできる、という様子を示した図。

この記事では、こうしてフォークして自分の管理下1に置いたリポジトリを作業用リモートリポジトリと呼ぶことにします。 作業に使うローカルリポジトリは、中央リポジトリをクローンするのではなく、この作業用リモートリポジトリをクローンした物を使います。

$ git clone git@gitlab.com:myaccount/redmine-plugin-testcase-management.git
$ cd redmine-plugin-testcase-management

中央リポジトリのデフォルトブランチの変更に追従する

さて、この状況において、自分がまだ何も作業をしていない段階で、中央リポジトリのデフォルトブランチに誰かの変更が反映されたとします。 このまま作業を開始するとマージリクエスト(プルリクエスト)をマージできなくなる恐れがあるため、まずは作業の開始前に、作業用リモートリポジトリやローカルリポジトリのデフォルトブランチを中央リポジトリに追従させる必要があります。

自分はまだ何もしておらず、他の人が中央リポジトリのデフォルトブランチに変更を加えていた様子の図。

作業用リモートリポジトリのデフォルトブランチを中央リポジトリの最新の状況に追従させる方法は2通りあります。 1つ目は、作業用リモートリポジトリを直接更新する方法。 2つ目は、ローカルリポジトリを経由する方法です。 順番に紹介していきましょう。

 

1つ目の方法は、リポジトリをホスティングしているサービスがそのような機能を提供している場合2に可能です。 例えば、現在のGitHubでは、中央リポジトリからフォークしたリポジトリ(今回であれば作業用リモートリポジトリ)のページを開くと、同名のブランチを同期するためのUIが表示されます。

作業用リモートリポジトリのページに、中央リポジトリの同名ブランチの変更を反映するための「Sync fork」リンクが表示されている様子。

このリンクをクリックすると、中央リポジトリのデフォルトブランチの内容が作業用リモートリポジトリの同名ブランチにプルされます。 これにより、作業用リモートリポジトリを簡単に中央リポジトリに追従させられます。

中央リポジトリのデフォルトブランチの変更が作業用リモートリポジトリの同名ブランチに反映された様子。

この状態でローカルリポジトリでデフォルトブランチをプルすれば、ローカルリポジトリも最新の状況に追従できます。

作業用リモートリポジトリのデフォルトブランチの変更がローカルリポジトリの同名ブランチに反映された様子。

 

2つ目の方法は、ホスティングサービスが先述のような機能を提供していない場合でも使える汎用的な手順です。 まず、作業用リモートリポジトリを抜かして、ローカルリポジトリのデフォルトブランチに中央リポジトリのデフォルトブランチの内容をプルします。

中央リポジトリのデフォルトブランチの変更がローカルリポジトリに反映された様子の図。

$ git stash # 作業中の変更がもしあれば退避。
$ git checkout main # デフォルトブランチに切り替え。
$ git pull --rebase https://gitlab.com/redmine-plugin-testcase-management/redmine-plugin-testcase-management.git main # 中央リポジトリの変更を同期。

git pullは、引数としてリポジトリのURLを指定すると、クローン元であるorigin以外のリモートリポジトリからもプルすることができます。

筆者は利便のために、中央リポジトリをupstreamという名前のリモートリポジトリとして参照できるようにしています。 具体的な操作は以下の要領です。

$ git remote add upstream https://gitlab.com/redmine-plugin-testcase-management/redmine-plugin-testcase-management.git

こうしておくと、プルの操作で毎回URLを書かなくて済むので便利です。

$ git pull --rebase upstream main # 中央リポジトリの変更を同期。

そうしてローカルリポジトリのデフォルトブランチが中央リポジトリと同期されたら、その内容を今度はoriginつまり作業用リモートリポジトリにプッシュします。 これにより、ローカルリポジトリを経由して作業用リモートリポジトリが中央リポジトリと同期されることになります。

中央リポジトリのデフォルトブランチの変更がローカルリポジトリを経由して作業用リモートリポジトリに反映された様子の図。

$ git push # git push origin main の略。

このとき、中央リポジトリのデフォルトブランチにない変更が作業用リモートリポジトリのデフォルトブランチで行われていると、競合が発生してすんなりプッシュできません。 また、作業の成果を中央リポジトリに戻すためのマージリクエスト(プルリクエスト)にも、想定と異なる余計な変更が入ってきてしまう場合があります。 こういった競合はトラブルの元でしかないので、フォークした後のリポジトリを元のプロジェクトとは独立した派生プロジェクトとして運用していく場合を除いて、作業用リモートリポジトリのデフォルトブランチには、中央リポジトリのデフォルトブランチから持ってきた変更以外は絶対にプッシュしないようにしましょう。

このような場面での作業用リモートリポジトリのデフォルトブランチは、あくまで中央リポジトリのデフォルトブランチに対するミラーやキャッシュのような物として扱い、中央リポジトリの変更をプル・プッシュするとき以外は基本的にリードオンリー扱いにしておくと、トラブルが起こりにくくなるのでお薦めです。

デフォルトブランチへの直接の変更は、原則「しない」

これを別の言い方で言うと、外部コントリビューターとしてOSS開発に関わる場面では、自分で行った変更のデフォルトブランチへのコミットは厳禁ということになります。

前の記事では「場合によってはデフォルトブランチへの直接のコミットも合理的」と述べましたが、それは開発の初期などごく限られた場合の話であって、中央リポジトリにコミット権が無い外部コントリビューターとして関わる場合にはほぼ該当しません。 というのも、自分の作業の成果を作業用リモートリポジトリのデフォルトブランチにプッシュして、そこからマージリクエスト(プルリクエスト)を作ってしまうと、それがマージされるまでは別の作業を進められなくなってしまうからです。

作業用リモートリポジトリのデフォルトブランチからのマージリクエストが未処理の状態であるために、トピックブランチからのマージリクエストを行えない様子の図。

この図は、「main」ブランチで作業をして成果をマージリクエストにした後で、そこからさらにトピックブランチ「fix-something」を作って別の作業を行った様子を表しています。 「fix-something」の起点となるコミットは「b」ですが、それを反映するためのマージリクエストがまだ中央リポジトリに取り込まれていないため、「b」を起点とした「fix-something」からは(「main」の変更が取り込まれてからでないと)マージリクエストを作成できません。

「main」からのマージリクエストなしで「fix-something」からマージリクエストを作ることも不可能ではないのですが、その場合、1つのマージリクエストの中に「mainで行った作業(b)」と「トピックブランチで行った作業(c)」の両方が含まれることになります。

作業用リモートリポジトリにおける2つの個別の作業の成果が、1つのマージリクエストに入ってしまっている様子。

作業用リモートリポジトリを使うブランチ運用では、作業用リモートリポジトリのデフォルトブランチ自体もまた1つのトピックブランチに等しい扱いとなります。 トピックブランチを用いる運用では、トピックブランチで行う変更作業は1つの趣旨にのみ限定する必要があります。 このような複数の話題を含むマージリクエストになってしまうと、レビューで「それぞれ別のマージリクエストにしてくれ」と指摘されがちで、すんなりとはマージしてもらえません。

このような事態の発生を防ぐためにも、作業用リモートリポジトリを使うブランチ運用では、デフォルトブランチに作業の成果をコミットするのではなく、必ず作業前にデフォルトブランチからトピックブランチを作り、そちらに成果をコミットするようにしましょう。

トピックブランチを使った、中央リポジトリのデフォルトブランチの変更

トピックブランチを使った作業の最も基本的な流れは、originが中央リポジトリでも作業用リモートリポジトリでもあまり変わりません。

まずローカルリポジトリでデフォルトブランチからトピックブランチを作成し、そこで作業を行います。

ローカルリポジトリにトピックブランチ「add-usage-documents」を作成し、変更をコミットした様子の図。

$ git checkout main
$ git checkout -b add-usage-documents
... # 何か変更の作業を行う。
$ git diff # 変更内容を再確認する。
$ git add path/to/added/file # 追加したファイルをバージョン管理対象に含める。
$ git commit -p # 差分のパートごとにコミットする。git diffの代わりにこちらだけ行うことも多い。

そして、変更をorigin=作業用リモートリポジトリにプッシュします。

トピックブランチを作業用リモートリポジトリにプッシュした様子の図。

$ git push --set-upstream origin add-usage-documents # 最初の1回目のみ。
$ git push # 2回目以降。--set-upstream した後であれば、「git push origin add-usage-documents」の略となる。

変更のプッシュ後にGitLabやGitHubの中央リポジトリまたは作業用リモートリポジトリのページを開くと、今プッシュしたブランチからマージリクエスト(プルリクエスト)を作成することを促すボタンが表示されます。 後の流れは中央リポジトリのトピックブランチからマージリクエスト(プルリクエスト)を作成した場合と同様となります。

作業用リモートリポジトリのトピックブランチからのマージリクエストが中央リポジトリのデフォルトブランチにマージされた様子の図。

マージが完了した後は、作業用リモートリポジトリとローカルリポジトリから不要になったトピックブランチを忘れずに削除しておきましょう。

中央リポジトリの変更との競合を、ローカルで解決する

GitHubやGitLabのWebサイト上での操作で競合を解消できる場合もありますが、必ずしも上手く行くとは限りません。 最も安全かつ確実に競合を解消できる方法としては、筆者はローカルでの解決をおすすめしたいです。

まず、中央リポジトリのデフォルトブランチの変更を、作業用リモートリポジトリを経由せず、直接ローカルリポジトリのデフォルトブランチに反映します。 ローカルリポジトリのデフォルトブランチを不用意に変更していなければ、この操作はスムーズに行えます。

中央リポジトリのデフォルトブランチの変更がローカルリポジトリに同期される様子の図。

$ git stash # 作業中の変更がもしあれば退避。
$ git checkout main # デフォルトブランチに切り替え。
$ git pull --rebase https://gitlab.com/redmine-plugin-testcase-management/redmine-plugin-testcase-management.git main # 中央リポジトリの変更を同期。

中央リポジトリをupstreamという名前のリモートリポジトリとして参照できるようにしておくと、プルの操作で毎回URLを書かなくて済みます。

$ git remote add upstream https://gitlab.com/redmine-plugin-testcase-management/redmine-plugin-testcase-management.git
$ git pull --rebase upstream main # 中央リポジトリの変更を同期。

この操作でローカルリポジトリと作業用リモートリポジトリのデフォルトブランチがズレたので、忘れないうちにローカルリポジトリのデフォルトブランチの内容を作業用リモートリポジトリに反映しておきます。

作業用リモートリポジトリのデフォルトブランチを中央リポジトリのデフォルトブランチに同期する様子の図。

$ git push # 「git push origin main」の略。作業用リモートリポジトリのデフォルトブランチを中央リポジトリと同期。

次に、ローカルリポジトリのトピックブランチを最新のデフォルトブランチにリベースして、中央リポジトリのデフォルトブランチに対するマージリクエスト(プルリクエスト)を行える状態にします。

ローカルリポジトリのトピックブランチを中央リポジトリのデフォルトブランチにリベースする様子の図。

$ git checkout add-usage-documents # トピックブランチに切り替え。
$ git rebase main # 中央リポジトリのデフォルトブランチを対象にリベース。

ローカルリポジトリから直接マージリクエスト(プルリクエスト)は行えないので、今度はこのトピックブランチを作業用リモートリポジトリに強制プッシュします。

ローカルリポジトリのトピックブランチを作業用リモートリポジトリに強制プッシュする様子の図。

$ git push --force-with-lease # 「git push --force-with-lease origin add-usage-documents」の略。

リポジトリの設定で強制プッシュを禁止している場合は、作業用リモートリポジトリのトピックブランチを一旦削除してからプッシュし直します。

$ git push origin --delete add-usage-documents # 作業用リモートリポジトリのトピックブランチを削除。
$ git push origin add-usage-documents # 改めて、トピックブランチを作業用リモートリポジトリに作り直す。

後は、作業用リモートリポジトリのトピックブランチから中央リポジトリのデフォルトブランチへマージリクエスト(プルリクエスト)を行い、レビューを経てマージされるのを待ちます。

ローカルリポジトリのトピックブランチが作業用リモートリポジトリを経由して、中央リポジトリのデフォルトブランチにマージされた様子の図。

変更がマージされたら、不要になったトピックブランチを作業用リモートリポジトリとローカルリポジトリから削除するなどの後始末をして、一連の変更作業は完了となります。

$ git stash pop # 退避していた作業中の変更を再適用。

中央リポジトリの変更との競合を、Web UIを併用して解決する

先の基本的なやり方では、中央リポジトリ←→作業用リモートリポジトリ←→ローカルリポジトリ という流れを一部わざと飛ばして、中央リポジトリのデフォルトブランチの内容をローカルリポジトリに反映していました。 GitHubでは、そのような変則的な流れを経ずに変更をローカルリポジトリまで反映する方法があります。

先に紹介したとおり、現在のGitHubでは、中央リポジトリからフォークしたリポジトリ(今回であれば作業用リモートリポジトリ)のページを開くと、同名のブランチを同期するためのリンクが表示されます。

作業用リモートリポジトリのページに、中央リポジトリの同名ブランチの変更を反映するための「Sync fork」リンクが表示されている様子。

このリンクをクリックすれば、中央リポジトリのデフォルトブランチの内容が作業用リモートリポジトリのデフォルトブランチにプルされます。

中央リポジトリのデフォルトブランチの変更がローカルリポジトリに反映された様子の図。

作業用リモートリポジトリが中央リポジトリと同期されれば、後は中央リポジトリにコミット権がある場合の「トピックブランチで作業していて、中央リポジトリに他の人が変更を行っていた場合」と同じ流れになります。 まず、作業用リモートリポジトリのデフォルトブランチをローカルリポジトリのデフォルトブランチにプルします。

中央リポジトリのデフォルトブランチの変更が作業用リモートリポジトリを経由してローカルリポジトリに反映された様子の図。

$ git stash # 作業中の変更がもしあれば退避。
$ git checkout main # デフォルトブランチに切り替え。
$ git pull --rebase # ローカルリポジトリのデフォルトブランチを作業用リモートリポジトリのデフォルトブランチと同期。

次に、トピックブランチをデフォルトブランチにリベースします。

ローカルリポジトリのトピックブランチが最新のデフォルトブランチからのブランチとしてリベースされた様子の図。

$ git checkout add-usage-documents # トピックブランチに切り替え。
$ git rebase main # 中央リポジトリのデフォルトブランチを対象にリベース。

そうしたら、更新されたトピックブランチを作業用リモートリポジトリに強制プッシュします。

中央リポジトリのデフォルトブランチの変更が作業用リモートリポジトリを経由してローカルリポジトリに反映された様子の図。

$ git push --force-with-lease # 「git push --force-with-lease origin add-usage-documents」の略。

リポジトリの設定で強制プッシュを禁止している場合は、作業用リモートリポジトリのトピックブランチを一旦削除してからプッシュし直します。

$ git push origin --delete add-usage-documents # 作業用リモートリポジトリのトピックブランチを削除。
$ git push origin add-usage-documents # 改めて、トピックブランチを作業用リモートリポジトリに作り直す。

後は、作業用リモートリポジトリのトピックブランチから中央リポジトリのデフォルトブランチへマージリクエスト(プルリクエスト)を行い、レビューを経てマージされるのを待ちます。

ローカルリポジトリのトピックブランチが作業用リモートリポジトリを経由して、中央リポジトリのデフォルトブランチにマージされた様子の図。

変更がマージされたら、ローカルリポジトリで作業中だった物を退避した変更を再適用したり、不要になったトピックブランチを作業用リモートリポジトリとローカルリポジトリから削除したりといった後始末をして、一連の変更作業は完了となります。

$ git stash pop # 退避していた作業中の変更を再適用。

中央リポジトリの変更との競合を、Web UIだけで解決する(非推奨)

既にマージリクエスト(プルリクエスト)を作成済みの状態で中央リポジトリのデフォルトブランチが変更された場合、GitLabやGitHubでは、マージリクエスト(プルリクエスト)の画面上にはリベースの実行を促すボタンが表示されます。

GitLabのマージリクエストに表示された、リベースを促すボタン。アクティビティ欄の上の位置に「Merge blocked: the source branch must be rebased onto the target branch.」とメッセージが表示され、「Rebase」ボタンがハイライトされている。

このボタンを押すと、前項の流れと同様のプルおよびリベース操作が作業用リモートリポジトリ上で行われ、それに追従してマージリクエスト(プルリクエスト)も自動的に更新されます。

作業用リモートリポジトリ上でデフォルトブランチの同期とトピックブランチのリベースが行われる様子の図。

ただ、この方法を取る時は以下の点に注意が必要です。

  • 競合が絶対に発生しないと断言できる状況でだけ行う。
  • この方法で作業用リモートリポジトリのトピックブランチをリベースしている間は、ローカルリポジトリでの作業は停止する。

まず、リベース中に競合が発生すると、ローカルでリベースした時と同様の競合解消操作をWeb UIで行う必要が生じます。 ローカルでの競合解消作業時には、随時自動テストを実行したり文法チェックににかけたりして「本当に競合が解消されているのか?」を確認できますが、Web UIだとそういう事ができないので、筆者としてはWeb UIでのリベースはお勧めしにくいのが正直な所です。 中央リポジトリのデフォルトブランチの変更内容を完全に把握できていて、リベースで競合が絶対に発生しない(単に変更の適用順が調整されるだけになる)、と断言できる状態でのみWeb UIでのリベースを行い、それ以外の場合は行わないのが安全でしょう。

また、こうしてリベースされた作業用リモートリポジトリ上のトピックブランチをそのままローカルリポジトリに持ってこようとすると、ローカルリポジトリ上で変更があってもなくても、必ずマージが発生します

作業用リモートリポジトリのトピックブランチをローカルリポジトリにプルして、マージが発生する様子の図。

Gitには「強制プッシュ」の逆の「強制プル」という機能はないので、この問題を回避するには、「リモートリポジトリの内容をローカルリポジトリにダウンロードする(フェッチ)」と「ダウンロードされた内容をローカルリポジトリの同名ブランチに強制適用する(リセット)」を個別に手動で行う必要があります3

$ git fetch --all # リモートリポジトリの内容を現在のブランチに適用せず、ダウンロードだけする
$ git reset --hard origin/add-usage-documents # 現在のブランチの内容を、リモートリポジトリからダウンロードした内容で置き換える

もしくは、一旦ローカルでトピックブランチを削除してからプルすることでも同じ結果を得られます。

$ git checkout main # 現在のブランチは削除できないので、一旦デフォルトブランチに切り替える。
$ git branch -D add-usage-documents # トピックブランチを削除する。
$ git pull --rebase # プルする。この時点でトピックブランチの情報が origin/add-usage-documents としてフェッチされる。
$ git checkout add-usage-documents # トピックブランチに切り替える。

ローカルリポジトリのトピックブランチを消すということは、もしマージリクエスト(プルリクエスト)作成後にローカルで何か作業を行ってトピックブランチにコミットしていた変更があっても、未プッシュだった物は失われてしまいます。 それらを再適用するためには、リビジョンIDを指定してチェリーピックするなど、さらに別の操作がまた必要になってきます。 とにかくどう転んでも厄介なことにしかならないので、Web UIでリベースを行う場合、ローカルでの作業は停止しておくという運用を徹底するのが安全でしょう。

このように、Web UIでのリベースは色々と面倒事が多いです。 ドツボにはまった時のリカバリーの容易さを考えると、筆者としては、リベース作業は中央リポジトリの変更をローカルリポジトリに反映してからローカルで行うよう徹底する事をお薦めします。

まとめ

トランクベース開発のスタイルでブランチを運用しているプロジェクトに対して、リポジトリへのコミット権を持たない外部コントリビューターとして開発に参加する場面を想定して、ブランチ操作に戸惑いがちな場面での操作の手順の例をご紹介しました。

自分がコミット権を持っていない開発プロジェクトに外部コントリビューターとして関わる場合は、ブランチ操作がややこしくなりがちです。 しかし、自分がコミット権を持っているリポジトリでのトピックブランチを用いるケースの延長線上で考えると、その順当な発展だという事が、ここまでの説明でお分かり頂けるのではないでしょうか。

「既存のフリーソフトウェア開発プロジェクトへコントリビュートする」のと「自分がフリーソフトウェアを公開する」のは全く別のことだ、と考える方もいると思いますが、自分のリポジトリで何か1つでもフリーソフトウェアを開発・公開してみると、既存のプロジェクトへコントリビュートするときに必要になることの訓練になります。 また、自分のプロジェクトに対してコントリビューションを受ける機会が増えると、「受け手にとって、どのようなコントリビュートの仕方だとありがたいか・受け入れやすいか」を実感を持って学ぶ機会にもなります。 皆さんも、自作のライブラリやコマンド作業補助用のスクリプトなどがあれば是非、GPLやMITライセンスといった自由なライセンスを設定して公開してみて下さい。

クリアコードは、SpeeeさんのOSS活動支援などのように、フリーソフトウェア・OSS開発コミュニティへ参加したい企業のお手伝いを承っています。 会社としてのコミュニティとの接し方や、新人へのコミュニティ活動の奨励の仕方などについてお悩みの、CTOやシニア/リーダー格のソフトウェアエンジニアの方がおられましたら、お問い合わせフォームよりご相談ください

  1. 自分個人、あるいは自分が所属している組織のアカウント下など。

  2. 具体的には、GitHubでは行えることを確認済みで、GitLabでは行えない模様です。

  3. ここまでの説明では省略していますが、実はローカルリポジトリ上には「リモート追跡ブランチ」という物が含まれています。git branch --all を実行したときに一覧の最後の方に表示される remotes/origin/add-usage-documents といった名前の物がそれで、ユーザーが任意に変更することはできず、純粋にリモートリポジトリのブランチの複製(ミラー)となっています。git fetch --all は、リモートリポジトリの各ブランチの内容をこのリモート追跡ブランチにダウンロードして最新の状態に更新する操作です。git pull は、(1)リモート追跡ブランチを更新した上で、(2)ローカルリポジトリの同名ブランチに変更を適用する、という2つの事を一度にまとめて行うコマンドですが、このとき(2)に失敗すると「競合」状態になります。競合を無視して(2)を強制的に行うためには、ここでの例のように(1)と(2)を別々に行う必要があります。