Claude CodeでTODO・FIXMEコメントを棚卸しし、古さ順にIssueへ起こす
git grepで拾ったTODO・FIXMEにgit blameの日付を付けて古い順に並べ、Claude Codeに放置・即削除・Issue化へ仕分けさせます。担当と期限つきでgh issue createに渡す手順です。
ソースに散らばったTODOとFIXMEは、数えるだけならgit grepで足ります。難しいのは「どれが今も生きているか」の判断です。3年前のコメントと先週のコメントが同じ列に並ぶと、全件が同じ重さに見えて、誰も手を付けません。
この記事では、git grepで拾った全件にgit blameの日付と著者を付け、古い順のTSVにします。その表をClaude Codeに渡して「放置してよい」「すぐ消す」「Issueにする」へ仕分けさせ、担当と期限つきでgh issue createに流します。仕分けの基準を決めるのは人です。Claudeに任せるのは、基準を全件に当てる作業のほうです。
古さは「コメントを書いた日」ではなく「その行を最後に触った日」
TODOの棚卸しで最初に必要なのは、各コメントの古さです。git blameは行ごとに、その行を最後に変更したコミットの日付と著者を返します。公式ドキュメントによると、--line-porcelainを付けるとコミット情報が行ごとに全部出力され、機械で読むのに向いた形式になります。
古さを表す日付は、ここから取ります。ただし意味は「コメントを書いた日」ではなく、その行を最後に触った日です。フォーマッタの一括整形やインデントの変更でも日付は新しくなります。この癖への対処は、後の節で扱います。
仕組みは3段です。
TSVができるまで
- 1
git grepで全件の場所を拾う
追跡されているファイルだけが対象です。
node_modulesのような未追跡のものは入りません。 - 2
ファイルごとにgit blameを1回だけ実行する
1行ずつ
blameを呼ぶと遅いので、ヒットしたファイルごとに--line-porcelainで全行分を取り、該当行だけ使います。 - 3
古い順に並べてTSVで出す
日数、日付、著者、場所、コメント本文の5列にします。
git grepの正規表現で\bが空振りした
拾う側で、先に落とし穴を1つ見ておきます。TODOとFIXMEを単語として拾うために\bを使うと、環境によっては0件になります。
macOS付属のApple Git 2.50.1で、TODOを3件、FIXMEを1件含む小さなリポジトリに対して試した結果は次のとおりです。
git grep -nE '\b(TODO|FIXME)\b' # 0件(終了コード1)
git grep -nwE 'TODO|FIXME' # 4件ヒット-w(単語として一致)を使う書き方なら、同じ環境で4件が出ました。\bが効くかどうかは、gitが使う正規表現の実装に左右されます。Linuxで動いたコマンドをmacOSにそのまま持ち込むときは、件数が0にならないかを見てください。-wで書けば、この環境差を避けられます。
終了コードも要注意です。git grepは1件も当たらないと終了コード1を返すため、スクリプト側でcheck=Trueにしていると、そのまま例外になります。「0件で正常」の場合を分けたいなら、check=Falseで受けて空の出力を扱います。
古い順のTSVを出すスクリプト
次のスクリプトをtodo_scan.pyとしてリポジトリ直下で実行します。標準ライブラリだけで動きます。
#!/usr/bin/env python3
"""TODO/FIXMEをgit grepで拾い、blameの日付と著者を付けて古い順に出す"""
import subprocess, os, time, re, collections
PAT = "TODO|FIXME"
EXCLUDES = [":!vendor", ":!*.lock", ":!*.min.js"] # 必要に応じて足す
IGN = ".git-blame-ignore-revs"
IGNORE = ["--ignore-revs-file", IGN] if os.path.exists(IGN) else []
def run(*args):
r = subprocess.run(args, capture_output=True, text=True)
return r.stdout
hits = collections.defaultdict(list)
cmd = ["git", "grep", "-nwE", PAT, "--", ".", *EXCLUDES]
for line in run(*cmd).splitlines():
path, lineno, text = line.split(":", 2)
hits[path].append((int(lineno), text.strip()))
now = time.time()
rows = []
for path, items in hits.items():
out = run("git", "blame", "--line-porcelain", *IGNORE, "--", path)
info, cur, n = {}, {}, 0
for l in out.splitlines():
if re.match(r"^[0-9a-f]{40} \d+ \d+", l):
n = int(l.split()[2]); cur = {}
elif l.startswith("author "):
cur["author"] = l[7:]
elif l.startswith("author-time "):
cur["time"] = int(l[12:])
elif l.startswith("\t"):
info[n] = cur
for lineno, text in items:
b = info[lineno]
days = int((now - b["time"]) / 86400)
date = time.strftime("%Y-%m-%d", time.localtime(b["time"]))
rows.append((days, date, b["author"], f"{path}:{lineno}", text))
print("age_days\tblame_date\tauthor\tlocation\ttext")
for r in sorted(rows, reverse=True):
print("\t".join(map(str, r)))日付が違う3コミットを積んだ検証用リポジトリで実行すると、次の表が出ます(python3 todo_scan.py > todo.tsv)。
age_days blame_date author location text
1355 2023-02-10 alice src/ui.js:1 // TODO: remove after 2024-03 migration
1355 2023-02-10 alice src/pay.py:6 # FIXME 二重返金の防止が未実装
1355 2023-02-10 alice src/pay.py:2 # TODO(alice): 端数処理を銀行丸めに変える
56 2026-09-01 bob src/pay.py:10 # TODO retry with backoff実際の出力はタブ区切りです。ここでは読みやすいよう空白に揃えました。daysが大きい順に並ぶので、先頭から読めば古いものから順に判断できます。
EXCLUDESは、コミット済みの外部ライブラリやロックファイルを除くための例です。サードパーティのコードに残ったTODOは、自分たちが返済する負債ではないので、最初から落としておくと仕分けの対象が減ります。
整形コミットで日付が新しくなる問題
古さの列が信用できなくなる典型は、フォーマッタの一括適用です。検証用リポジトリで、2023年のFIXME行のインデントだけを変えて1コミット追加すると、同じ行のage_daysは0に変わりました。内容は何も変わっていないのに、3年放置のFIXMEが「今日書いた」ことになります。
git blameには、特定のコミットを無かったことにして責任を前の変更へ戻す--ignore-revと、リストをファイルで渡す--ignore-revs-fileがあります。公式ドキュメントでは、無視したコミットで変わった行は、その前にその行(や近くの行)を変更したコミットに割り当てられると説明されています。上のスクリプトは、リポジトリ直下に.git-blame-ignore-revsがあれば自動で渡す作りです。
# 整形コミットのハッシュを1行1件で書く
git rev-parse HEAD >> .git-blame-ignore-revs
python3 todo_scan.py | grep FIXME検証用リポジトリでは、この指定で当該FIXMEが1355日に戻りました。整形や一括リネームのコミットを見つけたら、ハッシュをこのファイルに足していきます。ファイルの形式はfsck.skipListと同じとされており、1行1ハッシュで書けます。
なお、無視できるのは「どのコミットが整形だったか」を人が知っている場合だけです。リストにない整形コミットは、日付を新しく見せ続けます。表の中で、古いはずの行が妙に新しい日付だったときは、整形コミットを疑う手がかりになります。
Claudeに3つの箱へ仕分けさせる
TSVができたら、Claude Codeに渡します。公式の「Common workflows」には、git log --oneline -20 | claude -p "summarize these recent commits"のように、標準入力をパイプでclaude -pに渡す使い方が載っています。同じ形で、todo.tsvをそのまま渡せます。
先にtoday=$(date +%F)で日付を変数に取り、プロンプトをダブルクォートで組んでいます。ヒアドキュメントの区切り語をクォートすると$(...)が展開されず、文字列のまま渡ってしまうためです。echo "今日は ${today} です。"で日付が入ることを確かめてから実行できます。
仕分けにはコードの文脈が必要なので、読み取りだけを許可します。--allowedToolsはワイルドカード付きのルールで指定でき、Bash(git log *)はgit logで始まるコマンドだけを許可します。末尾の *の前の空白が前方一致の境界です。
today=$(date +%F)
claude -p "標準入力はTODO/FIXMEコメントの一覧(古い順)です。各行を次の3つに分類し、
TSVで出力してください。列は location, 分類, 理由, 件名, 期限 です。
- 即消す: すでに実装済み、参照先が消えた、書かれた期限を過ぎている
- Issue化: 何をどこまでやるかが読み取れ、公開API・データ・金額に関わる
- 放置: 「いつか」「あれば便利」など、やらなくても困らない
必ず該当ファイルの前後を読んでから判断すること。
Issue化の期限は、FIXMEなら今日から30日後、TODOなら90日後を
YYYY-MM-DD形式で。今日は ${today} です。" --allowedTools "Read,Bash(git log *)" < todo.tsv > triage.tsv出力の形は、たとえば次のようになる想定です(公式の仕様ではなく、プロンプトで指定した列の例です)。
src/ui.js:1 即消す 2024-03を過ぎている - -
src/pay.py:6 Issue化 返金の二重実行に関わる 二重返金の防止を実装する 2026-11-27
src/pay.py:10 放置 ... - -仕分けの結果は、そのまま信じず、「即消す」の行から目で確かめます。消すのはコミットなので、間違えても戻せますが、戻す手間を払うのは自分たちです。判断の基準は次の表のとおりに置くと、分類の理由が読み返しやすくなります。
| 箱 | 判断の材料 | 古さの扱い |
|---|---|---|
| 即消す | 判断の材料本文に期限や移行完了の条件がある。コードがすでに条件を満たしている | 古さの扱い古いほど確度が上がる |
| Issue化 | 判断の材料影響する範囲(決済・認証・データ移行など)が読み取れる | 古さの扱い古い行は先に誰かが決めるべき |
| 放置 | 判断の材料実現の条件も期限もなく、欠けても動作に影響しない | 古さの扱い古くても放置のまま |
古い順に並べたことの効果は、「古いのに放置にもできないもの」が先頭に見える点です。3年触られていないFIXMEは、本当に問題なら誰かが踏んでいるはずなので、まず「実装済みか」「もう要らないか」を疑うのが筋です。そのどちらでもなければ、Issueにして担当を付けます。
担当と期限をつけてIssueに起こす
gh issue createは、--title、--body(または--body-file)、--label、--assignee、--milestoneを指定できます。--titleと--bodyを省くと入力を促されるため、スクリプトからは必ず両方を渡します。
担当は--assignee <login>で、GitHubのログイン名を渡します。ところがgit blameが返すのは、コミットに記録された名前です。この2つは一致しません。Claudeに推測させず、対応表を1枚用意してください。
# assignees.tsv : blameの著者名<TAB>GitHubログイン名
printf 'alice\talice-gh\n' > assignees.tsv
# Issue化の行だけ取り出し、まずは dry-run(echo)で内容を確かめる
awk -F'\t' '$2=="Issue化"' triage.tsv |
while IFS=$'\t' read -r loc _ reason title due; do
who=$(git blame -L "${loc#*:},${loc#*:}" --porcelain -- "${loc%%:*}" \
| sed -n 's/^author //p')
login=$(awk -F'\t' -v w="$who" '$1==w{print $2}' assignees.tsv)
echo gh issue create --title "$title" --label tech-debt \
${login:+--assignee "$login"} \
--body "場所: $loc / 期限: $due / 最終更新者: $who / 根拠: $reason"
doneechoを外すと実行されます。最初は必ずechoのまま全件の中身を読み、問題がなければ外してください。Issueは作るのは簡単でも、消すのが面倒です。
ここで決めている点が3つあります。
- 期限の置き場:
gh issue createのオプションに、期限を直接指定するものはありません。ここでは本文に期限: YYYY-MM-DDと書き、マイルストーンで管理したい場合は--milestone <名前>で既存のマイルストーンに入れます - 担当の決め方:
blameの著者は「最後に触った人」であり、今も担当とは限りません。対応表にない人は--assigneeを付けず、本文に名前だけ残します。辞めた人に割り当て続けるのを避けるためです - ラベル:
tech-debtのような固定のラベルを付けておくと、次回の棚卸しで「前回起こした分」を除外できます
担当が空のIssueが並ぶようなら、パスごとの担当を決めるCODEOWNERSの作り方が次の手になります。git logから担当の候補を出す手順は、ここでのblameと同じ発想です。
定期的に回すときの置き場所
一度やると、次の棚卸しまでにまたTODOは増えます。公式の「Common workflows」には、定期実行の選択肢として、クラウドのスケジュール実行、デスクトップアプリのスケジュール、GitHub Actions、セッション内の/loopが並んでいます。リポジトリの外に出せない処理なら、Actionsのcronに載せる形が分かりやすいです。具体的な組み方は、claude-code-actionをcronで回す手順にあります。
定期実行にするなら、スクリプトの出力を差分で見る形が向きます。前回のTSVとの差だけをclaude -pに渡せば、新しく増えた分と、消えた分だけを判断すれば済みます。
棚卸しの先は返済計画
TODOの棚卸しは、技術的負債の一部だけを見ています。コメントを書かずに放置された負債は拾えません。変更が集中するファイルや古い依存を含めて点数化するなら、tech-debtスキルによる優先順位付けが補完になります。Issueにした項目の実際の返済は、テストが無いコードのリファクタリング手順が続きです。
まとめ
コメントの棚卸しで効くのは、git blameの日付で古い順に並べ、先頭の「古いのに誰も困っていない」行から疑うことです。\bの空振りや整形コミットによる日付の更新は、表を作る段階で潰せます。Claudeの仕分けは「即消す」を目で確かめてから採用し、Issueはechoで中身を読んでから作ってください。期限は本文、担当は対応表で決める、という割り切りが、棚卸しを一度きりで終わらせないための最小の仕組みです。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。