今朝、手元の複製リポジトリで 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 呼び出しに畳み込み、集計済みの結果だけを返す形にしておくべきでした。
読み取り専用の .git が静かに奪う時間
ここまでの計測は、書き込みできる複製で行っていました。本題は読み取り専用の .git なのに、そこを確かめないまま話を進めるのは落ち着きません。.git 配下を丸ごと chmod -R a-w して、測り直しました。
集計側は無傷でした。git log --name-status・git rev-list --count・git blame はいずれも警告ひとつ出さず、書き込み可能なときと同じ時間で返ってきます。パック生成の経路は読み取りだけで完結しているので、ここは想定通りです。
意外だったのは、その隣でした。git status と git diff です。
git はこれらを実行するとき、作業ツリーの stat 情報が index の記録とずれているファイルについて中身を読み直し、ハッシュを取り、結果を index に書き戻します。書き戻せていれば、次回からは stat の比較だけで済みます。読み取り専用では書き戻せないため、毎回ゼロからやり直すことになります。
400KB のファイル 1,200 件(作業ツリー 474MB)を用意し、mtime だけを更新してから git status --short を 3 回繰り返した結果です。
.git の状態1 回目 2 回目 3 回目
書き込み可能 1.30 s 0.00 s 0.00 s
読み取り専用 1.31 s 1.31 s 1.33 s
読み取り専用 + --no-optional-locks 1.32 s 1.32 s 1.31 s
書き込み可能なら 2 回目以降は実質ゼロです。読み取り専用では、同じ 1.3 秒を何度でも払い続けます。エージェントに差分を確認させるたび、変更点の検出ではなくハッシュの再計算に時間が消えていきます。
そして、ここでもエラーは出ません。git はロックを作れないと分かると、index への書き戻しを黙って諦めます。明確に落ちるのは書き込み操作のときだけで、git add を叩くと fatal: Unable to create '/repo/.git/index.lock': Permission denied と即座に止まります。読み取り専用の影響が「書き込みが失敗する」ではなく「読み取りが毎回高くつく」形で出てくるのは、今回いちばん予想を外した点でした。
--no-optional-locks は効きません。あれはロックの取得を試みない指示であって、再ハッシュそのものを止めるものではないためです。
効いたのは、index の複製だけを書き込み可能な場所に置き、GIT_INDEX_FILE で指す方法でした。
# .git は読み取り専用のまま、index の作業用コピーだけ書き込み可能にする
export GIT_INDEX_FILE = /tmp/agent-idx/index
mkdir -p /tmp/agent-idx
cp /repo/.git/index " $GIT_INDEX_FILE "
git status --short # 1 回目 1.32 s、2 回目以降 0.00 s
3 方式(そのまま / --no-optional-locks / GIT_INDEX_FILE)の git status --short の出力が完全に一致することは md5 で確認しています。速くなっているのは検出の精度を落としたからではなく、書き戻し先ができたからです。
ただし、この複製は放っておくと古くなります。ブランチが切り替われば index の内容は現状と食い違うので、セッションの開始時に取り直す前提で扱ってください。私はパック生成スクリプトの中で、履歴チェックと同じタイミングで作り直すようにしております。
もう一点、この差が出るのはファイルが大きいリポジトリに限られます。3KB のソースファイル 4,000 件で同じ手順を踏むと、読み取り専用でも git status は 0.06 秒で、書き込み可能なときとの差は測定誤差に沈みました。再ハッシュのコストは中身のバイト数に比例するので、テキスト中心のリポジトリなら気づかないまま済みます。画像やモデルの重みを抱えているなら、先に測っておく価値があります。
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 を前提にした仕組みを作るなら、このガードは最初から入れておくことをお勧めします。
パックの中身を決める — 上位 N 件をどこで切るか
集計から JSON に落とす部分は、これまで pack() としか書いていませんでした。ここが実質的な設計判断なので、中身を開いておきます。
def pack (churn, pairs, authors, n, top_files = 14 , top_pairs = 10 ):
total = sum (churn.values()) or 1
return {
"schema" : "history-pack/1" ,
"commits" : n,
"hotspots" : [
{ "path" : p, "changes" : c, "share" : round (c / total, 3 )}
for p, c in churn.most_common(top_files)
],
"cochange" : [
# conf: a が変わったコミットのうち b も変わった割合
{ "pair" : [a, b], "together" : c, "conf" : round (c / churn[a], 2 )}
for (a, b), c in pairs.most_common(top_pairs)
],
"authors" : [{ "name" : a, "commits" : c} for a, c in authors.most_common( 5 )],
}
上位何件で切るかは、そのままパックのサイズを決めます。先ほどの 2,000 コミットのリポジトリで測ると、上位 12 ファイル・8 対で 1,455 バイト、14 ファイル・10 対で 1,724 バイト、16 ファイル・10 対で 1,834 バイトでした。1 件あたり約 55 バイト、共変更 1 対あたり約 80 バイトの単価です(パス名の長さで前後します)。
私は 14 と 10 に落ち着きました。ホットスポットは、上位 10 件を切ると 1 モジュールの実装とテストが分断されることがあり、20 件まで伸ばすと変更 1 回の常連が混ざり始めます。共変更のほうは、10 対を超えたあたりから信頼度が 0.3 を下回る組が並び、読む側の判断材料になりません。
conf を添えているのは、together の生の回数だけでは判断できないためです。よく変わるファイルは何とでも一緒に変わります。実際の出力から抜粋します。
{
"schema" : "history-pack/1" ,
"commits" : 2000 ,
"hotspots" : [
{ "path" : "src/core/registry.py" , "changes" : 367 , "share" : 0.095 },
{ "path" : "src/api/mod_34.py" , "changes" : 45 , "share" : 0.012 }
],
"cochange" : [
{ "pair" : [ "src/api/mod_1.py" , "tests/test_mod_1.py" ],
"together" : 35 , "conf" : 0.83 }
],
"authors" : [
{ "name" : "Masaki" , "commits" : 1545 },
{ "name" : "ci-bot" , "commits" : 455 }
]
}
conf: 0.83 は「mod_1.py を触った 83% のコミットで、対応するテストも一緒に変わっている」という意味になります。エージェントに「実装だけ直してテストを放置した変更」を指摘させたいとき、この数字がしきい値になります。逆に registry.py は全体の 9.5% のコミットに顔を出す共有ファイルで、これは共変更の相手というより「触ると広く影響する場所」として読むべき項目です。
share を持たせているのも同じ理由です。changes の絶対値はリポジトリの年齢に引きずられるので、他プロジェクトのパックと並べたときに比較できなくなります。割合にしておけば、その場で相対的な偏りが読めます。
出力はプロンプトに直接埋め込まず、.agent/history-pack.json として置き、エージェントが最初に読む対象に含めています。AGENTS.md 側の記述は 3 行で足りました。
## リポジトリ履歴
作業を始める前に `.agent/history-pack.json` を読んでください。
`hotspots` は変更が集中している場所、 `cochange` の `conf` は
片方を変更したときにもう片方も変わってきた割合です。
埋め込みではなくファイルにしたのは、エージェントが必要なときだけ読めるようにするためです。1.7KB は小さい数字ですが、毎回のプロンプトに固定で乗せると、セッションが長くなるほど累積します。読む判断はエージェントに任せて、こちらは置いておくだけにしました。
手元の運用に落とす
計測結果を踏まえて、運用は次の形に落ち着きました。
私自身の運用では、パックの生成をセッション開始時に毎回行うことにしました。8,000 コミットで 0.38 秒・出力 1.7 KB なら、立ち上げに紛れて気になりません。生成物はプロンプトへ直接埋め込まず、作業ディレクトリにファイルとして置き、エージェントが最初に読む対象に含めます。
渡さないと決めたものも記録しておきます。全履歴の diff 本文は渡しません。サイズが大きく、要約を渡す意味が消えるためです。blame の全ファイル走査も今回のパックには含めていません。ホットスポットと共変更で「どこを警戒するか」は足りており、行単位の由来はエージェントが必要になった時点で個別に引けば済みます。
読み取り専用の .git は、派手さのない変更です。ただ、「情報は渡し、権限は渡さない」という分離を、回避策なしで素直に実装できるようになったのは確かな前進だと感じております。
まずは手元のリポジトリで git rev-list --count HEAD を打ってみてください。その数字が期待通りかどうかを確かめるところが、履歴を渡す仕組みづくりの最初の一歩になります。計測スクリプトの構造が、その足がかりになれば幸いです。