git for-each-refを極める:自動化に役立つブランチ・タグの正確なフィルタリングとフォーマット

Git tutorial - IT technology blog
Git tutorial - IT technology blog

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へ置き換えるだけで、パイプラインの思わぬ不具合を防げるだけでなく、実行速度を何倍にも引き上げることが可能です。

Share: