4 つのリポジトリを submodule で束ねた ― 親のコミットの 62% は「子が進んだ」だけだった
一人でやっている会社ですが、リポジトリは 4 つに分かれています。API、ブログ、コーポレートサイト、インフラ。それぞれ言語もデプロイ先も違うので、分けること自体に迷いはありませんでした。
迷ったのは、その 4 つをどう見渡すかです。結局、親リポジトリを 1 つ作って git submodule で束ねました。
4 か月ほど運用したので、良かったところと面倒だったところを書いておきます。先に結論を言うと、面倒の大半は 1 つのことに集約されていて、それは自動化で消せました。
構成
親が 4 つの子を submodule として持つ、それだけです。
katatsumuri-work/ 親(アンブレラ)
├── api/ → katatsumuri-work/api Rust + axum public
├── blog/ → katatsumuri-work/blog Hugo public
├── web/ → katatsumuri-work/web Astro public
└── infra/ → katatsumuri-work/infra Terraform private
.gitmodules には branch = main を明示しています。git submodule update --remote が「どのブランチの最新を取るか」を決める値です。
省略しても既定は remote HEAD なので、子の HEAD が main である今の構成では結果は同じです(gitmodules(5)、Git 2.39.5 で確認)。それでも書いているのは、将来 HEAD が変わったときに親の意図が残るようにという理由だけです。
参考: gitmodules(5)
[submodule "api"]
path = api
url = git@github.com:katatsumuri-work/api.git
branch = main
クローンするときは再帰指定が要ります。
git clone --recurse-submodules git@github.com:katatsumuri-work/katatsumuri-work.git
なぜ 1 つのリポジトリにまとめなかったか
子が独立して動けることを優先しました。
ブログには Hugo のビルドとデプロイがあり、API には Rust のテストがあり、インフラには Terraform があります。これらを 1 つのリポジトリに入れると、CI が「変更されたパスを見て走るジョブを切り替える」構造になります。書けないことはありませんが、一人でやる規模で払うコストとしては重い。
それに、公開範囲が違います。API・ブログ・サイトは public ですが、インフラは private です。1 つにまとめるなら全体を private にするしかなく、そうすると「コードを見せる」という目的が失われます。
なぜバラバラのままにしなかったか
逆に、親を作らず 4 つ並べるだけでもよかったはずです。実際それでも動きます。
親を作った理由は 「その時点の全体」をひとまとまりで記録したかったからです。API をこのバージョンにしたとき、インフラはこの状態で、サイトはこう出ていた——という組み合わせが親のコミットとして残ります。あとから「あの頃どうなっていたか」を辿るとき、これがあると楽です。
もう 1 つは入口が 1 つになること。README を親に置いておけば、そこから 4 つに散っていけます。自分のためというより、あとから見る人のためです。
運用ルールは 2 行で足りる
- 子リポジトリで作業して push する
- 親で pointer を追従させる
子は普通のリポジトリなので、単独でクローンしてそのまま作業できます。submodule であることを意識するのは親側だけです。ここは想像していたより快適でした。
追加するときはこれだけです。
git submodule add -b main git@github.com:katatsumuri-work/blog.git blog
git commit -m "blog を submodule として追加"
親で最新を取り込むときはこうです。
git submodule update --remote --merge # 各子の main の最新を取る
git add blog && git commit -m "blog の pointer を追従"
参考: git-submodule(1)
面倒なのは pointer 追従、それだけ
submodule の面倒さは、ほぼこの 1 点に集約されます。
子で push しても、親は勝手に追いつきません。親が記録しているのは「子のどのコミットを指すか」という情報なので、これを手で更新してコミットする必要があります。
$ git status
Changes not staged for commit:
modified: blog (new commits)
この (new commits) が出るたびに、親でコミットを作る。子を触るたびに発生するので、地味に効いてきます。
数えてみたら 3 分の 2 だった
実際どのくらいの比率になっているか数えてみました。
$ git log --oneline | wc -l
45
$ git log --format=%s | grep -cE '^chore: submodule pointer' # 自動で追従したぶん
19
手で追従していた時期のコミット(… pointer を … に追従 という書き方をしていました)が別に 9 件あるので、pointer 追従は合わせて 28 件。親の 45 コミットのうち 62% が「子が進んだことを記録するだけ」でした。
内容のあるコミットは 17 しかありません。
ただし、この数字の読み方には注意が要ります。28 件のうち 19 件は自動化を入れたあとのものです。30 分ごとに定期実行が回って、子が進むたびに PR を立てるので、自動化は比率を下げるどころか押し上げています。
手間はゼロになりましたが、履歴のノイズはむしろ増えました。「面倒だったから自動化して解決」ではなく、**「面倒が目に見えない場所へ移った」**というのが正確なところです。
自動化したら気にならなくなった
そこで、pointer 追従を GitHub Actions に任せました。定期的に各子の main を見て、進んでいれば親に PR を作って即マージする、という仕組みです。
詳しくは submodule pointer の追従を GitHub Actions で自動化した話 に書きました。
これを入れてから、pointer のことは考えなくなりました。
……と、しばらくは思っていました。実際にはこの定期実行、あとでエラーも出さずに止まっていたことが分かります。public リポジトリの scheduled workflow は、60 日間動きがないと GitHub が自動で無効化するためです。親のログを見ると、2026 年 6 月 17 日から 9 月 8 日まで約 2 か月半、コミットがゼロでした。その間 pointer は追従していません。
顛末は GitHub Actions の cron が、ある日から 1 日 48 回中 6 回しか動かなくなった に書きました。
**自動化は「考えなくてよくなる」のではなく、「考える対象が pointer から workflow に移る」**というのが正直なところです。それでも手で追従するより楽なのは間違いないのですが、任せきりにできるわけではありませんでした。
もう 1 つの面倒:private な子を CI から引くとき
infra だけ private なので、GitHub Actions から submodule を fetch しようとすると認証で止まります。.gitmodules は SSH URL なので、トークン付きの https に差し替える必要がありました。
git config --global \
url."https://x-access-token:${SYNC_PAT}@github.com/".insteadOf "git@github.com:"
「公開範囲を混ぜられる」のは submodule の利点として挙げましたが、その分だけ CI にトークンを渡す手間が増えます。表と裏です。
参考: git config - url.<base>.insteadOf
共通ファイルも配れる
親を正にして、共通のファイルを子へ配る仕組みも作りました。AI エージェント向けのルールファイル(AGENTS.md など)を全リポジトリで揃えたかったからです。
コピペで配ると必ずズレます。親で更新したら各子に PR を立てて即マージする、という配布パイプラインにしました。これも submodule 構成だから素直に書けた部分です。
向いている場合とそうでない場合
4 か月やってみて、こう感じています。
向いていそう
- 子ごとに CI やデプロイ先が違う。1 つにまとめるとパス分岐だらけになる場合
- 公開範囲が混在する。public と private を同居させたい場合
- 子が単独でも意味を持つ。それだけクローンして動かせる場合
向いていなさそう
- 子をまたぐ変更が頻繁。API と web を一緒に直すことが多いなら、pointer 追従が毎回ついて回る
- チームが大きい。submodule の作法を全員に周知するコストが、得られる見通しに見合わない
- 子が細かく分かれすぎている。数が増えるほど pointer 追従の回数も増える
今回のケースは「子ごとに技術が違う」「public と private が混在」「子が単独で完結する」が揃っていたので、素直に嵌まりました。逆にこれらが当てはまらないなら、無理に submodule にする理由はないと思います。
まとめ
- 親 1 つ + 子 4 つの submodule 構成。子は単独のリポジトリとして普通に扱える
- 親を作ったのは「その時点の全体」を記録したかったから。入口が 1 つになる副次効果もある
- 面倒は 2 つ。pointer 追従(親のコミットの 62% がそれだった)と、private な子を CI から引く認証
- どちらも機械的なので自動化できる。ただし自動化は比率を下げるどころか押し上げる(履歴のノイズは増える)
- そして自動化したら忘れてよいわけではない。その定期実行が 2 か月半、黙って止まっていた
- 向くのは「子ごとに技術・公開範囲が違い、子が単独で完結する」場合
submodule は評判があまり良くない機能ですが、面倒の正体が分かっていれば付き合えるというのが、やってみての感想です。