golden-luckyの日記

ツイッターより長くなるやつ

gitで2つのリポジトリを混ぜる戦略を考える

「2つのgitリポジトリがあって、その片方をもう一方に取り込みたい」という状況を考えます。依存ライブラリのソースを自分のプロジェクトで保持したい、といった状況が典型的でしょう。

この場合、通常は git submodule を使うと思います。 git submodule であれば、他のプロジェクトを履歴ごと自分のソースの一部として管理できて、かつ双方の履歴をきれいに分離できます。

ただ、双方の履歴が分離できるということは、双方の履歴を混ぜられないということでもあります。そのため、 git submodule は、他のプロジェクトのソースに自プロジェクト独自の変更を加えて管理するといった用途には向かないように思います。ではどうすればいいだろうか、という試行錯誤の記録です。

文章で書くだけでは状況がよく見えないので、本稿では主に図で状況を示します。 なお、2つのリポジトリにおけるコミットの状況を図示していきますが、実際の作業はこれらをローカルへcloneして行うことになるので、適宜読み替えてください。

git submodule で取り込む

いま、自プロジェクトをgitリポジトリAで管理しており、そこにgitリポジトリBで管理されている他プロジェクトを取り込みたいとします。ここで、セオリー通りに git submodule を使い、AにBを取り込むとします。

$ git clone [AのURL]
$ git submodule add [BのURL] [取り込み先のディレクトリ]

このときの状況を図示するとこんな感じです。

f:id:golden-lucky:20200504141935p:plain

以降、図中の色と記号はこう読んでください。

  • gitリポジトリAでのコミット:白(ギリシア文字)
  • gitリポジトリBでのコミット:緑(英小文字)
  • もともとAで管理していたファイルへの変更:〇
  • もともとBで管理していたファイルへの変更:□

「Aのcloneにおけるsubmoruleへの変更をAのリモートのみに push 」はできない

ここで、submoduleとしたディレクトリ内で何かを変更してコミットし、その変更をリモートで共有したいとします。 submoduleへの変更を含めてpushする必要があるので、そのディレクトリ内で git push するか、 git push --recurse-submodules=on-demand を実行することになります。

このとき、このpushはAのみならずBにも適用されます。これをAのみに適用する方法はなさそうです。

f:id:golden-lucky:20200504145242p:plain

この挙動は、「双方の履歴が独立している」というsubmoduleの意図には合致していますが、Bに伝播させたくないA独自の変更をB由来のファイルに施したい場合には困ります。 そうしたケースに対する対策としては以下のような方法が考えられそうです。

  • Bで「A専用」のブランチを切ってそれをsubmoduleで取り込む
  • Bをフォークして「A専用B」のリポジトリを作る

ただ、Aがプライべートだったり、専用にカスタマイズしたBを使いたいAのようなプロジェクトのgitリポジトリが増えたりすると、これらの対策ではあまりうれしくありません。

「B由来のソースをAの内部で独自に管理する」とBの上流の変更が取り込めない

では、AではBの履歴管理とは完全に独立してB由来のソースを使うのはどうでしょうか? たとえば、先ほどの状況でBへのpushが起こってほしくないので、submoduleについては「ローカルにcloneしたリポジトリ」での履歴管理だけで我慢する、という手はありそうです。

しかし、これだと、当然のことながら「submoduleの内容に依存するAの変更」をリモートで共有できません。 また、下図のように、Bの変更をあとで取り込むこともできません。

f:id:golden-lucky:20200504150659p:plain

git subtree で履歴を完全に混ぜる

結局、やりたかったことに一番近い解決策は、「A内でのB由来のファイルに対する履歴をAの履歴と完全に混ぜる」になりそうです。 それを実現する方法はいくつか考えられます。

  • git subtree を使う
  • Bを取り込む専用のブランチを作り、その変更を主たるブランチに随時マージして使う(その際、混乱を避けるために git worktree を使うとよさそう)

やりたいことを図にするとこうなります。

f:id:golden-lucky:20200504151310p:plain

ここでは、上記を git subtree で実現する手順をメモしておきます。 git subtree については下記の記事を参考にしました。

まず、Aをcloneしたディレクトリ内で、Bをリモートとして追加します。 そのBを git subtree で指定のディレクトリにとってくる、というのが基本的な流れです。 gitコマンドとしては以下の2つを入力すればOK。

$ git remote add -f [B用のリモートの名前] [BのURL]
$ git subtree add --prefix [Bを取り込むディレクトリ] [B用のリモートの名前] [Bのブランチ] --squash

# あとはふつうにgit pushすればAのリモートのみに変更が反映される

もし仮に、ここで「Bにも変更を戻したい」場合には、 git subtree push というコマンドを使って「そのための差分」を作り、それをGitHubのPull Requestなどで上流に送ればいいようです。 しかし、Aの履歴が混ざっているので、うまくやらないとうまくいかなそう(やってないのでわかりません)。

一方、Bの上流の変更をA内のB由来のファイルに適用するのは簡単です。簡単といっても、もちろん衝突は覚悟しないといけませんが…。

$ git fetch [B用のリモートの名前] [Bのブランチ]
$ git subtree pull --prefix [Bを取り込んだディレクトリ] [B用のリモートの名前] [Bのブランチ] --squash

git worktree を使った方法もそのうち試してみようと思います。

謝辞

この状況を考えることになったきっかけは、下記のなにげないツイートでした。

このツイートにコメントをいただいた @anohana さん、@ymotongpoo さん(と某チャットで付き合っていただいたみなさん)、@soranoba さん、@YukiharuYABUKI さん、ありがとうございます。おかげで自分が本当は何をしたかったのか整理できました。とくに @soranoba さんには git subtree の存在を教えていただき本当に助かりました。