CI/CD環境におけるGitリポジトリ運用の悪夢
約半年前、チーム内のGitサーバーがついに悲鳴を上げ始めました。メインリポジトリは3年間で積み重なった800以上のリモートブランチと約2,500個のタグで肥大化していたのです。そこで私は、直ちに自動削除用のBashスクリプトをcronジョブとして設定しました。目的は極めてシンプルで、「60日間コミットのないfeatureブランチを自動で検索して削除する」というものでした。
深く考えず、私はgit branch -rにgrep、awk、cutを組み合わせて実装しました。最初の1週間は問題なく稼働していましたが、2週目に惨事が発生します。
ある開発者が、ブランチ名にスペースと#記号を含めて作成したのです。その結果、cutコマンドで切り出す文字列の位置がずれました。さらに悪いことに、ターミナルの出力に含まれるANSIカラーコードまで混入してしまいました。スクリプトがブランチ名を誤認し、デプロイ直前だったリリース候補(RC)ブランチを誤って削除してしまったのです。チーム総出でreflogと格闘し、復旧作業に丸3時間追われることになりました。
Gitのテキスト出力をBashパイプで手動パースするのは非常に脆く危険です。OSやGitのバージョンで出力形式がわずかに変わるだけで、スクリプトはいとも簡単に破綻してしまいます。
なぜgit branchやgit tagはスクリプト向きではないのか?
Gitの内部アーキテクチャにおいて、コマンドは大きく2つに分類されます。人間が手動で叩くためのUI指向コマンドであるPorcelain(磁器コマンド)と、自動化ツールやスクリプト向けに設計された低レベルなPlumbing(配管コマンドです。
Porcelainコマンドは「人間の目」向けに設計されている
普段よく使うgit branchやgit tagはPorcelainコマンドに属します。これらは視認性を最優先して作られています:
- 現在チェックアウトしているブランチの先頭に、自動でアスタリスク
*やスペースが付与される。 - ターミナルのカラーコードが挿入され、パイプ経由で渡す生データにノイズが混ざる。
- コミッター日時、アップストリームの追跡ブランチ、作成者のメールアドレスといったメタデータに直接クエリする仕組みがない。
Porcelainの出力を正規表現でパースしようとするのは、砂の上に城を建てるようなものです。
よく使われがちだが避けるべき2つのその場しのぎ策
1. git branchとawk、sedの組み合わせ
Bashスクリプトを書き始めた人がよくやりがちな典型例がこちらです:
git branch --sort=-committerdate | head -n 10 | awk '{print $1}'
問題は1行目から発生します。カレントブランチの場合、$1はブランチ名ではなく*記号を返してしまいます。後続のコマンドがエラーになるのは確実です。結局、それを取り除くためにsed 's/^[* ]*//'などを追加で挟む羽目になり、コードが冗長化するだけでなくエッジケースを見落とす温床になります。
2. 各refをgit logで1つずつループ処理する
各ブランチのログをループで1つずつ読み出そうとするアプローチもあります:
for ref in $(git rev-parse --symbolic --branches); do
last_date=$(git log -1 --format="%cd" --date=short "$ref")
echo "$ref: $last_date"
done
ロジックとしては正しく動作しますが、速度面で大きな問題を抱えています。800ブランチある場合、Gitは800回ものサブプロセスを個別に生成しなければなりません。最終コミット日時を取得するだけで45秒以上かかってしまいます。
真の解決策:git for-each-ref
git for-each-refは、まさにこの課題を根本から解決するために用意されたPlumbingコマンドです。UI向けの表示レイヤーを経由せず、ディスク上のrefバイナリオブジェクトに直接アクセスします。先ほどと同じ800ブランチのリポジトリでも、わずか0.05秒足らずで処理を完了できます。
Gitにおけるrefパスの構造
リポジトリ内のすべてのポインタ(参照)は、refs/ディレクトリ配下に整理されています:
- ローカルブランチ:
refs/heads/ - リモートブランチ:
refs/remotes/ - リリースタグ:
refs/tags/
1. 不要な記号を含まないクリーンなブランチ名を取得する
アスタリスクや余分なスペースを一切含めず、純粋なローカルブランチ名の一覧を取得するには次のように実行します:
git for-each-ref --format='%(refname:short)' refs/heads/
:shortモディファイアがrefs/heads/という接頭辞を自動的に取り除いてくれます。得られる出力は完全にクリーンな文字列であるため、Bashの配列へ安全に格納できます。
2. 最新コミット順にブランチをソートする
放置されている古いブランチを特定したい場合は、--sortオプションを活用します:
git for-each-ref --sort=-committerdate \
--format='%(committerdate:short) | %(refname:short) | %(authorname)' refs/heads/
committerdateの先頭にマイナス記号-を付けることで、最新のコミットを先頭にして降順でソートできます。
3. タグからメタデータを抽出する
注釈付きタグ(Annotated tag)であれば、タグ付けされた日時やコミットメッセージも簡単に抽出できます:
git for-each-ref --sort=-creatordate \
--format='%(refname:short) | %(creatordate:iso8601) | %(contents:subject)' refs/tags/
4. 実践スクリプト:CI/CDクリーンアップ用の古いブランチJSON出力
以下は、実際にGitLab CIのパイプラインで運用しているBashスクリプトです。外部ツールを追加インストールすることなく、30日以上更新のないリモートブランチの一覧を有効なJSON形式で出力します:
#!/usr/bin/env bash
set -euo pipefail
THIRTY_DAYS_AGO=$(date -d "30 days ago" +%s)
first_item=true
echo "["
# 安全な区切り文字としてタブ文字(%09)を使用
git for-each-ref --format='%(refname:short)%09%(committerdate:raw)' refs/remotes/origin/ | while IFS=$'\t' read -r branch_name date_raw; do
[[ "$branch_name" == "origin/HEAD" ]] && continue
# Unixタイムスタンプを抽出(date_rawのフォーマット: 1715000000 +0700)
commit_timestamp="${date_raw%% *}"
if [ "$commit_timestamp" -lt "$THIRTY_DAYS_AGO" ]; then
[ "$first_item" = false ] && echo ","
printf ' {"branch": "%s", "last_commit_epoch": %d}' "$branch_name" "$commit_timestamp"
first_item=false
fi
done
echo -e "\n]"
%09(タブ文字)を標準の区切り文字として活用しています。ブランチ名や作成者名にスペースが含まれていても、Bashは各変数を正確に読み取ることができます。スクリプトの処理をgit for-each-refへ置き換えるだけで、パイプラインの思わぬ不具合を防げるだけでなく、実行速度を何倍にも引き上げることが可能です。

