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

本記事はプロモーションを含みます。
ブランチの一覧に ahead/behind (↑N↓M) を出す自作スクリプトで、触っていない develop が「4 コミット先」と出た。比較先に使っていた origin/HEAD が、clone した時点の main を指したままだったのが原因だ。リモート側では既定ブランチがすでに develop に変わっていた。
スクリプトから「既定ブランチ」や「このブランチの派生元」を判定すると、同じ種類のずれを何度も踏む。結論は次の 4 つだ。
origin/HEAD は git fetch で更新されない。追従させるなら remote.origin.followRemoteHEAD always@{u}) を優先し、無いときだけ origin/HEAD にするgit config --list を grep せず、git config --get-all に任せる以下、どれも筆者が実際に踏んだもので、何と何を比べてどちらを選んだかも書く。
origin/HEAD は「リモートの既定ブランチ」の手元の写しだ。clone のときに作られ、その後は既定の設定では書き換わらない。リモートで既定ブランチを main から develop に変えても、git fetch は何も表示せずに古い値を残す。
今の値とリモートの値は、次の 2 つで比べられる。
git ls-remote --symref origin HEAD # リモートの今の既定
git symbolic-ref refs/remotes/origin/HEAD # 手元の印
fetch のときに origin/HEAD をどう扱うかは remote.<name>.followRemoteHEAD で決まる。git 2.55.0 で、リモートの既定を main から develop に変えて fetch した結果は次のとおりだ。
remote.origin.followRemoteHEAD | fetch 後の origin/HEAD |
|---|---|
未設定 (既定 create) | main のまま。何も表示しない |
warn | main のまま。hint: Run 'git remote set-head origin develop' to follow the change などを出す |
always | develop に変わる |
git config のドキュメントでは、既定の create は手元に remotes/<name>/HEAD が無いときだけ作り、“this will not touch an already existing local reference” とある。always は “silently update”。ほかに warn-if-not-$branch と never がある。
git remote set-head origin -a # 今だけ合わせる
git config --global remote.origin.followRemoteHEAD always # 以後 fetch のたびに合わせる
筆者は always をグローバル設定に入れた。--global に入れておけば、どのリポジトリでも fetch のたびに揃う。
--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 の終了コードで行う。
origin/HEAD を直すだけでは足りない。そもそも全ブランチを origin/HEAD と比べていたことが、ずれを表に出していた。
ブランチごとの「push していないコミットがいくつあるか」を知りたいなら、比べる相手はそのブランチの upstream (@{u}) だ。そこで比較先を次のように分けた。
@{u} と比べる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
一覧にはもう 1 つ、各作業ブランチが本流からどれだけ離れたかを示す印 (↑N↓M) を出している。こちらは比較先の候補が 2 つあった。
origin/HEAD): 約 0.07 秒手元の 14 セッションで両方を出して比べたところ、表示は全部同じだった。そこで速い既定ブランチの方を採った。
正しさで勝る案があっても、手元のデータで差が出ないなら、軽い方を選んでよい。ただし前の節のとおり origin/HEAD を追従させる設定とセットにしないと、この判断は成り立たない。
git はブランチをどのブランチから作ったかを記録しない。ブランチは「あるコミットを指す名前」でしかないからだ。手掛かりに見えるものも当てにならない。
branch: Created from X: 手元にしか無く、gc.reflogExpire の既定 90 日で消える。HEAD から作るツールだと Created from HEAD としか残らないbranch.<name>.merge: 上流を設定したときにしか入らないこれを踏んだのは、ブランチで変更したファイルに lint を掛ける仕組みだった。比較の基準 (base) を「develop があれば develop、無ければ main」の固定順で選んでいた。
あるリポジトリでは origin/develop が main の系列から外れた古い履歴のまま残っていた。base にこれを採ったせいで差分が 291 ファイルに膨らみ、上限の 200 件で打ち切られた。ファイルはパス名順に並んでいたので、直前に書いたファイルが枠の外に落ち、lint が掛からないまま通った。
同じ週に PR の base を決めるときも、古い develop に向けると 186 コミット、別のリポジトリでは約 150 コミットの無関係な変更が載る状態だった。どちらも main に向けた。
固定の順番は、リポジトリごとの運用の違いを吸収できない。「develop があるか」ではなく「どの候補から分かれたか」で選ぶ形に直した。
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 から分かれた扱いになると数が大きくなるので、正しく外れる。
最終的な選び方は次のとおりにした。
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" # こちらはパス名順
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 の終了コードで見るorigin/HEAD に落とす。比較先の候補が複数あるなら、手元で差が出るかを測ってから選ぶrev-list --count が一番少ないものを採るgit config --get-all で判定するorigin/HEAD を基準に使うコマンドはほかにもある。マージ済みブランチの掃除で --delete-merged origin を使うときの注意は Gitのマージ済みブランチを一括削除する方法 に書いた。
.. と ... の違いや ref の仕組みなど、今回のスクリプトが前提にしている部分を手を動かして確かめるなら、次の本が手元にあると引きやすい。
独習Git (リック・ウマリ 著、吉川邦夫 訳)
remote.<name>.followRemoteHEAD、大文字小文字の扱い)set-head)--count、--left-right)