---
id: 773766eac9e6ff3c46bd686d78539aed
author: Lemon Mint
title: Git Monorepo の再考：人間と Coding Agents のための CAS-Native Architecture
description: 標準的なGit上にゼロデーモンでCAS-ネイティブなモノレポを構築する方法:親なしのトランクスナップショット、分離されたワークスペース参照、インメモリのマージツリー、階層的なOWNERS、そしてO(delta)のミラー同期を用いて。
language: ja
date: 2026-10-30T01:17:15.292299Z
path: /blog/posts/rethinking-the-git-monorepo-a-cas-native-architecture-for-humans-and-coding-agents-zfd3d7931
category: lowlevel
---

成長を続けるエンジニアリング組織は、いずれ「多数のリポジトリ」か「単一のリポジトリ」かの選択を迫られます。小さなリポジトリを数十個で始めると、当初は生活がシンプルに感じられます。各ライブラリは独自の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には一切のオーバーヘッドを与えません。

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

```
refs/heads/main               コントロールプレーンのみ（約20 KB：ルール、.gitconfig、同期設定、スクリプト）
refs/trunk/main               最新の統合ルートツリーを指す親なしのスナップショットコミット
refs/trunk/history            正確に同じルートツリーのSHA-1を共有する、線形の親連結コミットチェーン
refs/ws/<actor>/<id>          開発者またはエージェントごとの分離されたワークスペーススナップショット（変更リスト）
refs/ws/cl/<change-id>/<tip>  不変のパッチセット：レビューに送られた変更のすべての先端
refs/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ハッシュ**を指す二つのコミットオブジェクトを作成します。

```bash
# 即時の深さ1ワークスペースチェックアウト用の親なしスナップショットコミット
SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")

# 線形のgit logとgit blame用の親連結コミット
HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")

git push --atomic origin \
  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
  "${SNAP}:refs/trunk/main" \
  "${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とリポジトリのエイリアスを用いて四つのステップで完了します。

```bash
# 1. blob フィルタリングとスパースモードを有効にして、20 KBのコントロールプレーンをcloneする
git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
cd monorepo

# 2. リポジトリのエイリアスを読み込み、refs/trunk/* を接続する
git config include.path ../.gitconfig
git trunk-init

# 3. このタスクに必要なドメインのディレクトリを宣言する
git sparse-checkout set net/relay ipc/codec

# 4. 最新のトランクスナップショットを起点とする分離されたワークスペースを作成する
git 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を用いてメモリ内で実行される様子です。

```bash
# 1. ワークスペースが分岐した元のルートスナップショットコミットを特定する
BASE=$(git rev-list --max-parents=0 "$WORK_SHA")

BASE_TREE=$(git rev-parse "${BASE}^{tree}")
MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")

# 2. ワーキングツリーをチェックアウトせずに、メモリ内でマージ済みのルートツリーを計算する
if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
  NEW_TREE="$WORK_TREE"
elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
  NEW_TREE=$(head -n1 <<<"$OUT")
else
  echo "マージの競合を検出しました。git ws-sync を実行してrebaseしてください。" >&2
  exit 1
fi
```

ステップ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                                 | 禁止されるimport               | `OWNERS`ゲート |
| :------------------------------------------------------------------------------------- | :------------------- | :----------------------------------------------- | :----------------------------- | :------------- |
| `//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をマージします。オンボーディングのコストはタスクが必要とするディレクトリの数に応じて増加し、着地とミラー同期のコストは、リポジトリ全体の規模ではなく、変更の規模に応じて増加します。
