はじめに
Bitbucket Pipelines で GitHub へリポジトリをミラーする構成を組んでいます。長らく問題なく動いていたのですが、あるブランチを push した時に突然パイプラインが落ちました。
エラーメッセージが Git の内部構造に踏み込んだ内容で、原因にたどり着くまで遠回りをしたので記録として残します。
問題
発生したエラー
+ git push --all --force sync
Warning: Permanently added the ECDSA host key for IP address 'xxx.xxx.xxx.xxx' to the list of known hosts.
remote: fatal: did not receive expected object 6f06723ac20d6bd8d0406b9d3a2580b32d37f243
error: remote unpack failed: index-pack failed
To github.com:example-org/example-frontend.git
! [remote rejected] feature/new-stack -> feature/new-stack (failed)
error: failed to push some refs to 'github.com:example-org/example-frontend.git'
当時の設定
image: atlassian/default-image:3
pipelines:
default:
- step:
script:
- git remote add sync git@github.com:example-org/example-frontend.git
- git push --all --force sync
Bitbucket 側の push をトリガーに、GitHub 側へ強制的に同期するだけのシンプルな構成です。
前提となる Git の仕組み
エラーを読むために、いくつか用語を整理します。
オブジェクト
Git が保存する最小単位です。ファイルの中身(blob)、ディレクトリ(tree)、コミット(commit)などがあり、すべて中身から計算した SHA-1 ハッシュで識別されます。6f06723ac20d... はこのハッシュです。
パックファイル
オブジェクトをまとめて圧縮したファイルです。push の時はこの形式でネットワークに流れます。
デルタ(差分)
パックファイルの中では、似たオブジェクトを「別のオブジェクト + 変更点」の形で記録して圧縮します。この基準になるオブジェクトを土台(base)と呼びます。
thin pack(シンパック)
土台を同梱せずに省略したパックファイルです。push では既定でこの形式が使われます。「相手はもう持っているはずだから、送らなくていい」という判断です。
index-pack
受信側がパックファイルを展開・検証する処理です。土台が見つからないと失敗します。
エラーの意味
これを踏まえると、エラーはこう読めます。
did not receive expected object 6f06723... は、受信側の GitHub が「6f06723... を土台にした差分が入っていたのに、その土台本体が同梱されていない。自分も持っていない」と言っています。復元できないので index-pack failed で拒否した、という流れです。
つまり push したコミットが悪いのではなく、その土台になるオブジェクトが行方不明 という状態です。
原因
犯人は Bitbucket Pipelines の clone 設定でした。
Pipelines はビルド高速化のため、既定で depth 50 の shallow clone(浅いクローン) でリポジトリを取得します。shallow clone とは git clone --depth N で取得した状態のことで、最新 N 件のコミットだけを持ち、それより古い履歴は切り落とされています。
CI の用途であれば全履歴は不要なので、これは合理的な既定値です。ところが今回のように ミラー push をする場合は話が変わります。
問題のブランチを Bitbucket の画面で確認したところ、master から Ahead 1236 でした。1236 コミット分を送る必要があるのに、コンテナの手元には 50 コミット分しかありません。持っていないものは送れないので、土台の欠けたパックファイルが出来上がり、GitHub 側で復元不能になった、というのが真相です。
なぜ今まで動いていたのか
ここが腑に落ちなかった点でした。同じ設定でずっと成功していたからです。
答えは 差分の大きさ にあります。
ミラーが継続的に回っていれば、毎回の差分はせいぜい数コミットです。必要な土台は前回までのミラーで GitHub 側に送り込み済みなので、thin pack で省略しても相手が持っている。だから depth 50 でも成立していました。
今回は master から 1236 コミット離れた新しいブランチを push したことで、GitHub 側が持っていない領域が一気に広がりました。その結果、depth 50 という手元の窓から必要な土台が溢れ出た、というわけです。
設定が壊れたのではなく、設定が依存していた前提条件のほうが崩れた ケースでした。
解決方法
bitbucket-pipelines.yml に clone: depth: full を追加します。
image: atlassian/default-image:3
clone:
depth: full
pipelines:
default:
- step:
script:
- git remote add sync git@github.com:example-org/example-frontend.git
- git push --all --force sync
clone は pipelines と同じ階層に置きます。これで全履歴がクローンされ、何コミット離れていても土台が揃うので push が通ります。
これだけで解消しました。
トレードオフ
全履歴を取得するので、クローンの時間とディスク使用量は増えます。今回のリポジトリは約 243MB / 105,923 オブジェクトで、数十秒程度の増加でした。
ミラー用途のパイプラインであれば、そもそも depth を絞る意味がありません。逆に、ビルドとテストだけを行う通常の CI では既定の depth 50 のままで問題ありません。ジョブの目的に応じて使い分けるのが正解 です。
切り分けの小ネタ
同じエラーで「本当に受信側が壊れているのでは?」と疑った場合、以下で確認できます。
git clone --mirror git@github.com:foo/bar.git check.git
cd check.git
git fsck --full
git cat-file -t <ハッシュ>
git fsck はオブジェクト同士のつながりを検証する読み取り専用のコマンドです。missing ... や broken link ... が出れば受信側の破損、何も出なければ健全です。今回は警告ゼロ・該当オブジェクトも commit として存在していたため、受信側の問題ではないと切り分けられました。
なお dangling commit ... は「どこからも参照されていない」だけで破損ではないので、無視して構いません。
補足: --no-thin という選択肢
送信側に土台の省略をやめさせる --no-thin というオプションもあります。
git push --no-thin --all --force sync
必要なオブジェクトをすべて同梱するため、相手の状態に左右されなくなります。ただし これは手元に全履歴があって初めて成立する ので、clone: depth: full とセットで使う必要があります。今回は depth の修正だけで解決したため、最終的には使いませんでした。
おわりに
エラーメッセージに出てくるハッシュを見ると、つい「自分が push したコミットが壊れたのか」と考えてしまいますが、実際に指していたのは 差分の土台になるオブジェクト でした。
Git が push でパックファイルをどう組み立てているかを知っていると、この手のエラーは一気に読みやすくなります。同時に、CI の既定値は「ビルドして捨てる」用途に最適化されているという点も、ミラーのような例外的なジョブを書く時には意識しておきたいところです。
参考
- Bitbucket Pipelines configuration reference
- Git - git-push Documentation
- Git - git-fsck Documentation
- Git Internals - Packfiles
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp
