0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code のログでブランチ運用を数えたら、git_branch の "HEAD" が2つの意味を持っていた

0
Posted at

Claude Code のログには、そのセッションがどのブランチで動いていたかが残ります。

4か月分・436セッションを数えたところ、名前付きブランチで作業していたのは12セッションでした。

ただ、この数字を出すまでに3か所つまずきました。とくに git_branch に入る "HEAD" は、detached HEAD と「そもそもリポジトリではない」の両方を指していて、区別しないまま数えると結論が変わります。

この記事では、集計の手順とその3か所を書きます。ログの構造は 前の記事 と同じなので、SQLite への取り込みまで済んでいる前提で進めます。

ブランチ名はどこに入っているか

Claude Code の JSONL は、1レコードごとに gitBranch を持っています。

{
  "type": "user",
  "timestamp": "2026-07-27T01:40:28.000Z",
  "cwd": "/path/to/project",
  "gitBranch": "limits-feature"
}

phuryn/claude-usage をフォークして使っている scanner.py は、これをセッション単位に畳んで sessions.git_branch に入れています。

if git_branch and not meta["git_branch"]:
    meta["git_branch"] = git_branch

**最初に現れた空でない値だけが残ります。**セッションの途中でブランチを切り替えても、その後の値は捨てられます。1セッション1ブランチという前提が入っているわけです。

まずは素直に数えてみます。

SELECT git_branch, COUNT(*) FROM sessions GROUP BY git_branch ORDER BY 2 DESC;
main                          232
HEAD                          202
limits-feature                  7
feat/local-llm-gateway-impl     4
bare-rn                         1

名前付きブランチは12件。main が半分で、HEAD が45%。

ここから3か所つまずきました。

つまずき1: 母数が他の集計と合わない

合計は446です。ところがトークン数やコストの集計では436を母数にしていました。

差の10件は、sessions に行はあるがターンが1件も無いセッションです。

SELECT COUNT(*) FROM sessions
WHERE session_id NOT IN (SELECT DISTINCT session_id FROM turns);
-- 10

起動しただけで何も返ってきていないセッションが、行としては残ります。母数を446にすると、過去に出した数字と割合がずれます。

**ターンが1件以上あるセッションだけを母数にします。**この記事では436です。

つまずき2: "HEAD" が2つの意味を持っている

HEAD を detached HEAD だと読んで、「リポジトリの中にはいるが、ブランチには乗っていない状態が45%」と解釈しかけました。

実際に該当セッションの cwd をいくつか開いてみると、.git が無いディレクトリが混ざっています。原稿を書いていたフォルダ、ローカルのメモ、外に出さない設定ファイルの置き場です。

Claude Code は、リポジトリでないディレクトリで起動されたときも gitBranch"HEAD" を入れます。つまり "HEAD" は、

  • リポジトリの中にいるが、ブランチに乗っていない(detached HEAD)
  • そもそもリポジトリの中ではない

両方を指します。ログだけでは区別できません。

区別するには、セッションの作業ディレクトリを実際に見に行くしかありません。

def find_git_root(cwd: str) -> Path | None:
    p = Path(cwd).resolve()
    while True:
        if (p / ".git").exists():
            return p
        if p in STOP_AT or p.parent == p:
            return None
        p = p.parent

.git はディレクトリとは限りません。git worktree や submodule ではファイルとして置かれるので、is_dir() ではなく exists() で見ます。

つまずき3: cwd は1セッションで1つとは限らない

turns.cwd はターンごとに記録されます。1セッションの中でディレクトリを移動していると、複数の値が入ります。

最初の1件を採ると、移動前の場所で判定してしまいます。ここでは多数決にしました。

cwds.setdefault(session_id, Counter())[cwd] += 1
...
cwd = counter.most_common(1)[0][0]

もう1つ、上に辿る処理には打ち切りが要ります。

ホームディレクトリ自体が Git 管理下にある(dotfiles を管理しているなど)と、どこから辿っても最後に .git を見つけてしまい、全部が「リポジトリの中」になります。

STOP_AT = {Path.home(), Path.home() / "Nextcloud", Path("/")}

私の場合はプロジェクトの置き場が ~/Nextcloud 配下なので、そこで止めています。

スクリプト全文

#!/usr/bin/env python3
"""usage.db のセッションを「Git リポジトリの有無」と「ブランチ」で分類する。"""
import sqlite3
from collections import Counter
from pathlib import Path

DB = Path.home() / ".claude" / "usage.db"
STOP_AT = {Path.home(), Path.home() / "Nextcloud", Path("/")}


def find_git_root(cwd: str) -> Path | None:
    """cwd から上に辿って最初に見つかった .git を持つディレクトリを返す。"""
    try:
        p = Path(cwd).resolve()
    except (OSError, ValueError):
        return None
    while True:
        if (p / ".git").exists():
            return p
        if p in STOP_AT or p.parent == p:
            return None
        p = p.parent


def main() -> None:
    db = sqlite3.connect(DB)

    # セッションごとの代表 cwd。複数の cwd を跨ぐことがあるので多数決にする。
    cwds: dict[str, Counter] = {}
    for session_id, cwd in db.execute(
        "SELECT session_id, cwd FROM turns WHERE cwd IS NOT NULL AND cwd != ''"
    ):
        cwds.setdefault(session_id, Counter())[cwd] += 1

    branches = dict(db.execute("SELECT session_id, git_branch FROM sessions"))

    buckets: Counter = Counter()
    for session_id, counter in cwds.items():
        cwd = counter.most_common(1)[0][0]
        branch = (branches.get(session_id) or "").strip()
        root = find_git_root(cwd)

        if root is None:
            buckets["リポジトリなし"] += 1
        elif branch in ("HEAD", ""):
            buckets["detached HEAD"] += 1
        elif branch in ("main", "master"):
            buckets[f"{branch} で作業"] += 1
        else:
            buckets["名前付きブランチ"] += 1

    total = sum(buckets.values())
    in_repo = total - buckets["リポジトリなし"]

    print(f"母数(ターンが1件以上あるセッション): {total}\n")
    print("1段目: そもそも Git リポジトリの中だったか")
    n = buckets["リポジトリなし"]
    print(f"  {'リポジトリなし':14} {n:4}  {n / total:6.1%}")
    print(f"  {'リポジトリの中':14} {in_repo:4}  {in_repo / total:6.1%}\n")

    print(f"2段目: リポジトリの中にいた {in_repo} セッションの内訳")
    for label, n in buckets.most_common():
        if label == "リポジトリなし":
            continue
        print(f"  {label:16} {n:4}  {n / in_repo:6.1%}")


if __name__ == "__main__":
    main()

cwds のキーが turns 由来なので、ターンが0件のセッションは自動的に落ちます。つまずき1はここで解消しています。

結果

母数(ターンが1件以上あるセッション): 436

1段目: そもそも Git リポジトリの中だったか
  リポジトリなし           132   30.3%
  リポジトリの中          304   69.7%

2段目: リポジトリの中にいた 304 セッションの内訳
  main で作業           226   74.3%
  detached HEAD       66   21.7%
  名前付きブランチ          12    3.9%

HEAD の198件は、**66件が detached HEAD で、132件はリポジトリではありませんでした。**3分の2が「Git の話ですらなかった」ことになります。

最初の SQL のまま読んでいたら、「45%が detached HEAD」という、実態と違う数字を出していました。

名前付きブランチの割合も変わります。436に対しては2.8%、リポジトリがあった304に対しては3.9%です。どちらを出すかで意味が違うので、母数を書かずに「4%」とだけ出すことはできません。

この数字の限界

3つあります。

1つ目。git_branch はセッションにつき1つしか残りません。途中で切り替えた場合、後の値は落ちています。実際にブランチを使った割合は、これより高い可能性があります。

**2つ目。**リポジトリの有無は、集計を回した時点の状態です。セッションのあとで git init したディレクトリは「リポジトリの中」に数えられ、当時使っていたフォルダを消していれば「リポジトリなし」に落ちます。過去の状態を復元しているわけではありません。

3つ目。~/.claude/settings.jsoncleanupPeriodDays を過ぎた JSONL は削除されます。未設定だと約30日で消えるので、そもそも母数が「消え残った分」です。この件は別の記事に書きました。

いずれも数字を実際より低く見せる向きに働くので、3.9%は下限として読んでいます。

まとめ

  • sessions にはターンが0件の行が混ざる。母数は turns 側から作る
  • git_branch"HEAD" は detached HEAD と「リポジトリではない」の両方を指す。ログだけでは分けられず、cwd を実際に見に行く必要がある
  • .git は worktree や submodule ではファイルexists() で見る
  • cwd はセッション内で変わりうる。多数決にする
  • 上に辿る処理には打ち切りが要る。ホームが Git 管理下だと全件が「管理下」になる

同じ数字を、集計する側ではなく書き手側の視点でまとめた記事を note に書きました。「それなりに切っているつもりだったのに、実際は4%だった」という話です。よかったら、ぜひこちらも読んでみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?