公開:2025年6月16日
21分で読めます
git-diff-pairs(1)コマンドや、参照の一括更新を行うためのgit-rev-list(1)オプションなど、GitLabのGitチームとGitコミュニティによるコントリビュートをご紹介します。
Gitプロジェクトは最近、Gitバージョン2.50.0をリリースしました。今回のリリースの注目すべきポイントをいくつかご紹介します。これには、GitLabのGitチームやより広範なGitコミュニティからのコントリビュートも含まれています。
差分は、すべてのコードレビューの中心となるもので、2つのリビジョン間で行われた
すべての変更を表示します。GitLabでは、さまざまな場所で差分が表示されますが、最も
一般的なのはマージリクエストの「変更」タブです。
その裏側では、差分の生成にgit-diff(1)
が
使われています。たとえば、以下のように使います。
$ git diff HEAD~1 HEAD
このコマンドは、変更されたすべてのファイルの完全な差分を返します。ただし、リビジョン間で変更されたファイル数が非常に多い場合、スケーラビリティの課題が生じる可能性があります。GitLabのバックエンドでは、コマンドが自己設定されたタイムアウトに達してしまうこともあります。変更数が多い場合、
差分の計算をより小さく扱いやすい単位に分割できる方法があれば、より効果的です。
この課題を解決する1つの方法は、
git-diff-tree(1)
を使って、
変更されたすべてのファイルに関する情報を取得することです。
$ git diff-tree -r -M --abbrev HEAD~ HEAD
:100644 100644 c9adfed339 99acf81487 M Documentation/RelNotes/2.50.0.adoc
:100755 100755 1047b8d11d 208e91a17f M GIT-VERSION-GEN
Gitはこの出力を「raw」フォーマットと呼んでいます。
簡単に言えば、出力の各行にはファイルのペアと、
それらの間で何が変更されたかを示すメタデータが表示されます。大規模な変更に対して
「パッチ」形式の出力を生成する方法と比べて、
この処理は比較的高速な上、すべての変更の概要を把握できます。また、このコマンドでは、-M
フラグを付けることでリネーム検出を有効にし、変更がファイルのリネームによるものかどうかを判別することもできます。
この情報を使えば、git-diff(1)
を使って各ファイルペアの差分を
個別にコンピューティングすることができます。たとえば、以下のようにblob IDを
直接指定することも可能です。
$ git diff 1047b8d11de767d290170979a9a20de1f5692e26 208e91a17f04558ca66bc19d73457ca64d5385f
この処理は、各ファイルペアごとに繰り返すことができますが、
個別のファイル差分ごとにGitプロセスを立ち上げるのは、あまり効率的ではありません。
さらに、blob IDを使った場合、変更ステータスやファイルモードといった、
親ツリーオブジェクトに格納されているコンテキスト情報が差分から失われてしまいます。
本当に必要なのは、「raw」なファイルペア情報を元に、
対応するパッチ出力を生成する仕組みです。
バージョン2.50から、Gitに新しい組み込みコマンド
git-diff-pairs(1)
が追加されました。このコマンドは、
標準入力(stdin)から「raw」形式のファイルペア情報を受け取り、どのパッチを出力すべきかを正確に判断します。以下の例は、
このコマンドの使用方法を示しています。
$ git diff-tree -r -z -M HEAD~ HEAD | git diff-pairs -z
このように使用した場合、出力結果はgit-diff(1)
を使った場合と同じになります。
パッチ出力を生成する専用コマンドを分けることで、
git-diff-tree(1)
から得られた「raw」出力を、より小さなファイルペアのバッチに分割し、それぞれを別々の
git-diff-pairs(1)
プロセスにフィードすることができます。これにより、差分を一度にすべてコンピューティングする必要がなくなるため、
先に挙げたスケーラビリティの課題が解決されます。今後のGitLabリリースでは、
この仕組みの応用により、
特に変更量が多い場合における差分生成のパフォーマンス向上が
期待されます。この変更についての詳細は、該当する
メーリングリストのスレッドをご覧ください。
このプロジェクトはJustin Toblerが主導しました。
Gitには、参照を更新するためのgit-update-ref(1)
コマンドが用意されています。このコマンドを--stdin
フラグとともに使用すると、
複数の参照を1つのトランザクションとしてまとめて更新できます。
これを行うには、各参照更新の指示を標準入力(stdin)で指定します。
この方法で参照を一括更新すると、アトミックな動作も実現できます。つまり、1つでも参照の更新に失敗した場合、
トランザクション全体が中断され、
どの参照も更新されません。以下は、この動作を示す例です。
# 3つの空のコミットと「foo」という名前のブランチを持つリポジトリを作成する
$ git init
$ git commit --allow-empty -m 1
$ git commit --allow-empty -m 2
$ git commit --allow-empty -m 3
$ git branch foo
# コミットIDを出力する
$ git rev-list HEAD
cf469bdf5436ea1ded57670b5f5a0797f72f1afc
5a74cd330f04b96ce0666af89682d4d7580c354c
5a6b339a8ebffde8c0590553045403dbda831518
# トランザクションで新しい参照を作成し、既存の参照を更新しようとします。
# 指定された古いオブジェクトIDが一致しないため、更新は失敗することが予想されます。
$ git update-ref --stdin <<EOF
> create refs/heads/bar cf469bdf5436ea1ded57670b5f5a0797f72f1afc
> update refs/heads/foo 5a6b339a8ebffde8c0590553045403dbda831518 5a74cd330f04b96ce0666af89682d4d7580c354c
> EOF
fatal: cannot lock ref 'refs/heads/foo': is at cf469bdf5436ea1ded57670b5f5a0797f72f1afc but expected 5a74cd330f04b96ce0666af89682d4d7580c354c
# 「bar」リファレンスは作成されませんでした。
$ git switch bar
fatal: invalid reference: bar
多くの参照を個別に更新する場合と比べて、一括で更新するほうがはるかに効率的です。
この方法は基本的にうまく機能しますが、
一括更新による効率面のメリットを優先するために、
リクエストされた参照更新の一部が失敗することを許容したい場合も
あります。
今回のリリースで、git-update-ref(1)
に新しく--batch-updates
オプションが追加されました。
このオプションを使用すると、1つ以上の参照更新が失敗しても、処理を続行できるようになります。
このモードでは、個々の失敗が次の形式で出力されます。
rejected SP (<old-oid> | <old-target>) SP (<new-oid> | <new-target>) SP <rejection-reason> LF
これにより、成功した参照の更新はそのまま進行しつつ、どの更新が拒否されたのか、
またその理由についての情報も得ることができます。前の例と同じリポジトリを
使った例は以下のとおりです。
# トランザクションで新しい参照を作成し、既存の参照を更新しようとします。
$ git update-ref --stdin --batch-updates <<EOF
> create refs/heads/bar cf469bdf5436ea1ded57670b5f5a0797f72f1afc
> update refs/heads/foo 5a6b339a8ebffde8c0590553045403dbda831518 5a74cd330f04b96ce0666af89682d4d7580c354c
> EOF
rejected refs/heads/foo 5a6b339a8ebffde8c0590553045403dbda831518 5a74cd330f04b96ce0666af89682d4d7580c354c incorrect old value provided
# 「foo」への更新が拒否されたにもかかわらず、「bar」参照が作成されました。
$ git switch bar
Switched to branch 'bar'
今回は--batch-updates
オプションを使用したことで、
更新処理が失敗しても参照の作成は成功しました。この一連のパッチは、
git-fetch(1)
やgit-receive-pack(1)
における今後の一括参照更新時の
パフォーマンス改善の基盤となります。詳細については、該当する
メーリングリストのスレッドをご覧ください。
このプロジェクトは、Karthik Nayakが主導しました。
git-cat-file(1)
を使うと、
リポジトリ内に含まれるすべてのオブジェクトの情報を
--batch–all-objects
オプションで出力できます。たとえば、以下のように実行します。
# シンプルなリポジトリを設定します。
$ git init
$ echo foo >foo
$ git add foo
$ git commit -m init
# 到達不能なオブジェクトを作成します。
$ git commit --amend --no-edit
# git-cat-file(1)を使用して、到達不能なオブジェクトを含むすべてのオブジェクトに関する情報を出力します。
$ git cat-file --batch-all-objects --batch-check='%(objecttype) %(objectname)'
commit 0b07e71d14897f218f23d9a6e39605b466454ece
tree 205f6b799e7d5c2524468ca006a0131aa57ecce7
blob 257cc5642cb1a054f08cc83f2d943e56fd3ebe99
commit c999f781fd7214b3caab82f560ffd079ddad0115
状況によっては、リポジトリ内のすべてのオブジェクトを検索し、
特定の属性に基づいて一部のオブジェクトだけを出力したい場合があります。
たとえば、コミットオブジェクトだけを表示したいときは、
grep(1)
を使って以下のように実行できます。
$ git cat-file --batch-all-objects --batch-check='%(objecttype) %(objectname)' | grep ^commit
commit 0b07e71d14897f218f23d9a6e39605b466454ece
commit c999f781fd7214b3caab82f560ffd079ddad0115
この方法でも目的は達成できますが、出力のフィルタリングには欠点があります。
それは、git-cat-file(1)
が
ユーザーが関心を持っていないオブジェクトも含め、リポジトリ内のすべてのオブジェクトをたどらなければならない点です。これはかなり非効率です。
今回のリリースでは、git-cat-file(1)
に--filter
オプションが追加され、
指定した条件に一致するオブジェクトだけを表示できるようになりました。これはgit-rev-list(1)
にある同名のオプションと似ていますが、
対応しているフィルターの種類はその一部に限られています。
対応しているフィルターはblob:none
、blob:limit=
および
object:type=
です。先ほどの例と同様に、オブジェクトはGitを使用して直接
種類でフィルタリングできます。
$ git cat-file --batch-all-objects --batch-check='%(objecttype) %(objectname)' --filter='object:type=commit'
commit 0b07e71d14897f218f23d9a6e39605b466454ece
commit c999f781fd7214b3caab82f560ffd079ddad0115
Gitに処理を任せられるのは便利なだけでなく、オブジェクト数の多い大規模なリポジトリにおいては
効率面のメリットも期待できます。
リポジトリにビットマップインデックスがある場合、
Gitが特定の種類のオブジェクトを効率的に検索できるようになり、パックファイル全体をスキャンする必要がなくなるため、
処理速度が大幅に向上します。
Chromiumリポジトリで行われた
ベンチマークでは、こうした最適化による大きな改善が確認されています。
Benchmark 1: git cat-file --batch-check --batch-all-objects --unordered --buffer --no-filter Time (mean ± σ): 82.806 s ± 6.363 s [User: 30.956 s, System: 8.264 s] Range (min … max): 73.936 s … 89.690 s 10 runs
Benchmark 2: git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=tag Time (mean ± σ): 20.8 ms ± 1.3 ms [User: 6.1 ms, System: 14.5 ms] Range (min … max): 18.2 ms … 23.6 ms 127 runs
Benchmark 3: git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=commit Time (mean ± σ): 1.551 s ± 0.008 s [User: 1.401 s, System: 0.147 s] Range (min … max): 1.541 s … 1.566 s 10 runs
Benchmark 4: git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=tree Time (mean ± σ): 11.169 s ± 0.046 s [User: 10.076 s, System: 1.063 s] Range (min … max): 11.114 s … 11.245 s 10 runs
Benchmark 5: git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=blob Time (mean ± σ): 67.342 s ± 3.368 s [User: 20.318 s, System: 7.787 s] Range (min … max): 62.836 s … 73.618 s 10 runs
Benchmark 6: git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=blob:none Time (mean ± σ): 13.032 s ± 0.072 s [User: 11.638 s, System: 1.368 s] Range (min … max): 12.960 s … 13.199 s 10 runs
Summary git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=tag 74.75 ± 4.61 times faster than git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=commit 538.17 ± 33.17 times faster than git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=tree 627.98 ± 38.77 times faster than git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=blob:none 3244.93 ± 257.23 times faster than git cat-file --batch-check --batch-all-objects --unordered --buffer --filter=object:type=blob 3990.07 ± 392.72 times faster than git cat-file --batch-check --batch-all-objects --unordered --buffer --no-filter ```
興味深いことに、これらの結果からは、処理時間がパックファイル内の総オブジェクト数ではなく、
指定された種類のオブジェクト数に比例して増減することが示されています。
元のメーリングリストのスレッドは、
[こちら](https://lore.kernel.org/git/20250221-pks-cat-file-object-type-filter-v1-0-0852530888e2@pks.im/)でご覧いただけます。
_このプロジェクトは[Patrick Steinhardt](https://gitlab.com/pks-gitlab)が主導しました。_
## バンドル生成時のパフォーマンスが向上
Gitには、指定した参照とそれに関連する到達可能なオブジェクトを含むリポジトリのアーカイブを生成する機能があります。
具体的には、
[`git-bundle(1)`](https://git-scm.com/docs/git-bundle)コマンドを使用します。この操作は、
GitLabがリポジトリのバックアップを作成する際や、
[バンドルURI](https://git-scm.com/docs/bundle-uri)メカニズムの一部としても使用されます。
何百万もの参照を含む大規模なリポジトリでは、
この操作に数時間から数日かかることもあります。たとえば、GitLabのメインリポジトリ
([gitlab-org/gitlab](https://gitlab.com/gitlab-org/gitlab))では、
バックアップに約48時間を要していました。調査の結果、バンドルに重複した参照が含まれないようにチェックする処理において、
パフォーマンスのボトルネックが存在することが判明しました。
この実装では、すべての参照をイテレーションして比較するために入れ子の`for`loopが使われており、
時間計算量はO(N^2)となっていました。これは、
リポジトリ内の参照数が増えるほど、処理性能が大きく低下する構造です。
今回のリリースでは、この問題に対応し、
ネストされたloopをマップ型のデータ構造に置き換えることで、処理速度が大幅に向上しました。以下は、
10万件の参照を含むリポジトリでバンドルを作成した際のパフォーマンス改善を
示すベンチマーク結果です。
```text
Benchmark 1: bundle (refcount = 100000, revision = master) Time (mean ± σ): 14.653 s ± 0.203 s [User: 13.940 s, System: 0.762 s] Range (min … max): 14.237 s … 14.920 s 10 runs
Benchmark 2: bundle (refcount = 100000, revision = HEAD) Time (mean ± σ): 2.394 s ± 0.023 s [User: 1.684 s, System: 0.798 s] Range (min … max): 2.364 s … 2.425 s 10 runs
Summary bundle (refcount = 100000, revision = HEAD) ran 6.12 ± 0.10 times faster than bundle (refcount = 100000, revision = master) ```
詳しくは、
[GitLabがリポジトリのバックアップ時間を48時間から41分に短縮した方法](https://about.gitlab.com/blog/how-we-decreased-gitlab-repo-backup-times-from-48-hours-to-41-minutes/)を紹介するブログ記事をご覧ください。
元のメーリングリストのスレッドは
[こちら](https://lore.kernel.org/git/20250401-488-generating-bundles-with-many-references-has-non-linear-performance-v1-0-6d23b2d96557@gmail.com/)でご覧いただけます。
_このプロジェクトは[Karthik Nayak](https://gitlab.com/knayakgl)が主導しました。_
## バンドルURIのアンバンドルの改善
Gitの[バンドルURI](https://git-scm.com/docs/bundle-uri)メカニズムは、
フェッチするバンドルの場所をクライアントに提供することで、クローンやフェッチの速度を
向上させることを目的としています。クライアントがバンドルをダウンロードすると、
`refs/heads/*`以下の参照が、その関連オブジェクトとともにバンドルから
リポジトリにコピーされます。バンドルには`refs/tags/*`のように
`refs/heads/*`以外の参照も含まれていることがありますが、
これらはクローン時にバンドルURIを使用する場合、単に無視されていました。
Git 2.50ではこの制限が解除され、
ダウンロードされたバンドルに含まれる`refs/*`に一致するすべての参照がコピーされるようになりました。
この機能を実装した[Scott Chacon](https://github.com/schacon)さんは、
[gitlab-org/gitlab-foss](https://gitlab.com/gitlab-org/gitlab-foss)をクローンした際の
挙動の違いを紹介しています。
```shell
$ git-v2.49 clone --bundle-uri=gitlab-base.bundle https://gitlab.com/gitlab-org/gitlab-foss.git gl-2.49
Cloning into 'gl2.49'...
remote: Enumerating objects: 1092703, done.
remote: Counting objects: 100% (973405/973405), done.
remote: Compressing objects: 100% (385827/385827), done.
remote: Total 959773 (delta 710976), reused 766809 (delta 554276), pack-reused 0 (from 0)
Receiving objects: 100% (959773/959773), 366.94 MiB | 20.87 MiB/s, done.
Resolving deltas: 100% (710976/710976), completed with 9081 local objects.
Checking objects: 100% (4194304/4194304), done.
Checking connectivity: 959668, done.
Updating files: 100% (59972/59972), done.
$ git-v2.50 clone --bundle-uri=gitlab-base.bundle https://gitlab.com/gitlab-org/gitlab-foss.git gl-2.50
Cloning into 'gl-2.50'...
remote: Enumerating objects: 65538, done.
remote: Counting objects: 100% (56054/56054), done.
remote: Compressing objects: 100% (28950/28950), done.
remote: Total 43877 (delta 27401), reused 25170 (delta 13546), pack-reused 0 (from 0)
Receiving objects: 100% (43877/43877), 40.42 MiB | 22.27 MiB/s, done.
Resolving deltas: 100% (27401/27401), completed with 8564 local objects.
Updating files: 100% (59972/59972), done.
これらの結果を比較すると、Git 2.50はバンドル展開後に43,887個(40.42 MiB)のオブジェクトをフェッチしているのに対し、
Git 2.49は合計で959,773個(366.94 MiB)のオブジェクトをフェッチしています。
Git 2.50では、取得されるオブジェクト数が約95%、
データ量が約90%削減されており、クライアントとサーバーの双方にとってメリットがあります。
サーバー側ではクライアントに送信するデータ量が大幅に減り、
クライアント側でもダウンロードおよび展開するデータが少なくて済みます。Chaconさんの提供した例では、
これによって処理速度が25%向上しました。
詳細については、
該当するメーリングリストのスレッドをご覧ください。
この一連のパッチは、Scott Chaconさんによって提供されました。
本記事でご紹介したのは、最新リリースにおいてGitLabと広範なGitコミュニティによって行われた
コントリビュートのごく一部にすぎません。Gitプロジェクトの公式リリースのお知らせでは、
さらに詳しい情報をご覧になれます。また、
以前のGitリリースのブログ記事もぜひご覧ください。GitLabチームメンバーによる過去の主なコントリビュートをご確認いただけます。