私が携わっているシステムでは、DB のマイグレーションファイルを別リポジトリに分け、アプリ側のリポジトリにサブモジュールとして取り込んでいます。
そのサブモジュール(マイグレーション側)の参照を更新しようとするたびに、つまずいていました。
最初は「自分のサブモジュール理解が浅いせいだ」と思っていましたが、
原因を整理してみると、つまずいていたのはサブモジュールそのものではなく、コミット・ブランチ・親子と兄弟 という Git の基礎が、自分の中で曖昧だったのです。
そこで本記事では、まず Git の前提を固め、そのうえでサブモジュールの仕組みと運用(コンフリクトの直し方・正しいリリース順序)を整理し、最後に簡単な具体例で進めていきます。
本記事のリポジトリ名、ハッシュ、ブランチ名、テーブル名・マイグレーションなど本記事中の固有名詞はすべてフィクションの例示です。
実在の顧客・契約・個人情報・社内情報とは一切関係ありません。
まずはGitの基礎を整理する
コミットとハッシュ ―「消せない本のページ」
コミットとは、ある時点のスナップショット(その瞬間のプロジェクト全体の写し)です。
各コミットには、aaaaaaa のような一意なハッシュが付きます。
重要なのは、一度作ったコミットの内容は、二度と変わらないことです。
本でいえば、印刷済みのページです。書き換えはできません。間違えたら、新しいページを足します。
コミット aaaaaaa ← このページの中身は永久に固定
コミット bbbbbbb ← 次に足したページ。これも固定
過去のページは消せません。だから安心して履歴をたどれます。
これが Git の根幹になっています。
ブランチとHEAD ―「しおり」と「今開いているページ」
「コミット=消せないページ」と決めました。では、たくさんあるページの中で「最新はどれか」を示す役割を担うのがブランチです。
ブランチとは、「最新コミットを指す、動かせる名前(ポインタ)」です。
ここが重要です。
ブランチ自体は中身(データ)を持っていません。 ページ番号を指している「しおり」にすぎません。新しいコミットを作ると、しおりが次のページへ移動します。
そして HEADは「今、自分が開いているページ」 です。普段はどれかのブランチ(しおり)を指しています。
語彙を一枚にまとめておきます。以降もこのたとえで説明します。
| Gitの用語 | 本のたとえ | ひとことで |
|---|---|---|
| コミット | ページ | 消せない、その時点の中身 |
| ハッシュ | ページ番号 | コミットを一意に指す記号 |
| ブランチ | しおり | 最新ページを指す可動ポインタ(中身は持たない) |
| HEAD | 今開いているしおり | 今いる位置 |
分けて、くっつける ― マージ・fast-forward・そして親子と兄弟
しおりは複数挟めます。つまりブランチは分けられます。
分けたものをくっつけることもできます。それがマージです。
ここで覚えたいのが fast-forward(早送り) です。
ひとことで言えば、 「しおりを前に進めるだけの、いちばん単純なマージ」 です。
新しいマージコミットを作らず、ブランチのしおりを相手のコミットまでスッと進めるだけで済みます。
それが起きる正確な条件は、次のとおりです。
マージ先(=今いるブランチ HEAD)のコミットが、取り込むコミットの「祖先」であるとき。 言い換えると、取り込むコミットがマージ先の子孫であるとき、fast-forward が起きます。
逆に、しおりを進めるだけでは追いつけない(枝分かれしている)場合は fast-forward できず、合流用のマージコミットが必要になります。
そして、この「相手が自分の祖先か否か」という違いが、親子と兄弟 の正体です。
まずは図で見てみましょう。
【親子(子孫)=一直線】 【兄弟=枝分かれ】
A → B → C → D C ← 自分
/
fast-forward 可能 A → B ──┤
(しおりを進めるだけ。 \
新しいコミットは作られない) E ← 相手
C と E は別ハッシュ。
互いに相手の祖先を含まない
→ fast-forward 不可
→ マージコミットが必要
図のとおり、コミット同士の関係は大きく二種類あります。
-
親子(子孫)関係:一方が他方の祖先を含む。履歴を一直線にたどれる。
→ fast-forward で解決できる。 -
兄弟関係:共通の祖先から枝分かれし、互いに相手の祖先を含まない。
→ fast-forward できない。マージコミットが必要。そしてサブモジュールでは自動解決できず「コンフリクト」になる。
「親子か、兄弟か」という区別を覚えておいてください。
後半は、この区別でサブモジュールを説明できます。
本記事で前提とするブランチ運用
このあと release や feature というブランチが出てきます。
本記事では、次のような一般的な運用を前提にします。
- main … 基点となるブランチ
- release … リリース対象を集約する共有ブランチ(統合先)
- feature … 自分の作業用ブランチ(
feature/#123など)
作業の流れは「feature で作業し、release へ PR(プルリクエスト) を出してマージする」です。以降の release / feature はこの意味で使います。
そもそも、なぜわざわざ別リポ+サブモジュールにするのか
分ける主な狙いは、アプリのコードとDBスキーマ(SQL)を別々に管理することです。
とくに複数のアプリが同じDBを使う場合は、マイグレーション(変更SQL)を db リポジトリ1つに集約でき、各アプリに同じSQLをコピーして持たずに済みます(各アプリは、その1つのリポジトリを参照するだけ)。
また、影響の大きいDB変更を、アプリとは別のPR・別のタイミングでレビュー/リリースできます。
さらに、アプリ側が 依存するDBの正確なバージョン(どのコミットまでのマイグレーションを前提にするか)を固定して開発できます。
その代わり参照先を変えるときは、本記事で扱う参照更新・コンフリクト・リリース順序の手間が生じます。
この利点とコストはセットと捉えると、この先の話が腑に落ちると思います。
ではサブモジュールとは
サブモジュールは、あるリポジトリの中に別のリポジトリへの参照を埋め込む仕組みです。重要なのは一点で、親リポジトリ(アプリ側)は子リポジトリ(マイグレーションファイル側)の中身を持たないことです。記録しているのは「子リポのどのコミットを使うか」というハッシュ(ポインタ)だけです。
親リポジトリが持つのは、次の2つです。
-
.gitmodules… サブモジュールの置き場所(path)と取得元(url)の定義 - gitlink … 子リポの特定コミットを指す“ハッシュ1個の印”(Git 内部での正式な呼び名。中身は持たない)
sample-app(親)が記録しているもの
├── .gitmodules ← path = sample-db, url = https://example.com/sample-db.git
└── sample-db → aaaaaaa ← 子リポのコミットハッシュ“1個”だけ
※ sample-db の中身そのものは、sample-app には入っていない
ここから、戸惑いやすい2つの「正常な挙動」が説明できます。
-
clone直後はフォルダが空 … 親はハッシュしか持たないので、子の中身はまだ手元に来ていません。
git submodule update --init(ネストしたサブモジュールがあれば--recursiveも付ける)で取得します。最初から取得するならgit clone --recurse-submodulesを使います。 -
detached HEAD(直訳:切り離された HEAD)と出る … サブモジュールは「特定のコミットを指す」ことが役割です。ふだん HEAD はブランチ(しおり)を指しますが、ここではブランチを経由せずコミットを直接指している=ブランチから「切り離された(detached)」状態です。
なぜコンフリクトするのか ― 原因は「兄弟」
普通のファイルのコンフリクトと、サブモジュールのコンフリクトは、衝突しているものが違います。
| 普通のファイル | サブモジュール | |
|---|---|---|
| 衝突するもの | ファイルの本文 | 親が指す子のコミットハッシュ |
| 直し方 | 本文を編集して選ぶ | どのハッシュを指すか選び直す |
| Webエディタで直せるか | 直せる | 直せない(コマンドラインが必要) |
サブモジュールの衝突は「本文の衝突」ではなく「参照ハッシュの不一致」です。だから本文を編集する GitHub の Web エディタでは直せません。
そのハッシュがなぜ食い違うのか。原因は、自分のブランチが指す子のコミットと、release が指す子のコミットが、前提で見た**「兄弟」**の関係になることです。
C ← 自分(feature が参照)
/
A ──┤ ← 互いに相手の祖先を含まない=「兄弟」
\
B ← release が参照
兄弟は fast-forward できません。サブモジュールが指せるハッシュは1個だけなので、B と C のどちらか一方しか選べず、Git が自動解決できずコンフリクトになります。
直し方 ― release を merge して「親子」にする
兄弟が原因なら、対処は「兄弟を親子に変える」ことです。
自分のブランチで release の最新を取り込めば、自分の参照が release の参照の子孫になり、fast-forward で解決できるようになります。
流れは「まず子リポ(sample-db)に入って合流させ、次に親リポに戻って“どのハッシュを指すか”を記録する」の2段階です。
# サブモジュール側で release を取り込む(兄弟 → 親子)
cd sample-db
git fetch origin
git merge origin/release
cd ..
# 親側で、更新後のハッシュ(参照)を記録する
git add sample-db
git commit -m "Update submodule sample-db to release"
git push origin "feature/#123"
どちらを使うかは、次の基準で選びます。
-
自分のサブモジュール側に残したい独自コミットがある
→git merge(合流させる) -
独自の変更は無く、release 最新に合わせるだけ
→git checkout origin/release(独自コミットがある状態で使うと、detached HEAD に移動してその変更を捨ててしまうので注意)
迷ったら git merge(相手を消さない安全側)
なお、force push や git reset --hard で相手のハッシュを上書きするのは避けます。release 側に正式には存在しない状態を指してしまい、他の開発者が release を取得したときに整合性が崩れるおそれがあるためです。消すのではなく、合流させるのが正攻法です。
なぜ db を先にマージするのか ― Flyway の依存
リリースでは「順序」も問われます。
sample-db を先にマージしなければならないのは、Flyway の事情です。
Flyway は DB のマイグレーションを V1, V2, … と番号順に適用し、一度適用した SQL は変更しません(イミュータブル)。新しい変更は、新しい番号で足します。マイグレーションは、デプロイや起動のタイミングで、未適用の SQL だけを番号順に自動実行します。
ここで依存のねじれが生まれます。
-
sample-app(親=アプリ)のコードは、新しいテーブルに依存している。 - そのテーブルは、
sample-db(子=マイグレーション)のV2が作る。
Git 上は別リポですが、業務上は完全にセットです。
アプリは V2 が作るテーブルに依存するため、依存先である sample-db(V2)を先にリリースするのが鉄則です。正しい順序と、逆順にしたときの破綻は次のとおりです。
✅ 正しい順序
① sample-db の PR を先にマージ → release に V2 が入る
② sample-app の参照を release 最新(V2 入り)に更新
③ sample-app の PR をマージ → コードと依存先テーブルが揃う
❌ 逆順
sample-app を先にマージしようとする
→ release 側の sample-db にまだ V2 が無い
→ 参照が合わず(兄弟化して)コンフリクト
→ アプリは存在しないテーブルを参照して破綻
具体例:新テーブルに依存する機能をリリースする
ここまでの理屈を、最小の例で通します。
sample-app に、sample-db の新テーブル(V2 で作成)に依存する機能を追加してリリースする場面です。
状況を整理します。
sample-db release: V1 まで(ハッシュ aaaaaaa)
feature: V2 追加済み(ハッシュ bbbbbbb)
sample-app release: sample-db → aaaaaaa を参照
feature: sample-db → bbbbbbb を参照 + 新機能のコード
Step 1. sample-db の PR を先にマージ
sample-db で feature → release の PR を作成し、マージします。これで release に V2 が入ります(マージ後の新しいハッシュを ccccccc とします)。
Step 2. sample-app の参照を release 最新に更新
# サブモジュールを release 最新(V2 入り)に合わせる
cd sample-db
git fetch origin
git checkout origin/release # ccccccc(V2 を含む release 最新)に移動
cd ..
# 親で参照を記録して push
git add sample-db
git commit -m "Update submodule to release (includes V2)"
git push origin "feature/#123"
この例では子側に独自のコミットが無いため、
release最新へ合わせるだけのgit checkout origin/releaseで問題ありません。自分の変更を残したい場合は、前章のgit mergeを使います。
Step 3. 確認
GitHub 上で sample-app の PR を開き、「conflicts なし」と表示されれば完了です。参照が release 最新(ccccccc)に揃い、アプリのコードと依存先テーブルが一致します。
ここまでで解説した「ポインタ」「兄弟→親子」「db を先に」が、結局この3手に集約されます。
要点のまとめ
- サブモジュール=ポインタ … 親はハッシュ1個だけ持つ。中身は子リポジトリにある
-
空フォルダ/detached HEAD … どちらも正常。中身は
git submodule update --initで取得 - 直せない conflict … 本文でなくハッシュの衝突。Web では直せず、コマンドで参照を選び直す
-
衝突の原因=兄弟 … 直し方は
releaseを merge して親子にし、fast-forward -
リリース順序=db 先 … Flyway の依存(アプリは新テーブルに依存)
sample-db→ 参照更新 →sample-appの順
基本の手順:
cd sample-db → git fetch → git merge origin/release → cd .. → git add → git commit → git push
参考文献
Git 公式
-
gitsubmodules … 親は gitlink(特定コミットを指すツリーエントリ)と
.gitmodulesで追跡する -
gitmodules …
.gitmodulesの path / url の定義 -
Pro Git ― 7.11 Submodules … サブモジュール総合(detached HEAD・取得・
CONFLICT (submodule)) - git-merge … fast-forward の定義
- git-checkout … detached HEAD の定義
-
git-submodule …
update --init/--recursive -
git-push … non-fast-forward は既定で拒否/
--force
GitHub
- Working with submodules … サブモジュールの利用と clone 後の取得
- Dealing with non-fast-forward errors … force ではなく fetch/merge で対処
Flyway(Redgate)
- Versioned migrations … 番号順に一度だけ適用・checksum で適用済みの不変性を検証
- Migrations … マイグレーションと schema history の概念(補足)