今朝、手元の複製リポジトリで git log --oneline を打ったら、1 件しか返ってきませんでした。
数秒、手が止まりました。履歴が消えたのかと思ったのです。実際には何も壊れていません。自動処理用に --depth 1 でクローンした複製で、履歴がそもそも 1 件しか存在しないだけでした。
この小さなつまずきが、ちょうど考えていた設計とぶつかりました。Antigravity CLI 1.1.10 で、サンドボックスから .git を読み取り専用で参照できるようになっています。書き込み権限を渡さずに、履歴を前提にした作業を成立させられる。この変更を活かして「エージェントに毎セッション履歴の要約を渡す」仕組みを作ろうとしていた矢先でした。
作る前に、コストの当たりを取ることにしました。履歴の要約を組み立てる実装を 2 通り書き、コミット数を変えながら実測した結果をこの記事にまとめます。同じ出力を得るのに 38.4 秒かかる書き方と 0.38 秒で済む書き方があり、差を生んでいたのはデータ量ではありませんでした。
書き込み権限を渡さずに、履歴だけを渡す
1.1.10 の Antigravity changelog に、.git のリードオンリーサンドボックスアクセスという項目があります。一行の変更ですが、権限設計の観点では扱いが変わる話だと受け止めています。
エージェントに履歴を見せたい場面は、以前からありました。レビューを頼むとき、変更対象が「直近でよく壊れている場所」かどうかで読み方は変わります。テストの補強を頼むとき、あるファイルと一緒に変わりがちなファイルを知っていれば、修正の巻き添え漏れを指摘できます。
一方で、そのために .git への書き込みまで開ける理由はありません。refs やオブジェクトを書き換えられる状態は、履歴を読むという目的に対して権限が広すぎます。情報は渡す、権限は渡さない。読み取り専用の .git は、この分離をそのまま実装できる入口です。
私はアクセス制御を考えるとき、まず「渡さないもの」を先に決めるようにしております。今回渡さないと決めたのは、書き込み権限と、生の履歴全体です。生ログをそのまま渡さない理由は次の節で数字と一緒に述べます。
履歴から何を取り出すか — 1.7KB のコンテキストパック
毎セッション渡すものは、小さくなければ続きません。要約に含める情報を 3 つに絞りました。
第一に、変更頻度の高いファイル(ホットスポット)。直近の不具合はたいてい、よく触られている場所で起きます。第二に、同一コミットで一緒に変わるファイルの対(共変更)。「このファイルを直すならあちらも見る」という関係の近似になります。第三に、作者ごとのコミット数。個人開発でも、自動コミットと手動コミットの比率が見えるだけで履歴の読み方が変わります。
この 3 つを JSON にまとめた出力を、ここでは履歴コンテキストパックと呼びます。実測でのサイズは次の通りでした。
コミット数 パックサイズ .git のサイズ
500 1,679 バイト —
2,000 1,702 バイト 2.0 MB
8,000 1,734 バイト 7.2 MB
コミット数を 16 倍にしても、パックは 55 バイトしか増えません。上位 N 件に切り詰めた集計だけを持つ設計なので、履歴の長さはサイズにほぼ影響しないためです。7.2 MB の履歴が 1.7 KB に畳まれるなら、毎セッション先頭で渡す運用に無理はありません。
素直に書いたループは 38 秒かかりました
計測には合成リポジトリを使いました。実リポジトリではなく合成にしたのは、コミット数だけを段階的に振りたかったためです。git fast-import にストリームを流し込む方式で、60 モジュール構成、実装ファイルとテストが 72% の確率で一緒に変わり、共有レジストリが 18% の確率で巻き込まれる、という偏りを乱数シード固定で注入しています。8,000 コミットの生成が 2.2 秒で終わるので、やり直しも気楽です。
# gen_fixture.py(抜粋): fast-import ストリームで合成履歴を作る
files = [ f "src/api/mod_ { m } .py" ]
if rng.random() < 0.72 :
files.append( f "tests/test_mod_ { m } .py" ) # 実装とテストの共変更
if rng.random() < 0.18 :
files.append( "src/core/registry.py" ) # 共有ファイルの巻き込み
# ... blob と commit を文字列で組み立て、最後に1回だけ流し込む
subprocess.run([ "git" , "-C" , path, "fast-import" , "--quiet" ],
input = stream, check = True )
最初の実装は、誰でも最初に書くであろう形です。git rev-list でコミット一覧を取り、コミットごとに git diff-tree で変更ファイルを、git show -s で作者を聞いて回ります。
# 素直な実装: コミットごとに git を呼ぶ
shas = run([ "rev-list" , "HEAD" ], repo).split()
for sha in shas:
out = subprocess.run(
[ "git" , "-C" , repo, "diff-tree" ,
"--no-commit-id" , "--name-status" , "-r" , sha],
capture_output = True , text = True , check = True ).stdout
files = [l.split( " \t " , 1 )[ 1 ] for l in out.splitlines() if " \t " in l]
churn.update(files)
# 作者も同様に git show -s --format=%an で1コミットずつ取得
動きます。出力も正しい。ただ、8,000 コミットで 38.4 秒かかりました。
コミット数 素直な実装(中央値)
500 2,320.3 ms
2,000 9,490.9 ms
8,000 38,384.9 ms
セッションの立ち上げに 38 秒を上乗せする案は、この時点で消えました。
同じ出力を、git の呼び出し 1 回で
次の実装は、git の呼び出しを 1 回に畳みます。git log --name-status は全コミットの変更ファイルと作者を 1 回の実行で吐き出せるので、その出力を 1 パスで解析します。
# 1パス実装: git は1回しか起動しない
def build_onepass (repo):
out = run([ "log" , "--name-status" , "--pretty=format:@%H| %a n" ], repo)
churn, pairs, authors = Counter(), Counter(), Counter()
files, n = [], 0
def flush ():
churn.update(files)
for a, b in itertools.combinations( sorted (files)[: 10 ], 2 ):
pairs[(a, b)] += 1
for line in out.splitlines():
if line.startswith( "@" ): # コミット境界
if n:
flush()
files, n = [], n + 1
authors[line.split( "|" , 1 )[ 1 ]] += 1
elif " \t " in line: # name-status の行
files.append(line.split( " \t " , 1 )[ 1 ])
if files or n:
flush()
return pack(churn, pairs, authors, n)
共変更の組み合わせは 1 コミットあたり先頭 10 ファイルに制限しています。まれに大量のファイルを触るコミット(一括整形など)があると、組み合わせ数が二乗で膨れるためです。ここを制限しないと、集計が特定の 1 コミットに支配される問題もあります。
結果です。両実装でホットスポット上位と共変更上位が一致することを確認した上で比較しています。
コミット数 素直な実装 1パス実装 比率
500 2,320.3 ms 25.6 ms 90.6 倍
2,000 9,490.9 ms 100.9 ms 94.1 倍
8,000 38,384.9 ms 382.1 ms 100.5 倍
各値は 3 回計測の中央値です(8,000 コミットの素直な実装のみ、時間の都合で 1 回)。環境は Linux 6.8、4 vCPU、メモリ 3.8 GB、git 2.34.1、Python 3.10.12。数値は環境に依存するので、手元で走らせて取り直していただくのが確実です。
遅さの正体はデータ量ではなくプロセス起動でした
計測前の私の予想は逆でした。1 回の git log に全履歴を吐かせる方が、出力が巨大になって重いのではないかと思っていたのです。8,000 コミット分の name-status をまとめて受けるのは、いかにも高くつきそうに見えます。
実際に高くついていたのは、データではなくプロセスの起動でした。素直な実装の所要時間をコミット数で割ると、規則性が見えます。
コミット数 1 コミットあたりの所要時間
500 4.64 ms
2,000 4.75 ms
8,000 4.80 ms
リポジトリの規模にほぼ依存せず、1 コミットあたり約 4.7 ms で一定です。この実装はコミットごとに subprocess を 2 回起動する(diff-tree と show)ので、git プロセス 1 回の起動と終了に約 2.3〜2.4 ms を払っている計算になります。処理の中身ではなく、入口と出口に時間が消えていました。
2.3 ms は単体では気にならない数字です。それがコミット数 × 2 で線形に積み上がり、8,000 コミットでは 38 秒になります。一方の 1 パス実装は、同じ情報量を受け取っているのに 0.38 秒で終わります。パース処理は Python の素朴なループで十分足りていました。
この構造は、エージェントにツールを持たせるときの設計にそのまま跳ね返ります。「コミット 1 件の情報を返すツール」を作ると、エージェントが誠実にループを回すほど、spawn コストを 1 回ずつ全額払うことになります。履歴の集計は、ツールの内側で 1 回の git 呼び出しに畳み込み、集計済みの結果だけを返す形にしておくべきでした。
shallow clone は集計を静かに壊します
冒頭の話に戻ります。--depth 1 の複製に対してこのパックを生成すると、どうなるか。
エラーは出ません。パックは正常に生成されます。中身だけが無意味になります。
実測では、2,000 コミットのリポジトリを --depth 1 でクローンすると git rev-list --count HEAD は 1 を返し、パックのホットスポット 1 位は「docs/changelog.md・変更 1 回」でした。たまたま最後のコミットに含まれていたファイルが 1 位に座るだけの、履歴とは呼べない集計です。
壊れ方として一番厄介なのは、この沈黙です。集計は成功し、JSON は整っていて、エージェントはそれを信じて「このリポジトリで最も変更が多いのは docs/changelog.md です」と読み始めます。嘘のコンテキストは、コンテキストがないより悪い。
対処は単純で、パック生成の先頭で履歴の実在を確かめ、足りなければ明示的に失敗させます。
def assert_history (repo, min_commits = 50 ):
count = int (run([ "rev-list" , "--count" , "HEAD" ], repo))
shallow = os.path.exists(os.path.join(repo, ".git" , "shallow" ))
if shallow or count < min_commits:
raise SystemExit (
f "history too short: commits= { count } shallow= { shallow } — "
"fetch full history before building the pack" )
.git/shallow の存在確認を併用しているのは、コミット数のしきい値だけでは「本当に若いリポジトリ」と「切り詰められたリポジトリ」を区別できないためです。自動処理のプロビジョニングは depth 1 が既定になっていることが多いので、読み取り専用 .git を前提にした仕組みを作るなら、このガードは最初から入れておくことをお勧めします。
手元の運用に落とす
計測結果を踏まえて、運用は次の形に落ち着きました。
私自身の運用では、パックの生成をセッション開始時に毎回行うことにしました。8,000 コミットで 0.38 秒・出力 1.7 KB なら、立ち上げに紛れて気になりません。生成物はプロンプトへ直接埋め込まず、作業ディレクトリにファイルとして置き、エージェントが最初に読む対象に含めます。
渡さないと決めたものも記録しておきます。全履歴の diff 本文は渡しません。サイズが大きく、要約を渡す意味が消えるためです。blame の全ファイル走査も今回のパックには含めていません。ホットスポットと共変更で「どこを警戒するか」は足りており、行単位の由来はエージェントが必要になった時点で個別に引けば済みます。
読み取り専用の .git は、派手さのない変更です。ただ、「情報は渡し、権限は渡さない」という分離を、回避策なしで素直に実装できるようになったのは確かな前進だと感じております。
まずは手元のリポジトリで git rev-list --count HEAD を打ってみてください。その数字が期待通りかどうかを確かめるところが、履歴を渡す仕組みづくりの最初の一歩になります。計測スクリプトの構造が、その足がかりになれば幸いです。