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する必要gがあります。

もし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 commit 1つにコミットをまとめてしまえばいいのです。これで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)