ローカルブランチが123本に増えた。hermes worktree pruneが消す54本を、実行前に照合してコピーで試した

ローカルブランチが123本に増えた。hermes worktree pruneが消す54本を、実行前に照合してコピーで試した

朝8時にgit branch --list | wc -lを打つと、123と返ってきました。

このブログは、毎朝8時と夜11時にAIが下書きを1本ずつ書き、1本ごとにブランチを切ります。最古のブランチは8月17日の日付で、約2か月半でここまで溜まりました。置き場所を取るわけではありません。困るのは、次にgit switchの補完を押したとき、どれが生きている下書きなのか見分けがつかないことです。

Hermes Agentにはhermes worktreeというサブコマンドがあり、その中のpruneがマージ済みのローカルブランチを消してくれます。便利そうです。ただ、消すのは下書きを積んだブランチです。名前を見ずに実行する気にはなれませんでした。

listは数だけ教えて、名前は教えてくれない

まずhermes worktree listを実行しました。出力のうち、作業ツリーのパスを含む行は読者の環境に無いので省いています。

hermes worktree list 2>/dev/null | grep -v '^  /'

6 externally-registered worktree(s) (never touched by prune):
54 local branch(es) fully merged/patch-equivalent upstream would also be deleted.

6本の作業ツリーは「pruneが触らない」と書いてあります。問題は2行目です。54本のブランチを消す、とだけ言っていて、名前が出ません。最初は、この54が作業ツリーの数なのかブランチの数なのか、行を2回読み直しました。ブランチです。

名前を出すには--dry-runを付けます。ブランチだけに絞るなら--branches-onlyも一緒に付けます。

hermes worktree prune --dry-run --branches-only 2>/dev/null | sed -n '2,3p'
hermes worktree prune --dry-run --branches-only 2>/dev/null | tail -1
  would delete branch ci/opencode-review-issue-15-quality-bar (all commits patch-equivalent upstream)
  would delete branch ci/remove-opencode-pr-review (all commits patch-equivalent upstream)
54 action(s) planned.

括弧の中の理由は2種類ありました。fully merged into origin/HEADが34本、all commits patch-equivalent upstreamが20本です。後者は聞き慣れない言い方なので、あとで確かめます。

54本のうち、生きている下書きは混じっているか

ツールの言い分を、別の道具で突き合わせました。見たのは3点です。

  • 開いているPRのheadブランチと重なっていないか
  • 本当にorigin/mainに入っているか
  • originに同名のブランチが残っているか

どれも、ツールが自分で出した理由とは別の経路で確かめたかった項目です。同じツールの判定を同じツールで見直しても、間違いの置き場所が変わりません。

最初のスクリプトは1行目で止まりました。--jsonのactionsを辞書の配列だと思い込み、a.get('branch')と書いたところ、'str' object has no attribute 'get'です。中身はwould delete branch ...という文章の配列でした。正規表現で名前を取り出す形に直したものが次のスクリプトです。

check_prune_plan.py の全文
import collections
import json
import re
import subprocess


def run(*cmd, ok_codes=(0,)):
    p = subprocess.run(cmd, capture_output=True, text=True)
    if p.returncode not in ok_codes:
        raise SystemExit(f"failed: {' '.join(cmd)}\n{p.stderr}")
    return p


# 1. hermes が消す予定のブランチ(--json の actions は文字列の配列)
raw = run("hermes", "worktree", "prune", "--dry-run", "--branches-only", "--json").stdout
plan = json.loads(raw[raw.index("{"):])
planned = [re.match(r"would delete branch (\S+)", a)[1] for a in plan["actions"]]

# 2. 開いているPRの head ブランチ
prs = json.loads(run("gh", "pr", "list", "--state", "open", "--limit", "100",
                     "--json", "headRefName").stdout)
open_heads = {p["headRefName"] for p in prs}

# 3. 予定ブランチを origin/main と照合
ancestor, cherry_plus, no_remote = 0, 0, 0
remotes = set(run("git", "for-each-ref", "refs/remotes/origin",
                  "--format=%(refname:short)").stdout.split())
for b in planned:
    is_anc = run("git", "merge-base", "--is-ancestor", b, "origin/main",
                 ok_codes=(0, 1)).returncode == 0
    if is_anc:
        ancestor += 1
    else:
        out = run("git", "cherry", "origin/main", b).stdout.splitlines()
        cherry_plus += sum(1 for line in out if line.startswith("+"))
    if "origin/" + b not in remotes:
        no_remote += 1

print("planned:", len(planned))
print("in open PR heads:", len(set(planned) & open_heads))
print("ancestor of origin/main:", ancestor)
print("not ancestor, git cherry '+' commits:", cherry_plus)
print("no origin/<branch> ref:", no_remote)

# 4. 残るブランチの内訳
local = run("git", "for-each-ref", "refs/heads", "--format=%(refname:short)").stdout.split()
kept = [b for b in local if b not in planned and b != "main"]
all_prs = json.loads(run("gh", "pr", "list", "--state", "all", "--limit", "500",
                         "--json", "headRefName,state").stdout)
states = collections.defaultdict(set)
for p in all_prs:
    states[p["headRefName"]].add(p["state"])
tally = collections.Counter()
for b in kept:
    if b in open_heads:
        tally["open PR"] += 1
        continue
    plus = sum(1 for line in run("git", "cherry", "origin/main", b).stdout.splitlines()
               if line.startswith("+"))
    label = "/".join(sorted(states.get(b, {"no PR"})))
    tally[f"{label}, unmatched commits: {'yes' if plus else 'no'}"] += 1
print("kept (excluding main):", len(kept))
for k, v in sorted(tally.items()):
    print(f"  {k}: {v}")

hibanas-netのリポジトリで、ghにログイン済みの状態で実行します。スクリプトは$TMPDIR/check_prune_plan.pyに置きました。

python3 "$TMPDIR/check_prune_plan.py"
planned: 54
in open PR heads: 0
ancestor of origin/main: 34
not ancestor, git cherry '+' commits: 0
no origin/<branch> ref: 40
kept (excluding main): 68
  CLOSED, unmatched commits: yes: 1
  MERGED, unmatched commits: no: 1
  MERGED, unmatched commits: yes: 45
  no PR, unmatched commits: yes: 1
  open PR: 20

読み方はこうです。

2行目で、開いているPR 22件のheadブランチとは1本も重なっていません。まず安心しました。次に、34本はgit merge-base --is-ancestorでorigin/mainの祖先だと確認できました。普通にマージされたブランチです。

残りの20本がpatch-equivalentです。ブランチの履歴はorigin/mainに無いのに、中身の変更は同じものが入っている、という意味になります。git cherry origin/main <branch>は、origin/mainに無い変更の行頭に+を付けます。20本とも+は0行でした。Gitのgit cherryとgit patch-idが使う判定で、コミットIDではなく変更内容から求めた値で比べる仕組みです。

もう1つ気になったのが、no origin/<branch> ref: 40です。54本のうち40本は、GitHub側のブランチがすでに消えています。つまりpruneの実行後、ローカルのコピーを失えばブランチの名前も手がかりも残りません。それでも、全54本の変更がorigin/mainに入っていることは今の照合で確かめました。

残る69本のほうが気になった

消えないブランチのうち、mainを除く68本を数えると、中身はばらばらでした。

内訳本数
開いているPRのブランチ(ローカルにあるもの)20
マージ済みPRだが、origin/mainと変更が一致しない45
マージ済みで一致するが、残る1
クローズされたPR(マージされていない)1
PRなし1

45本が目を引きます。PRはマージされているのに、git cherryが+を付けます。ghで取ったマージコミットを調べると、44本は親が1つでした。親が2つの普通のマージなら、ブランチは祖先として見つかるはずです。親が1つのマージ(squashかrebase)だと、複数コミットが1つにまとまったり書き換わったりして、コミット単位の判定に引っかかりません。ただ、1コミットだけのブランチも混じっているので、複数コミットのsquashだけでは説明がつきません。残る1本は普通のマージなのに、ローカルのブランチ先端がPRの先端と別のコミットでした。どちらも原因は突き止めていません。

残りの3本は、消えなくて正解でした。

  • 「マージ済みで一致するが、残る」はfix/astro-shiki-spacingです。隣のフォルダの作業ツリーで使用中のブランチなので、Gitがそもそも消せません
  • クローズされたPRのブランチには、マージされていない3コミットがあります。消えれば作業が失われます
  • PRなしは、手元の確認用らしいpr-161-checkです。3コミットがどこにも出ていません

つまり、ツールは「確信が持てないものは残す」側に倒れています。消しすぎる事故は起きにくい代わりに、取りこぼしの45本は自分で片付けない限り減りません。

本番の.gitに触る前に、コピーで実行する

照合では、ツールが実際に消す集合と、予定の集合が同じかどうかまでは分かりません。そこで.gitディレクトリをまるごとコピーし、コピー先で本物のpruneを走らせました。--repoで対象を指定します。

hermes worktree prune --dry-run --branches-only 2>/dev/null \
  | sed -n 's/^  would delete branch \([^ ]*\) .*/\1/p' | sort > "$TMPDIR/planned.txt"
wc -l < "$TMPDIR/planned.txt"
mkdir "$TMPDIR/sb" && cp -a .git "$TMPDIR/sb/.git"
cd "$TMPDIR/sb" && git checkout -q -f main
git branch --list | wc -l
hermes worktree prune --repo "$TMPDIR/sb" --branches-only 2>/dev/null | tail -1
git branch --list | wc -l
git branch --format='%(refname:short)' | sort > "$TMPDIR/kept.txt"
cd - >/dev/null
git for-each-ref refs/heads --format='%(refname:short)' | sort \
  | comm -23 - "$TMPDIR/kept.txt" | diff - "$TMPDIR/planned.txt" && echo "削除された集合 = 予定の集合"
54
123
54 action(s) done.
69
削除された集合 = 予定の集合

コピーするのは.gitだけです。最初はこの形のままgit statusを見て、作業ファイルが全部削除扱いで並んで驚きました。作業ツリーを持ち込んでいないので当然で、git checkout -q -f mainで整えています。コピーの.gitには本物の作業ツリーのパスが残っているため、作業ツリーの掃除は走らせたくありませんでした。--branches-onlyを付けたのはそのためです。

123本が69本になり、消えた54本は予定の54本と1行も違いませんでした。本番側のgit branch --list | wc -lは、実行後も123のままです。

本番では、まだ消していません

ここまでで分かったのは、予定の54本が安全そうだということまでです。本番のリポジトリではpruneを実行していません。理由は2つあります。

1つ目は、40本がorigin側にも無いことです。内容はorigin/mainにありますが、手元に残る手がかりはブランチ名と履歴の参照だけで、それが消えます。2つ目は、朝8時の時点で開いているPRは22件でしたが、今日の下書きPRでもう1件増えます。照合は、実行する直前にやり直す必要があります。

今回の実験で、手順は次の順に決まりました。

  1. listで数を見ます
  2. --dry-runで名前を出します
  3. 開いているPRとorigin/mainに突き合わせます
  4. コピーで先に走らせます

本番に触るのは最後です。AIに毎日ブランチを作らせる運用では、片付けの手順も毎日の作業に含めておかないと、見分けのつかないブランチがまた増えていきます。

まだ納得していないこと

45本の理由は、まだ分かっていません。git cherryが一致しない理由は、squashの仕方か、PRの途中で直した内容か。切り分けるには、1本ずつコミットを比べるしかありません。

もう1つあります。pruneの判定にorigin/HEADが使われていることです。今回のorigin/HEADはorigin/mainを指していました(git symbolic-ref refs/remotes/origin/HEADの結果です)。別のブランチを指す環境で予定の本数が変わるかどうかは、確かめていません。

出典

この記事をシェア