はじめに #
ある web アプリのソースコードリポジトリに、Docker 化と AWS CDK のリソース管理コードを足した状態のリポジトリを作りたい、という要件に遭遇しました。
このとき選択肢として git submodule と git subtree が並びましたが、要件を1つずつ当てていくと、両者の向き不向きがはっきり分かれます。本記事ではその判断ロジックを整理しておきます。
今回のシチュエーション #
要件は次の通りでした。
- 取り込み元のリポジトリは web アプリのソースコードを管理しているリポジトリ
- web アプリのソースコードに Docker 化や AWS CDK のコード (リソース管理コード) を追加した状態にしたい
- 最終的にリソース管理コードのリポジトリごとに管理して pipeline で deploy したい
- web アプリのソースコード側の履歴はすべて必要なのではなく、取り込むときに何のための取り込みかを記録できれば良い
- 外部リポジトリへの認証情報は集約したい (pipeline に持たせたくない)
ざっくり図にすると次のような関係です。
flowchart LR
A[web アプリリポ
外部]
B[リソース管理リポ
web アプリ + Docker + CDK]
P[pipeline]
AWS[AWS]
A -- 取り込み --> B
B -- clone --> P
P -- deploy --> AWS
ポイントは pipeline が リソース管理リポしか見ない という点で、ここが認証情報の集約条件に効いてきます。
submodule と subtree の位置づけ #
両者は「別リポを自分のリポに含める」という同じゴールに見えますが、含め方の方向性が違います。
git submodule: 別リポを 参照 として埋め込む (実体は別管理)git subtree: 別リポを 履歴ごとマージ して自リポの一部にする
git subtree には add / pull / push 系 (取り込み・同期) と split (切り出し) の2つの顔がありますが、今回の要件で関係するのは add (と必要なら pull) のほうです。
git submodule #
仕組み #
- 親リポは子リポのコミット SHA だけを記録する
- 子リポの実体は別管理で、clone 時に
--recursiveかgit submodule update --initが必要 - 親リポの履歴は軽いまま保てる
今回の要件で使うとどうなるか #
submodule だと、リソース管理リポは web アプリリポへの 参照 だけを持つ形になります。pipeline がリソース管理リポを clone した後、git submodule update --init で web アプリの実体を引いてくる流れになります。
ここで詰まるのが要件「外部リポジトリへの認証情報は集約したい (pipeline に持たせたくない)」です。pipeline が submodule を展開するためには、web アプリリポへの read 権限を pipeline 側に渡す必要があります。認証情報が pipeline に必要になる時点で、この要件が満たせなくなります。
git subtree #
仕組み #
git subtree は同じコマンドの中に用途の違うサブコマンドが同居しています。
git subtree add --prefix=<dir> <repo> <ref>: 別リポを履歴ごと自リポにマージして取り込むgit subtree add --prefix=<dir> <repo> <ref> --squash: 取り込む際に履歴を1コミットに潰すgit subtree pull --prefix=<dir> <repo> <ref>: 取り込んだサブツリーを上流から更新するgit subtree push --prefix=<dir> <repo> <ref>: サブツリー側に加えた変更を上流リポへ戻すgit subtree split --prefix=<dir> -b <branch>: サブディレクトリの履歴だけを新ブランチに切り出す
--prefix=<dir> (略記 -P) について
これは「自リポ内のどのディレクトリが上流リポと対応するか」を示すパスです。--prefix=app を指定すれば、上流リポの中身は自リポの app/ 配下に展開され、以降の pull / push / split でも app/ 配下が上流との対応窓口になります。squash コミットの trailer (git-subtree-dir: app) にも記録され、subtree が「この trailer は app/ 用のものだ」と認識するためのキーになります。
今回の要件で使うとどうなるか #
subtree (add) を使うと、web アプリのソースコードがリソース管理リポの中に 実体として マージされます。pipeline はリソース管理リポを clone するだけで web アプリのコードも一緒に手に入るので、web アプリリポへの認証情報を pipeline に渡す必要がありません。要件「認証情報を pipeline に持たせない」が満たせます。
履歴については --squash オプションが効きます。
--squashなしだと、取り込み元の全コミットがマージされて親リポの履歴が太る--squashありだと、取り込み元の履歴は1コミットに潰され、「いつ・何を取り込んだか」だけがマージコミットに残る
要件「履歴は全部必要ではないが、取り込み理由は記録できればよい」にこの挙動がぴったり合います。マージコミットメッセージに「リソース管理のため web アプリ X を取り込み (rev abc1234)」のように書いておけば、後から取り込みの意図が辿れます。
また subtree push を使えば、リソース管理リポ側で web アプリ部分に加えた変更を上流の web アプリリポに戻すこともできます。これは開発者がローカルで実行する操作なので、pipeline に認証情報を持たせない条件とは衝突しません。今回の要件では基本的に上流から取り込む向きが主ですが、双方向の同期経路が用意されている点は押さえておきたいところです。
--squash が成り立つ仕組み: git-subtree-split トレイラ
#
--squash でローカル履歴を1コミットに潰してしまっても、後から subtree pull や subtree push がちゃんと動くのは、squash コミットのメッセージに残る trailer のおかげです。
subtree add --squash を実行すると、実は 2つのコミット が作られます。
- squash コミット (親なしの孤立コミット。上流の tree スナップショットと trailer を保持する マーカー 役)
- それを親リポにマージする merge コミット (こちらが親リポの履歴に連なる)
squash コミットのメッセージはこういう形になっています。
Squashed 'app/' content from commit abc1234
git-subtree-dir: app
git-subtree-split: abc1234
git-subtree-split: abc1234 の行が subtree の起点マーカーです。「この squash コミットは上流の abc1234 のスナップショットを取り込んだもの」というメタ情報を残しているので、後続の pull / push がここを基準に「ここまでは上流に合流済み」と判断できます。
補足として押さえておきたいのが、--squash でローカルに残っているのは上流の ファイル内容 (tree) だけで、ローカル履歴に上流の abc1234 というコミットが連なるわけではない、という点です。abc1234 自体は subtree add が内部で実行する fetch の副作用で一時的にローカル object DB に存在しますが、どこからも参照されないので時間が経てば git gc で消えていきます。trailer はあくまで「上流のあの ID 相当を取り込んだ」というマーカー (文字列) として機能する のがポイントで、後続の操作で abc1234 を commit object として引き戻す必要は基本的にありません。
例えば subtree push は公式ドキュメントの記述通り「split → git push」だけを行います。
subtree splitが走り、過去の squash コミットの trailer (git-subtree-split) を「ここまで上流に合流済み」というマーカーとして使う- そのマーカー以降にローカルで prefix 配下に加えた変更だけを抽出し、prefix を root に持ち上げた flat な合成履歴 (synthetic history) を新規に組み立てる
- できあがった history を
git pushで上流の<ref>に送る
リモート側で fast-forward 可能かどうかはリモートの状態次第なので、ズレている場合は別途 git fetch してローカルで状態を見るのはユーザ側の責務です (subtree push が自動で fetch するわけではない点に注意)。
flowchart LR
subgraph U["上流リポ"]
u1[abc1234
取り込み時点]
u2[その後の上流コミット]
u1 --> u2
end
subgraph L["ローカルの subtree split 結果
(合成履歴)"]
s1[abc1234 をマーカーとして起点扱い]
s2[ローカル変更 1]
s3[ローカル変更 2]
s1 --> s2 --> s3
end
u1 -. trailer が指す .-> s1
subtree pull --squash のたびに新しい trailer 付き squash コミットが積まれ、それが次回 push の起点マーカーを上書きしていく構造です。これにより「ローカル履歴は軽い」「上流との同期経路は維持できる」を両立しています。
ローカルリポでこの trailer の軌跡を確認したい場合はこのコマンドで一覧できます。
git log --grep='git-subtree-split' --oneline
比較表 #
| 軸 | submodule | subtree (add) |
|---|---|---|
| 含め方 | 参照 | 履歴をマージ |
| 取り込み先の実体 | 持たない | 持つ |
| clone 時の追加権限 | 子リポへの認証が必要 | 不要 (親リポだけで OK) |
| 履歴の扱い | 子リポにそのまま残る | --squash で 1 コミットに潰せる |
| 取り込み元の更新追従 | submodule の SHA 更新 | subtree pull |
| 上流へ変更を戻す経路 | 子リポに cd して通常の push |
subtree push |
| 親リポの肥大化 | しない | する (--squash で軽減可) |
今回の選択 #
要件を順に当てていくと、選択肢は自然に絞られます。
- 認証情報を pipeline に持たせたくない → 参照型の submodule は外れる
- 履歴は取り込み理由だけ残ればよい → subtree add は
--squashで要件に合う
結果として git subtree add --prefix=<dir> <web-app-repo> <ref> --squash の形でリソース管理リポに取り込むのが、要件にもっとも素直に合いそうという判断になりました。
最後に #
- 「別リポを含める」と一括りに見えても、submodule と subtree は性質がかなり違う
- pipeline 周りの認証情報設計がどちらを選ぶかの強い決め手になった
- subtree の
--squashは「履歴は要らないが取り込み記録は残したい」要件に綺麗にハマった