オープンソースのGitプロジェクトは、100人以上のコントリビュータ(うち33人が新規)による機能とバグ修正を盛り込んだGit 2.55をリリースしました。前回はGit 2.54のリリース時に最新情報を取り上げました。

今回のリリースを記念して、GitHubが前回以来に導入された最も興味深い機能と変更点について紹介します。

インクリメンタルなマルチパックインデックスによる再パック

本シリーズの常連読者の方は、インクリメンタルなマルチパックインデックスインクリメンタルなマルチパック到達可能性ビットマップに関する記事を覚えているかもしれません。復習が必要な方のために、簡単に説明します。

Gitはリポジトリの内容を個々のオブジェクト(コミット、ツリー、ブロブ)として保存します。これらのオブジェクトは通常、パックファイルに格納されます。パックファイルはオブジェクトの圧縮コレクションであり、各パックファイルには対応するパックインデックスがあり、Gitがパック内の任意のオブジェクトを素早く見つけることができます。しかし、大規模なリポジトリは通常、1つのパックファイルだけを持つわけではありません。時間の経過とともに、フェッチ、プッシュ、メンテナンスタスク、再パックによって多くのパックが残ることがあります。

マルチパックインデックス(MIDX)は、多くのパックに対して単一のインデックスを提供します。各パックの個別インデックスを開いて検索する代わりに、GitはMIDXに特定のオブジェクトを含むパックとそのオフセットを問い合わせることができます。これは大規模なリポジトリに特に有用で、GitHubのリポジトリメンテナンス戦略の基盤の1つとなっています。

Git 2.47でインクリメンタルMIDX形式が導入された際に取り上げたように、リポジトリはすべてのパックをカバーする単一のMIDXではなく、レイヤーのチェーンとしてMIDXを保存できます。単一ファイルのMIDXは読み取りがシンプルで効率的ですが、重要なメンテナンスコストがあります。そのファイルにはカバーするすべてのパックが含まれるため、小さな更新でも大規模なリポジトリでは大きな書き込みが必要になることがあります。

インクリメンタルMIDXは、MIDXレイヤーのチェーンを保存することでこの問題に対処します。各レイヤーはパックのコレクションをカバーし、チェーンファイルはそのレイヤーの順序を記録します。チェーンの先端に新しいレイヤーを追加しても、古いレイヤーは無効化されないため、Gitはリポジトリ全体をカバーする単一のMIDXを書き換えることなく、新しく作成されたパックをインデックス化できます。

Git 2.55はgit repackにこれらのインクリメンタルMIDXチェーンを直接書き込む機能を追加しました:

$ git repack --write-midx=incremental 

他のオプションなしでは、このモードは追記専用です。Gitは再パックによって作成されたパックの新しいレイヤーを書き込み、既存のレイヤーはそのままにします。これは、メンテナンス実行中に書き換えられるメタデータの量を最小限に抑えたい場合にすでに役立ちます。

ただし、追記専用のチェーンは永遠に成長することはできません。各メンテナンス実行で新しいレイヤーが追加されると、最終的にチェーン自体がメンテナンスの対象になります。Git 2.55は--write-midx=incrementalとジオメトリック再パックの組み合わせもサポートしています:

$ git repack --write-midx=incremental --geometric=2 -d

これらのモードを併用すると、各再パックは新しい先端レイヤーを作成し、隣接するレイヤーをコンパクトにするかどうかを決定します。デフォルトのルールはrepack.midxSplitFactorによって制御されます。新しいレイヤーの累積オブジェクト数が次の古いレイヤーに対して十分に大きくなった場合、Gitはそれらのレイヤーを単一の置換レイヤーにマージします。それ以外の場合は、古いレイヤーはそのままにします。

高レベルでは、アルゴリズムは次のように動作します。以下でNNはrepack.midxNewLayerThresholdの値、ffはrepack.midxSplitFactorの値を指します:

  1. MIDX化されていないパックをジオメトリック再パックの候補として選択します。先端MIDXレイヤーに少なくともNN個のパックがある場合、それらも候補に含めます。
  2. その候補セットに通常のジオメトリック再パックルールを適用し、結果のパックをカバーする新しい先端MIDXレイヤーを書き込みます。
  3. 新しいレイヤー(複数可)の累積オブジェクト数が次の深いレイヤーのオブジェクト数の1/f1/fを超える間、隣接するMIDXレイヤーをコンパクトにします。

これらの要素がどのように組み合わさるかを見るために、すでにインクリメンタルMIDXチェーンを持つリポジトリから始めましょう。古いレイヤーは左側に、先端レイヤーは右側にあります。一方、通常のリポジトリ活動は新しいパックを継続的に生成します。これらのパックはまだMIDXレイヤーでカバーされていないため、次のメンテナンス実行には2つの作業があります。再パックするものを決定することと、MIDXチェーンのどれだけを書き換えるかを決定することです。

新しく書き込まれたパックを持つMIDXレイヤーのチェーンを示す図

通常、これらのMIDX化されていないパックのみがジオメトリック再パックの候補となります。Gitは既存のレイヤーを乱すことなく新しいパックと新しい先端MIDXレイヤーを書き込むことができます。下の図はより興味深いケースを示しています。現在の先端レイヤーが設定されたrepack.midxNewLayerThresholdを満たすのに十分なパックを蓄積した場合です。このしきい値に達すると、先端レイヤーのパックは新しく書き込まれたパックと一緒にジオメトリック再パックの候補に加わることができます。

ジオメトリック再パックは、最新の候補パックについて局所的な質問をします。ジオメトリック再パックは、最新の候補パックについて局所的な質問をします。パックの接尾辞(𝒫\mathcal{P})の直左のパックが、Gitが𝒫\mathcal{P}をロールアップした場合にジオメトリック進行を維持するのに十分大きいかどうかです。最初の試行では、𝒫\mathcal{P}は現在の先端レイヤーの最小パックと新しいMIDX化されていないパックを含みます。しかし、分割の左側のパックは30,000オブジェクトしかなく、𝒫\mathcal{P}のサイズの2倍より小さいため、この分割は右側に寄りすぎています。

最初のジオメトリック分割が小さすぎることを示す図

そこでGitは分割を1パック左に移動し、同じ質問をもう一度します。今度は𝒫\mathcal{P}に先端レイヤーからもう1つのパックが含まれます。分割の直左のパックは100,000オブジェクトで、選択された接尾辞のサイズの少なくとも2倍です。これはジオメトリック不変量が成立するポイントなので、Gitはちょうどこれらのパックを新しいパックにロールアップできます。

分割を左に移動することでジオメトリックロールアップが見つかる様子を示す図

この新しいパックを書き込んだ後、Gitは前の先端レイヤーの残存パックと新しく書き込まれたロールアップパックの上に新しい先端MIDXレイヤーを書き込みます。この時点でパックファイル自体は良好な状態ですが、MIDXチェーンはまだ多くの小さな隣接レイヤーを蓄積している可能性があります。GitはMIDXレイヤー自体にも同じ「新しいものと古いものの比較」の考え方を適用します。新しいレイヤーが隣接するレイヤーに対して十分に大きい場合、それらのメタデータを置換レイヤーにコンパクトにします。

新しい先端レイヤーが隣接するレイヤーとコンパクト化できないことを示す図

このコンパクト化ステップは意図的にメタデータのみです。Gitはこれらのレイヤーのオブジェクトを再度パックしません。同じパックファイルをカバーする新しいMIDXレイヤーを書き込みます。その後、次の古いレイヤーを検討します。ここでは、コンパクト化されたレイヤーはまだ深いレイヤーの半分より小さいため、Gitは停止します。古いレイヤーはそのままにされ、これがメンテナンスをインクリメンタルに保つ重要な特性です。

より深いレイヤーを書き換える前にコンパクト化が停止する様子を示す図

結果は2つの極端な間の妥協案です。単一ファイルのMIDXはルックアップの複雑さを最小化しますが、メンテナンス中に大きな書き換えを必要とする場合があります。純粋に追記専用のインクリメンタルMIDXは各書き込みを最小化しますが、チェーンが無制限に成長することを許容します。ジオメトリックインクリメンタル再パックは、レイヤー数をオブジェクトの総数に対して対数的に保ちながら、最も新しい最小のレイヤーが古く大きなレイヤーよりも頻繁に書き換えられることを保証します。

これはGitの既存の再パック機構とも統合されます。MIDXチェーンでまだカバーされていない新しく書き込まれたパックは常にジオメトリック再パックの候補となります。深いMIDXレイヤーのパックはそのままにされます。先端MIDXレイヤーのパックは、先端レイヤーが少なくともrepack.midxNewLayerThreshold個のパックを持つようになった後にのみ候補セットに加わります。先端レイヤーがまだそのしきい値より小さい場合、Gitはそれを乱すことを完全にスキップし、新しく書き込まれたパックの新しいレイヤーを単に追記します。

新しいオブジェクトの安定した流れを受け取るリポジトリの場合、これによりルーチンメンテナンスはオブジェクトストア全体をカバーする単一のMIDXを強制的に書き換えることなく、リポジトリのパックメタデータをインクリメンタルに更新できます。

[ソース]

git historyによる以前のコミットの修正

レビュー用に送信する前にコミットシリーズを洗練させたことのある人なら、おそらくこの経験があるでしょう。作業ツリー内の変更が、ブランチの先端ではなく以前のコミットに属していることに気づく。

現在、これを処理する一般的な方法の1つは、fixupコミットを作成してからautosquashすることです:

$ git commit --fixup=<commit> 
$ git rebase --autosquash <commit>^

これは機能しますが、意図ではなくメカニズムを明示的に指定する必要があります。Git 2.55は、Git 2.54で導入された実験的なgit historyコマンドを基盤に、新しいfixupサブコマンドを追加しました。これは現在インデックスにステージングされている変更を以前のコミットに適用します:

$ git history fixup <commit>

以下に小さな例を示します。最初のコミットはパンケーキのレシピを導入し、その上にさらにいくつかのコミットが続きました。その後、レシピにメープルシロップが欠けていることに気づきました。その1行の変更をステージングした後、git history fixup <commit>はそれを元のレシピコミットに折り込み、子孫のコミットをその上にリプレイします。

ここに小さな例を示します。最初のコミットはパンケーキのレシピを導入し、その上にさらにいくつかのコミットが続きました。その後、レシピにメープルシロップが欠けていることに気づきました。その1行の変更をステージングした後、git history fixup <commit>はそれを元のレシピコミットに折り込み、子孫のコミットをその上にリプレイします。

ここでは、ステージングされた変更が対象のコミット自体の一部になります。対象のコミットはデフォルトでメッセージと著者情報を保持します(--reedit-messageを渡さない限り)。Gitは後続のコミットを書き換え、修正が適切な場所にある等価の履歴でブランチを終了させます。

git historyの他の部分と同様に、このコマンドはまだ実験的です。また、意図的に保守的です。fixupはインデックスから読み取るため、作業ツリーが必要で、ベアリポジトリでは動作できません。ステージングされた変更の適用が競合を引き起こす場合、コマンドはステートフルな書き換えの途中でユーザーを放置する代わりに中止します。

[ソース]

氷山の一角…

ここまでで最大の変更点を詳しく取り上げましたので、このリリースの他の新機能と更新のいくつかを見てみましょう。

  • 本シリーズの常連読者の方は、Git 2.54で取り上げた設定ベースのフックを覚えているかもしれません。これは、$GIT_DIR/hooks内の実行可能ファイルとしてだけでなく、Git設定内でフックを定義できるようにするものです。フックは、コミットの作成前やプッシュの受信後など、ワークフローの既知のポイントでGitが実行するスクリプトです。これらを設定に移動することで、各リポジトリのフックディレクトリにスクリプトをコピーすることなく、フックを共有、構成、選択的に無効化しやすくなります。

    Git 2.55は、互換性のある設定済みフックを並列に実行できるようにすることでこの機能を拡張します。例えば、プロジェクトにリンティングとユニットテスト用の独立したpre-commitフックがある場合、両方がhook.<name>.parallel = trueを宣言していれば、Gitはそれらを同時に実行できます。同時ジョブの数は、グローバルにhook.jobsで、イベントごとにhook.<event>.jobsで、またはコマンドラインでgit hook run -jで制御できます。コミットメッセージフックやインデックスや作業ツリーを検査する他のフックのように共有状態を必要とするフックは、引き続きシリアルに実行されます。

    [ソース]

  • git statusを実行した際にターミナルで長い一時停止に遭遇したことがあるなら、Gitの組み込みファイルシステムモニターを使って速度を向上させたことがあるかもしれません。core.fsmonitorが有効になっている場合、git statusのようなコマンドは作業ツリー全体をスキャンする代わりに、どのパスが変更されたかを長時間実行されるデーモンに問い合わせることができます。

    これまで、この組み込みデーモンはmacOSとWindowsでのみ利用可能でした。Git 2.55はLinuxへのサポートを追加し、実装ではinotifyを使用します。これには昇格した権限は必要ありませんが、ディレクトリごとに1つの監視が必要なため、非常に大規模なリポジトリではfs.inotify.max_user_watchesの制限を引き上げる必要がある場合があります。他のプラットフォームと同様に、デーモンはネットワークマウントされたリポジトリについては保守的で、オプトインのままです。

    [ソース]

  • 到達可能性ビットマップは、Gitが「このコミットから到達可能なオブジェクトはどれか?」のような質問に、オブジェクトグラフ全体を最初から走査することなく答えるために使用するトリックの1つです。これらはオブジェクト走査を高速化しますが、Gitはgit repack --write-midx-bitmapsのようなメンテナンスタスク中にこれらのビットマップを構築および更新する必要があります。

    Git 2.55は、不要なツリー再帰を避け、すでに計算済みの選択されたビットマップを再利用し、オブジェクト位置をキャッシュし、XORする前にビットマップをソートすることで、この生成パスを高速化します。パッチシリーズのベンチマークでは、これらの一般的な改善により、1つの大規模リポジトリでのビットマップ生成時間が約612秒から約294秒に短縮されました。

    同じシリーズは疑似マージビットマップも改善します。これは、関連する参照をグループ化して走査中に事前計算されたビット配列を結合できるようにし、同じオブジェクトを繰り返し再発見することを避けます。1つのベンチマークでは、疑似マージにより完全なgit rev-list --objects --use-bitmap-index走査が約20倍高速になりましたが、以前はビットマップ生成時間をほぼ2倍にしていました。これらの変更後、疑似マージは走査の高速化の大部分を維持しながら、ビットマップ生成パスへの作業の追加を大幅に削減します。

    [ソースソース]

  • 部分クローン、フィルタリングされたパック、またはGitが意図的に一部のオブジェクトを省略する他のワークフローを使用する場合でも、パックサイズは重要です。git pack-objects --path-walkモードは、Git 2.51で導入され、オブジェクトをパスごとにグループ化してから2回目の圧縮パスを実行することで、パスの局所性が重要な場合により良いデルタを生成できます。

    Git 2.55では、--path-walkblob:noneblob:limit=<n>tree:0object:type=<type>sparse:<oid>、および互換性のあるcombine:フィルタを含むフィルタと組み合わせることができます。これにより、--path-walkを使用したパッキングが、より多くの部分クローンおよびフィルタリングされたパックのワークフローで利用可能になります。Git自身のリポジトリでの1つのベンチマークでは、ブロブレスなpath-walk再パックにより、パックサイズが約16%小さくなりましたが、新鮮なデルタの計算は遅くなりました。

    [ソース]

  • Gitは、標準入力からリビジョンを整形フォーマットするための新しい実験的なコマンドgit format-revを習得しました。git logのように履歴の範囲を走査するのではなく、git format-revはコミットを1つずつ、または他のテキストに埋め込まれた状態で遭遇する場合向けに設計されています。

    例えば、git last-modifiedを使用してディレクトリ内の各パスの最終更新コミットを出力しているとします。各パスを更新したコミットだけでなく、誰が更新したかを知りたい場合はどうでしょうか?出力のパイプを通じてそれらのコミットを著者名に置き換えることができます:

    $ git last-modified | perl -F'\t' -lane ' 
      chomp($F[0] = qx(git show -s --format=%an $F[0])); 
      print join "\t", @F 
    ' 
    Junio C Hamano	builtin/commit.c 
    [...]

    これは機能しますが、各行ごとに新しいGitプロセスを開始してコミットをフォーマットする必要があります。Git 2.55では、git format-revがその部分を通常のパイプラインとして処理できます:

    $ git last-modified | 
      git format-rev --stdin-mode=text --format=%an 
    Junio C Hamano	builtin/commit.c 
    [...]

    コマンドのテキストモードは、自由形式のテキストで見つかった完全なコミットオブジェクト名も書き換えることができるため、コミットメッセージフックやその他のスクリプトワークフローに役立ちます。

    [ソース]

  • リポジトリをどこかにプッシュすると、remote:で始まる出力に気づくことがあるでしょう:

    $ git push origin main 
    Enumerating objects: 5, done. 
    [...] 
    remote: Resolving deltas: 100% (2/2), completed with 1 local object.

    クライアントがフェッチまたはプッシュを行うと、Gitは同じ接続上で異なるストリームを多重化できます。1つのサイドバンドはパックファイルデータ(実際に転送されるオブジェクト)を運び、もう1つはクライアントが通常stderrに印刷する進捗メッセージを運び、3つ目はリモートからのエラーを運びます。

    これらの進捗メッセージは有用ですが、接続の反対側から来てターミナルに直接印刷される場合もあります。Git 2.55以前では、サーバーがサイドバンド出力に任意のターミナル制御シーケンスを含めることができ、カーソルを移動したりテキストを消去したりするシーケンスも含まれていました。Gitは現在、デフォルトでこれらの制御文字のほとんどをマスクしつつ、ANSIカラーシーケンスは許可するため、カラー付きの進捗出力は引き続き動作します。

    [ソース]

  • ファイルの編集中に、間違ったブランチから開始したことに気づくことがあるでしょう。切り替えたいブランチが同じパスを変更している場合、単純なgit checkout <branch>は移動を拒否し、作業を上書きするリスクがあります。git checkout -m <branch>は、その操作の「ローカルの編集を一緒に持っていくことを試みる」バージョンです。

    しかし、反対側が同じパスに対して変更を持っている場合はどうなるでしょうか?以前は、git checkout -mは結果の競合を即座に解決する1回の機会を与えました。Git 2.55は、内部でautostashを使用することでこれをより安全にし、競合したローカル変更をスタッシュエントリとして保存し、すぐに解決するか後で再適用するかを選択できるようにしました。

    [ソース]

  • 一部のプロジェクトは、同じブランチを複数の場所(プライマリホストと1つ以上のミラー)に公開する必要があります。リモートグループはgit fetchで長く利用可能で、グループはremotes.<name>で空白で区切られたリモートのリストとして設定されます。Git 2.55はgit pushに同じ省略形を使用できるようにします:

    $ git config remotes.publish "github gitlab mirror" 
    $ git push publish main

    これは、グループ内の各リモートに順番にプッシュするのと同等です。アトミック性は単一のトランスポート接続でのみ保証できるため、グループへのプッシュでは--atomicはサポートされません。

    [ソース]

  • git log --graphはブランチ構造の視覚化に優れていますが、グラフ自体が読みにくくなるほど幅が広くなると問題になります。多くの並列ブランチを持つリポジトリでは、グラフレーンがコミットサブジェクトに到達する前にターミナルの大部分を消費することがあります。

    Git 2.55はgit log --graphおよび関連コマンドに--graph-lane-limit=<n>を追加しました。制限を超えるレーンは`~`に置き換えられ、非常に幅の広い履歴を持つリポジトリでのグラフ出力が管理しやすくなります。

    [ソース]

  • ブランチ上の最新の10コミットをリストしたいとします。これは簡単です。git log -n 10がまさにそれを行います。しかし、最も古い10コミットを知りたい場合はどうでしょうか?「おそらくgit log --reverse --10ではない」と考えているなら、おめでとうございます。あなたはベテランのGitユーザーです!履歴を反転させてから10コミットを印刷する代わりに、Gitは最新の10コミットを取得してその順序を反転させます。

    範囲全体を後処理することで(例えばgit log --reverse <range> | tail -10で)実現できますが、そうするとシェルが捨てる予定のすべてのコミットをGitに印刷およびフォーマットさせることになります。Git 2.55はgit rev-listおよびgit logファミリのコマンドに新しい--max-count-oldest=<n>オプションを追加し、範囲内の最も古いnコミットを選択できるようにしました。

    [ソース]

  • フェッチ中、クライアントとサーバーはクライアントがすでに持っているコミットをhave行としてアドバタイズすることで交渉します。これによりサーバーはクライアントがすでに到達可能なオブジェクトの送信を避けることができます。しかし、多くの参照を持つリポジトリでは、交渉アルゴリズムが共通の履歴を見つけるために特に重要な参照をスキップすることがあります。

    Git 2.55は、どの参照が交渉に参加するかを制御する新しいオプションを追加しました。新しいincludeおよびrestrictオプションと、対応するremote.*設定により、ユーザーは特定の参照をhave行として送信することを要求したり、交渉を特定の参照セットに制限したりできます。

    [ソース]

…氷山の残り

これは最新リリースからの変更のサンプルです。詳細については、2.55のリリースノート、またはGitリポジトリ内の以前の任意のバージョンを確認してください。