Git実務ワークフロー #7 モノレポとGit — sparse-checkout・サブモジュール・LFS

読了 9分

ここまでの6回は、リポジトリの大きさと関係なく通用する内容でした。ところがリポジトリがある規模を超えると、新しい種類の問題が現れます。複数のプロジェクトを1つのリポジトリに集めたモノレポではcloneに数十分かかり、デザインの元データやモデルファイルのような大容量バイナリがリポジトリを膨らませ続けます。これらの問題はブランチ戦略やコミット整理では解決できず、Gitが別に用意しているツールが必要です。

全7回で構成します。

  • #1 ブランチ戦略 — GitHub Flowとtrunk-based
  • #2 rebase vs merge — 判断基準と禁則
  • #3 interactive rebase — squash・fixupでコミット整理
  • #4 コンフリクト解決 — 構造とmergetool・rerere
  • #5 PR運用 — レビュー単位・コミットメッセージ・draft PR
  • #6 stash・cherry-pick・bisect — 日常ツールセット
  • #7 モノレポとGit — sparse-checkout・サブモジュール・LFS ← この記事

この記事は各ツールの深いチュートリアルではありません。目標はどんなツールがあり、いつ取り出して使うのかという地図を描くことです。まず症状を確認し、その症状に合うツールをひとつずつ対応させていきます。

リポジトリが大きくなると何が遅くなるのか #

Git基礎シリーズで確認したとおり、cloneは最新のファイルだけを受け取るのではなく、最初のコミットから現在までの履歴全体を受け取ります。リポジトリが小さいうちは、このモデルの利点だけが見えます。ネットワークなしですべての作業ができ、すべてのコミットをローカルで照会できます。ところがリポジトリが大きくなると、同じモデルがコストになります。

  • cloneの時間とディスク — 10年分の履歴、数万個のファイル、これまでコミットされたすべてのバイナリのすべてのバージョンを丸ごとダウンロードします。
  • 日常コマンドの速度git statusは作業ディレクトリのファイルを走査するため、チェックアウトされたファイル数に比例して遅くなります。ファイル50万個のモノレポなら、status一回に数秒かかり始めます。

整理すると、問題は2方向です。受け取る量(履歴とblob全体)と展開する量(作業ディレクトリのファイル数)です。前者を減らすツールがpartial clone、後者を減らすツールがsparse-checkoutです。

partial clone — 履歴はすべて、内容は必要なときに #

partial cloneは、コミットグラフはすべて受け取りつつ、ファイルの内容(blob)は実際に必要になるまで受け取らないcloneです。

partial clone
git clone --filter=blob:none https://github.com/example/big-monorepo.git

--filter=blob:noneは、コミットとディレクトリ構造はすべて受け取り、blobは省略するという意味です。チェックアウト時点で最新バージョンのblobだけをダウンロードし、過去バージョンが必要なコマンド(git log -pgit diffなど)を実行すると、そのつどサーバーから取得します。履歴の照会がときどきネットワークを経由する代わりに、最初のcloneが劇的に軽くなります。

似たように見えるものとしてshallow cloneがあります。

shallow clone — CI用途
git clone --depth 1 https://github.com/example/big-monorepo.git

--depth 1は最新コミット1つだけを受け取り、履歴を丸ごと切り落とします。一度ビルドして捨てるCI環境には合いますが、履歴がないためlogの照会とbisectが制限され、以降のfetchの挙動にも細かな癖が出ます。開発マシンにはpartial clone、使い捨てのCIにはshallow cloneと用途を分ければ十分です。

sparse-checkout — 作業するディレクトリだけを展開する #

モノレポで自分が触る部分がservices/webだけなら、残りの数十万ファイルを作業ディレクトリに展開しておく理由はありません。sparse-checkoutはチェックアウト対象を指定したディレクトリに絞ります。

sparse-checkoutの設定
git sparse-checkout init --cone
git sparse-checkout set services/web shared/ui

これで作業ディレクトリには最上位のファイルとservices/webshared/uiだけが残ります。履歴はそのまますべてあるのでlogの照会やブランチ作業に制約はなく、git statusが走査するファイル数が減って日常コマンドがまた速くなります。作業範囲が変わったらgit sparse-checkout setを再実行して調整します。

ヒント
大規模モノレポの定番の組み合わせは、partial cloneとsparse-checkoutを併用することです。git clone --filter=blob:none --sparse <URL>で受け取ったあとgit sparse-checkout set <パス>で自分の作業領域だけを展開すれば、受け取る量と展開する量を同時に減らせます。

サブモジュール — 別のリポジトリをコミット単位で参照する #

方向を変えて、リポジトリ1つが大きくなる問題ではなく、複数のリポジトリを束ねる問題です。サブモジュールは、別のGitリポジトリを自分のリポジトリの下位パスに特定のコミットで固定して参照する機能です。

サブモジュールの追加
git submodule add https://github.com/example/vendor-lib.git vendor/lib
git commit -m "Add vendor-lib submodule"

自分のリポジトリに記録されるのはvendor-libのファイルではなく、URLとコミットハッシュ1つです。参照先が正確なバージョンで固定されるため、外部依存をソースごと特定バージョンで固定しておく必要がある状況に向いています。ファームウェアが参照する外部SDK、フォークして管理するサードパーティライブラリが典型例です。

ただし、サブモジュールには悪名もついて回ります。理由はおおむね3つです。

  • cloneのときgit clone --recurse-submodulesを使わないと、サブモジュールのディレクトリが空のまま残ります。チームの誰かがこのオプションを忘れることが繰り返し起きます。
  • サブモジュールを新しいバージョンに上げる手順が別立てです。サブモジュールの中で目的のコミットをチェックアウトし、外側のリポジトリで変わった参照を改めてコミットする必要があります。
  • サブモジュールの内部は基本的にdetached HEAD状態のため、中でうっかりコミットすると、どのブランチにも属さないコミットが生まれます。

実務での判断基準は単純です。依存がパッケージマネージャー(npm、pip、Go modules)で解決できるならそちらが先で、複数のプロジェクトを常に一緒に修正するならモノレポのほうが合います。サブモジュールはソースごとバージョン固定がどうしても必要な場合のために残しておく選択肢です。

Git LFS — 大容量バイナリを外に出す #

最後はファイルサイズの問題です。Gitはテキストのソースコードに最適化されているため、数百MBのデザイン元データ(PSD)や学習済みモデルファイルをコミットすると、リポジトリが急激に重くなります。バイナリはバージョンごとに丸ごと保存されるのと変わらず、一度コミットされたすべてのバージョンが履歴に残ってcloneの時間に永久に上乗せされます。

Git LFS(Large File Storage)は、大容量ファイルをリポジトリの中に入れる代わりに、リポジトリには小さなポインタファイルだけをコミットし、実際の内容は別のストレージにアップロードします。

Git LFSの設定
git lfs install                # リポジトリにLFSフックを設置 (1回)
git lfs track "*.psd"          # PSDファイルをLFS対象に指定
git add .gitattributes         # 追跡ルール自体をコミットに含める
git add design/main.psd
git commit -m "Add main design file"

git lfs trackが作る.gitattributesが追跡ルールの実体なので、必ず一緒にコミットします。以降はチェックアウトのときにポインタが実際のファイルへ自動的に置き換わるため、使う側からは通常のファイルとほぼ同じに見えます。注意すべきはホスティングの費用です。GitHubのLFSストレージと帯域は無料枠が小さいため、大容量アセットの多いチームは料金プランや外部ストレージ連携を事前に確認しておく必要があります。

注記
LFSが適用されるのはこれからコミットするファイルだけです。すでに通常のコミットとして入った大容量ファイルは、いま削除しても履歴の中にそのまま残り、cloneを重くし続けます。秘密鍵をコミットしたときと同じ構造の問題で、履歴そのものを書き直す復旧手順が必要です。この復旧は別の記事で扱います。

症状別のツール選択 #

この記事の地図を表1つにまとめます。

症状ツール
cloneが遅すぎてディスクも大きく占有するpartial clone (--filter=blob:none)
CIで一度だけビルドするリポジトリを速く受け取りたいshallow clone (--depth 1)
モノレポで一部のディレクトリだけ作業するsparse-checkout (+ partial cloneの併用)
別のリポジトリを特定バージョンで固定して参照する必要があるサブモジュール (パッケージマネージャーで済むならそちらを優先)
大容量バイナリをバージョン管理する必要があるGit LFS (コミット済みの分は履歴の整理が必要)

シリーズ全体のまとめ #

7回分の核心を1行ずつ整理します。

  • #1 ブランチ戦略は、チームの規模とデプロイ方式に合わせてGitHub Flowやtrunk-basedから選ぶ合意です。
  • #2 rebaseとmergeは優劣ではなく用途の問題で、共有ブランチのrebaseだけが禁則です。
  • #3 interactive rebaseは、squashとfixupでコミットをレビューしやすい単位に整えるツールです。
  • #4 コンフリクトは3-wayの構造で理解し、mergetoolとrerereで解決コストを下げます。
  • #5 PRは小さく保ち、コミットメッセージと説明に変更の意図を残すことがレビューの質を決めます。
  • #6 stash、cherry-pick、bisectは、作業の切り替え、コミットの選択的な移動、リグレッション地点の探索という日常の問題の専用ツールです。
  • #7 リポジトリが大きくなったら、partial clone、sparse-checkout、サブモジュール、LFSで受け取る量と展開する量を管理します。

まとめ #

Git基礎シリーズでスナップショットモデルと3つの領域、ブランチとリモート、取り消しまでの基本を固め、このシリーズでチーム協業の実際の状況、すなわちブランチ戦略、履歴の整理、コンフリクト、PR運用、日常ツール、大規模リポジトリ対応まで広げました。この2つのシリーズがあれば、日常業務で出会うGitの状況の大部分を自分で判断できます。

残っているのは、事故が起きたあとの対応です。消したコミットをreflogで復元する方法、秘密鍵や大容量ファイルをコミットしてしまったときに履歴を書き直す方法といった復旧テーマを、検索から直接たどり着ける単発記事として準備しています。以上でGit実務ワークフローシリーズを締めくくります。

X