Gitの既定ブランチと派生元をスクリプトで判定する落とし穴

自作のブランチ一覧が触っていないブランチを「4コミット先」と出す。原因はfetchで更新されないorigin/HEAD。比較先の選び方、派生元をmerge-baseで推定した判断、config --listをgrepしない理由を解説する。

Gitの既定ブランチと派生元をスクリプトで判定する落とし穴

本記事はプロモーションを含みます。

はじめに: 何もしていないブランチが「4 コミット先」と出る

リモートの既定ブランチは develop なのに、手元の origin/HEAD は clone 時の main を指したまま

ブランチの一覧に ahead/behind (↑N↓M) を出す自作スクリプトで、触っていない develop が「4 コミット先」と出た。比較先に使っていた origin/HEAD が、clone した時点の main を指したままだったのが原因だ。リモート側では既定ブランチがすでに develop に変わっていた。

スクリプトから「既定ブランチ」や「このブランチの派生元」を判定すると、同じ種類のずれを何度も踏む。結論は次の 4 つだ。

  • origin/HEAD は git fetch で更新されない。追従させるなら remote.origin.followRemoteHEAD always
  • ahead/behind の比較先は upstream (@{u}) を優先し、無いときだけ origin/HEAD にする
  • git は派生元を記録しない。候補を絞ったうえで、分岐点が一番近いものを選ぶ
  • 設定済みかの判定は git config --list を grep せず、git config --get-all に任せる

以下、どれも筆者が実際に踏んだもので、何と何を比べてどちらを選んだかも書く。

origin/HEAD は fetch で更新されない

origin/HEAD は「リモートの既定ブランチ」の手元の写しだ。clone のときに作られ、その後は既定の設定では書き換わらない。リモートで既定ブランチを main から develop に変えても、git fetch は何も表示せずに古い値を残す。

今の値とリモートの値は、次の 2 つで比べられる。

git ls-remote --symref origin HEAD           # リモートの今の既定
git symbolic-ref refs/remotes/origin/HEAD    # 手元の印

followRemoteHEAD の値で fetch の動きが変わる

followRemoteHEAD が未設定なら main のまま何も出ず、warn なら hint を出し、always なら develop に変わる

fetch のときに origin/HEAD をどう扱うかは remote.<name>.followRemoteHEAD で決まる。git 2.55.0 で、リモートの既定を main から develop に変えて fetch した結果は次のとおりだ。

remote.origin.followRemoteHEADfetch 後の origin/HEAD
未設定 (既定 create)main のまま。何も表示しない
warnmain のまま。hint: Run 'git remote set-head origin develop' to follow the change などを出す
alwaysdevelop に変わる

git config のドキュメントでは、既定の create は手元に remotes/<name>/HEAD が無いときだけ作り、“this will not touch an already existing local reference” とある。always は “silently update”。ほかに warn-if-not-$branch と never がある。

1 回だけ合わせるか、以後ずっと合わせるか

今だけ合わせるなら git remote set-head origin -a、以後 fetch のたびに合わせるなら followRemoteHEAD を always にする
git remote set-head origin -a                                # 今だけ合わせる
git config --global remote.origin.followRemoteHEAD always    # 以後 fetch のたびに合わせる

筆者は always をグローバル設定に入れた。--global に入れておけば、どのリポジトリでも fetch のたびに揃う。

グローバルに入れると git remote が origin を出す

グローバル設定に入れると remote の無い repo でも git remote が origin を出し、get-url はエラーになる

--global に remote.origin.* を書くと、グローバル設定に [remote "origin"] 節ができる。その結果、remote を 1 つも足していないリポジトリでも git remote が origin を出す。git remote get-url origin は error: No such remote 'origin' (rc=2) のままだ。

origin があるかどうかを git remote の出力で判定しているスクリプトは、この設定を入れた日から誤判定する。判定は git remote get-url origin の終了コードで行う。

ahead/behind は何と比べるか

upstream があるブランチは @{u} と比べ、upstream が無いブランチと detached HEAD だけ origin/HEAD と比べる

origin/HEAD を直すだけでは足りない。そもそも全ブランチを origin/HEAD と比べていたことが、ずれを表に出していた。

ブランチごとの「push していないコミットがいくつあるか」を知りたいなら、比べる相手はそのブランチの upstream (@{u}) だ。そこで比較先を次のように分けた。

  • upstream があるブランチ: @{u} と比べる
  • upstream が無いブランチと detached HEAD: origin/HEAD と比べる
if git rev-parse -q --verify '@{u}' >/dev/null; then
  base='@{u}'
else
  base=origin/HEAD
fi
git rev-list --left-right --count "HEAD...$base"   # 左が ahead、右が behind

一覧の印は、速い方の比較先にした

一番近い派生元との比較は約 0.2 秒、既定ブランチとの比較は約 0.07 秒。14 セッションで表示が同じだったので既定ブランチを採った

一覧にはもう 1 つ、各作業ブランチが本流からどれだけ離れたかを示す印 (↑N↓M) を出している。こちらは比較先の候補が 2 つあった。

  • 一番近い派生元 (下の節の方法で推定する): 約 0.2 秒
  • 既定ブランチ (origin/HEAD): 約 0.07 秒

手元の 14 セッションで両方を出して比べたところ、表示は全部同じだった。そこで速い既定ブランチの方を採った。

正しさで勝る案があっても、手元のデータで差が出ないなら、軽い方を選んでよい。ただし前の節のとおり origin/HEAD を追従させる設定とセットにしないと、この判断は成り立たない。

派生元は記録されないので、分岐点の近さで推定する

git はブランチをどのブランチから作ったかを記録しない。ブランチは「あるコミットを指す名前」でしかないからだ。手掛かりに見えるものも当てにならない。

  • reflog の branch: Created from X: 手元にしか無く、gc.reflogExpire の既定 90 日で消える。HEAD から作るツールだと Created from HEAD としか残らない
  • branch.<name>.merge: 上流を設定したときにしか入らない

固定の順番で選ぶと、更新の止まったブランチを採る

develop があれば develop という固定順では古い develop を採って差分が 291 ファイルに膨らみ、分岐点が一番近い候補を選ぶ形に直した

これを踏んだのは、ブランチで変更したファイルに lint を掛ける仕組みだった。比較の基準 (base) を「develop があれば develop、無ければ main」の固定順で選んでいた。

あるリポジトリでは origin/develop が main の系列から外れた古い履歴のまま残っていた。base にこれを採ったせいで差分が 291 ファイルに膨らみ、上限の 200 件で打ち切られた。ファイルはパス名順に並んでいたので、直前に書いたファイルが枠の外に落ち、lint が掛からないまま通った。

同じ週に PR の base を決めるときも、古い develop に向けると 186 コミット、別のリポジトリでは約 150 コミットの無関係な変更が載る状態だった。どちらも main に向けた。

固定の順番は、リポジトリごとの運用の違いを吸収できない。「develop があるか」ではなく「どの候補から分かれたか」で選ぶ形に直した。

候補ごとにコミット数を数え、一番少ないものを採る

候補ごとに base..HEAD のコミット数を数え、HEAD にしか無いコミットが一番少ない候補を派生元とみなす
for b in origin/develop origin/main origin/master; do
  git rev-parse -q --verify "$b" >/dev/null || continue
  printf '%s %s\n' "$(git rev-list --count "$b..HEAD")" "$b"
done | sort -n | head -1

git rev-list --count "$b..HEAD" は、HEAD にあって $b に無いコミットの数だ。この数が一番少ない候補ほど、分岐点が HEAD に近い。古い develop から分かれた扱いになると数が大きくなるので、正しく外れる。

最終的な選び方は次のとおりにした。

  1. 明示の指定 (環境変数など) があればそれを使う
  2. 無ければ develop / main / master のうち、上の数が一番少ないもの

推定はあくまで推定なので、外れたときに人が上書きできる口を最優先に置いておく。

候補に自分のブランチを入れない

候補を「すべてのリモートブランチ」に広げると、自分が push した origin/feature/... が一番近くなる。push した直後は数が 0 になり、コミット済みの変更が比較から外れる。候補は PR の向き先になりうるものに絞る。

ついでに lint 側は、変更ファイルを新しいコミットで触った順に並べるよう直した。上限で打ち切っても、直前の変更が枠の内側に入る。

git log --name-only --format= "$base..HEAD" | awk 'NF && !seen[$0]++'   # 新しい順、重複なし
git diff --name-only "$base...HEAD"                                      # こちらはパス名順

設定済みかの判定で git config –list を grep しない

git config --list はキー名を小文字で出すので followRemoteHEAD で grep しても 0 件になり、判定は --get-all に任せる

followRemoteHEAD を入れる setup スクリプトでも、もう 1 つ踏んだ。「未設定の項目だけ git config --global で入れる」作りで、設定済みかを git config --list の grep で判定していた。

git config --list はキー名を小文字にして出す。ファイルには followRemoteHEAD = always と書かれていても、--list の出力は remote.origin.followremotehead=always だ。grep 'remote.origin.followRemoteHEAD=' は 0 件になり、設定済みなのに毎回「未設定」と判定された。

git config のドキュメントには、セクション名と変数名は大文字小文字を区別しない (“case-insensitive”) とある。判定は git 自身に任せる。

if ! git config --global --get-all "$key" >/dev/null; then
  git config --global "$key" "$value"
fi

--get-all はキー名の大小を区別せず、未設定なら rc=1 を返す。

逆に、比較のためにキー全体を小文字にするのもよくない。サブセクション名 (remote "Origin" や branch "Feature/X" の部分) は大文字小文字を区別するので、別の設定を取り違える。

まとめ

  • origin/HEAD は clone 時の値のまま。git remote set-head origin -a で合わせ、followRemoteHEAD always で追従させる
  • グローバルに remote.origin.* を書くと git remote が origin を出す。origin の有無は get-url の終了コードで見る
  • ahead/behind は upstream と比べ、無いときだけ origin/HEAD に落とす。比較先の候補が複数あるなら、手元で差が出るかを測ってから選ぶ
  • 派生元は記録されない。候補を絞り、明示の指定を優先し、rev-list --count が一番少ないものを採る
  • 設定の有無は git config --get-all で判定する

origin/HEAD を基準に使うコマンドはほかにもある。マージ済みブランチの掃除で --delete-merged origin を使うときの注意は Gitのマージ済みブランチを一括削除する方法 に書いた。

.. と ... の違いや ref の仕組みなど、今回のスクリプトが前提にしている部分を手を動かして確かめるなら、次の本が手元にあると引きやすい。

独習Git (リック・ウマリ 著、吉川邦夫 訳) 画像: 楽天市場

独習Git (リック・ウマリ 著、吉川邦夫 訳)

参考