きっかけ
「今回のリリースで何が変わったの?」——この質問にチームの誰かが毎回 git log を遡って手作業で答えている。Conventional Commits を使っていれば答えはコミット履歴に埋まっているのに、抽出する手段が面倒すぎる。
既存の conventional-changelog (Node) や git-cliff (Rust) は高機能だけど、設定ファイルやプラグインが前提。欲しいのは「リポジトリで実行したら即 CHANGELOG が出る」ゼロ設定のツール。というわけで changelog-rs を書きました。
📦 GitHub: https://github.com/sen-ltd/changelog-rs
作ったもの
changelog-rs は git log の出力をパースし、Conventional Commits のサブジェクトをタイプ別にグループ化して、Keep a Changelog 形式の Markdown を生成する Rust CLI です。
# リポジトリで実行するだけ
$ changelog-rs --version 2.0.0
# 特定の範囲だけ
$ changelog-rs --from v1.0.0 --to HEAD --version 2.0.0
# stdin からパイプも可
$ git log --format="%H %s" v1.0.0..HEAD | changelog-rs --stdin
依存は clap(引数パース)と regex(コミットパース)の 2 つだけ。リリースバイナリは約 1 MB。
技術的なポイント
アーキテクチャ:3 つの純粋関数
ライブラリ全体が 3 関数に収まっています。意図的にこの構造にしました。
1. パース — 1 行を受け取り Option<Commit> を返す:
pub fn parse_commit(line: &str) -> Option<Commit> {
let line = line.trim();
if line.is_empty() {
return None;
}
let (hash, subject) = split_hash_subject(line)?;
let re = Regex::new(
r"^(feat|fix|refactor|perf|docs|test|tests|build|ci|style|chore)(\([^)]+\))?(!)?\s*:\s*(.+)$"
).unwrap();
let caps = re.captures(subject)?;
// ... フィールド抽出、Some(Commit { ... }) を返す
}
Conventional Commits に合致しないコミット(マージコミット、WIP など)は None を返すだけ。Result ではなく Option を使うのがポイントで、非 conventional なコミットはエラーではなく「期待される入力」だからです。
2. グループ化 — BTreeMap でタイプ別に分類:
pub fn group_commits(commits: &[Commit]) -> BTreeMap<Category, Vec<Commit>> {
let mut groups: BTreeMap<Category, Vec<Commit>> = BTreeMap::new();
for commit in commits {
groups.entry(commit.category).or_default().push(commit.clone());
}
groups
}
HashMap ではなく BTreeMap を使う理由は、セクションの出力順を決定的にするため。Added → Fixed → Refactored の順番が毎回同じになり、リリース間で changelog をスキャンしやすくなります。
3. レンダリング — Keep a Changelog 形式の Markdown を生成:
Breaking changes は元のコミットタイプに関係なく、専用セクションに切り出して先頭に配置。feat!: drop Node 14 support は Breaking Changes セクション と Added セクションの両方に出ます。
依存を最小限に
chrono を入れれば日付フォーマットは楽ですが、200+ の推移的依存が付いてくる。必要なのは YYYY-MM-DD 形式の今日の日付だけなので、システムの date コマンドにシェルアウトしました。実用上の問題はゼロで、バイナリサイズとコンパイル時間を大幅に節約。
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
テスト戦略
20 テストを 3 層で構成:
-
パーステスト — 全 10 タイプの認識、スコープ抽出、
!とBREAKING CHANGE:両方の破壊的変更検出、空行やハッシュなしの入力 - グループ化テスト — カテゴリ分配の正確性、空入力
- レンダリングテスト — Markdown 出力全体の検証(ヘッダ、バージョンセクション、スコープ書式、ハッシュ書式)
#[test]
fn test_parse_feat_with_scope() {
let c = parse_commit("abc1234 feat(parser): add JSON support").unwrap();
assert_eq!(c.category, Category::Feat);
assert_eq!(c.scope.as_deref(), Some("parser"));
assert_eq!(c.description, "add JSON support");
assert!(!c.breaking);
}
使い方
# 基本:未リリースの changelog を生成
$ changelog-rs
# バージョン付き(今日の日付が自動挿入)
$ changelog-rs --version 2.0.0
# 日付を指定
$ changelog-rs --version 2.0.0 --date 2026-04-01
# ファイルに書き出し
$ changelog-rs --version 2.0.0 -o CHANGELOG.md
# コミットハッシュ付き
$ changelog-rs --hashes
Docker でも使えます:
docker run --rm -v "$(pwd):/work" changelog-rs --version 1.0.0
おわりに
changelog 生成は「あると便利だけど毎回面倒」の典型。Conventional Commits を使っているなら、コミット履歴が既に構造化されたデータなので、パースしてフォーマットするだけ。3 関数、2 依存、1 MB のバイナリで十分実用になります。
