コンテンツにスキップ

Gitサブモジュールのコンフリクト解消ガイド

サブモジュールのコンフリクトはなぜ起きるのか

Section titled “サブモジュールのコンフリクトはなぜ起きるのか”

サブモジュールは、親リポジトリ側で「サブモジュールがどのコミット(SHA)を指すか」だけを記録しています。
そのため、複数ブランチでサブモジュールの参照先SHAが別々に更新されると、親リポジトリ上ではコンフリクトになります。

%%{init: {'themeVariables': {'fontSize':'14px'}, 'flowchart': {'nodeSpacing': 28, 'rankSpacing': 35, 'padding': 10}}}%%
flowchart LR
    A[main docs→A] --> C[マージ]
    B[feature docs→B] --> C
    C --> D{同じ参照?}
    D -->|はい| E[自動マージ]
    D -->|いいえ| F[コンフリクト]

具体例: 同じ親コミットから2本のブランチを切ったとき

Section titled “具体例: 同じ親コミットから2本のブランチを切ったとき”

ここでは、親リポジトリ(メインプロジェクト)とサブモジュールの関係を、できるだけ具体的に追います。

  • 親リポジトリの main はコミット P0 にいる
  • サブモジュール docs/ はコミット S0 を指している
  • P0 から feature-afeature-b の2ブランチを切る(親の起点SHAは同じ
%%{init: {'themeVariables': {'fontSize':'14px'}, 'flowchart': {'nodeSpacing': 28, 'rankSpacing': 35, 'padding': 10}}}%%
flowchart LR
    P0[main docs→0] --> FA[feature-a docs→0]
    P0 --> FB[feature-b docs→0]

この時点では、両ブランチともサブモジュール参照は S0 のままです。
ブランチを切っただけではコンフリクトは起きません。

コンフリクトの判定ルール(親リポジトリ視点)

Section titled “コンフリクトの判定ルール(親リポジトリ視点)”

マージ時に Git が見ているのは、サブモジュール内のファイル差分ではなく 親が記録している SHA 1行 だけです。

状況結果
片方のブランチだけがサブモジュール参照を変更したコンフリクトしない(変更側の SHA が採用される)
両方ともサブモジュール参照を変更し、同じ SHA を指しているコンフリクトしない
両方ともサブモジュール参照を変更し、異なる SHA を指しているコンフリクトする
どちらのブランチもサブモジュール参照を変更していないコンフリクトしない

ケース1: コンフリクトしない(片方だけサブモジュール更新)

Section titled “ケース1: コンフリクトしない(片方だけサブモジュール更新)”

feature-a だけがサブモジュールを S1 に更新し、親でその参照更新をコミットする。
feature-b は親の別ファイルだけ変更し、サブモジュール参照は S0 のまま。

%%{init: {'themeVariables': {'fontSize':'14px'}, 'flowchart': {'nodeSpacing': 28, 'rankSpacing': 35, 'padding': 10}}}%%
flowchart LR
    P0[起点 docs→0] --> P1[feature-a docs→1]
    P0 --> P2[feature-b docs→0]
    P1 & P2 --> M[マージ] --> OK[競合なし →1]

feature-afeature-bmain にマージすると、サブモジュール参照は S1 に更新された側がそのまま採用され、通常は競合しません。

ケース2: コンフリクトしない(両方とも同じ SHA に更新)

Section titled “ケース2: コンフリクトしない(両方とも同じ SHA に更新)”

feature-afeature-b も、サブモジュール側で同じコミット S1 を作り(または同じ既存コミットを checkout し)、親でもどちらも S1 を指すコミットを作った場合です。

%%{init: {'themeVariables': {'fontSize':'14px'}, 'flowchart': {'nodeSpacing': 28, 'rankSpacing': 35, 'padding': 10}}}%%
flowchart LR
    P0[起点 docs→0] --> P1[feature-a docs→1]
    P0 --> P2[feature-b docs→1]
    P1 & P2 --> M[マージ] --> OK[競合なし]

親から見ると最終的にどちらも S1 なので、マージ時に参照の衝突は起きません。

ケース3: コンフリクトする(両方が異なる SHA に更新)

Section titled “ケース3: コンフリクトする(両方が異なる SHA に更新)”

feature-a はサブモジュールを S1 に、feature-bS2 に更新して親へコミットした場合です。
サブモジュール内の差分が似ていても、親が記録する SHA が違えば競合します。

%%{init: {'themeVariables': {'fontSize':'14px'}, 'flowchart': {'nodeSpacing': 28, 'rankSpacing': 35, 'padding': 10}}}%%
flowchart LR
    P0[起点 docs→0] --> P1[feature-a docs→1]
    P0 --> P2[feature-b docs→2]
    P1 & P2 --> M[マージ] --> NG[競合 1か2?]

マージ時に both modified: docs のような表示になり、親側でどちらの SHA を採用するかを人が決める必要があります。

ケース4: コンフリクトする(内容は同じだが SHA が違う)

Section titled “ケース4: コンフリクトする(内容は同じだが SHA が違う)”

feature-afeature-b が、サブモジュール内で同じ修正を別々にコミットした場合です。
たとえば同じ1行の typo 修正でも、コミットが別なら SHA は別になります。

  • feature-a 側: サブモジュール S1abc1234
  • feature-b 側: サブモジュール S2def5678
%%{init: {'themeVariables': {'fontSize':'14px'}, 'flowchart': {'nodeSpacing': 28, 'rankSpacing': 35, 'padding': 10}}}%%
flowchart LR
    P0[親 docs→0] --> PA[feature-a docs→1]
    P0 --> PB[feature-b docs→2]
    PA & PB --> C[競合: 中身同じでも参照2つ]

git diff でサブモジュールの中身を比較すると差分ゼロでも、親の gitlink は abc1234def5678 の二択になるため競合します。

ケース5: コンフリクトしない(サブモジュールは触らず親だけ変更)

Section titled “ケース5: コンフリクトしない(サブモジュールは触らず親だけ変更)”

両ブランチとも、親リポジトリの READMEastro.config.mjs など、サブモジュール以外だけを変更した場合です。
このときは通常のファイルマージの話になり、サブモジュール参照は S0 のままなので競合しません。

%%{init: {'themeVariables': {'fontSize':'14px'}, 'flowchart': {'nodeSpacing': 28, 'rankSpacing': 35, 'padding': 10}}}%%
flowchart LR
    P0[起点 docs→0] --> P1[feature-a 親のみ変更]
    P0 --> P2[feature-b 親のみ変更]
    P1 & P2 --> M[マージ] --> OK[競合なし]

ケース6: 順番にマージすれば済むこともある

Section titled “ケース6: 順番にマージすれば済むこともある”

feature-a を先に main にマージしたあと、feature-b を最新 main にリベースしてからマージする運用では、状況によっては競合を回避できます。
ただし feature-b 側でもサブモジュール参照を別 SHA に更新している場合、リベース時点で競合が表面化することがあります。

%%{init: {'themeVariables': {'fontSize':'14px'}}}%%
sequenceDiagram
    participant M as main
    participant A as feature-a
    participant B as feature-b
    M->>A: 分岐
    M->>B: 分岐
    A->>M: docs→1 をマージ
    B->>M: main を取り込み
    Note over B,M: Bが docs→2 なら競合
  1. まず親リポジトリ側でどのパスが衝突しているかを確認する
  2. サブモジュールに移動して、採用したいコミット(またはそのコミットを含むブランチ)を決める
  3. 親リポジトリに戻り、サブモジュールの参照先更新を1つに確定してコミットする

1. 親リポジトリで競合ファイルを確認

Section titled “1. 親リポジトリで競合ファイルを確認”
Terminal window
git status

both modified: path/to/submodule のように表示されたら、サブモジュール参照の競合です。

2. サブモジュール側で採用コミットを決定

Section titled “2. サブモジュール側で採用コミットを決定”
Terminal window
cd path/to/submodule
git log --oneline --decorate --graph -n 20

必要であれば、サブモジュール内で mergerebase を行って、最終的に採用したいコミットを作ります。

3. 親リポジトリで参照先を確定

Section titled “3. 親リポジトリで参照先を確定”
Terminal window
cd ..
git add path/to/submodule
git commit -m "Resolve submodule pointer conflict"

git add path/to/submodule は、サブモジュール自体のファイル中身ではなく「どのSHAを指すか」をステージします。

内容が同じならコンフリクトしない?

Section titled “内容が同じならコンフリクトしない?”

いいえ、コンフリクトします。
内容が同じでも、コミットSHAが異なれば別コミットとして扱われるため、サブモジュール参照は競合します(上記ケース4)。

例えば次のようなケースです。

  • チームA: 同じ修正をコミットしてSHAが abc1234
  • チームB: 同じ修正を別コミットとして作成してSHAが def5678

コード差分は実質同じでも、親リポジトリから見ると abc1234def5678 は別参照です。
このとき、サブモジュール参照更新が同時に入ると競合します。

サブモジュール内でコンフリクトしなければ親も安全?

Section titled “サブモジュール内でコンフリクトしなければ親も安全?”

そうとは限りません。
サブモジュール内のマージがきれいに終わっても、親リポジトリ側では「どのコミットSHAを指すか」が食い違っていれば競合します。
解消は親リポジトリで参照先SHAを1つに確定し、必要ならサブモジュール側で採用コミットの内容を確認します。

  • サブモジュール更新を含むPRは、できるだけ小さく保つ
  • マージ前に親リポジトリとサブモジュールの両方で最新取り込みを行う
  • 「サブモジュール更新PR」と「別機能PR」を分けてレビューしやすくする
  • どのコミットを採用するかをPR説明に明記する