🎃

git gc の仕組みを原理から理解してサイズを 136MB → 7.2MB(95%減)まで削減した時の勉強メモ

commits25 min read

個人用メモです。

git gcってあんまし容量減らないよなぁ」

と思ったのが動機です。調べたけどパッと腑に落ちる記事がなかったので「自分で git のソースコード見た方がいいな」と急にモチベ発動してグワっと勉強しました。またついでに歴史改変の方法も調べたのですが、公式で既に WARNING が出てるほど非推奨化されてるfilter-branchを使用してる記事が多かったので、2021 年現在で多分一番推奨されてるfilter-repoを使ってやる方法もまとめました。

nya

ちなみに容量減らしても高速化するかというとそこまで単純ではないです。そもそも減らさなくても partial clone で blob オブジェクトを必要最低限に指定して昔の blob をデフォルトで持ってこないようにしたり(--no-checkoutと併用するとより効果有る)、その後本当に自分が必要なやつだけ sparse-checkout したり、全部必要なんだけど一発ビルドしかしないのなら tree less か shallow clone でコミット履歴も切り捨てればいい。

ただ大規模プロジェクトにいるとたまに「あーそろそろ大掃除したい!」ってなる時があります。「過去にやらかしてないかのチェック」もできるし、「将来的にも機械的に制御する仕組みが作れたらいいね」くらいの気持ちで勉強してみたメモを残します。

そもそもgit gcよくわからないので整理

(*注) ユーザーが通常触るコマンドをporcelainコマンド、通常触らないような低レベルでコマンド内部に非依存してるコマンドをplumbingコマンドと呼びます。

git gc の役目はリポジトリの掃除です。

デフォルトの自動発動条件はautogcがオンの際にcommit, fetch, am, merge, rebaseが行われた時です。この時run-command.c の run_auto_maintenanceが走ります。

int run_auto_maintenance(int quiet)
{
  int enabled;
  struct child_process maint = CHILD_PROCESS_INIT;

  if (!git_config_get_bool("maintenance.auto", &enabled) &&
      !enabled)
    return 0;

  maint.git_cmd = 1;
  strvec_pushl(&maint.args, "maintenance", "run", "--auto", NULL);
  strvec_push(&maint.args, quiet ? "--quiet" : "--no-quiet");

  return run_command(&maint);
}

この run_auto_maintenance はgc.c の maintenance_run 関数へ繋がります。最終的に cmg_gc, need_to_gc あたりへ繋がります。このあたりを読み解いてみます。(関係ない独り言ですけど、git は plumbing コマンドに依存しまくってるコードが多くて IDE でめっちゃ追いづらいなぁと思いました。GIT_TRACE=1を使用しても plumbing コマンドが依存している際に printf する側の実行側の行(run_command.c:667, git.c:447)しか見れない。それらは各ファイルの内部に埋め込まれてるので、grep 力が必要です。)。

static int gc_auto_threshold = 6700;
static int gc_auto_pack_limit = 50;

// ..........

static int need_to_gc(void)
{
  // ..........

  /*
   * If there are too many loose objects, but not too many
   * packs, we run "repack -d -l".  If there are too many packs,
   * we run "repack -A -d -l".  Otherwise we tell the caller
   * there is no need.
   */
  if (too_many_packs()) {
    // ..........
  } else if (too_many_loose_objects())
    // ..........
  else
    return 0;

  // ..........
}

loose object が 6700 個(gc_auto_threshold で直打ち。config で変えられる。)か packfile が 50 個(gc_auto_pack_limit で直打ち。config で変えられる。)ある場合のみ実行される事がわかります(詳細はtoo_many_packstoo_many_loose_objects関数を参照してください。)。この時、実はgit gcの前にrepack -d -l (objectsが多いなら) もしくは repack -d -l -A (packが多いなら)が行われています(詳細は後述)。ただ後述の理由で「packfile の過多による」自動発動は基本的に行われません。

loose object というのは「あるオブジェクトが最初に git に格納され、まだ最適化(pack)されてない時の状態。」を指します。これは.git/objects/**/***に存在してます。git という仕組み上では SHA-1 が衝突しない限り数の上限は存在しなく(7 年前のソースですが多分今まで衝突してないから大丈夫そう)、サイズの上限もないのですが、リポジトリストレージサービス(github とか gitlab とか)の制限だったり、後述の packfile の差分管理プロセス(deltification)はメモリ内で行われるのでデカすぎるとマシンによってはメモリ枯渇で死ぬので現実的には上限は存在します。ちなみにソースコードを追ってる途中に too_many_loose_objects 関数の中で、謎のobjects/17という条件分岐がありました

static int too_many_loose_objects(void)
{
  // ..........

  dir = opendir(git_path("objects/17"));
  if (!dir)
    return 0;

  // ..........
}

これ、ソースコードを追って勉強してる人からしたら理解不能で詰まると思います。自分もgit blame -> showして追ったのですが、このコメントだけ読んでも全然ピンと来ないので「ぐぬぬ・・・」ってここで 2 日くらい止まっちゃいました。結局、git のコミッターなどがいる場所で質問してみたら、

  • SHA-1 は 16 進数なので、最初の2桁は 256 通りある
  • 理論上 SHA-1 はランダムなので、均等に分割される
  • つまり最も逼迫した状態では「00, 01, 02, ..., 17, ..., FF」全てのディレクトリに均等に 6700(上述したgc_auto_threshold)/256 = 26 個のオブジェクトが存在してるはず
  • この時、全てのディレクトリの数を調べる必要はなく1個のディレクトリだけ見ればいい。1個だけチェックする事で効率よくチェックできる
  • で、なんで 00 でも 77 でもなく17なの?って話なんだけど、実は MIT にthe least random numberって呼ばれてる(ソース)

って知りました。まさかここで17という文字のちょっとしたトリビアを知れると思わなくて「なんか詰まって逆に勉強になってよかったーーー!」ってなりました(誰得)。

さて話は戻り、次は packfile について。packfile というのは、「git gcされた時やリモートサーバーに push された時などに、複数以上の loose object が 1 つのバイナリファイルに圧縮(pack)された物」の事。./git/objects/pack/*に存在してる。保管の仕方としては例えば 100MB ある loose object が最初に commit された後、1MB だけ差分発生させてまた commit した時、2つの 100MB の loose object を残すのではなく、容量節約のために pack 内で差分のみを新しいオブジェクトとして保管する(なので合計約 101MB のみになる)。この差分登録法が delta encoding と呼ばれて、差分(もしくは最初のオブジェクトならファイルそのもの)が俗的に delta と呼ばれて packfile に保存される。よく git push などするとCounting objects: 1000, done. Total 1000 (delta 100), reused 0 (delta 0)とか出るが、これはつまり「この commit には 1000 個の object が存在して、delta は 100 個あり、同様の delta(再利用できそうな差分) は 0 個だから再利用分は 0 個」という意味になる。

ちょっと実際にやってみます。

756k ある ca.po は、最初の commit で zlib で圧縮されて 228K くらいまでに圧縮されています(.git/objects/cd)。そこに"1"という文字を append しただけでもう一度 commit してみると、同じだけのサイズのファイルが爆誕しちゃっています(.git/objects/70)。ここで deltification しましょう。git repack(オプションはあとで詳細書きます)で圧縮したらダブった分がなくなり 196K の packfile だけが残りました。これだけでも大きな効果を感じ取れますね。実際に packfile を覗いてみましょう。Total 7 (delta 1)の delta は ca.po の二個目以降を指しており、それ以外の object はnon delta: 6 objectsとなっているので先程の表示結果と合致しているのが確認できます。

この packfile、基本的に最大容量は存在しません。つまり 1pack に追加され続けて、適宜 repack されます。なので packfile が増えすぎる事は起きないのでgit gcの自動発動が基本的に行われないのです。(ただ最小単位が 1MB なので--max-pack-size=1mにしたり、pack が生成されるたびにその pack ディレクトリに.keepをおいて repack 対象から除外すれば packfile はどんどん増やす事は可能です。)ちなみに packfile はgit verify-packで中を見る事ができます。この中にこれまでの object 履歴が存在しているので、packfile を全て見れば、過去の git object 全てを参照する事が可能になる。 基本的に git オブジェクトは zlib で圧縮されてるが、git unpack-objectsで deltification された packfile を loose file に戻してから更に zlib で解凍しても全然読みづらいのでこちらの方が楽です。中を見ると分かりますが、直近バージョンのファイルほど高速にアクセスできるように、parent(旧)の方が差分を持って child(新)が元の状態で格納されています。

だいたい役目とその周辺の知識が理解できてきたので、具体的な処理を見てみます。

git gcの具体的な処理内容

git gc本体は、下記のように plumbing コマンドが直列で実行されていきます。

int cmd_gc(int argc, const char **argv, const char *prefix)
{
  // ..........

  strvec_pushl(&reflog, "reflog", "expire", "--all", NULL);
  strvec_pushl(&repack, "repack", "-d", "-l", NULL);
  strvec_pushl(&prune, "prune", "--expire", NULL);
  strvec_pushl(&prune_worktrees, "worktree", "prune", "--expire", NULL);
  strvec_pushl(&rerere, "rerere", "gc", NULL);

  // ..........

  // インデントはかなり省略してます
  gc_before_repack()
    // 下記2つの処理に飛ぶ
    // strvec_pushl(&pack_refs_cmd, "pack-refs", "--all", "--prune", NULL); => run_command_v_opt(pack_refs_cmd.v, RUN_GIT_CMD);
    // run_command_v_opt(reflog.v, RUN_GIT_CMD)
  run_command_v_opt(repack.v, RUN_GIT_CMD)
  run_command_v_opt(prune.v, RUN_GIT_CMD)
  run_command_v_opt(prune_worktrees.v, RUN_GIT_CMD)
  run_command_v_opt(prune_worktrees.v, RUN_GIT_CMD)
  run_command_v_opt(rerere.v, RUN_GIT_CMD)

  // ..........
}
  1. git pack-refs : .git/refs配下のブランチ/タグ-commit オブジェクトの参照を.git/packed-refsに一纏めにする。ブランチ削除したときに参照が消えるのもこのタイミング。
  2. git reflog expire : .git/logsのログを消す。デフォルトは 90 日前より古ければ消す。reflogExpire={N日前}|{now=全部}のオプションを gc に渡せば調整できる。
  3. git repack: .git/objectsを整理する。loose object は pack に、pack は最適化された状態に変える。さっきから長文書いてきたのはこれ。内部的にはgit pack-objectsという更に非依存の plumbing コマンドを使用している。
  4. git prune.git/refs配下にあるブランチから到達できない loose object を内部的にgit fsck --unreachable を実行して削除し、ついでに既に pack されてるはずなのに objects に残ってる loose objects もあったら消します。なんとなく察するように「pack されてるけどどの ref からも到達できない」ゴミ packfile は残ってしまうので、repack の後にやらないとだめ。
  5. git worktree prune: 追加した worktree を削除する。多重する人は結構多重してると思うので注意。デフォルトは 3 ヶ月以上前の worktree のみ。
  6. git rerere gc: コンフリクトを解決した際の記録を消す。forgetとの違いは現在コンフリクトかどうかとの違い。gcで全部消す事ができて、デフォルトは 15 日以上前のみ。

なんとなくそれぞれの概要はわかりました。でもオプションの意味がわかっていません。この plumbing コマンドは何をしていて、本当は他にも何ができるのかを網羅して勉強します。ドキュメントだけパッと訳しても理解できないのでコード読んだり動かしながら言語化します。

  • git pack-refsのオプション。git gc 時のデフォルトは"--all --prune"
    • gc.packRefsでオンオフ可能
    • --all: 全ての ref を pack する。「全ての ref を pack しない時ってどんな時だろう?」と思ったけど現在開発中の ref とかは一時無視したいニーズもあるんだと思われる。(memo: 全部消す時はこれを on にしたい
    • --prune: loose ref を全て削除する。これがデフォルトなので、なくてもいい。(memo: 全部消す時はこれを on にしたい
    • --no-prune: loose ref を全て削除しない。
  • git reflog expireのオプション。git gc 時のデフォルトは"--all"
    • -n: ドライラン
    • -v: verbose
    • --all: 全てのブランチ(ref)の reflog に対して実行する。
    • --single-worktree: 現在ワークツリーのみに限定する。デフォルトは全て。
    • --expire $time: $time より古い log のみ捨てる。デフォルトは 90 日。(memo: 全部消す時はこれを all にしたい
    • --expire-unreachable $time: $time より古い log のみ捨てる。この時現在ブランチの指すポインタから到達できる最新の tip から到達できるかどうかで判定している。デフォルトは 30 日。(memo: 全部消す時はこれを all にしたい
    • --rewrite: 直前の reflog が消えた場合、"old"カラムの方の sha-1 を消して、"new"側と同じに上書きする(logs/ref 内部を見るとわかるが、reflog は old と new 側を保存し続けている。)。途中のやつを消したか特殊なケースでない限り、特に必要ない気がする。
    • --stale-fix: 到達不可能なコミットオブジェクトに紐付いてるログを全て消す。通常時には結構有用なオプション。
  • git repackのオプション。git gc 時のデフォルトは"-d -l -A --unpack-unreachable=2.weeks.ago"
    • -a / -A: 全ての loose object を pack する。全ての loose object を pack するが、その後どこからも参照されないオブジェクトは残される。この挙動はだいたいのケースでニーズがないので基本的に-dと一緒に使れる。-Aは基本的に-aと同じだが-dが使われる場合を除き到達不可能なオブジェクトが loose object に変わる。(memo: 全部消す時はこれを on にしたい
    • -d: どこからも参照されないオブジェクトや、冗長になった packfile を消す。(memo: 全部消す時はこれを on にしたい
    • -l: non-local pack(どっかから持ってきた packfile)だったら無視する。そんな事あるのかは謎。
    • -f: 既に存在する pack を元に repack する場合、元の delta 形式の pack は使わない。再計算するので更に最適化されるのかと思いきや、逆に悪化するかもしれないらしくて「意味有るのか・・・?」ってなる。多分メモ枯渇して pack できない時の救済策・・・?
    • -F: 既に存在する delta も、既に存在する objects も一切元にしないで repack する。色々なオプションで repack しまくったせいでごっちゃになったときに使えそう?
    • -q: quiet
    • -n: ドライラン
    • --window:「repack 時、最小の delta を作りたいという時に、どれだけの数の似ている他の object と比較するか」。デフォルトは 10。なんとなくわかると思うが、大きければ大きいほど packfile は最適化されてサイズは小さくなるのだが、ここで指定した数 x object 数の時間がかかるので、大きくしたらとんでもない時間がかかってしまうので現実的ではない。ではどのくらいがいいのかというと、後述するが git 側である程度の保証をしている250で良い(と思う)。実際にやるとわかるが可逆的で、低 → 高でやるともちろんサイズは小さくなるが、一旦小さくしたあとに低い値でもっかいかけるとまたサイズは大きくなる。(memo: 全部消す時はこれを 250 にしたい
    • --depth:「1 つの root データから、deltification を行える最大回数」。デフォルトは 50 で、最大値は 4095(とドキュメントにはあるが、他のドキュメント箇所では記載なかったりコード上にもないので多分古い情報・・・?)。要は A->A'->A''->A'''→・・・という風に差分のみを登録していった時、4095 回も差分を許容すると、昔の branch に checkout すると再構築にかなりの時間がかかってしまいます。ただもちろん出来るだけ大きくしたほうが packfile のサイズは小さくなります。ではどのくらいがいいのかというと、これもまた後述するが git 側である程度の保証をしている50で良い(と思う)。(memo: 全部消す時はこれを 50 にしたい
    • --threads: delta 検索時に、何個スレッドをあてるかを指定する。デフォルトは 0 で、これは 0 個という意味ではなく CPU の数を自動で検知して、その最大数でやる。
    • --max-pack-size: packfile の上限サイズ。デフォルトは無制限。最小は 1MB。
    • -b / --write-bitmap-index: packfile の保存方法を bitmap index 型にする。通常はここのstatic int write_bitmaps = -1 の通りオフなのだが、では通常時の保存方法はどういう型なのかというとオリジナル(multi pack index)。試しにオンにしても特に容量変わらなかった。
    • --keep-pack: 特定ファイルを repack させない。実は pack ディレクトリに.keepを置くと repack 対象から除外できるのだが、それと同様の効果がある。
    • --unpack-reachable: pack 内で探索できない object の中でも指定した日よりも前の object は loose object にはしない。ここは通常git pruneの対象期間と同じにすることで、prune する対象を少なくする効果がある。(memo: 全部消す時はこれを all にしたい
    • -k / --keep-unreachable: -a-dを使って repack すると通常到達不可能な object は削除されるのだが、このオプションをオンにする事で packfile の最後尾に append してくれる。到達不可能な loose object は全て pack される。(どういう意図かはわからなかった)。
    • -i / --delta-islands: delta 再利用条件の方式を island 方式に限定する。どういう事かというと、ref 毎に delta を区分けすると決定する。例えばローカル A には A というブランチがあってローカル B には B と C というブランチがある時、delta の再利用条件が A と B で異なってしまう障害がある。こういった場合にもともと ref 毎に delta の再利用を限定すれば、消失を防ぐ事ができる。その代わりお察しの通り、A,B,C 間で本来は再利用できたはずの delta が使えなくなるので packfile のサイズはでかくなる。
  • git pruneのオプション(前述の repack の説明の通り、これ単体で動かすのはゴミが残る可能性あるので非推奨)。git gc 時のデフォルトは"--expire 2.weeks.ago"
    • 上述の通り、gc.pruneExpireで時間指定可能
    • -n: ドライラン
    • -v: verbose
    • --expire $time: $time より古いルーズオブジェクトのみ捨てる。(memo: 全部消す時はこれを all にしたい
  • git worktree pruneのオプション。git gc 時のデフォルトは"--expire 3.months.ago"
    • 上述の通り、gc.worktreePruneExpireで時間指定可能
    • -n: ドライラン
    • -v: verbose
    • --expire $time: $time より古い worktree のみ捨てる。(memo: 全部消す時はこれを all にしたい
  • git rerere gcのオプション。git gc 時のデフォルトはなし。オプションも無し。
    • gc 側のオプションであるgc.rerereUnresolved(未解消状態の記録), gc.rerereResolved(解消状態の記録)に指定すれば何日前以上かどうかを設定できる。

これで、plumbing コマンド群が実際に何をしていて、限界まで頑張ればどういう事ができるのか理解できました。上述の通りgit gc側からのオプションによってそれぞれの強度を変える事ができます。なので次はgit gcのオプションを勉強します。

  • git gcのオプション (gitconfig で設定できる内容が多いのでそちらも併記)
    • --aggressive: git-repack-fオプションを渡し、「delta を全部破棄して全部再計算する」ように指示します。また上述の通り、depth=50, window=250 の引数を渡します。公式で「パフォーマンス計測して本当に packfile がボトルネックになってると確定しているわけではないなら、別に効果ないよ」と言われています(パフォーマンス計測というのはGIT_TRACE_PERFORMANCE=1の事で、これを用いてどこで時間がかかっているのかを調査する必要があるという意味だと思う)が、今回の自分の目的はファイルサイズを小さくしたいという趣旨なのでこのまま続けます。(memo: 最適化したい時はこれはいれたい
    • --prune: 指定した日から古い loose objects を削除します。デフォルトは 2 週間ですが、nowと指定すれば全ての objects を削除します。後述のgc.pruneExpireと同じ。(memo: 全部消す時はこれは now にしたい
    • --keep-largest-pack: packfile が複数以上あるとき最も大きな packfile に統合される(上述の通り.keepがある pack ディレクトリは無視)。
    • gc.aggressiveDepth: repack の depth を指定する。デフォルトは上述の通り 50。ここでもっと厳しくもできる。
    • gc.aggressiveWindow: repack の window を指定する。デフォルトは上述の通り 250。ここでもっと厳しくもできる。
    • gc.auto: 自動で gc を始める基準となる loose object の数。上述の通りデフォルトは 6700。0 にすると無効になる。
    • gc.autoPackLimit: 自動で gc を始める基準となる packfile の数。上述の通りデフォルトは 50。0 にすると無効になる。
    • gc.bigPackThreshold: 指定したサイズより大きな packfile は統合させないで保持するしきい値。ただしautoPackLimitの方が優先されるので、数が増えたら統合される。
    • gc.writeCommitGraph: commit graph を書き換えるかどうか。「え、オフにできるのか」とびっくりするがもちろんデフォルトはオン。
    • gc.pruneExpire: git prune --expireにわたす「いつより古い loose objects は捨てるか」の基準。(memo: 全部消す時はこれは now にしたい
    • gc.worktreePruneExpire: git worktree prune --expireにわたす「いつより古い worktree は捨てるか」の基準。(memo: 全部消す時はこれは now にしたい
    • gc.reflogExpire: git reflog expireにわたす「いつより古い log は捨てるか」の基準。(memo: 全部消す時はこれは now にしたい
    • gc.reflogExpireUnreachable: git reflog expire-unreachableにわたす「いつより古い log は捨てるか」の基準。(memo: 全部消す時はこれは now にしたい
    • gc.rerereResolved: rerere自体にはオプションはないが、gc 側のこのオプションから「○○ 日より前の解消済の conflict の記録を消す」か指定できる。デフォルトは 60 日。(memo: 全部消す時はこれは now にしたい
    • gc.rerereUnResolved: rerere自体にはオプションはないが、gc 側のこのオプションから「○○ 日より前の未解消の conflict の記録を消す」か指定できる。デフォルトは 15 日。(memo: 全部消す時はこれは now にしたい

ということで、どのオプションをどう使えばgit gcでどの不要ファイルをどの程度削除できるか、一旦余すことなくわかる事ができました。もし gc から設定可能な分は全て消したく、かつwindowdepthは aggressive 基準でやるならばこう(おそらく大掃除する1回のタイミングでしか行わないし、設定が永続化されてしまう git config とは別にしたほうがいいんじゃないかと思うので、引数型にしてます)。

git \
  -c gc.pruneExpire=now \
  -c gc.worktreePruneExpire=now \
  -c gc.reflogExpire=now \
  -c gc.reflogExpireUnreachable=now \
  -c gc.rerereResolved=now \
  -c gc.rerereUnResolved=now \
  gc --aggressive

このオプションで実際どのくらい整理されるのか見てみましょう。何も一切 blob オブジェクトの削除などを行ってない現行 git のソースコードで実行してみると・・・・・・

218MB -> 142MB にまで削減できました。かなりの効果を出していますね。繰り返しになりますがこれはかなりキツめにかけてるので実際には適宜 Value を変えたほうが良いと思います。

更にもっと強めの圧縮をしたいならば、 windowdepth を可能な限り高めるために -c gc.aggressiveDepth=${hoge}-c gc.aggressiveDepth=${hoge} を新たに追加すればできそうだな、とあたりが付きました。一旦方針を整理します。

だいたいgit gcがわかったので、では容量削減のために具体的にどうやっていくか

  1. まず要らないブランチ・tag・stash を消す
  2. 本当にいらない blob オブジェクトを全ブランチ・全ヒストリーから消す
  3. git gc を最強オプションでかける(2を踏まえた上で再計算し直す)
  4. force push してリモートリポジトリに反映

これで良さそうだな、とあたりを付けます。

1. まず要らないブランチ・tag・stash を消す

全ブランチ・全ヒストリーから特定ファイルを消そうとすると、とんでもない時間がかかるので一旦要らないブランチから消していきます。refs の大本をへらす意味もあります。また stash もlogs/refsrefsに残ってしまうので、無駄な分が発生しないように削除します。

git fetch --all --prune
git branch -D ${要らないブランチ}
# fzfあるならこっちの方が一気にインタラクティブに選択できて楽。
# git branch | fzf -m --print0 | tr -d ' ' | xargs -0 git branch -D
# この時点でremoteのブランチも消します。refs/remotes に残ってしまう為。
git push --prune origin "refs/heads/*:refs/heads/*"

git fetch --prune-tags
git tag -d ${要らないtag}
# fzfあるならこっちの方が一気にインタラクティブに選択できて楽。
# git tag | fzf -m --print0 | xargs -0 git tag -d
git push --prune --tags origin

git stash drop ${要らないstash}
# fzfあるならこっちの方が一気にインタラクティブに選択できて楽。
# git stash list | awk -F':' '{print $1}' | fzf -m --print0 | xargs git stash drop

2. 本当にいらない blob オブジェクトを全ブランチ・全ヒストリーから消す

まず、そもそも過去の commit に存在するファイルのサイズをどうやって調べるかです。これは HEAD を任意の commit に移してduするとかgit log --name-onlyの出力整形してやれば力技で出来るんですけど、当然膨大な checking objects と重複量があり超非効率なのは容易に想像できます。なので.git/objects から過去からずっとぶち込まれ続けてきた blob オブジェクトを抜き出して、それら hash をgit cat-file を format オプションと一緒にかませてファイルサイズを割り出します。端的に言うと「blob ファイルだけ一覧して、しかもサイズで sort できて、ファイル名もわかれば、やりたいことめっちゃやりやすくできそう」という事です。

# (1) 力技
# ./git/objects/XX/YYYYYYの時、XXYYYYYYについて
res=$(git cat-file -t XXYYYYYY)
if [ res eq "blob" ]; then echo "XXYYYYYY" > bloblist.txt
# → メチャクチャ手間がかかる

# (2) 力技
git verify-pack -v .git/objects/pack/pack-XXXXX.idx | grep " blob " | cut -d" " -f1
# → これで過去含めた全てのblobのSHA1一覧が取得できる。
# → ただファイル名がわからなくて辛い。

# (3) 最終形
git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectsize:disk) %(rest)' | grep '^blob' | sort --numeric-sort --key=2

最終形を叩くとこんな感じに出る。

rev-list で全ての objects をリストした上で、それらを全て cat-file で確認します。cat-file は format を指定できるので、これを利用して blob で grep できるようにして、size で最後に sort します。これで「過去の履歴全てにおいてデカかった blob オブジェクト」が高速に可視化できるようになりました。

# 一旦blob_list.txtに入れる
git rev-list --objects --all | \
  git cat-file --batch-check='%(objecttype) %(objectsize:disk) %(rest)' | \
  grep '^blob' | \
  sort --numeric-sort --key=2 \
  > blob_list.txt
# 消してもいいなというやつだけ選択する。最終的に↓のようになるとする。
# blob 85870 gitk-git/gitk
# blob 222610 po/fr.po
# blob 275827 po/bg.po
# blob 380680 t/t0013/shattered-1.pdf

target_blobs=$(echo $(cat blob_list.txt | cut -d" " -f3 | sed 's/^/--path /g'))
# "--path gitk-git/gitk --path po/fr.po --path po/bg.po --path t/t0013/shattered-1.pdf"って感じになる。空白ファイル名対応は適宜escape必要。

# ここから完全に歴史からblobオブジェクトを消します。
# filter-branchは使わない。
git filter-repo --force --invert-paths ${target_blobs}

この記事の冒頭でも記載しましたが、履歴書き換えしたことがある人なら一度は見たこと有るこのfilter-branch、既に git 公式より WARNING が出るほど非推奨化されててgit-filter-repoなどの他ツールの使用を推奨されてます。git-filter-repoはかなり短い記述で、しかも高速です。regex-pattern での指定もすごい直感的にできるし、本家にあった--**-filter系も揃ってます。更に update-ref によるバックアップ削除であったり、その後の gc も--prune=nowオプション付きでやってくれるスグレモノです。

ですので今後は基本的にこちらのgit-filter-repoを使いましょう・・・・・・と〆たいのですが、とはいえ今回自分は「git の中をしっかり掘るモチベが湧いた貴重な機会だからなぁ」という事で一応filter-branchを使う場合の方法も下記にまとめときました。

# 昔はこうしてた (タグの改変も行うとか、色々オプションあり)
git filter-branch --tree-filter 'git rm --cached -r --ignore-unmatch ${target_blobs}' --prune-empty -- --all
# バックアップされた参照も消す。
git for-each-ref --format="%(refname)" refs/original/ | xargs -n 1 git update-ref -d

# (注)ちなみに本当に高速化したい場合、そのファイルが追加された時点の commit は下記でわかるので
# 下な感じで「そのファイルが存在している commit」のみを狙い撃ちできる。とはいえいちいちそれを計算するのは面倒なのであまり気にしなくていいと思う。
git log --oneline --branches -- ${filepath}
git filter-branch --tree-filter "git rm --ignore-unmatch --cached ${filepath}" -- ABCDEF^..

これで全ての不要な blob オブジェクトが消えました。

3. git gc を最強オプションでかける(2を踏まえた上で再計算し直す)

不要な blob オブジェクトを削除した上で、許容できる範囲で git gc をかけます。git-filter-repoを使う場合git gc --prune=nowは既にかかってるのですが、この場合 log などは中途半端に残っているので先程調べたオプションを使い任意の強度で改めて掃除します。

# 仮に事前にgcしててもfilter-branchを行ったその瞬間のrefsとreflogが無駄な参照を生んでたりするのでここでgcする。
git \
  -c gc.pruneExpire=now \
  -c gc.worktreePruneExpire=now \
  -c gc.reflogExpire=now \
  -c gc.reflogExpireUnreachable=now \
  -c gc.rerereResolved=now \
  -c gc.rerereUnResolved=now \
  gc --aggressive
# もしこれらをデフォルトgcに設定したいならgit configのgcカラムに手動設定すればOK。git config gc.pruneExpire=now でも可。

【注】 git gcがメモリ枯渇で死ぬ場合

ちなみにガチで環境が貧弱だと、deltification はメモリで行われる事もあってout of memoryで死ぬ事があります。そんなに頻繁には発生しません。というか発生したときは相当環境がヤバいです。そして私はヤバいところにいました(ぇ)。その時は下記オプションでメモリ消費量を抑えてなんとかなりました。

# deltification が使うのは(deltaCacheSize + windowMemory) * thread
git config pack.deltaCacheSize 1m # packに最適化したデータを書き込む前のキャッシュ用メモリ上限量。デフォルトは256m。
git config pack.windowMemory 10m # メモリ上限量。デフォルトはなんと無制限。
git config pack.thread 1  # スレッド。デフォルトはCPUの数を自動で検知して、その最大数でやる。

# 山のようにある既存環境全てで一回流したい・・・って時はいちいちconfig修正せずoptionをargumentで渡せばおkです
git -c pack.deltaCacheSize=1m -c pack.windowMemory=10m -c pack.thread=1 gc
# ↑ これをsshにかませて全環境で行うイメージ

4. force push してリモートリポジトリに反映

バックアップは適宜行った上で、リモートリポジトリに反映させます。

git push --force --all

これで終わりです。

まとめ

自分は去年まで git 系 OSS の活動をよくしてて(これは C ではなく Rust)よく git 本体のソースコードを勉強する機会はあったのですが、gc は本当に漠然としか知らなくって実際に追ってみると「コードもドキュメントも凄いよくできてるなー」ってとても面白く勉強できました。なんかまた違う機会にモチベが湧いたら面白そうだなーと思いました。

自分用スクリプトはこちら。

https://gist.github.com/ulwlu/a54252f731b6a05c60cf444295015755

色々間違ってるかもしれないので、もし間違いがあればコメントかZenn のリポジトリへプルリクでご指摘頂ければ嬉しいです。

note

特に説明不要かもしれませんが一応メモ。記事内でよくblobオブジェクトだなんだと言ってますが、./git/objects 配下には下記の4種類ありそのうちの1つです。

  • blob:ファイル。最小単位。普通にgit add, git commitしてたら生まれるが、その中ではgit hash-object -w ${filename} という plumbing コマンドが実行されて SHA1 ハッシュに基づいた命名で保存される。
  • tree:ディレクトリ。blob の名前管理=格納管理を行う。git cat-file -p ${tree オブジェクト}を実行すると何が格納されてるのか見る事ができる。通常 blob object と sub tree object の2つを保持している(存在している場合は submodule も含まれる)。手動で作るのは凄い面倒なんだけど、「1: add してステージングにあげる。2: git update-index ${filename}を実行してインデックスを.git/indexに作る(index はプロジェクトのディレクトリツリー全体を保持してる。git ls-files --stage で確認可能。) 3: そのディレクトリで git write-tree ${引数無し}を実行する」で作成できる。
  • commit:commit 情報。1 つのルート tree オブジェクトにポインタが向いてる。このルート tree オブジェクトをたどる事でディレクトリツリー全体を再現できている(cat-file -pでどの tree オブジェクトを見てるか確認できる)。ルート tree オブジェクトを引数にcommit-treeの plumbing コマンドを叩きつつ 、stdin で commit message、config あるいは-c "user.name=X" -c "user.email=X"の4つで爆誕できる。その瞬間の commit object に含まれる object 数はgit count-objects -vで見れたり、いつも git commit とかするときに出てくるCounting objects: xxx, done.で見れる。
  • tag:注釈付きタグ。
  • ※ ちなみに git は SHA-1 かけるときに contents と一緒に blob ${length}\0というオブジェクトの種別が書かれた header も付いている。cat-file -tで簡単に種別は確認できる。

ここまで書いたけど手動で色々動作確認しようとしたら案の定 commit から追跡できないゴミ loose object が爆誕したりしたので人間がやる作業ではない。

雑談ですが昔 github の URL の意味がわからなくて、初めて git object について勉強したとき「あーあの blob とか tree って URL 文字列ってそういうことか!」と地味に感動した記憶が残ってます。

  • ファイル: https://github.com/ulwlu/dotfiles/blob/master/bundle/Brewfile
  • ディレクトリ: https://github.com/ulwlu/dotfiles/tree/master/bundle
  • コミット: https://github.com/ulwlu/dotfiles/commit/28d261255f7d9027c49c33bdc35f4157fe13c8c6

Thanks

GitHubで編集を提案

この記事に贈られたバッジ

Discussion

ログインするとコメントできます