tech.guitarrapc.cóm

Technical updates

git submodule と git subtree から見る外部リポジトリの取り扱い

先日、外部の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の場合の違いです。

git submodule と git subtree の概略図

submodule

最も広く利用されている外部リポジトリの利用方法です。多くのケースはこれで済むはずです。

Git submoduleは、外部リポジトリの特定のcommit idへの参照だけを自分のリポジトリにおきます。(便宜上ここでは、このsubmoduleで取り込んだローカルの外部リポジトリをサブリポジトリと呼びます。また、submoduleを行ったリポジトリを親リポジトリと呼びます。)

参照であるがゆえに、外部リポジトリのコード自体は、親リポジトリの管理下に置かないことがうれしいところです。(つまりGit historyはそれぞれのリポジトリが自分で管理してお互いに知ったことではありません)

ここからの説明を読む前に、公式資料を読むのがおすすめです。

git - submodule

ポイントはこれです。

サブモジュールを使うと、ある Git リポジトリを別の Git リポジトリのサブディレクトリとして扱うことができるようになります。これで、別のリポジトリをプロジェクト内にクローンしても自分のコミットは別管理とすることができるようになります。

submodule の参照はポインタ

感覚的には、.gitmoduleに書かれたcommit idがポインタとなり、サブリポジトリの該当commit idのコード状態を自リポジトリで利用するとイメージしやすいです。

git submodule は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 initgit 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-treegit merge -s subtreeがあるとわかります。

Github - git/git - git-subtree.sh

git-subtree の実装は subtree merge 戦略のコマンドのラッパー

git-subtree の実装は subtree merge 戦略のコマンドのラッパー 2

ここからの説明を読む前に、公式資料を読むのがおすすめです。

git - subtree merge

ポイントはこれです。

サブツリーマージの考え方は、ふたつのプロジェクトがあるときに一方のプロジェクトをもうひとつのプロジェクトのサブディレクトリに位置づけたりその逆を行ったりするというものです。サブツリーマージを指定すると、Git は一方が他方のサブツリーであることを理解して適切にマージを行います。驚くべきことです。

subtree はリモートリポジトリのブランチをローカルサブディレクトリに展開する

subtree mergeを使って親リポジトリのサブディレクトリとして展開したサブリポジトリは、subtree対象となったリモートリポジトリの指定したブランチの状態であり実体を持ちます。このため、サブリポジトリの変更は親リポジトリにコミット/プッシュされます。また、subtree対象のリモートリポジトリにも反映したい場合は、Git subtree pull/pushで、リモートリポジトリとのマージを行います。

git subtree は自リポジトリのディレクトリに外部リポジトリのブランチを展開して取り込む

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 addしてpushしたリモートリポジトリをcloneした直後の状態

そこでsubtree対象のサブディレクトリをsubtree addします。

clone 後にsubtreeをリンクしなおす

これで意図したとおりsubtreeとして利用ができるようになります。

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に対応していないためです。

https://github.com/git-lfs/git-lfs/issues/1948

頑張ってLFSしつつ自前で書くという手もあります。サポートはないので自己責任ですが。

Git GUIクライアントの対応状況

対応済み

  • SourceTree

未対応

  • GitKraken

upstream 操作という複雑性

Git subtreeの最大の利点は、submoduleのようにmeta情報やgit submoduleコマンドを知らずとも、git cloneすればすぐに依存モジュールも触れて始めやすいことです。

一方で、複数リポジトリがまとまることによる肥大化、コミット履歴が混じること、subtree merge戦略を使うべきではない言葉なので修正してくださいとまともに扱おうとしたときに苦労すること、upstreamとのmergeという複雑性と戦う必要があり複雑性を呼びます。

submoduleに比べて、別の理解を求められるケースがあるため注意してください。

参考サイト

When to use git subtree?

Git submoduleの押さえておきたい理解ポイントのまとめ

gitで外部moduleを扱う方法(subtree)