🦔

【Git submodule】チームで運用する際の不安と対策(pre-commit編)

2022/11/28に公開

Git submoduleは複数のコードから同じリポジトリを指すことができて便利ですが、通常のGitより操作することが多くなり、ちょっと難しくなります。
操作が多くなるということはヒューマンエラーの可能性も高くなります。ちょうどチーム開発でsubmoduleを使うことになったので、運用していくにあたって気付いた問題と対策をまとめようと思います。

その1:Git submoduleについて

別のリポジトリのコミットへの参照をGitに含める機能です。内容や操作方法については別の記事を参考にしてください。

https://git-scm.com/book/ja/v2/Git-のさまざまなツール-サブモジュール
https://qiita.com/sotarok/items/0d525e568a6088f6f6bb
https://prograshi.com/general/git/how-to-use-git-submodule/

その2:親子を並行して開発したい

並行して開発する場合、それぞれで扱わずにsubmoduleの状態で操作することが多いと思います。すると、親Gitの方では差分として検出され、add+commitを要求されます。

# 例(子Gitでcommit前)
modified:   src/database/migrations (modified content)
# 例(子Gitでcommit後)
modified:   src/database/migrations (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

子Gitでcommitしていれば、push前であっても親Gitでadd+commit+pushすることが可能です。

するとどうなるかというと...

404ページ
他の人から見えない

問題点

  • 子がpushする前のコミットを親が参照している場合でも親Gitでcommitできてしまう

対処

こういう時はpre-commitで対応しましょう。 git log origin/{branch name}..HEAD だと、ブランチの先頭以外にいる時にうまく動かないので、 git branch -r --contains $commit を使います。繋がっているリモートブランチがあればブランチ名が返ってくるので、空行であればpush前とします。

pre-commit
#!/usr/bin/env bash
# 区切り文字を改行のみに変更
IFS=$'\n'
submodules=$(git submodule)
currentPath=$(pwd)
exitCode=0
for line in $submodules
do
    commit=$(echo $line | awk '{ print $1 }' | sed -e 's/^[-+ ]//g')
    submodulePath=$(echo $line | awk '{ print $2 }')
    # git submoduleの結果がマイナスから始まる場合はcloneできていないのでチェック不可として落とす
    if echo $line | awk '{ print $1 }' | grep -E '^-'; then
        echo "git submodule update --init --recursive を実行してください"
        exitCode=$(($exitCode | 1))
    else
        cd $submodulePath
        # 未pushのcommitを参照することをブロック
        remoteBranches=$(git branch -r --contains $commit)
        if [ "$remoteBranches" = "" ]; then
            echo "$submodulePath の変更がリモートにプッシュされていません"
            exitCode=$(($exitCode | 1))
        fi
        cd $currentPath
    fi
done
exit $exitCode

検証

🎐 ❯ git status  
On branch workspace/submodule
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   src/database/migrations (new commits)

no changes added to commit (use "git add" and/or "git commit -a")

🎐 ❯ git add . 

🎐 ❯ git commit -m "test"
src/database/migrations の変更がリモートにプッシュされていません

その3:参照できるブランチを固定したい

通常の開発フローだと、トピックブランチを切って開発することが多いと思います。親も子も新規ブランチを作成→開発→pushしてPRを出して......と進むと1つ問題が発生します。トピックブランチを指したsubmoduleが生まれてしまいます
子側でマージされれば小問題ですみますが、もしマージされずに放置されてしまった場合......

問題点

  • 親:デフォルトブランチ、子:トピックブランチという状態になる
    • 子Gitが指しているコミットはトピックブランチのものなので、そのまま新規ブランチを切ると、トピックブランチから別のトピックブランチが発生する
    • 子がマージされていない場合、子をデフォルトブランチに戻すとデグレが発生する

対処

参照できるブランチを固定しましょう。これも2と同じでpre-commitが手軽ですね。 git branch --contains $commit を使うとブランチ名を取ることができます。
取り敢えずデフォルトのmainを決め打ちしてますが、今後進めていくにあたって気づきがあれば追記します。

git-flowだとreleaseはreleaseを参照したいとかになりそうな予感がありますな

pre-commit
#!/usr/bin/env bash
# 区切り文字を改行のみに変更
IFS=$'\n'
submodules=$(git submodule)
currentPath=$(pwd)
exitCode=0
for line in $submodules
do
    commit=$(echo $line | awk '{ print $1 }' | sed -e 's/^[-+ ]//g')
    submodulePath=$(echo $line | awk '{ print $2 }')
    # git submoduleの結果がマイナスから始まる場合はcloneできていないのでチェック不可として落とす
    if echo $line | awk '{ print $1 }' | grep -E '^-'; then
        echo "git submodule update --init --recursive を実行してください"
        exitCode=$(($exitCode | 1))
    else
        cd $submodulePath
# ---------------------- 追記ここから -----------------------------
        # main以外を参照することをブロック
        currentExitCode=$(git branch --contains $commit | sed -e 's|[\* ]*||' | grep -E ^main$ > /dev/null; echo $?)
        exitCode=$(($exitCode | $currentExitCode))
        if [ $((currentExitCode)) -ne 0 ]; then
            echo "$submodulePath がmainブランチに存在しないコミットを参照しています(${commit:0:7})"
        fi
# ---------------------- 追記ここまで -----------------------------
        # 未pushのcommitを参照することをブロック
        remoteBranches=$(git branch -r --contains $commit)
        if [ "$remoteBranches" = "" ]; then
            echo "$submodulePath の変更がリモートにプッシュされていません"
            exitCode=$(($exitCode | 1))
        fi
        cd $currentPath
    fi
done
exit $exitCode

検証

🎐 ❯ git submodule
 f6e3657a8fa1246de4ed03fdefe273fb11523d3d submodule-test-child (heads/feat/new-2)

🎐 ❯ git commit -m "feat: new-2"
submodule-test-child がmainブランチに存在しないコミットを参照しています(f6e3657)

その4:デグレを防ぎたい

ここまでで十分かというと、まだあります。
後から発生したPRが先にマージされるなんてこと、あると思います。GitHubのPR上ではしっかりコンフリクトになるので、気をつけていれば何も起こりません。
ですがコンフリクト修正の結果が巻き戻っていたとしても、レビュアーは気付けないと思います。

???「8027e7とce564eってどっちが新しいんだ?」

問題

  • 意図せず昔のコミットに戻ってしまう

対処

こちらの記事ではGHAを使って対処しています。
https://zenn.dev/helicoir/articles/d5e77f9a1844ea

なので、今回はpre-commitでやってみます。

🎐 ❯ git diff --cached --submodule=log                                                                                                          
Submodule submodule-test-child ce564ea..8027e79 (rewind):
  < Merge pull request #1 from sun-yryr/hogehoge

--submodule=log オプションを使うと、短い形で変更を表示してくれるので、こちらを使います。rewind(= 巻き戻し)があるのでこれをgrepしてもいいんですが、丁度コミットハッシュが2つあるので git log を使ってみましょう。

git log 新しいコミット..古いコミット をすると空行になることを利用します。

🎐 ❯ git log ce564ea..8027e79

🎐 ❯ git log 8027e79..ce564ea
commit ce564ea23089d971408ba5f184cd41a3ab4a38af
Merge: 8027e79 5e79825
Author: ゆるゆる <develop@sun-yryr.com>
pre-commit
#!/usr/bin/env bash
# 区切り文字を改行のみに変更
IFS=$'\n'
submodules=$(git submodule)
currentPath=$(pwd)
exitCode=0
for line in $submodules
do
    commit=$(echo $line | awk '{ print $1 }' | sed -e 's/^[-+ ]//g')
    submodulePath=$(echo $line | awk '{ print $2 }')
# ---------------------- 追記ここから -----------------------------
    submoduleCommitDiff=$(\
        git diff --cached --unified=0 --no-color --submodule=log -- $submodulePath \
        | head -n 1 \
        | sed -r 's/^.*(.{8}\.\..{8}).*$/\1/g' \
        | tr -d ' '\
    )
# ---------------------- 追記ここまで -----------------------------
    # git submoduleの結果がマイナスから始まる場合はcloneできていないのでチェック不可として落とす
    if echo $line | awk '{ print $1 }' | grep -E '^-'; then
        echo "git submodule update --init --recursive を実行してください"
        exitCode=$(($exitCode | 1))
    else
        cd $submodulePath
        # main以外を参照することをブロック
        currentExitCode=$(git branch --contains $commit | sed -e 's|[\* ]*||' | grep -E ^main$ > /dev/null; echo $?)
        exitCode=$(($exitCode | $currentExitCode))
        if [ $((currentExitCode)) -ne 0 ]; then
            echo "$submodulePath がmainブランチに存在しないコミットを参照しています(${commit:0:7})"
        fi
        # 未pushのcommitを参照することをブロック
        remoteBranches=$(git branch -r --contains $commit)
        if [ "$remoteBranches" = "" ]; then
            echo "$submodulePath の変更がリモートにプッシュされていません"
            exitCode=$(($exitCode | 1))
        fi
# ---------------------- 追記ここから -----------------------------
        # 過去に戻るのをブロック
        if [ "$submoduleCommitDiff" != "" ]; then
            # git logが存在しない = 時系列が逆 = 時間逆行
            log=$(git log $submoduleCommitDiff)
            if [ "$log" = "" ]; then
                echo "$submodulePath が過去に戻っています"
                exitCode=$(($exitCode | 1))
            fi
        fi
# ---------------------- 追記ここまで -----------------------------
        cd $currentPath
    fi
done
exit $exitCode

検証

🎐 ❯ git commit -m "test"             
submodule-test-child が過去に戻っています

まとめ

全部pre-commitでやってるので、誰かが --no-verify したらおしまいなんですよね〜 基本pre-commit、追加でGHAもやるのが一番いいと思います。

ここまでチェックを自動化した上でも、リリースとかまだまだ考えないといけないことがありそうなので、正直使わないでいけるのであれば使わない選択肢を取りたいですね。

参考になる↓

https://qiita.com/ex_SOUL/items/be1a7299be7b09d90a50

https://zenn.dev/helicoir/articles/d5e77f9a1844ea

株式会社ゆめみ

Discussion