自作AIツールがnode_modulesまで読みに行く問題をgit ls-filesで直した
AIにコードを読ませる前処理が遅い
自作のAIエージェントにリポジトリを読ませる処理を書いていたら、妙に遅かった。
最初はモデルが重いのかと思った。プロンプトが長すぎるのか、コンテキストの詰め方が悪いのか、と疑った。
でも原因はもっと手前だった。
AIに渡すファイルを集める処理が、node_modules や .next まで見に行っていた。
ありがちなコードはこれ:
from pathlib import Path
files = []
for pattern in ["*.md", "*.py", "*.ts", "*.tsx"]:
files.extend(Path(".").rglob(pattern))
リポジトリ内の Markdown / Python / TypeScript を集めて、AIに渡す。READMEを要約する。設計書を読む。コードをざっくり探索する。
小さいうちはこれで動く。
でも少し運用すると、嫌なことが起きる。
-
node_modulesまで見に行く -
.nextやoutなどの生成物を踏む -
venvやキャッシュが混ざる - 探索だけで数秒待つ
- AIに渡したいソースより、ノイズの方が多くなる
体感としては「AIが遅い」。でも実際には、AIに渡す前のファイル集めが遅い。
ここを git ls-files に変えたら、手元のリポジトリでは最大200倍くらい速くなった。
欲しかったのは「全ファイル」ではなかった
よく考えると、欲しかったのは「ディスク上にある全ファイル」ではなかった。
欲しかったのは、だいたいこれ:
Gitで管理している、人間が読むべきソースファイルだけ
node_modules も .next も venv もいらない。ビルド成果物もログもいらない。
それなら、Pythonでファイルシステムを全部歩くより、Gitに聞いた方が自然だった。
git ls-files --cached --others --exclude-standard '*.md' '*.py' '*.ts' '*.tsx'
これで、Git管理済みのファイルと、まだaddしていない未追跡ファイルを取得できる。しかも .gitignore も尊重される。
先にコード
Pythonから使うなら、こうした。
import subprocess
from pathlib import Path
def source_files(repo: str | Path = ".") -> list[Path]:
root = Path(subprocess.check_output(
["git", "rev-parse", "--show-toplevel"],
cwd=repo,
text=True,
).strip())
out = subprocess.check_output(
[
"git", "ls-files",
"--cached", "--others", "--exclude-standard",
"*.md", "*.py", "*.ts", "*.tsx",
],
cwd=root,
text=True,
)
return [root / line for line in out.splitlines() if line]
使う側はこれだけ。
for path in source_files():
print(path)
自作AIツール、ドキュメント生成、簡易コードレビュー、独自lintなどで「リポジトリ内のソースを集めたい」なら、まずこれで十分だった。
実測: 生成物があるほど差が出る
手元の3つのリポジトリで、次の2つを5回ずつ測った。
-
Path.rglob()で探索して、あとから除外ディレクトリを弾く -
git ls-files --cached --others --exclude-standardを使う
対象拡張子はこれ:
*.md, *.py, *.ts, *.tsx
結果:
| repo | rglob median | git ls-files median | 差 |
|---|---|---|---|
| todo-app-demo | 294.0ms | 1.3ms | 約226倍 |
| zenn-content | 21.7ms | 1.6ms | 約14倍 |
| cocop | 9.4ms | 1.9ms | 約5倍 |
todo-app-demo は Next.js 系の生成物があるので差が大きく出た。
ただ、ここで言いたいのは「常に200倍速い」ではない。
大事なのは、AIに読ませる対象として不要なファイルを、最初から候補に入れない こと。
探索が速くなるだけでなく、AIに渡す前のノイズも減る。
Path.rglob() は悪くない。ただ用途が違う
Path.rglob() は便利だ。悪者ではない。
ただし、見ている対象が違う。
Path(".").rglob("*.ts")
これは「今ディスク上にある *.ts を探す」処理。
Git管理されているかどうか、.gitignore されているかどうか、人間が読むべきソースかどうかは関係ない。
だから、プロジェクトによってはこういうものが混ざる。
node_modules/
.next/
out/
venv/
__pycache__/
coverage/
dist/
もちろん、自分で除外すればある程度は避けられる。
skip = {".git", "node_modules", ".next", "venv", "__pycache__", "out"}
files = [
p for p in Path(".").rglob("*.ts")
if not (set(p.parts) & skip)
]
でも、この除外リストは育ち続ける。
dist を足す。coverage を足す。.turbo を足す。別プロジェクトではまた違うディレクトリを足す。
AIツールの前処理としては、このメンテが地味に面倒だった。
git ls-files は「人間が見たいファイル」に近い
git ls-files はGitが知っているファイルを返す。
git ls-files '*.md' '*.py' '*.ts' '*.tsx'
ただし、これだけだと未追跡ファイルは出ない。
AIエージェントでは「今作ったばかりの下書き」も読みたいことがある。なので、未追跡ファイルも含めるならこうする。
git ls-files --cached --others --exclude-standard '*.md' '*.py' '*.ts' '*.tsx'
それぞれの意味はこう:
-
--cached: Git管理済みのファイル -
--others: 未追跡ファイル -
--exclude-standard:.gitignoreなど標準の除外ルールを使う
この組み合わせが、自作AIツールの入力収集にはかなり使いやすかった。
使い分け
自分の基準はこう。
Path.rglob() が向いている
- Gitリポジトリではないディレクトリを読む
- ログや生成物も含めて調べたい
- ディスク上の実態を確認したい
- バックアップやファイル整理をしたい
git ls-files が向いている
- AIに渡すソースファイルを集めたい
- READMEや設計書だけ拾いたい
- ドキュメント生成対象を集めたい
- 独自lintやチェックの対象を決めたい
-
.gitignoreされたものを自然に除外したい
自分が欲しかったのは後者だった。
Gitリポジトリ外ではフォールバックする
もちろん git ls-files はGitリポジトリでしか使えない。
ツールとして配るなら、Git外では rglob にフォールバックしておくと扱いやすい。
from pathlib import Path
import subprocess
def source_files_or_rglob(repo: str | Path = ".") -> list[Path]:
repo = Path(repo)
try:
return source_files(repo)
except subprocess.CalledProcessError:
patterns = ["*.md", "*.py", "*.ts", "*.tsx"]
files = []
for pattern in patterns:
files.extend(repo.rglob(pattern))
return files
これで、Gitリポジトリなら速く・ノイズ少なく、Git外なら普通に探索できる。
まず「何を読ませないか」を決める
Path.rglob() を使うな、という話ではない。
ただ、自作AIツールが遅いとき、ついモデルやプロンプトを疑ってしまう。
でも実際には、モデルに渡す前の前処理で node_modules や生成物を踏んでいるだけ、ということがある。
その場合、改善は大げさな最適化ではなく、この1行で済む。
git ls-files --cached --others --exclude-standard '*.md' '*.py' '*.ts' '*.tsx'
AIエージェントの待ち時間を減らしたいなら、まず「何を読ませるか」より先に、「何を読ませないか」を決めるのが効く。
Discussion