先日、外部のGitリポジトリを参照しつつ開発を進めたい時に、改めて今ならどのようにやるといいのか調査と検証しました。
開発においてシンプルさは重要です。そのため、利用している言語やフレームワークで標準提供されたパッケージシステムを使うのは優先的に検討するべきです。 しかし会社などでprivate repoでコード参照をしたいときには、参照する外部リポジトリも適切にGit管理したいものです。
Gitは、自分のリポジトリを扱うことに関してとても便利な機能がそろっています。 一方で、自分のリポジトリの外、外部リポジトリを連携させることについては、自リポジトリほど楽に取り扱えるわけではありません。
そこで今回は、Gitが提供している外部リポジトリのハンドリングとして、submoduleとsubtreeの2つを通して外部リポジトリをどのように扱うのか考えてみます。 これまでは外部リポジトリを参照するときはgit submoduleを多用してきたのですが、git subtreeも併せてどのようにすればいいのでしょうか。
TL;DR;
- サブリポジトリでLFSを使いたい場合は、Git submodule一択となる
- submoduleでも書き込みはできるので、読み込み専用というのは違う
- submoduleで編集する際は、ブランチを切ってdetouched HEAD状態をいかに把握可能な状態にするかは更新頻度が高ければ高いほど重要
- CIのワークフローも考えると、submoduleを用いるとSSHがほぼ確定するので、subtreeの方がただビルドするだけなら楽
- subtreeはGit clone後にgit subtree addでリンクしなおさないと整合性を維持できないので注意
- subtreeはLFS対応さえしていれば、リポジトリ構造的にはシンプル
- subtreeは、サブリポジトリの状態が親リポジトリとローカルで分離されるので仕組みを理解せず使うのは危険
UnityでLFSを使っていて、upmも使えないならGit submodule一択になります。 依存している外部ライブラリの更新頻度が高く、でもコード参照したいならgit subtreeはいい手段になり得ます。
前提
- CIや普段の開発フローから、Git標準提供しているコマンドを用いる (このためgit subrepoは用いない)
- LFSの対応状況も改めて調べる
- GUIツールのサポート状況も考慮する
submodule / subtree を使わないという選択
submoduleとsubtreeいずれもリポジトリの管理に伴い考えることが増えます。
もしGitではなくツールレベルの解決でいいなら、RubyGem、CocoaPods、go get (go modules) などの標準提供されたコード参照ツーリングを使うほうが圧倒的に管理が楽です。 もしコードレベルでの参照が不要であれば、NuGetなどを使ったバイナリ参照 + シンボル参照を使うと管理が減って楽です。 Unityならupmを使うことでコード参照しつつ、readonlyに、循環参照のない一方参照を担保できます。
できれば、submoduleやsubtreeを使わない選択を検討してください。
ただし、パッケージングにもビルドや展開、バージョン更新が必要となってきます。 はsubmoduleなどでGit pushして参照ヘッドを変えるの同様に、それぞれのパッケージマネージャーにおける時間や手間がかかるため、gitの仕組みでなんとかしたいことも多いでしょう。
概論
Git submoduleとgit subtreeは、外部リポジトリを自リポジトリでどのように扱うかの違いです。 大きく分けると、submoduleはCommitIdを使った参照、subtreeはsubtree mergeを使ったリポジトリのまとめ上げに相当します。
概略図
理解を整理して説明するために、雑に図を書いていました。 Parent Repoを中心として、右がsubmodule、左がsubtreeの場合の違いです。
submodule
最も広く利用されている外部リポジトリの利用方法です。多くのケースはこれで済むはずです。
Git submoduleは、外部リポジトリの特定のcommit idへの参照だけを自分のリポジトリにおきます。(便宜上ここでは、このsubmoduleで取り込んだローカルの外部リポジトリをサブリポジトリと呼びます。また、submoduleを行ったリポジトリを親リポジトリと呼びます。)
参照であるがゆえに、外部リポジトリのコード自体は、親リポジトリの管理下に置かないことがうれしいところです。(つまりGit historyはそれぞれのリポジトリが自分で管理してお互いに知ったことではありません)
ここからの説明を読む前に、公式資料を読むのがおすすめです。
ポイントはこれです。
サブモジュールを使うと、ある Git リポジトリを別の Git リポジトリのサブディレクトリとして扱うことができるようになります。これで、別のリポジトリをプロジェクト内にクローンしても自分のコミットは別管理とすることができるようになります。
submodule の参照はポインタ
感覚的には、.gitmoduleに書かれたcommit id
がポインタとなり、サブリポジトリの該当commit idのコード状態を自リポジトリで利用するとイメージしやすいです。
submodule の特徴
項目 | 対応 | 備考 |
---|---|---|
参照の柔軟性 | △ | ブランチなどではなく特定の commit id でのみ参照する |
読み取り利用 | 〇 | readonly で参照するには十分 |
書き込み利用 | △ | submodule で行った変更をcommit/checkout/pushすること。 |
LFS対応 | 〇 | Git lfs を利用していても正常にポインターが解決されます |
Gitリポジトリの独立性 | X | サブリポジトリは親リポジトリのGit操作の影響を受ける |
clone時に実体化されるか | X | git submodule update --init が必要 (再帰なら--recursive 追加) |
submodule の利用用途
利用用途 | 推奨度 | 備考 |
---|---|---|
サブリポジトリを参照用として利用 | ◎ | サブリポジトリにコミットしない場合に最適です。 |
サブリポジトリを変更もして双方向で利用 | △ | サブリポジトリにコミットして、それをpushして親でもmergeするならありです。 |
サブリポジトリを特定プロジェクトでポートする | × | おとなしく fork しましょう。サブリポジトリのブランチがどんどん増えて、ブランチごとにプロジェクトでの参照切り替えるんですか? |
submodule の運用上の注意
参照であるがために、いくつかsubmodule用の操作が必要になります。
サブリポジトリはclone以外にsubmodule updateで実体化が必要
サブリポジトリをクローンしても、その時点ではsubmoduleで参照している親リポジトリのあるべきフォルダは.Git以外空っぽになっています。そのため、親リポジトリで、git submodule init
とgit submodule update
を行いサブリポジトリのcommitidからコードを実体化する必要があります。これは1つのGitコマンドで行えます。
git submodule update --init --recursive
もしもsubmoduleの参照先が更新されたときには、各自は必ずgit submodule update
を行う必要があります。
サブリポジトリで行った保持したい変更は必ずpushする
サブリポジトリは、親リポジトリから見ると別のGitリポジトリです。 そのため、サブリポジトリで変更して、それを親リポジトリから参照するためには、サブリポジトリで行った変更を必ずpushする必要があります。
もしpushを忘れたら?を考えてみましょう。
元のsubmodule参照がcommit id 1234
とします。
ローカルのサブリポジトリで変更してcommit id 5678
ができました。しかし、ここでサブリポジトリの変更をpushしないということは非公開の変更といえます。とはいえ、ローカルの親リポジトリからは変更が検知できているのでsubmoduleの参照を1234
から5678
に変更できます。
ではこの親リポジトリのsubmodule変更をpushするとどうなるでしょうか?
答えはほかの人がsubmoduleの変更にある参照コミット5678
を取り込もうと思っても、サブリポジトリの変更コミットはpushされていないので1234
までしかなく5678
は見つからずエラーになります。
この状態を解決するには、push忘れをしている人がローカルのサブリポジトリの変更コミット5678
をpushして、ほかの人はsubmodule updateしましょう。
犯人がわかっても、メールで彼を怒鳴りつけるのはやめましょう。
サブリポジトリに変更する場合の親リポジトリの注意
サブリポジトリは事実上1つのGitリポジトリです。そのため、変更もできますし、コミットも、ブランチ作成やpushもできます、普通のgitリポジトリです。
しかし、通常のGitリポジトリと大きく違うのが親リポジトリの操作でサブリポジトリの状態が変わりえることです。通常のgitリポジトリは、各gitリポジトリが個別に独立しているのでお互いの操作は自身にしか影響しませんが、submoduleを行うと親リポジトリの操作で、サブリポジトリが操作されます。
親リポジトリがgit submodule update
すると、サブリポジトリでは親が参照しているcommit idに基づきgit checkout commithash
が実行されます。当然ですね。
しかし、サブリポジトリに変更を入れていた場合はどうでしょうか? commitidにcheckoutするということは、まだcommit/checkout/pushしていない変更は当然なくなります。
この親Gitリポジトリの操作がサブリポジトリに影響するというgitリポジトリの独立性がなくなります。これがsubmoduleの最大の注意点でわかりにくいといわれる所以のようです。
lfs 利用時の注意
LFSは対象ファイルを、「Git上のポインターファイル」と「外部ストレージの実体」に分離します。 コミットごとに、ポインターファイルによって正常に解決される必要があり、またGit上もポインターファイルが実体ではなく、実体ファイルが実体です。つまり、git上にポインターファイルだけがあって、それがLFSとして認識されていなかったら困るということです。
LFSは対象ファイルのコミットごとにポインター/実体の解決をsmugeフィルター/cleanフィルターで行います。そのため、commit id参照を戻したりする操作とはあまり相性がよくありません。フィルターを通して作業する分には問題ないのですが、何かしらの不具合でフィルターを通らずポインターファイルがそのままコミットされることも起こりえます。
とくにsubmoduleを使っていると起こりやすいので、submoduleにはご注意ください。
submodule の問題点
先ほどの注意はそのまま問題点といえます。
サブリポジトリでの作業とGit submodule update
submoduleのディレクトリであるサブリポジトリで作業するときは、必ずgit submodule update
をして特定のバージョンをチェックアウトする必要があります。しかしこのcommitidは「常にブランチの中にあるもの(どこかのブランチのHEAD)」を示すわけではなく特定のcommitに過ぎず切り離されたHEAD (detached HEAD) をさすことになります。さて、普段のGit操作でHEADでないコミットを操作することは頻繁にあるでしょうか? 普段から行うことはまず思わないはずです。HEADにいないということは、手元の変更が簡単に失われ、またその特定のcommid idに合わせるのが難しいからです。
怖い状況を作ってみましょう。git submodule update
を最初に行い、適当にサブリポジトリに変更をコミットし、再びgit submodule update
してみると、親プロジェクトでコミットが何もなくてもサブリポジトリの参照状態が更新されます。もちろんcommid idを丹念に眺めてcheckoutすれば戻せますが、commitidを探して頑張って戻すのは大変です。しかもそれが、こんなささいなことで変わるのです。
これに対処するためには、submoduleで作業をするときにはブランチを切って自分でheadを示す必要があります。 ブランチを切ってあればそのブランチのHEADに戻せばいいので簡単です。 最も楽なのは、submoduleの元リポジトリを更新して、自分のリポジトリのサブリポジトリをそのHEADに合わせることですが、そうも言ってられないときには留意しましょう。
submoduleとブランチ切り替え
もっと厄介な問題が、ブランチ間のsubmoduleの参照です。
例えばmasterではsubmoduleをもっていない状態で、ブランチAを作ります。ここでリポジトリXをsubmoduleで追加します。この後、masterに戻るとどうなるでしょうか?submoduleのディレクトリが「追跡されていないディレクトリ」として残ったままになります。
masterで継続して作業するには、このディレクトリをどこかに移すか削除する必要があります。そして、先ほどのブランチXに戻ったとき、改めてクローンしなおし必要があるでしょう。もしブランチAでsubmoduleに関するpushし忘れていたら?ローカルからは変更はpush、あるいはローカルコピーしていないと失われるでしょう。
サブディレクトリからsubmoduleへの切り替え
もしもHOGEというディレクトリをそのままsubmodule HOGEで置き換えたくなったら、まずHOGEというディレクトリをunstageする必要があります。いきなりHOGEを削除して、submodule addでHOGeリポジトリを追加しようとしても起こられます。
ブランチを切り替えるとどうでしょうか?
先ほどの操作をブランチBで行い、まだsubmoduleへ切り替えていないサブディレクトリのあるmasterブランチにチェックアウトしようとすると当然起こられます。これは、HOGEディレクトリをいったん逃がせばokです。
またブランチBに戻ったときは? git submodule update
をしましょう、忘れやすいですね!
subtree
外部のGitリポジトリを自分のgitリポジトリのブランチとして取り込み、そのブランチを丸ごとサブディレクトリに配置します。
Git subtreeはsubtree merge戦略に基づき、外部リポジトリの対象ブランチをサブディレクトリに配置しそこで対象のリポジトリを管理します。(便宜上、このsubtreeで取り込んだローカルの外部リポジトリをサブリポジトリと呼びます。また、subtreeを行ったリポジトリを親リポジトリと呼びます。)
Git submoduleと違い、git subtreeでは外部リポジトリのコード自体が親リポジトリの管理下に入ります。(つまり親リポジトリのgit historyにサブリポジトリのヒストリが乗ってきます。squashでまとめることはできます)
subtreeといったときにsubtree mergeとGit subtreeで混乱します。朗報です、git subtreeはsubtree merge操作のラッパーです。そのため、以降subtreeといったときはgit subtreeを指し、subtree mergeはその通り呼びます。
Git subtreeがsubtree merge操作のラッパーということは、git subtreeの挙動を理解して利用するには、subtree merge戦略を理解するのが早いです。実際git subtreeを提供しているShell Scriptにはgit read-tree
やgit merge -s subtree
があるとわかります。
ここからの説明を読む前に、公式資料を読むのがおすすめです。
ポイントはこれです。
サブツリーマージの考え方は、ふたつのプロジェクトがあるときに一方のプロジェクトをもうひとつのプロジェクトのサブディレクトリに位置づけたりその逆を行ったりするというものです。サブツリーマージを指定すると、Git は一方が他方のサブツリーであることを理解して適切にマージを行います。驚くべきことです。
subtree はリモートリポジトリのブランチをローカルサブディレクトリに展開する
subtree mergeを使って親リポジトリのサブディレクトリとして展開したサブリポジトリは、subtree対象となったリモートリポジトリの指定したブランチの状態であり実体を持ちます。このため、サブリポジトリの変更は親リポジトリにコミット/プッシュされます。また、subtree対象のリモートリポジトリにも反映したい場合は、Git subtree pull/pushで、リモートリポジトリとのマージを行います。
subtree の特徴
項目 | 対応 | 備考 |
---|---|---|
参照の柔軟性 | 〇 | リモートリポジトリのブランチを使って柔軟に対応ができる。また親リポジトリだけに変更することもでき、完全に乖離させることも簡単にできる。 |
読み取り利用 | △ | readonly で参照するには十分。親リポジトリで管理されているサブリポジトリと同期させる場合、subtreeのリモートリポジトリとのマージを行っていく必要があるため煩雑 |
書き込み利用 | 〇 | サブリポジトリの変更は親リポジトリに記録されるので簡単。subtreeのリモートリポジトリと同期する場合は、リモートリポジトリの反映を漏らさないようにしていく必要はある。 |
LFS対応 | × | Git lfs はサポート対象外 (2019/3現在) |
Gitリポジトリの独立性 | △ | サブリポジトリは親リポジトリの一部としてヒストリにも書き込まれる。subtreeもとのリモートリポジトリとは同期を試みない限り独立している |
clone時に実体化されるか | 〇 | 通常のGit push/pull操作で実体化されます。ただしclone後にgit subtree add をして外部リポジトリ |
subtree の利用用途
利用用途 | 推奨度 | 備考 |
---|---|---|
サブリポジトリを参照用として利用 | △ | それ submodule じゃだめですか? |
サブリポジトリを変更もして双方向で利用 | 〇 | まさにそれようです。親リポジトリで完結する一方で、リモートリポジトリと乖離しやすいので注意。 |
サブリポジトリを特定プロジェクトでポートする | 〇 | fork でいいならfork がいいですが、プロジェクトに突っ込みたいときの選択肢としては有用です。 |
subtree の運用上の注意
Git subtreeは実体であり、ただのsubtree mergeに基づいたコマンドです。このため、いくつかの注意があります。
サブリポジトリの追加時にサブリポジトリのGit historyが大量に入ってしまう
Git subtreeで親リポジトリにサブリポジトリを追加する際に、サブリポジトリのgit historyが入ります。
Mergeであることから当然なのですが、サブリポジトリに10000コミットあった場合、ただgit subtreee add
すると親リポジトリのコミットヒストリに10000件追加されます。親リポジトリのhistoryが埋め尽くされるとどれだけつらいことかは共感いただけるのではないでしょうか。
しかしこの対処は簡単です、squashを使ってコミットをまとめ上げましょう。git subbree add
は、mergeにすぎないので親リポジトリにサブリポジトリを追加する時にmerge commit1つへコミットをまとめてしまえばいいのです。これでmerge commit 1件のhistory追加で済みます。良かったよかった。
Git push と git subtree push
親リポジトリのリモートにpushするときは、git push
でokです。
一方で、サブリポジトリのリモートに対してサブリポジトリの変更を反映するためには、git subtree push
を別途する必要があります。
もちろんこの時に、リモート側に変更があった場合は先にgit subtree pull
をしてコンフリクト解決、Mergeを行ってからgit subtree push
をする必要があ倫さう。
親リポジトリのリモートはだれがsubtree mergeで管理されているのか使うべきではない言葉なので修正してください
subtree mergeはローカルで行われます。そのため、Git subtreeでsubtree addした親リポジトリをpushして、ほかの開発者がそのリポジトリをクローンしてもgit subtree
操作がされていない状態になります。
CIでビルドするだけなら、何もする必要はなくすぐにビルドを開始できます。最高!
一方で、開発者が継続してそのリポジトリでsubtreeとして開発していくには、git clone
した後git subtree add
を使って、どのローカルのサブフォルダがどのリモートリポジトリのsubtreeなのかリンクする必要があります。
たとえば、subtree化されたリポジトリをcloneしてみると、次のようにsubtreeが解けています。
そこでsubtree対象のサブディレクトリをsubtree addします。
これで意図したとおりsubtreeとして利用ができるようになります。
subtree の問題点
サブリポジトリのリモートの変更状況がわからない
Git subtreeを使っていても、git subtreeを行っているサブリポジトリのリモートとの差分はツリー上でわかりません。
あくまでも親リポジトリのツリーしかでず、subtree側のツリーを出すことはできないので注意です。
コミット履歴の複雑化
subtreeを使った場合、サブリポジトリのコミットは親リポジトリのコミットとなりヒストリに交じってきます。そして、subtreeで追加したサブリポジトリのリモートリポジトリの変更を取り込んだ時のmergeコミットも交じってきます。圧倒的なコミット爆増と複雑化は否めません。
サブリポジトリと、リモートにあるsubtree addしたリポジトリの2つ状態を持っているのですから仕方ないでしょう。
ただ、git subtree add
の時と同様に、 git subtree push
/ git subtree pull
した時のコミットもsquashしてまとめ上げることはできるので、追加タイミングをこれで明示的に示すのは可能です。
LFSが非対応
LFSを使っているリポジトリをsubtreeで追加して、pushしようとすると失敗します。
これは、LFSがまだGit subtreeに対応していないためです。
頑張ってLFSしつつ自前で書くという手もあります。サポートはないので自己責任ですが。
Git GUIクライアントの対応状況
対応済み
- SourceTree
未対応
- GitKraken
upstream 操作という複雑性
Git subtreeの最大の利点は、submoduleのようにmeta情報やgit submoduleコマンドを知らずとも、git cloneすればすぐに依存モジュールも触れて始めやすいことです。
一方で、複数リポジトリがまとまることによる肥大化、コミット履歴が混じること、subtree merge戦略を使うべきではない言葉なので修正してくださいとまともに扱おうとしたときに苦労すること、upstreamとのmergeという複雑性と戦う必要があり複雑性を呼びます。
submoduleに比べて、別の理解を求められるケースがあるため注意してください。