GoSuda

Git Monorepo の再考:人間と Coding Agents のための CAS-Native Architecture

By Lemon Mint views ...

成長を続けるエンジニアリング組織は、いずれ「多数のリポジトリ」か「単一のリポジトリ」かの選択を迫られます。小さなリポジトリを数十個で始めると、当初は生活がシンプルに感じられます。各ライブラリは独自のissueトラッカー、独自のCIパイプライン、そして数秒で完了するgit cloneを持っています。しかし、コアのネットワーキングパッケージの構造体を一つ変更すると、午後の残りの時間を、6つの下流リポジトリにまたがるバージョン更新のプルリクエストを開くことに費やし、CIキューがトポロジカル順に空くのを待つことになります。

すべてを従来型のGitモノレポに移行すると、リポジトリ間の調整の問題は、リポジトリのサイズの問題に置き換わります。コミット履歴が深くなり、過去のリビジョンにバイナリアセットが蓄積されると、git fetchは遅くなります。ワーキングツリーは数ギガバイトにまで膨れ上がります。やがて誰かが、日々の開発を耐えられるものに保つために、カスタムの仮想ファイルシステムデーモン、専用のコードレビューサーバー、あるいはリモートビルドワーカーの群れを立ち上げることを提案するでしょう。

自律型コーディングエージェントがチームに加わると、両方の失敗モードはさらに悪化します。ポリリポジトリでは、エージェントは横断的な呼び出し元を把握できず、リポジトリをまたいで認証情報を切り替えることを強いられます。従来型のモノレポでは、タスクの初期化のたびにエージェントのコンテナのディスクとネットワーク帯域が圧迫されます。さらに、同じブランチを同時に操作する複数のエージェントは、終わりのないrebaseの競合を引き起こします。

google3アーキテクチャに着想を得て、我々は単一トランクへのアトミックな変更、サブ秒で完了するスパースなオンボーディング、階層的なOWNERSによるガバナンス、そして標準的なGitの基盤と汎用オブジェクトストレージによるスタンドアロンのオープンソースミラーを構築しました。

この設計では、カスタムのファイルシステムデーモンもサードパーティ製のレビューアプライアンスも必要ありません。なぜなら、Gitを Merkle-tree Content-Addressable Store(CAS)として扱うからです。

遅いGitモノレポの中心にある誤解

開発者が「Gitは大規模なモノレポに対応できない」と不満を述べるとき、実際にGitのオブジェクトデータベースの限界に達していることはまれです。.git/objectsの内部では、Gitは SHA-1(または SHA-256)のコンテンツハッシュをキーとする三つの不変なプリミティブを格納します。

  1. blob: 単一ファイルの生のバイト内容。
  2. tree: ファイル名と権限をblobまたは子treeのハッシュに対応づけるディレクトリの一覧。
  3. commit: 単一のルートtreeハッシュ、ゼロ個以上の親commitハッシュ、および作成者のメタデータを指す、小さなテキストヘッダー(通常200〜300バイト)。

モノレポに50,000個のファイルが含まれており、ある1つのファイルの1行を編集したとします。その場合、新しいコミットは残りの49,999個のファイルについて、既存のblobとtreeのハッシュをそのまま再利用します。

従来型のGitモノレポが重く感じられるのは、慣習によって一つの参照refs/heads/mainに、互いに衝突する三つの役割が負わされているためです。

  • すべてのプロジェクトの統合されたディレクトリツリー全体を保持する。
  • 初日までさかのぼる親を連結した履歴コミットの全チェーンを引きずる。
  • Gitサーバーが各git cloneおよびgit fetchの際にデフォルトで通知・交渉を行う名前空間であるrefs/heads/*の中に置かれる。

開発者やCIランナーがrefs/heads/mainを取得するたびに、クライアントとサーバーは何千もの過去のリビジョンにわたるコミットグラフの到達可能性を交渉します。もし3年前に誤って100 MBのアーカイブをコミットし、翌日それを削除していた場合、そのblobは依然としてrefs/heads/mainから到達可能な過去のtreeにぶら下がったままになります。

参照名前空間の分離

Gitのワイヤープロトコルは、標準的なclone の際にrefs/heads/*とrefs/tags/*のみを通知します。refs/trunk/*やrefs/ws/*のようなカスタム名前空間の下にある参照は、サーバー上の同一のオブジェクトストアに存在し、同じpackfileのデルタ圧縮の恩恵を受け、明示的にfetchやpushできます。それでいて、デフォルトのcloneには一切のオーバーヘッドを与えません。

単一のリポジトリ内で、これらの役割を別々の参照に分割することで、ボトルネックが取り除かれます。

1refs/heads/main               コントロールプレーンのみ(約20 KB:ルール、.gitconfig、同期設定、スクリプト)
2refs/trunk/main               最新の統合ルートツリーを指す親なしのスナップショットコミット
3refs/trunk/history            正確に同じルートツリーのSHA-1を共有する、線形の親連結コミットチェーン
4refs/ws/<actor>/<id>          開発者またはエージェントごとの分離されたワークスペーススナップショット(変更リスト)
5refs/ws/cl/<change-id>/<tip>  不変のパッチセット:レビューに送られた変更のすべての先端
6refs/heads/land/<id>          着地と同時に削除される、一時的なマージキューのトリガー

1. コントロールプレーン(refs/heads/main)

デフォルトブランチにはアプリケーションのソースコードは含まれません。リポジトリの運用ルール(AGENTS.md、README.md)、ワークフローのエイリアスを集めた共有.gitconfig、Starlarkの同期設定(sync.star)、およびサーバー側のマージスクリプトのみを管理します。デフォルトブランチのcloneは約20 KBを転送し、数ミリ秒で完了します。

2. 親なしのトランクスナップショット(refs/trunk/main)

refs/trunk/mainは組織全体の完全で統合されたソースツリー(//web/*、//net/*、//ipc/*、//storage/*、//crypto/*、//ai/*、//devtools/*、//corp/*、//third_party/*、//experimental/*)を指しますが、そのコミットオブジェクトは**-pによる親の指定なし**で作成されます。

refs/trunk/mainは親を持たない(parents = [])ため、取得にあたってコミットグラフの交渉は不要です。Gitサーバーは履歴をたどらず、単一の先頭コミットと現在のディレクトリtreeオブジェクトを送信します。先月のバイナリや削除されたファイルはrefs/trunk/mainから到達不能であり、転送されることは決してありません。

3. 線形の監査履歴(refs/trunk/history)

開発者は回帰を調査する際に、依然としてgit blameとgit logを必要とします。変更がトランクに着地するたびに、マージスクリプトはまったく同じルート$NEW_TREEのSHA-1ハッシュを指す二つのコミットオブジェクトを作成します。

 1# 即時の深さ1ワークスペースチェックアウト用の親なしスナップショットコミット
 2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
 3
 4# 線形のgit logとgit blame用の親連結コミット
 5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
 6
 7git push --atomic origin \
 8  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
 9  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10  "${SNAP}:refs/trunk/main" \
11  "${HIST}:refs/trunk/history"

refs/trunk/mainとrefs/trunk/historyの両方を保持しても、ストレージの占有量は二倍にはなりません。両方のコミットが同一の$NEW_TREEハッシュを参照するため、Gitのcontent-addressable storeはtreeとblobオブジェクトを100%重複排除します。親なしスナップショットの追加コストは、250バイトのコミットヘッダー一つ分にすぎません。

4. 分離されたワークスペース(refs/ws/<actor>/<id>)

作業中のブランチは、refs/heads/*ではなくrefs/ws/<actor>/<id>にpushされます。数百のコーディングエージェントが毎分チェックポイントを保存しても、ブランチのドロップダウンを汚染することはなく、人間のエンジニアのgit fetchを遅くすることもありません。

四つのステップによるオンボーディング:スパースかつblobless なチェックアウトの仕組み

この参照レイアウトでは、新しい開発者や一時的なコーディングエージェントのコンテナは、カスタムのCLIやFUSEドライバをインストールする必要がありません。オンボーディングは、標準的なGitとリポジトリのエイリアスを用いて四つのステップで完了します。

 1# 1. blob フィルタリングとスパースモードを有効にして、20 KBのコントロールプレーンをcloneする
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. リポジトリのエイリアスを読み込み、refs/trunk/* を接続する
 6git config include.path ../.gitconfig
 7git trunk-init
 8
 9# 3. このタスクに必要なドメインのディレクトリを宣言する
10git sparse-checkout set net/relay ipc/codec
11
12# 4. 最新のトランクスナップショットを起点とする分離されたワークスペースを作成する
13git ws-new alice/optimize-framing

各ステップは、必要なものだけを取得します。

  1. git clone --filter=blob:none --sparseは、refs/heads/main上の20 KBのコントロールプレーンをダウンロードします。
  2. git trunk-initは+refs/trunk/*:refs/remotes/origin/trunk/*を設定し、blobなしのfetchを実行します。Gitはrefs/trunk/mainのtreeメタデータ(通常数十キロバイト)をダウンロードしますが、ファイルのblobはゼロ個です。
  3. git sparse-checkout set net/relay ipc/codecは、Gitのcone modeのインデックスフィルタに対し、ルートディレクトリとその二つのサブツリーのみを実体化するよう指示します。
  4. git ws-newは、origin/trunk/mainを起点とする新しいブランチに切り替えます。その後Gitは、net/relayとipc/codec内のblobオブジェクトのみをオンデマンドで要求します。モノレポの残りの95%はディスクにダウンロードされることはありません。

将来の展望:refs/trunk/mainを仮想ファイルシステム(VFS)としてマウントする

blobなしcloneによるスパースチェックアウトは、追加のクライアント側ツールを必要とせずに日常のワークフローをカバーします。refs/trunk/mainはすでにContent-Addressable Store内の純粋なMerkle treeであるため、このアーキテクチャは、Linux上のFUSE、macOS上のFileProvider、Windows上のProjFSを介した、任意のユーザー空間仮想ファイルシステム(VFS)の直接的な基盤にもなります。

  1. 即時の全ツリーマウント: 軽量なローカルマウントデーモンがrefs/trunk/mainのルートtreeオブジェクトを読み込み、モノレポ全体のディレクトリ階層を数ミリ秒でワーキングディレクトリに投影します。これによりgit sparse-checkout setを実行する必要がなくなります。
  2. オンデマンドのblobフォールト処理: コンパイラ、IDE、またはエージェントがopen(2)やread(2)のシステムコールを発行するまで、ファイルの内容は仮想のままです。その後デーモンはCASエンドポイントから対応するSHA-1のblobを取得し、ローカルのNVMeストレージにキャッシュします。
  3. ワークスペース用のCopy-on-Writeオーバーレイ: ローカルの編集は軽量な上位オーバーレイ層に書き込まれます。git ws-saveを実行すると、そのオーバーレイ内の変更されたパスのみをハッシュし、更新されたblobおよびtreeオブジェクトを書き込み、結果のコミットを、変更のないディレクトリをスキャンすることなくrefs/ws/<actor>/<id>にpushします。

純粋なGit上の変更リストモデル:サーバー側でのインメモリ着地

従来のGitワークフローでは、フィーチャーブランチをマージすると、履歴を非線形にする二親のマージコミットが作られるか、開発者が手動でrebaseを行い、mainへの同時pushと競争しなければなりません。

google3の変更リスト(CL)モデルに着想を得て、我々のアーキテクチャでは、すべてのワークスペースがトランクスナップショットに対する単一のアトミックな変更を表します。ws/alice/optimize-framingでローカルに作業している間、開発者やエージェントはwip、fix tests、lintのような小さなコミットを十個ほど作るかもしれません。変更の着地準備が整うと、サーバーはCIランナー上でワーキングディレクトリをチェックアウトすることなく、ワークスペースの差分を一つの線形なトランクのリビジョンに畳み込みます。

以下は、着地スクリプト(land.sh)がGit 2.40以降のplumbingを用いてメモリ内で実行される様子です。

 1# 1. ワークスペースが分岐した元のルートスナップショットコミットを特定する
 2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
 3
 4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
 5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
 6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
 7
 8# 2. ワーキングツリーをチェックアウトせずに、メモリ内でマージ済みのルートツリーを計算する
 9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10  NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12  NEW_TREE=$(head -n1 <<<"$OUT")
13else
14  echo "マージの競合を検出しました。git ws-sync を実行してrebaseしてください。" >&2
15  exit 1
16fi

ステップ2では次のことが行われます。

  • ワークスペースのブランチ上でローカルコミットの数がいくつであっても、スクリプトは三つのtreeハッシュ、すなわち元のベーススナップショット($BASE_TREE)、現在のトランクスナップショット($MAIN_TREE)、そして最終的なワークスペースのtree($WORK_TREE)のみを比較します。
  • Aliceが//net/relayを編集している間にrefs/trunk/mainが//web/blogで進んでいた場合、git merge-tree --write-treeは、Gitのオブジェクトストア内で直接、統合されたルートツリー$NEW_TREEを数ミリ秒で計算します。ランナーのファイルシステムにファイルを書き出すことはありません。
  • 以下のポリシーチェックが通過した後、--force-with-leaseを伴う一回のgit push --atomicがrefs/trunk/mainを更新し、refs/trunk/historyに一つの線形コミットを追加します。そして着地のトリガーであるrefs/heads/land/<id>は削除されます。

アーキテクチャ境界の強制:四階層の可視性と階層的なOWNERS

コードを一つのリポジトリに統合することが機能するのは、コミットが着地する前にドメイン間の境界が機械的に強制される場合に限られます。git merge-tree --write-treeが$NEW_TREEを生成した直後(かつgit push --atomicがトランクを更新する前)に、我々のポリシーエンジンはgit diff-tree "$MAIN_TREE" "$NEW_TREE"に対して二つのO(delta)チェックを実行します。

1. 四階層のドメイン可視性

ルートツリーは四つの可視性階層に分割されています。

ディレクトリ接頭辞階層許可されるimport禁止されるimportOWNERSゲート
//web/*、//net/*、//ipc/*、//storage/*、//crypto/*、//ai/*、//devtools/*公開プロダクション公開プロダクション、//third_party/*//corp/*、//experimental/*強制
//third_party/*外部ベンダー自己完結、または//third_party/*すべての非third_partyドメイン強制
//corp/*社内非公開公開プロダクション、//corp/*、//third_party/*//experimental/*強制
//experimental/<actor>/*サンドボックスcorp以外の任意のパッケージ//corp/*免除(自動承認)

//web/*内の公開パッケージが、//corp/*の社内パッケージや//experimental/*の未卒業のプロトタイプをimportしようとすると、着地ジョブはrefs/trunk/mainに触れる前に中止されます。

2. Gerritサーバーなしの階層的なOWNERS検証

検証器は、継承ルールを伴うディレクトリ単位のOWNERSファイルを強制します。

  • $MAIN_TREEと$NEW_TREEの間で変更されたすべてのパスについて、検証器はファイルのディレクトリから//OWNERSまで上方向にたどります。
  • どのOWNERSファイルも、親ディレクトリからの継承された承認をブロックするためにset noparentを宣言したり、ファイル名のglobによって承認の範囲を限定するためにper-file *.sql=@dba-teamを宣言したりできます。
  • 改ざん防止の不変条件: 既存のOWNERSファイルは、常に$MAIN_TREE(現在のトランクのtree)から読み込まれ、決して$NEW_TREEからは読み込まれません。承認されていない作成者が、同じワークスペースのコミットの中でOWNERSを編集して自らに権限を与えることはできません。
  • サンドボックスの免除: //experimental/<actor>/*配下のパスはすべてOWNERSチェックを迂回するため、エンジニアとエージェントはgit ws-exp <actor> <project>によって自由にプロトタイプを作成でき、後にgit ws-graduateによってそのパッケージを本番ドメインへ卒業させることができます。

摩擦のないコードレビュー:一時的なPRとスナップショットの差分

refs/heads/mainがコントロールプレーンのみを保持し、refs/trunk/mainが親なしのコミットであるため、ワークスペースのブランチからmainへ直接標準的なGitHubのプルリクエストを開くと、無関係な履歴を比較しようとしてしまいます。この問題は、異なる変更に合わせた三つのレビューワークフローによって解決しました。

  1. 即時の二点間ワークスペース差分(git ws-save + エッジ差分ルーター) git ws-saveでワークスペースをpushすると、軽量なエッジワーカーを通じてrefs/ws/<actor>/<id>が公開されます。このワーカーはrefs/trunk/mainとワークスペースの参照をそれぞれのコミットSHAに解決し、GitHubの二点比較ビュー(/compare/<trunk-sha>..<ws-sha>)へリダイレクトします。小さなパッチやエージェントが生成した変更については、レビュアーは差分を確認し、Webインターフェース上で直接Landをクリックできます。
  2. 一時的なGitHubプルリクエスト(git ws-pr) GitHub上で行単位のレビューを行うために、git ws-prはルートスナップショットコミット($(git rev-list --max-parents=0 HEAD))をrefs/heads/base/<id>に、ワークスペースの先端をrefs/heads/review/<id>にpushします。そしてreview/<id>をbase/<id>と比較するGitHubプルリクエストを開きます。ベースブランチはワークスペースが分岐したスナップショットであるため、GitHubのFiles changedタブにはワークスペースの差分のみが表示されます。レビュアーがMerge pull requestをクリックすると、webhookが先端コミットをrefs/heads/land/<id>に昇格させ、メモリ内の着地パイプラインを実行し、refs/heads/base/<id>とrefs/heads/review/<id>の両方を自動的に削除します。
  3. 外部ミラーのプルリクエスト オープンソースの貢献者は、モノレポのワークフローを学ぶ必要はまったくありません。通常どおりフォークし、スタンドアロンのミラーリポジトリに対してプルリクエストを開けます。

変更、パッチセット、および承認の引き継ぎ

ブランチ名では変更を識別できません。エージェントは、着地する前にブランチの名前を変えたり、先端をamendしたり、より新しいトランクに対して何度もrebaseしたりすることがあります。Gerritと同様に、このワークフローでは、先端のコミットメッセージにあるChange-Id: I<40 hex>トレーラーによって変更を識別します。git ws-save、git ws-pr、git ws-landは、先端にトレーラーがない場合はそれを追加し、ある場合はそのまま保持します。これにより、amend、rebase、リネームを経ても識別子が維持されます。着地したトランクのコミットはトレーラーを保持し、そのレビュー用プルリクエストの番号を記録するため、git trunk-cl <number|Change-Id>がrefs/trunk/historyからそれを見つけられます。

レビューに送られたすべての先端はパッチセットになります。git ws-prは、それをrefs/ws/cl/<change-id>/<tip>という不変の参照にpushし、レビュー用ブランチの隣に配置します。amendやgit ws-syncの後に再度git ws-prを実行すると、同じプルリクエストにパッチセットが追加されます。git ws-psは現在の変更のパッチセットを一覧表示し、git ws-psdiff <a> <b>は二つのパッチセット間のインターディフを出力するため、レビュアーは前回のラウンド以降に変更された内容のみを読めます。

GitHubは承認を単一のコミットに紐づけるため、通常はrebaseのたびに新たなレビューが必要になります。着地パイプラインでは、同じ変更の別のパッチセットに与えられた承認も、両方のパッチセットがそれぞれのベーススナップショットに対して同じパッチを適用している場合には受理されます。パイプラインは、完全なバイナリ差分に対するgit patch-id --verbatimを比較します。より新しいトランクへのクリーンなrebaseは、再レビューなしで着地できます。一方、空白やファイルモードの変更を含むあらゆる内容の編集には、新たな承認が必要です。

O(delta)の双方向同期と独立した本番ブランチ

多くの組織は、外部のユーザーがモノレポに触れることなくgo get、cargo installを実行したり、単一のプロジェクトをフォークしたりできるよう、個々のパッケージをスタンドアロンのGitHubリポジトリとして公開しています。静的ブログやリリース管理されたWebアプリのように、mainと並んで別個のproductionブランチを維持しているプロジェクトもあります。

我々のStarlark同期エンジンは、外向きのエクスポートと内向きの貢献の両方を、モノレポの規模ではなくコミットで変更されたファイルの量に比例する**O(delta)**の時間で処理します。

  • blobなしのインデックススプライシング: ミラーリポジトリのサブツリーをモノレポのルートツリーに組み込んだり、そこから切り離したりする際、エンジンはGIT_NO_LAZY_FETCH=1を設定し、git update-index --index-infoに続けてgit write-tree --missing-okを実行します。変更されていないサブツリーとblobはSHA-1で参照されたままとなり、CIランナーにダウンロードされることは決してありません。
  • デルタのみのblobプリフェッチ: エクスポートされたコミットをスタンドアロンのミラーにpushする前に、スクリプトはgit diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"を実行し、git cat-file --batchを通じて変更されたファイルの古いblobと新しいblobのみをプリフェッチします。これによりGitは薄いデルタpackを計算でき、数秒でミラーリポジトリへpushできます。
  • ワークフローとリリースブランチの保持: 外向きのエクスポートの際、同期ツールは各スタンドアロンリポジトリ独自の.github/workflows/ディレクトリを保持するため、公開CIランナーがミラー上でプロジェクトのテストを実行できます。Starlark設定内の各ワークフローはmainのような明示的なブランチを対象とするため、別個のproductionブランチからデプロイするリポジトリのリリース管理が維持されます。モノレポへの着地はミラーのmainブランチと同期され、main上で自動生成されたメタデータや翻訳のコミットは、webhookを介してrefs/trunk/mainに同期し直されます。mainをproductionに昇格させることは、引き続き明示的なリリース手順です。

結びに

本アーキテクチャは、Gitのオブジェクトストアを Content-Addressable Store として扱い、refs/heads/mainをrefs/trunk/mainおよびrefs/trunk/historyから分離し、各変更を不変のパッチセットを伴うChange-Idによって識別し、git merge-tree --write-treeによってメモリ内でtreeをマージします。オンボーディングのコストはタスクが必要とするディレクトリの数に応じて増加し、着地とミラー同期のコストは、リポジトリ全体の規模ではなく、変更の規模に応じて増加します。