[Astro] #184 ブラウザだけで動く .git 解析ツール — 重い理由と、中身を、読むだけで調べる

[Astro] #184 ブラウザだけで動く .git 解析ツール — 重い理由と、中身を、読むだけで調べる

はじめに

前回は、動画の大きさを変えて軽くするツール、Video Resize を作りました。

今回は、.git フォルダの中身を調べるツールです。名前は Git Inspector です。Inspector シリーズの、新しい 1 本です。

きっかけは、自分のブログのリポジトリでした。.git が約 3.9 GB あって、なぜそんなに大きいのか、分かりませんでした。手で調べると、次のことが分かりました。

  • 到達できるオブジェクト(ブランチやタグからたどれるもの)は、23,664 個
  • 1.12 GiB と 574.2 MiB の 2 つの大きなファイルは、ブランチやタグからはたどれず、reflog(操作の履歴)にだけ残っていた
  • その 2 つは、.git_backup/objects/pack/… という名前の、pack ファイル(別の .git のコピーとみられる)だった

調べるのに、git rev-list --objects --all に --reflog を付けたり、verify-pack を見たりと、いくつものコマンドを組み合わせました。これを、フォルダをドロップするだけで見えるようにしたのが、このツールです。ファイルはサーバーに送られません。ブラウザの中で読むだけで、書き換えも、削除も、しません。

画面

大きいもの

Git Inspector の画面。上に 4 つの数字、下に大きい順のオブジェクトの表。いちばん上の 2 行は、.git_backup の pack ファイル

.git のあるフォルダ(または .git そのもの)をドロップすると、上に 4 つの数字が出ます。

  • .git 全体: ディスク上の大きさと、オブジェクトの個数
  • 通常: ブランチやタグから、たどれるもの
  • reflog にだけ: 上にはなく、reflog からならたどれるもの
  • どこからも到達できない: どちらからもたどれないもの

数字をクリックすると、下の表が、その種類に絞られます。表は、大きい順です(最大 300 件)。名前(パス)、中身の推定、状態(通常 / reflog にだけ / 到達できない)が出ます。上の画面では、いちばん上の 2 行が、.git_backup の中の pack ファイルです。

履歴

履歴タブ。コミットの一覧に、そのコミットで増えた容量が並んでいる

「履歴」タブは、コミットの一覧です。各コミットで初めて入ったものの大きさの合計を、「増えた容量」として出します。「増えた容量の大きい順」に並べると、.git を重くしたコミットが、上に来ます。行をクリックすると、そのコミットで入った大きいもの(上位 5 つ)が出て、クリックで「大きいもの」に飛べます。

名前のないものの中身

WiredFrame のリポジトリで、どこからも到達できないもの 6,902 個の中身の推定が、Rust のコンパイラの出力と判定されている

到達できないものや、commit / tree には、名前がありません。そこで、先頭の 32 バイトを読んで、「中身の推定」の列に出します(PNG、PDF、zip、glTF、Git の pack ファイルなど)。上は、自作のスクリーンセーバー WiredFrame のリポジトリです。どこからも到達できない 6,902 個(1.28 GiB)は、中身を見ると、Rust のコンパイラの出力(.rmeta、ar、PDB、COFF など)が、ほとんどでした。

F1 ヘルプ

F1 キーで出る使い方。やりたいことを入力して探せる

F1 キーで、使い方が出ます。「大きい」「reflog」「到達」のように、言葉で探せます。ほかのツールと同じです。

作ったもの

  • .git のあるフォルダ、または .git そのものを、ドロップか選択で読む。作業フォルダのときは、中の .git だけを使う(node_modules などは数えない)
  • 4 つの数字(全体 / 通常 / reflog にだけ / どこからも到達できない)。クリックで絞り込み
  • 大きいもの(大きい順に最大 300 件、名前・SHA で検索)、中身の推定、先頭 96 バイトの 16 進ダンプ
  • 履歴(コミットごとの増えた容量、並べ替え、検索、reflog にだけ残るコミットも表示)
  • pack の一覧(大きさ、個数、いちばん大きい中身)、参照(HEAD、ブランチ、タグ、リモート)
  • 読み込みにかかった時間と、段階ごとの内訳
  • F1 ヘルプ

画面は素の HTML・CSS・JavaScript で、外部のライブラリは使っていません。

作り方

パーサーを先に作る

最初に、画面を作らず、.git を読む部分だけを作りました。git-parser.js という、依存のない 1 ファイルです。ブラウザでも、Node 22 でも、同じコードで動きます(Node では、fs.openAsBlob でファイルを Blob として扱います)。

こうしたのは、git 本体と突き合わせて検算するためです。画面を作ってからでは、数字が合わないときに、パーサーの誤りなのか、画面の誤りなのか、分かりません。

pack を丸ごと読まない

.git の大きなものは、objects/pack/ の pack ファイルです。1 つが 1 GB を超えることもあるので、丸ごとメモリには載せません。

  • 目次(idx v2)だけを読みます。先頭の 256 個の表(fanout)、SHA の並び、CRC、位置の表です。2 GB を超える位置のための 64 ビットの表も、コードにはあります
  • 必要なオブジェクトだけを、Blob.slice() で、位置を指定して読みます
  • 解凍は、ブラウザ標準の DecompressionStream です。大きなオブジェクトは、先頭だけ解凍して、そこで打ち切れます(先頭 32 バイトを見るだけなら、数 MB も読みません)
  • pack の中のオブジェクトは、差分(delta)で入っていることがあります。差分の元をたどって、組み立てます

ばらのオブジェクト(objects/ab/cdef…)は、1 つが 1 ファイルです。中は zlib で、先頭に「種類 大きさ\0」が付いています。

300 MB の blob が 1 個だけ入った pack(idx が 1.1 KB)で、一覧は 3 ms、先頭 32 バイトは 15 ms、メモリの増加は 3 MB でした。自分のブログの、1.1 GB の pack と同じ形です。

「到達できる」の 3 つの区分

ブランチとタグ(packed-refs、ばらの ref、別の ref を指す ref も含む)と、HEAD から、commit → tree と、順にたどります。blob の中身は読まず、tree の項目に載っている SHA だけを集めます。これが「通常」です。

reflog(logs/ の下)に出てくる SHA からも、同じようにたどります。「通常」には無くて、こちらにはあるものが、「reflog にだけ」です。どちらからもたどれないものが、「どこからも到達できない」です。

git reset で履歴から消したコミットや、コピーした .git をコミットしてしまった跡は、「reflog にだけ」に残ります。ブランチだけを見る道具では、ここが見えません。

コミットごとの増えた容量

「どのコミットで重くなったか」を出すために、コミットを、親が先になる順に並べます(同時に進められるものは、日時の古い順)。順に tree をたどって、まだ見ていないものだけを、そのコミットのものとして数えます。一度見た tree の中には、入りません。

この数え方には、git の rev-list C --not 親 と違うところがあります。枝分かれした 2 つの枝に同じファイルがあると、git は両方に数えますが、このツールは、先に数えた方にだけ付けます。一直線の履歴では、git と完全に一致します。

中身の推定

先頭の 32 バイトを見て、決めます。PNG、JPEG、GIF、WebP、PDF、zip、gzip、7z、RAR、SQLite、MP3、WAV、MP4、WebM、Ogg、wasm、glTF、Unity、EXE(MZ)、ELF、Git の pack など、30 種類ほどの判定です。

最初は、見つけられないものが多く、不明の行が並びました。実際の hex ダンプを見て、足していきました(詳しくは、「詰まったこと」に書きます)。

読み込みを速くする

自分のブログの .git(3.95 GiB、26,864 個)を最初に読み込ませたとき、13 分 15 秒かかりました。原因は、読み込みの回数でした。

自分の環境では、.git が WSL の中にあり、Windows からは \\wsl.localhost\… で見えます。ここでは、ファイルを読む 1 回ごとの待ちが長く、小さな読み込みを何万回もすると、その待ちが積み重なります。pack の中のオブジェクトを 1 つずつ読んでいたので、まさにそうなっていました。

そこで、次のように変えました。

  • 近くにあるものは、まとめて読む(8 MiB までの窓。間が 512 KiB 未満なら、つなげる)
  • 差分のオブジェクトは、先頭の 384 バイトを先に読んでおく(種類と、元になるものの場所が、ここで分かる)
  • commit と tree は、先にまとめて読む
  • 差分の元(ベース)を、メモリに覚えておく。全体で 256 MiB までの枠を、共有する

どの程度まとめられるかは、先に、擬似的に遅延を入れた測定で見ました。実機で確かめたのは、そのあとです(次の「実機で確認できたこと」)。

詰まったこと

「git gc で消える」は、言い過ぎでした

ヘルプに、「どこからも到達できないものは、git gc で消える」と書いていました。実際は、作ってから 2 週間(既定)は消されませんし、ステージしている途中(git add 済み)のファイルも、このツールには「到達できない」と見えます。.git/index は読んでいないためです。ヘルプを直して、「先に git status で確認してください」と書きました。

読み込みの時間が、短く出ていました

読み込みの時間を、ツールに出すようにしました。最初に出た数字は 4.3 秒でしたが、画面を見ていた体感は、10 秒ほどでした。原因は、計り始めが、ドロップしたあとの、解析の開始だったことです。フォルダを受け取る時間が、入っていませんでした。ドロップした瞬間から計るように直すと、6.5 秒になりました。

別の確認として、自作の時計(Lain Clock)の秒針の音に合わせて、数えました。ほぼ一致しました。

不明な行が、不明でなくなるまで

最初は、Rust のコンパイラの出力が、すべて「テキスト」と判定されていて、692 MiB の「テキスト」が 1 つありました。実際のバイトを見ると、次のものでした。

  • !<arch>\n(ar。ライブラリ)
  • rust\0\0\0(Rust の .rmeta)
  • RSIC(Rust の増分コンパイルのデータ)
  • PDB(Microsoft の MSF 形式)
  • COFF(オブジェクトファイル。先頭の機械の種類と、. の位置で判定)
  • zlib(78 01 などで始まる。.git_backup のばらのオブジェクトのコピー)
  • ONNX(protobuf。08 06 12 07 pytorch で始まる)

実機の hex ダンプをもらって、1 つずつ判定を足しました。WiredFrame の、どこからも到達できない 6,902 個は、この追加で、ほとんどが分かるようになりました。

それでも、まだ不明なものが残ります。lain_v4/glTF/data.bin(頂点データと思われる、浮動小数点の並び)や、.bpmx は、判定の手がかりが見つかっていません。

13 分 15 秒が終わらない、と思いました

自分のブログのリポジトリを読ませると、「履歴を調べています… 320 コミット」のまま、動いているのか止まっているのか、分からない時間が続きました。最終的に 13 分 15 秒で終わりました。結果は、手で調べた内容と一致しました。

読み方を変える前に、擬似的な測定で、「1 分ほどになるはず」と話しました。これは、言い過ぎでした。ばらのオブジェクトは、1 ファイルずつ読むしかなく、まとめられません。あとで、「確約できない」と直しました。実際は 45 秒でしたが、根拠は、擬似的な測定だけでした。

実機で確認できたこと

Windows の Chrome(localhost)で、確認しました。

  • 自分のブログのリポジトリ(.git 3.95 GiB、26,864 個): 通常 2.12 GiB(23,664 個)、reflog にだけ 1.83 GiB(3,187 個)、どこからも到達できない 3.5 KiB(13 個)。最大の 2 つは、.git_backup の pack で、1.12 GiB と 574.2 MiB でした。手で調べた結果と一致しました。読み込みは、13 分 15 秒から 45 秒になりました。数字は、どちらも同じです
  • WiredFrame(.git 1.29 GiB、7,296 個): どこからも到達できないものが 6,902 個(1.28 GiB)。git count-objects -vH の count(6902)と、git prune -n の行数(6902)が、ツールと一致しました。git status と git diff --cached は何も出なかったので、今、どこからも使われていないと言えます。履歴タブでも、どのコミットにも入っていません(最大のコミットで 5.3 MiB)。原因は、ビルドの出力(src-tauri/target)だと考えていますが、名前が残っていないので、確定はできません
  • 作業フォルダごとドロップしても、.git そのものをドロップしても、同じ結果になります

読み込みの内訳(自分のブログのリポジトリ、45.9 秒)は、次のとおりです。

ヘッダーの読み込み時間にカーソルを乗せたときの内訳。フォルダの受け取り 13.1 秒、pack の中の一覧 6.2 秒、ばらのオブジェクト 11.3 秒など
段階秒
フォルダの受け取り13.1
ブランチ・タグからたどる13.6
ばらのオブジェクト(3,127 個)11.3
pack の中の一覧6.2
commit と tree のまとめ読み0.3
reflog からたどる0.7
履歴0.6
ブランチ・タグ・reflog の読み込み0.1

13 分かかっていた部分の、大部分が、数秒以下になりました。残りの時間の多くは、ファイルを 1 つずつ読む作業と、最初にたどる作業です。

git 本体との突き合わせは、Node 22 で、次の 4 つのリポジトリで行いました(docs/git-inspector-parser.test.mjs)。

  • 合成リポジトリ(差分 112 件、最大の深さ 18、pack 2 つ、ばらのオブジェクト、注釈付きタグ、reflog にだけ残るコミット)
  • chalk(1,756 個、差分 859 件、最大の深さ 27。コミット 387 個、merge 21 個)
  • Spoon-Knife
  • 画面確認用の小さなリポジトリ

次の項目が、すべて一致しました。

  • verify-pack -v の全件(種類・大きさ・pack 内の大きさ・位置・深さ・元の SHA)
  • cat-file --batch-all-objects(pack とばらのオブジェクトの全件)
  • 中身を読んで SHA-1 を計算し直した値が、名前と一致する(差分を正しく組み立てられた証拠)
  • for-each-ref と HEAD
  • rev-list --objects --all(--reflog 付きも)
  • コミットの集合(rev-list --all --reflog)。親が子より先に並ぶこと。増えた容量の合計が、割り当てたものの大きさの合計と一致すること。一直線の履歴は、コミットごとの集合も完全に一致します

未確認のこと・制限

  • ウィンドウで開いたとき(?embed)の見た目は、まだ確かめていません
  • 画面が狭いとき(スマホ)は、確かめていません。専用の設計も、していません
  • Chrome 以外(Firefox、Safari)は、確かめていません
  • 2 GB を超える pack は、コードはありますが、試していません。idx v1 も、同じです
  • shallow clone(親のないコミット)と、コミットが数万〜数十万ある履歴も、試していません。一覧は 300 件までの表示です
  • worktree や submodule の .git(フォルダではなく、ファイル)は、読めない旨のエラーを出します
  • 読み込みの時間(45 秒)は、1 回の実機の結果です。OS のキャッシュが効いている可能性もあり、環境によって変わります
  • 名前(パス)は、同じ内容が複数の名前を持つとき、git と違う名前を出すことがあります(chalk で 7 件。集合は一致)
  • 枝分かれした履歴の「増えた容量」は、git の数え方と違います(先に数えた方だけに付きます)
  • ステージ中の(git add 済みの)ファイルは、「到達できない」に見えます
  • このツールは、見るだけです。消す作業(reflog の期限切れ、git gc、git prune)は、しません。実行する前に、このツールで中身を確かめて、git status も見てください
  • 中身の推定は、先頭 32 バイトだけの判定です。判定できなければ、「不明」になります

おわりに

「.git が重い理由を知りたい」だけのつもりが、「到達できるとは何か」「同じものを、どのコミットで数えるか」「何を読むと、どれだけ時間がかかるか」と、.git の作りを、順に学ぶことになりました。

13 分 15 秒が 45 秒になったのは、計算を賢くしたからではなく、読む回数を減らしたからでした。WSL の遅さではなく、小さな読み込みを数万回していた、こちらの作り方の問題でした。

作った本人が、スクリーンショットを見て、気づくことも多くありました。「不明」が並ぶ画面を見て、実際のバイトを見て、判定を足す。この繰り返しは、WASM Inspector や PE Inspector で、構造を読んで確かめるやり方と、同じでした。