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?

git log --pretty は小さな DSL だった:ゼロ依存の CHANGELOG 生成 CLI を作った

0
Last updated at Posted at 2026-05-04

きっかけ

新しいプロジェクトを始めるたびに同じ分岐に立つ。standard-versionsemantic-releasechangesets?どれも動く。どれも自分が必要としている以上のことをやる。タグを打って、バージョンを上げて、npm に publish して、PR を開いて、CI にフックして、そのために依存ツリーを引き込む。

実際に必要なのは、90% のケースでこれだけだ:

「最後のタグから HEAD までのマークダウンをくれ。GitHub Release に貼るから。」

バージョン計算は不要。publish も不要。PR ボットも不要。箇条書きだけほしい。

そこで changelog-gen を作った。1 バイナリ、npm ランタイム依存ゼロ、TypeScript 約 400 行。この記事はその設計解説と、もっと早く理解しておきたかったこと——git log --pretty=format: は立派な小さな DSL であり、git バイナリに shell out するのがほぼ常に正解だということ——について書く。

📦 GitHub: https://github.com/sen-ltd/changelog-gen

スクリーンショット

作ったもの

Conventional Commits(conventionalcommits.org)形式のコミットメッセージを解析し、CHANGELOG のマークダウン断片を生成する CLI。出力は markdownjsonplain の 3 形式に対応。ランタイム依存は git バイナリだけ。

問題の形

Conventional Commits のコミットサブジェクトは小さな文法を定義している:

<type>(<optional scope>)<optional !>: <description>

例:

feat: add greeting command
fix(parser): handle empty input
feat(api)!: rename v2 endpoints
docs: clarify --since default

CHANGELOG 生成器の仕事は正確に 3 つ:

  1. Read — 指定範囲のコミットを git から取得
  2. Parse — 各サブジェクトの Conventional 文法を解析
  3. Group — セクション(Features, Bug Fixes, Breaking Changes, ...)にまとめて描画

技術的なポイント

git log --pretty を DSL として使う

git log --pretty=format: が受け付けるフォーマット文字列は、ちゃんとした小さな DSL だ。% プレフィックスのトークンを書くと、git がコミットごとにその値を指定順で出力してくれる。

トークン 意味
%H 完全ハッシュ
%h 短縮ハッシュ
%an 著者名
%s サブジェクト(1 行目)
%b ボディ(2 行目以降)

%b が改行を含みうるため、単純な \n 分割ではレコード境界が壊れる。解決策として、ASCII 制御文字の Unit Separator(\x1f)と Record Separator(\x1e)を区切りに使う:

const FIELD_SEP = '\x1f';
const RECORD_SEP = '\x1e';

const PRETTY_FORMAT =
  `%H${FIELD_SEP}%h${FIELD_SEP}%an${FIELD_SEP}%s${FIELD_SEP}%b${RECORD_SEP}`;

パースは .split(RECORD_SEP).split(FIELD_SEP) で完了。ステートマシンもストリームパーサーも不要:

export function parseLog(stdout: string): RawCommit[] {
  const trimmed = stdout.replace(new RegExp(`${RECORD_SEP}\\n?$`), '');
  if (trimmed.length === 0) return [];
  const records = trimmed.split(new RegExp(`${RECORD_SEP}\\n?`));
  return records
    .filter((r) => r.length > 0)
    .map((r) => {
      const [hash, shortHash, author, subject, body] = r.split(FIELD_SEP);
      return { hash, shortHash, author, subject, body };
    });
}

唯一の落とし穴は git log が各レコード末尾に \n を付加すること。分割前にこれを除去しないと空のレコードが生まれる。

Conventional Commits のパース

パーサーは単一の正規表現でサブジェクトを解析し、ボディを走査して BREAKING CHANGE: とイシュー参照を拾う:

const HEADER_RE =
  /^(?<type>[a-zA-Z]+)(?:\((?<scope>[^)]+)\))?(?<bang>!)?: (?<desc>.+)$/;

const BREAKING_RE =
  /(?:^|\n)BREAKING[- ]CHANGE:\s*(.+?)(?:\n\n|$)/s;

const REF_RE =
  /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|ref[s]?)\s+([^\s,.]+#\d+|#\d+|GH-\d+)/gi;

設計上のポイント:

  • ヘッダー正規表現は description に対して greedy。feat: add colon: to output が正しくパースされる
  • 未知の type は other にフォールバック。wip: half done のようなコミットでもクラッシュしない
  • Breaking 検出は ! マーカーと BREAKING CHANGE: フッターの両方に対応
  • イシュー参照は重複排除される

グルーピングの判断

const DROPPED_TYPES = new Set(['chore', 'ci', 'build', 'style', 'test', 'refactor']);

リリースを読む人にとって、GitHub Action のバンプや Prettier でのフォーマットや 30 個のテスト追加は関係ない。CHANGELOG には載せない。もう一つの方針として、breaking コミットは Breaking Changes セクションネイティブセクションの両方に掲載する。feat!: rename v2 endpoints は breaking でもあり feature でもある。

マークダウン描画

function formatMarkdown(sections: Section[], opts: FormatOptions): string {
  if (sections.length === 0) {
    return '## Changelog\n\n_No notable changes._\n';
  }
  const out: string[] = ['## Changelog\n'];
  for (const section of sections) {
    out.push(`### ${section.title}\n`);
    for (const c of section.commits) {
      const scope = c.scope ? `**${c.scope}:** ` : '';
      const refs = c.refs.length > 0 ? ' (' + c.refs.join(', ') + ')' : '';
      out.push(`- ${scope}${c.description} (${c.shortHash})${refs}`);
    }
    out.push('');
  }
  return out.join('\n').replace(/\n+$/, '\n');
}

短縮ハッシュを括弧で、スコープを太字で、イシュー参照を末尾に。手で書くリリースノートの書式に合わせている。

やらないこと

  • バージョンバンプしないpackage.json は読み書きしない
  • タグを作らない。タグのタイミングは人間が決めるもの
  • Co-Authored-By トレーラー解析しない。著者はコミット著者から取る
  • 巨大範囲のストリーミングなし。全部メモリ上で処理する

試してみる

docker build -t changelog-gen https://github.com/sen-ltd/changelog-gen.git
docker run --rm -v "$PWD":/work -w /work changelog-gen --since v1.0.0

Docker なしの場合:

git clone https://github.com/sen-ltd/changelog-gen
cd changelog-gen && npm install && npm run build
node dist/main.js --help

おわりに

最近小さな CLI を作るたびに同じパターンが勝つ:下にあるバイナリに shell out して、構造化出力をパースして、面白い仕事を自分の言語でやるgit log --pretty はそのための特に良い DSL で、トークンはバージョン間で安定し、ドキュメントもポータブル。

TypeScript 約 400 行、テスト 38 件、150 MB の Docker イメージ、npm ランタイム依存ゼロ。npm run changelog にエイリアスして、出力をリリースに貼って、次へ進む。

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?