git gc の仕組みを原理から理解してサイズを 136MB → 7.2MB(95%減)まで削減した時の勉強メモ
個人用メモです。
「git gc
ってあんまし容量減らないよなぁ」
と思ったのが動機です。調べたけどパッと腑に落ちる記事がなかったので「自分で git のソースコード見た方がいいな」と急にモチベ発動してグワっと勉強しました。またついでに歴史改変の方法も調べたのですが、公式で既に WARNING が出てるほど非推奨化されてるfilter-branch
を使用してる記事が多かったので、2021 年現在で多分一番推奨されてるfilter-repo
を使ってやる方法もまとめました。
ちなみに容量減らしても高速化するかというとそこまで単純ではないです。そもそも減らさなくても 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_packs
とtoo_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)
// ..........
}
-
git pack-refs
:.git/refs
配下のブランチ/タグ-commit オブジェクトの参照を.git/packed-refs
に一纏めにする。ブランチ削除したときに参照が消えるのもこのタイミング。 -
git reflog expire
:.git/logs
のログを消す。デフォルトは 90 日前より古ければ消す。reflogExpire={N日前}|{now=全部}
のオプションを gc に渡せば調整できる。 -
git repack
:.git/objects
を整理する。loose object は pack に、pack は最適化された状態に変える。さっきから長文書いてきたのはこれ。内部的にはgit pack-objects
という更に非依存の plumbing コマンドを使用している。 -
git prune
:.git/refs
配下にあるブランチから到達できない loose object を内部的にgit fsck --unreachable
を実行して削除し、ついでに既に pack されてるはずなのに objects に残ってる loose objects もあったら消します。なんとなく察するように「pack されてるけどどの ref からも到達できない」ゴミ packfile は残ってしまうので、repack の後にやらないとだめ。 -
git worktree prune
: 追加した worktree を削除する。多重する人は結構多重してると思うので注意。デフォルトは 3 ヶ月以上前の worktree のみ。 -
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(解消状態の記録)
に指定すれば何日前以上かどうかを設定できる。
- gc 側のオプションである
これで、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 から設定可能な分は全て消したく、かつwindow
とdepth
は 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 を変えたほうが良いと思います。
更にもっと強めの圧縮をしたいならば、 window
と depth
を可能な限り高めるために -c gc.aggressiveDepth=${hoge}
と -c gc.aggressiveDepth=${hoge}
を新たに追加すればできそうだな、とあたりが付きました。一旦方針を整理します。
git gc
がわかったので、では容量削減のために具体的にどうやっていくか
だいたい- まず要らないブランチ・tag・stash を消す
- 本当にいらない blob オブジェクトを全ブランチ・全ヒストリーから消す
- git gc を最強オプションでかける(
2
を踏まえた上で再計算し直す) - force push してリモートリポジトリに反映
これで良さそうだな、とあたりを付けます。
1. まず要らないブランチ・tag・stash を消す
全ブランチ・全ヒストリーから特定ファイルを消そうとすると、とんでもない時間がかかるので一旦要らないブランチから消していきます。refs
の大本をへらす意味もあります。また stash もlogs/refs
とrefs
に残ってしまうので、無駄な分が発生しないように削除します。
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 オブジェクトが消えました。
2
を踏まえた上で再計算し直す)
3. git gc を最強オプションでかける(不要な 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
- レビューして頂いたお三方。
- お忙しい中レビューして頂いたので、記事の中に間違いがあっても 100%自分のミスです。
-
「GitHub トレーニングチームから学ぶ Git の内部構造」に行ってきました
- この勉強会羨ましすぎる。行きたすぎるけどもう無いんだろうなぁ・・・まぁ自分で掘ればいいか・・・
-
https://git-scm.com/
- 大体ここで知った。ただいくつかのドキュメントはわかりやすさを重視して「約」とか「だいたい」って表現になってるので、ソースコード見た方がいい。
Discussion