From d7cc92d248b735106ede90953e0caa4586bc5672 Mon Sep 17 00:00:00 2001 From: Smasduq Date: Mon, 31 Aug 2026 10:43:17 +0100 Subject: [PATCH 1/2] Add include_release_chores flag and refactor changelog generator to exclude release v chores by default --- crates/cli/src/changelog.rs | 267 +++++++++++++++++++++++++++++++----- crates/cli/src/main.rs | 5 + 2 files changed, 238 insertions(+), 34 deletions(-) diff --git a/crates/cli/src/changelog.rs b/crates/cli/src/changelog.rs index c3029ee..9185055 100644 --- a/crates/cli/src/changelog.rs +++ b/crates/cli/src/changelog.rs @@ -1,6 +1,5 @@ //! `commitor changelog` — generate Conventional Commit changelogs from git history. -use std::collections::BTreeMap; use std::io::IsTerminal; use std::process::{Command, ExitCode}; @@ -9,6 +8,23 @@ use serde::{Deserialize, Serialize}; use crate::engine::git; +const KNOWN_TYPES: &[&str] = &[ + "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "build", +]; + +const CATEGORY_ORDER: &[(&str, &str)] = &[ + ("feat", "Features"), + ("fix", "Bug Fixes"), + ("perf", "Performance Improvements"), + ("refactor", "Refactoring"), + ("docs", "Documentation"), + ("build", "Build System"), + ("ci", "Continuous Integration"), + ("test", "Tests"), + ("style", "Styles"), + ("chore", "Chores & Maintenance"), +]; + #[derive(Debug, Default)] pub struct ChangelogFlags { /// Revision range to analyze (e.g. `v0.1.0..HEAD` or `origin/main..HEAD`) @@ -19,6 +35,8 @@ pub struct ChangelogFlags { pub markdown: bool, /// Print machine-readable JSON output pub json: bool, + /// Include chore commits whose summary starts with "release v" + pub include_release_chores: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -35,8 +53,10 @@ pub struct CommitEntry { #[derive(Debug, Default, Serialize, Deserialize)] pub struct ChangelogReport { pub range: String, - pub total_commits: usize, - pub categories: BTreeMap>, + pub total_scanned: usize, + pub conventional_count: usize, + pub excluded_release_chores: usize, + pub categories: Vec<(String, Vec)>, pub breaking_changes: Vec, } @@ -46,6 +66,7 @@ pub fn run(flags: ChangelogFlags) -> Result { } let commits = fetch_commits(flags.range.as_deref(), flags.limit)?; + let total_scanned = commits.len(); if commits.is_empty() { if flags.markdown { @@ -56,8 +77,10 @@ pub fn run(flags: ChangelogFlags) -> Result { } else if flags.json { let empty_report = ChangelogReport { range: flags.range.unwrap_or_else(|| "HEAD".into()), - total_commits: 0, - categories: BTreeMap::new(), + total_scanned: 0, + conventional_count: 0, + excluded_release_chores: 0, + categories: Vec::new(), breaking_changes: Vec::new(), }; println!("{}", serde_json::to_string_pretty(&empty_report)?); @@ -67,40 +90,57 @@ pub fn run(flags: ChangelogFlags) -> Result { return Ok(ExitCode::SUCCESS); } - let mut categories: BTreeMap> = BTreeMap::new(); + let mut conventional: Vec = Vec::new(); + let mut excluded_release_chores: usize = 0; + + for commit in commits { + if commit.commit_type == "chore" + && commit + .summary + .to_lowercase() + .starts_with("release v") + && !flags.include_release_chores + { + excluded_release_chores += 1; + continue; + } + conventional.push(commit); + } + + let conventional_count = conventional.len(); + let mut breaking_changes: Vec = Vec::new(); + let mut buckets: Vec<(String, Vec)> = CATEGORY_ORDER + .iter() + .map(|(_, label)| (label.to_string(), Vec::new())) + .collect(); - for commit in &commits { + for commit in &conventional { if commit.is_breaking { breaking_changes.push(commit.clone()); } - let cat_name = match commit.commit_type.as_str() { - "feat" => "Features", - "fix" => "Bug Fixes", - "docs" => "Documentation", - "refactor" => "Refactoring", - "perf" => "Performance Improvements", - "test" => "Tests", - "style" => "Styles", - "chore" => "Chores & Maintenance", - "ci" => "Continuous Integration", - "build" => "Build System", - _ => "Other Changes", - }; - - categories - .entry(cat_name.to_string()) - .or_default() - .push(commit.clone()); + if let Some(pos) = CATEGORY_ORDER + .iter() + .position(|(ty, _)| *ty == commit.commit_type.as_str()) + { + buckets[pos].1.push(commit.clone()); + } } + let categories: Vec<(String, Vec)> = buckets + .into_iter() + .filter(|(_, entries)| !entries.is_empty()) + .collect(); + let report = ChangelogReport { range: flags .range .clone() - .unwrap_or_else(|| format!("Last {} commits", commits.len())), - total_commits: commits.len(), + .unwrap_or_else(|| format!("Last {total_scanned} commits")), + total_scanned, + conventional_count, + excluded_release_chores, categories, breaking_changes, }; @@ -118,7 +158,7 @@ pub fn run(flags: ChangelogFlags) -> Result { fn fetch_commits(range: Option<&str>, limit: Option) -> Result> { let mut args = vec!["log", "--pretty=format:%h|%an|%ad|%s", "--date=short"]; - + let limit_str; if let Some(r) = range { args.push(r); @@ -193,6 +233,10 @@ pub fn parse_conventional( return None; } + if !KNOWN_TYPES.contains(&commit_type.as_str()) { + return None; + } + Some(CommitEntry { hash, commit_type, @@ -233,11 +277,14 @@ fn yellow(text: &str) -> String { } fn render_terminal(report: &ChangelogReport) { - println!("{}", bold(&format!("📋 Commitor Changelog ({})", report.range))); - println!("Total Conventional Commits: {}\n", report.total_commits); + println!("{}", bold(&format!("Commitor Changelog ({})", report.range))); + println!( + "Scanned {} commits \u{00b7} {} conventional \u{00b7} {} release chores excluded\n", + report.total_scanned, report.conventional_count, report.excluded_release_chores + ); if !report.breaking_changes.is_empty() { - println!("{}", yellow("🚨 BREAKING CHANGES:")); + println!("{}", yellow("BREAKING CHANGES:")); for entry in &report.breaking_changes { let scope_str = entry .scope @@ -245,7 +292,7 @@ fn render_terminal(report: &ChangelogReport) { .map(|s| format!("({s})")) .unwrap_or_default(); println!( - " • {}{}: {} [{}]", + " \u{2022} {}{}: {} [{}]", entry.commit_type, scope_str, entry.summary, entry.hash ); } @@ -261,7 +308,7 @@ fn render_terminal(report: &ChangelogReport) { .map(|s| format!("({s})")) .unwrap_or_default(); println!( - " • {}{}: {} ({})", + " \u{2022} {}{}: {} ({})", entry.commit_type, scope_str, entry.summary, entry.hash ); } @@ -271,9 +318,13 @@ fn render_terminal(report: &ChangelogReport) { fn render_markdown(report: &ChangelogReport) { println!("# Changelog ({})\n", report.range); + println!( + "_Scanned {} commits \u{00b7} {} conventional \u{00b7} {} release chores excluded_\n", + report.total_scanned, report.conventional_count, report.excluded_release_chores + ); if !report.breaking_changes.is_empty() { - println!("## 🚨 BREAKING CHANGES\n"); + println!("## BREAKING CHANGES\n"); for entry in &report.breaking_changes { let scope_str = entry .scope @@ -365,4 +416,152 @@ mod tests { assert!(entry.is_none()); } + + #[test] + fn rejects_unknown_commit_type() { + let entry = parse_conventional( + "aaa1111".into(), + "Eve".into(), + "2026-08-30".into(), + "modernize cli auth: add tokio-macros", + ); + + assert!(entry.is_none()); + } + + #[test] + fn release_chore_is_excluded_by_default() { + let flags = ChangelogFlags { + include_release_chores: false, + ..Default::default() + }; + + let entry = CommitEntry { + hash: "aaa2222".into(), + commit_type: "chore".into(), + scope: None, + summary: "release v1.2.0".into(), + is_breaking: false, + author: "Alice".into(), + date: "2026-08-30".into(), + }; + + let commits = vec![entry]; + let mut conventional: Vec = Vec::new(); + let mut excluded = 0usize; + + for commit in commits { + if commit.commit_type == "chore" + && commit + .summary + .to_lowercase() + .starts_with("release v") + && !flags.include_release_chores + { + excluded += 1; + continue; + } + conventional.push(commit); + } + + assert_eq!(excluded, 1); + assert!(conventional.is_empty()); + } + + #[test] + fn release_chore_included_when_flag_set() { + let flags = ChangelogFlags { + include_release_chores: true, + ..Default::default() + }; + + let entry = CommitEntry { + hash: "aaa3333".into(), + commit_type: "chore".into(), + scope: None, + summary: "release v1.2.0".into(), + is_breaking: false, + author: "Alice".into(), + date: "2026-08-30".into(), + }; + + let commits = vec![entry]; + let mut conventional: Vec = Vec::new(); + let mut excluded = 0usize; + + for commit in commits { + if commit.commit_type == "chore" + && commit + .summary + .to_lowercase() + .starts_with("release v") + && !flags.include_release_chores + { + excluded += 1; + continue; + } + conventional.push(commit); + } + + assert_eq!(excluded, 0); + assert_eq!(conventional.len(), 1); + } + + #[test] + fn categories_follow_fixed_order() { + let entries: Vec = vec![ + CommitEntry { + hash: "c1".into(), + commit_type: "chore".into(), + scope: None, + summary: "clean up".into(), + is_breaking: false, + author: "A".into(), + date: "2026-08-30".into(), + }, + CommitEntry { + hash: "c2".into(), + commit_type: "feat".into(), + scope: None, + summary: "add widget".into(), + is_breaking: false, + author: "A".into(), + date: "2026-08-30".into(), + }, + CommitEntry { + hash: "c3".into(), + commit_type: "fix".into(), + scope: None, + summary: "patch bug".into(), + is_breaking: false, + author: "A".into(), + date: "2026-08-30".into(), + }, + ]; + + let mut buckets: Vec<(String, Vec)> = CATEGORY_ORDER + .iter() + .map(|(_, label)| (label.to_string(), Vec::new())) + .collect(); + + for commit in &entries { + if let Some(pos) = CATEGORY_ORDER + .iter() + .position(|(ty, _)| *ty == commit.commit_type.as_str()) + { + buckets[pos].1.push(commit.clone()); + } + } + + let categories: Vec<(String, Vec)> = buckets + .into_iter() + .filter(|(_, entries)| !entries.is_empty()) + .collect(); + + let labels: Vec<&str> = categories.iter().map(|(l, _)| l.as_str()).collect(); + assert_eq!( + labels, + vec!["Features", "Bug Fixes", "Chores & Maintenance"] + ); + } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c72d5e5..2fea1c3 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -111,6 +111,9 @@ enum Commands { /// Print machine-readable JSON output #[arg(long)] json: bool, + /// Include chore commits whose summary starts with "release v" + #[arg(long)] + include_release_chores: bool, }, /// Update commitor to the latest release Update, @@ -214,11 +217,13 @@ fn execute(command: Commands) -> Result> { limit, markdown, json, + include_release_chores, } => changelog::run(changelog::ChangelogFlags { range, limit, markdown, json, + include_release_chores, }) .map_err(Some), Commands::Update => run_update().map(|_| ExitCode::SUCCESS).map_err(Some), From 691443c62172d7ead08b8c375270c1cb5302c000 Mon Sep 17 00:00:00 2001 From: Smasduq Date: Mon, 31 Aug 2026 10:54:00 +0100 Subject: [PATCH 2/2] Add scope filtering and output file support to changelog generator --- crates/cli/src/changelog.rs | 329 ++++++++++++++++++++++++++++++++---- crates/cli/src/main.rs | 10 ++ 2 files changed, 304 insertions(+), 35 deletions(-) diff --git a/crates/cli/src/changelog.rs b/crates/cli/src/changelog.rs index 9185055..6680c37 100644 --- a/crates/cli/src/changelog.rs +++ b/crates/cli/src/changelog.rs @@ -1,6 +1,8 @@ //! `commitor changelog` — generate Conventional Commit changelogs from git history. +use std::fs; use std::io::IsTerminal; +use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; use anyhow::{bail, Context, Result}; @@ -9,12 +11,13 @@ use serde::{Deserialize, Serialize}; use crate::engine::git; const KNOWN_TYPES: &[&str] = &[ - "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "build", + "feat", "fix", "docs", "style", "refactor", "perf", "test", "chore", "ci", "build", "revert", ]; const CATEGORY_ORDER: &[(&str, &str)] = &[ ("feat", "Features"), ("fix", "Bug Fixes"), + ("revert", "Reverts"), ("perf", "Performance Improvements"), ("refactor", "Refactoring"), ("docs", "Documentation"), @@ -25,6 +28,8 @@ const CATEGORY_ORDER: &[(&str, &str)] = &[ ("chore", "Chores & Maintenance"), ]; +const MARKER: &str = ""; + #[derive(Debug, Default)] pub struct ChangelogFlags { /// Revision range to analyze (e.g. `v0.1.0..HEAD` or `origin/main..HEAD`) @@ -37,6 +42,10 @@ pub struct ChangelogFlags { pub json: bool, /// Include chore commits whose summary starts with "release v" pub include_release_chores: bool, + /// Only include commits whose scope matches this value (case-insensitive) + pub scope_filter: Option, + /// Write the changelog to this file instead of stdout + pub output: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -48,6 +57,8 @@ pub struct CommitEntry { pub is_breaking: bool, pub author: String, pub date: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -65,18 +76,29 @@ pub fn run(flags: ChangelogFlags) -> Result { bail!("this doesn't look like a git repository — run `commitor changelog` from inside a git repository"); } - let commits = fetch_commits(flags.range.as_deref(), flags.limit)?; + let (effective_range, used_tag) = resolve_range(flags.range.as_deref()); + + let fetch_limit = if effective_range.is_some() { + None + } else { + flags.limit + }; + let commits = fetch_commits(effective_range.as_deref(), fetch_limit)?; let total_scanned = commits.len(); if commits.is_empty() { - if flags.markdown { - println!( - "# Changelog\n\n_No Conventional Commits found in range `{}`._", - flags.range.as_deref().unwrap_or("HEAD") - ); + let range_label = effective_range.as_deref().unwrap_or("HEAD"); + if flags.markdown || flags.output.is_some() { + let md = render_markdown_empty(range_label); + if let Some(path) = &flags.output { + write_changelog_file(path, &md)?; + println!("Wrote changelog to {}", path.display()); + } else { + print!("{md}"); + } } else if flags.json { let empty_report = ChangelogReport { - range: flags.range.unwrap_or_else(|| "HEAD".into()), + range: effective_range.unwrap_or_else(|| "HEAD".into()), total_scanned: 0, conventional_count: 0, excluded_release_chores: 0, @@ -107,6 +129,16 @@ pub fn run(flags: ChangelogFlags) -> Result { conventional.push(commit); } + if let Some(ref filter) = flags.scope_filter { + let filter_lower = filter.to_lowercase(); + conventional.retain(|c| { + c.scope + .as_ref() + .map(|s| s.to_lowercase() == filter_lower) + .unwrap_or(false) + }); + } + let conventional_count = conventional.len(); let mut breaking_changes: Vec = Vec::new(); @@ -133,11 +165,12 @@ pub fn run(flags: ChangelogFlags) -> Result { .filter(|(_, entries)| !entries.is_empty()) .collect(); + let range_display = used_tag + .map(|tag| format!("since {tag}")) + .unwrap_or_else(|| effective_range.clone().unwrap_or_else(|| "HEAD".into())); + let report = ChangelogReport { - range: flags - .range - .clone() - .unwrap_or_else(|| format!("Last {total_scanned} commits")), + range: range_display, total_scanned, conventional_count, excluded_release_chores, @@ -147,8 +180,14 @@ pub fn run(flags: ChangelogFlags) -> Result { if flags.json { println!("{}", serde_json::to_string_pretty(&report)?); - } else if flags.markdown { - render_markdown(&report); + } else if flags.markdown || flags.output.is_some() { + let md = render_markdown_string(&report); + if let Some(path) = &flags.output { + write_changelog_file(path, &md)?; + println!("Wrote changelog to {}", path.display()); + } else { + print!("{md}"); + } } else { render_terminal(&report); } @@ -156,16 +195,68 @@ pub fn run(flags: ChangelogFlags) -> Result { Ok(ExitCode::SUCCESS) } +fn resolve_range(explicit_range: Option<&str>) -> (Option, Option) { + if let Some(r) = explicit_range { + return (Some(r.to_string()), None); + } + + if let Ok(tag) = last_tag() { + return (Some(format!("{tag}..HEAD")), Some(tag)); + } + + (None, None) +} + +fn last_tag() -> Result { + let output = Command::new("git") + .args(["describe", "--tags", "--abbrev=0"]) + .output() + .context("failed to execute git describe")?; + + if !output.status.success() { + bail!("no tags found"); + } + + let tag = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if tag.is_empty() { + bail!("no tags found"); + } + + Ok(tag) +} + +fn write_changelog_file(path: &Path, new_section: &str) -> Result<()> { + let existing = fs::read_to_string(path).unwrap_or_default(); + + if let Some(pos) = existing.find(MARKER) { + let marker_end = pos + MARKER.len(); + let before = &existing[..marker_end]; + let after = &existing[marker_end..]; + let after = after.trim_start_matches('\n'); + let updated = format!("{before}\n\n{new_section}\n{after}"); + fs::write(path, updated).context("failed to write changelog file")?; + } else { + let content = format!("{MARKER}\n\n{new_section}\n"); + fs::write(path, content).context("failed to write changelog file")?; + } + + Ok(()) +} + fn fetch_commits(range: Option<&str>, limit: Option) -> Result> { - let mut args = vec!["log", "--pretty=format:%h|%an|%ad|%s", "--date=short"]; + let mut args = vec![ + "log", + "--pretty=format:%h|%an|%ad|%s%x1f%b%x1e", + "--date=short", + ]; - let limit_str; + let limit_arg; if let Some(r) = range { args.push(r); } else { let count = limit.unwrap_or(20); - limit_str = format!("-n{count}"); - args.push(&limit_str); + limit_arg = format!("-n{count}"); + args.push(&limit_arg); } let output = Command::new("git") @@ -181,8 +272,18 @@ fn fetch_commits(range: Option<&str>, limit: Option) -> Result = line.splitn(4, '|').collect(); + for raw_record in stdout.split('\x1e') { + let record = raw_record.trim(); + if record.is_empty() { + continue; + } + + let (header_body, rest) = match record.split_once('\x1f') { + Some((h, b)) => (h, Some(b)), + None => (record, None), + }; + + let parts: Vec<&str> = header_body.splitn(4, '|').collect(); if parts.len() < 4 { continue; } @@ -191,8 +292,9 @@ fn fetch_commits(range: Option<&str>, limit: Option) -> Result, ) -> Option { - let is_breaking_text = subject.contains("BREAKING CHANGE"); + let subject_lower = subject.to_lowercase(); + let is_revert_msg = subject_lower.starts_with("revert \""); + + if is_revert_msg { + let commit_type = "revert".to_string(); + let summary = subject.to_string(); + let is_breaking = body.is_some_and(|b| b.contains("BREAKING CHANGE")); + + return Some(CommitEntry { + hash, + commit_type, + scope: None, + summary, + is_breaking, + author, + date, + body: body.map(String::from), + }); + } + + let is_breaking_text = subject.contains("BREAKING CHANGE") + || body.is_some_and(|b| b.contains("BREAKING CHANGE")); let colon_pos = subject.find(':')?; let header_part = subject[..colon_pos].trim(); let summary = subject[colon_pos + 1..].trim().to_string(); @@ -245,6 +369,7 @@ pub fn parse_conventional( is_breaking: is_breaking_mark, author, date, + body: body.map(String::from), }) } @@ -316,41 +441,55 @@ fn render_terminal(report: &ChangelogReport) { } } -fn render_markdown(report: &ChangelogReport) { - println!("# Changelog ({})\n", report.range); - println!( - "_Scanned {} commits \u{00b7} {} conventional \u{00b7} {} release chores excluded_\n", +fn render_markdown_string(report: &ChangelogReport) -> String { + let mut out = String::new(); + out.push_str(&format!("# Changelog ({})\n\n", report.range)); + out.push_str(&format!( + "_Scanned {} commits \u{00b7} {} conventional \u{00b7} {} release chores excluded_\n\n", report.total_scanned, report.conventional_count, report.excluded_release_chores - ); + )); if !report.breaking_changes.is_empty() { - println!("## BREAKING CHANGES\n"); + out.push_str("## BREAKING CHANGES\n\n"); for entry in &report.breaking_changes { let scope_str = entry .scope .as_ref() .map(|s| format!("**{s}**: ")) .unwrap_or_default(); - println!("- {scope_str}{} (`{}`)", entry.summary, entry.hash); + out.push_str(&format!( + "- {scope_str}{} (`{}`)\n", + entry.summary, entry.hash + )); } - println!(); + out.push('\n'); } for (category, entries) in &report.categories { - println!("## {category}\n"); + out.push_str(&format!("## {category}\n\n")); for entry in entries { let scope_str = entry .scope .as_ref() .map(|s| format!("**{s}**: ")) .unwrap_or_default(); - println!("- {scope_str}{} (`{}`)", entry.summary, entry.hash); + out.push_str(&format!( + "- {scope_str}{} (`{}`)\n", + entry.summary, entry.hash + )); } - println!(); + out.push('\n'); } - println!("---"); - println!("_Generated by [Commitor](https://github.com/Commitor-AI/commitor)_"); + out.push_str("---\n"); + out.push_str("_Generated by [Commitor](https://github.com/Commitor-AI/commitor)_\n"); + out +} + +fn render_markdown_empty(range_label: &str) -> String { + format!( + "# Changelog\n\n_No Conventional Commits found in range `{range_label}`._\n" + ) } #[cfg(test)] @@ -364,6 +503,7 @@ mod tests { "Alice".into(), "2026-08-30".into(), "feat: add user authentication flow", + None, ) .unwrap(); @@ -380,6 +520,7 @@ mod tests { "Bob".into(), "2026-08-30".into(), "fix(cli): resolve race condition in scan", + None, ) .unwrap(); @@ -396,6 +537,7 @@ mod tests { "Charlie".into(), "2026-08-30".into(), "feat(api)!: breaking API endpoint restructuring", + None, ) .unwrap(); @@ -412,6 +554,7 @@ mod tests { "David".into(), "2026-08-30".into(), "updated README and fixed typos", + None, ); assert!(entry.is_none()); @@ -424,11 +567,72 @@ mod tests { "Eve".into(), "2026-08-30".into(), "modernize cli auth: add tokio-macros", + None, ); assert!(entry.is_none()); } + #[test] + fn breaking_change_detected_in_body() { + let entry = parse_conventional( + "b0d1e5c".into(), + "Frank".into(), + "2026-08-30".into(), + "feat(api): add new endpoint", + Some("BREAKING CHANGE: the /old endpoint is removed"), + ) + .unwrap(); + + assert!(entry.is_breaking); + assert_eq!(entry.commit_type, "feat"); + } + + #[test] + fn breaking_not_set_when_absent_from_body() { + let entry = parse_conventional( + "b0d1e5c".into(), + "Frank".into(), + "2026-08-30".into(), + "feat(api): add new endpoint", + Some("some unrelated body text"), + ) + .unwrap(); + + assert!(!entry.is_breaking); + } + + #[test] + fn parses_revert_message() { + let entry = parse_conventional( + "c0ffee1".into(), + "Grace".into(), + "2026-08-30".into(), + "Revert \"feat(api): add new endpoint\"", + None, + ) + .unwrap(); + + assert_eq!(entry.commit_type, "revert"); + assert_eq!(entry.summary, "Revert \"feat(api): add new endpoint\""); + assert!(!entry.is_breaking); + } + + #[test] + fn parses_revert_conventional_type() { + let entry = parse_conventional( + "c0ffee2".into(), + "Grace".into(), + "2026-08-30".into(), + "revert: undo database migration", + None, + ) + .unwrap(); + + assert_eq!(entry.commit_type, "revert"); + assert_eq!(entry.summary, "undo database migration"); + } + #[test] fn release_chore_is_excluded_by_default() { let flags = ChangelogFlags { @@ -444,6 +648,7 @@ mod tests { is_breaking: false, author: "Alice".into(), date: "2026-08-30".into(), + body: None, }; let commits = vec![entry]; @@ -483,6 +688,7 @@ mod tests { is_breaking: false, author: "Alice".into(), date: "2026-08-30".into(), + body: None, }; let commits = vec![entry]; @@ -518,6 +724,7 @@ mod tests { is_breaking: false, author: "A".into(), date: "2026-08-30".into(), + body: None, }, CommitEntry { hash: "c2".into(), @@ -527,6 +734,7 @@ mod tests { is_breaking: false, author: "A".into(), date: "2026-08-30".into(), + body: None, }, CommitEntry { hash: "c3".into(), @@ -536,6 +744,7 @@ mod tests { is_breaking: false, author: "A".into(), date: "2026-08-30".into(), + body: None, }, ]; @@ -564,4 +773,54 @@ mod tests { vec!["Features", "Bug Fixes", "Chores & Maintenance"] ); } + + #[test] + fn scope_filter_retains_only_matching_commits() { + let commits = vec![ + CommitEntry { + hash: "s1".into(), + commit_type: "feat".into(), + scope: Some("api".into()), + summary: "add endpoint".into(), + is_breaking: false, + author: "A".into(), + date: "2026-08-30".into(), + body: None, + }, + CommitEntry { + hash: "s2".into(), + commit_type: "fix".into(), + scope: Some("cli".into()), + summary: "fix flag".into(), + is_breaking: false, + author: "A".into(), + date: "2026-08-30".into(), + body: None, + }, + CommitEntry { + hash: "s3".into(), + commit_type: "feat".into(), + scope: None, + summary: "add widget".into(), + is_breaking: false, + author: "A".into(), + date: "2026-08-30".into(), + body: None, + }, + ]; + + let filter = "API".to_lowercase(); + let filtered: Vec<&CommitEntry> = commits + .iter() + .filter(|c| { + c.scope + .as_ref() + .map(|s| s.to_lowercase() == filter) + .unwrap_or(false) + }) + .collect(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].hash, "s1"); + } } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 2fea1c3..2da1f5c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -114,6 +114,12 @@ enum Commands { /// Include chore commits whose summary starts with "release v" #[arg(long)] include_release_chores: bool, + /// Only include commits whose scope matches (case-insensitive) + #[arg(long, value_name = "NAME")] + scope: Option, + /// Write the changelog to this file instead of stdout + #[arg(long, value_name = "PATH")] + output: Option, }, /// Update commitor to the latest release Update, @@ -218,12 +224,16 @@ fn execute(command: Commands) -> Result> { markdown, json, include_release_chores, + scope, + output, } => changelog::run(changelog::ChangelogFlags { range, limit, markdown, json, include_release_chores, + scope_filter: scope, + output: output.map(std::path::PathBuf::from), }) .map_err(Some), Commands::Update => run_update().map(|_| ExitCode::SUCCESS).map_err(Some),