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.json の cleanupPeriodDays を過ぎた JSONL は削除されます。未設定だと約30日で消えるので、そもそも母数が「消え残った分」です。この件は別の記事に書きました。
いずれも数字を実際より低く見せる向きに働くので、3.9%は下限として読んでいます。
まとめ
-
sessionsにはターンが0件の行が混ざる。母数はturns側から作る -
git_branchの"HEAD"は detached HEAD と「リポジトリではない」の両方を指す。ログだけでは分けられず、cwdを実際に見に行く必要がある -
.gitは worktree や submodule ではファイル。exists()で見る -
cwdはセッション内で変わりうる。多数決にする - 上に辿る処理には打ち切りが要る。ホームが Git 管理下だと全件が「管理下」になる
同じ数字を、集計する側ではなく書き手側の視点でまとめた記事を note に書きました。「それなりに切っているつもりだったのに、実際は4%だった」という話です。よかったら、ぜひこちらも読んでみてください。