Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 154 additions & 50 deletions git-cliff-core/src/changelog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ use std::collections::HashMap;
use std::io::{Read, Write};
use std::time::{SystemTime, UNIX_EPOCH};

use log::{debug, trace};

use crate::commit::Commit;
use crate::config::{Config, GitConfig};
use crate::config::{Config, GitConfig, ProcessingStep};
use crate::error::{Error, Result};
use crate::release::{Release, Releases};
#[cfg(feature = "bitbucket")]
Expand Down Expand Up @@ -83,26 +85,6 @@ impl<'a> Changelog<'a> {
Ok(())
}

/// Processes a single commit and returns/logs the result.
fn process_commit(commit: &Commit<'a>, git_config: &GitConfig) -> Option<Commit<'a>> {
match commit.process(git_config) {
Ok(commit) => Some(commit),
Err(e) => {
let short_id = commit.id.chars().take(7).collect::<String>();
let summary = commit.message.lines().next().unwrap_or_default().trim();
match &e {
Error::ParseError(_) | Error::FieldError(_) => {
log::warn!("{short_id} - {e} ({summary})");
}
_ => {
log::trace!("{short_id} - {e} ({summary})");
}
}
None
}
}
}

/// Checks the commits and returns an error if any unconventional commits
/// are found.
fn check_conventional_commits(commits: &Vec<Commit<'a>>) -> Result<()> {
Expand Down Expand Up @@ -131,31 +113,141 @@ impl<'a> Changelog<'a> {
Ok(())
}

fn process_commit_list(commits: &mut Vec<Commit<'a>>, git_config: &GitConfig) -> Result<()> {
*commits = commits
/// Splits the commits by their message lines.
/// Returns a new vector of commits with each line as a separate commit.
fn apply_split_commits(commits: &mut Vec<Commit<'a>>) -> Vec<Commit<'a>> {
let mut split_commits = Vec::new();
for commit in commits {
commit.message.lines().for_each(|line| {
let mut c = commit.clone();
c.message = line.to_string();
c.links.clear();
if !c.message.is_empty() {
split_commits.push(c)
};
})
}
split_commits
}

/// Applies the commit parsers to the commits and returns the parsed
/// commits.
fn apply_commit_parsers(
commits: &mut Vec<Commit<'a>>,
git_config: &GitConfig,
) -> Vec<Commit<'a>> {
commits
.iter()
.filter_map(|commit| Self::process_commit(commit, git_config))
.flat_map(|commit| {
if git_config.split_commits {
commit
.message
.lines()
.filter_map(|line| {
let mut c = commit.clone();
c.message = line.to_string();
c.links.clear();
if c.message.is_empty() {
None
} else {
Self::process_commit(&c, git_config)
}
})
.collect()
} else {
vec![commit]
.filter_map(|commit| {
match commit.clone().parse(
&git_config.commit_parsers,
git_config.protect_breaking_commits,
git_config.filter_commits,
) {
Ok(commit) => Some(commit),
Err(e) => {
Self::on_step_err(commit.clone(), e);
None
}
}
})
.collect::<Vec<_>>()
}

/// Applies the commit preprocessors to the commits and returns the
/// preprocessed commits.
fn apply_commit_preprocessors(
commits: &mut Vec<Commit<'a>>,
git_config: &GitConfig,
) -> Vec<Commit<'a>> {
commits
.iter()
.filter_map(|commit| {
// Apply commit parsers
match commit.clone().preprocess(&git_config.commit_preprocessors) {
Ok(commit) => Some(commit),
Err(e) => {
Self::on_step_err(commit.clone(), e);
None
}
}
})
.collect::<Vec<_>>()
}

/// Converts the commits into conventional format if the configuration
/// requires it.
fn apply_into_conventional(
commits: &mut Vec<Commit<'a>>,
git_config: &GitConfig,
) -> Vec<Commit<'a>> {
commits
.iter()
.filter_map(|commit| {
let mut commit_into_conventional = Ok(commit.clone());
if git_config.conventional_commits {
if !git_config.require_conventional &&
git_config.filter_unconventional &&
!git_config.split_commits
{
commit_into_conventional = commit.clone().into_conventional();
} else if let Ok(conv_commit) = commit.clone().into_conventional() {
commit_into_conventional = Ok(conv_commit);
};
};
match commit_into_conventional {
Ok(commit) => Some(commit),
Err(e) => {
Self::on_step_err(commit.clone(), e);
None
}
}
})
.collect::<Vec<Commit>>();
.collect::<Vec<_>>()
}

/// Applies the link parsers to the commits and returns the parsed commits.
fn apply_link_parsers(
commits: &mut Vec<Commit<'a>>,
git_config: &GitConfig,
) -> Vec<Commit<'a>> {
commits
.iter()
.map(|commit| commit.clone().parse_links(&git_config.link_parsers))
.collect::<Vec<_>>()
}

/// Processes the commit list based on the processing order defined in the
/// configuration.
fn process_commit_list(commits: &mut Vec<Commit<'a>>, git_config: &GitConfig) -> Result<()> {
for step in &git_config.processing_order.order {
match step {
ProcessingStep::CommitParsers => {
debug!("Applying commit parsers...");
*commits = Self::apply_commit_parsers(commits, git_config);
}
ProcessingStep::CommitPreprocessors => {
debug!("Applying commit preprocessors...");
*commits = Self::apply_commit_preprocessors(commits, git_config);
}
ProcessingStep::IntoConventional => {
debug!("Converting commits to conventional format...");
*commits = Self::apply_into_conventional(commits, git_config);
}
ProcessingStep::LinkParsers => {
debug!("Applying link parsers...");
*commits = Self::apply_link_parsers(commits, git_config);
}
ProcessingStep::SplitCommits => {
debug!("Splitting commits...");
if git_config.split_commits {
*commits = Self::apply_split_commits(commits);
} else {
debug!("Split commits is disabled, skipping...");
}
}
}
}
Comment on lines +222 to +250
Copy link
Author

@dslemusp dslemusp Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A thing to keep in mind is that if all the steps are not listed in the processing_order they will be skipped. So maybe we need to check that the list always contains all the steps (no matter the order)


if git_config.require_conventional {
Self::check_conventional_commits(commits)?;
Expand All @@ -164,6 +256,16 @@ impl<'a> Changelog<'a> {
Ok(())
}

/// Logs the error of a failed step from a single commit.
fn on_step_err(commit: Commit<'a>, error: Error) {
trace!(
"{} - {} ({})",
commit.id.chars().take(7).collect::<String>(),
error,
commit.message.lines().next().unwrap_or_default().trim()
);
}

/// Processes the commits and omits the ones that doesn't match the
/// criteria set by configuration file.
fn process_commits(&mut self) -> Result<()> {
Expand Down Expand Up @@ -687,6 +789,7 @@ mod test {
output: None,
},
git: GitConfig {
processing_order: Default::default(),
conventional_commits: true,
require_conventional: false,
filter_unconventional: false,
Expand Down Expand Up @@ -1367,7 +1470,7 @@ style: make awesome stuff look better
releases[2].commits.push(Commit {
id: String::from("123abc"),
message: String::from(
"chore(deps): bump some deps
"merge(deps): bump some deps

chore(deps): bump some more deps
chore(deps): fix broken deps
Expand Down Expand Up @@ -1401,7 +1504,6 @@ chore(deps): fix broken deps
- document zyx

#### deps
- bump some deps
- bump some more deps
- fix broken deps

Expand All @@ -1414,9 +1516,9 @@ chore(deps): fix broken deps

### Commit Statistics

- 8 commit(s) contributed to the release.
- 7 commit(s) contributed to the release.
- 6 day(s) passed between the first and last commit.
- 8 commit(s) parsed as conventional.
- 7 commit(s) parsed as conventional.
- 1 linked issue(s) detected in commits.
- [#5](https://github.com/5) (referenced 1 time(s))
- -578 day(s) passed between releases.
Expand Down Expand Up @@ -1463,16 +1565,18 @@ chore(deps): fix broken deps
#### other
- support unconventional commits
- this commit is preprocessed
- use footer
- footer text
- make awesome stuff look better

#### ui
- make good stuff

### Commit Statistics

- 18 commit(s) contributed to the release.
- 12 day(s) passed between the first and last commit.
- 17 commit(s) parsed as conventional.
- 20 commit(s) contributed to the release.
- 13 day(s) passed between the first and last commit.
- 19 commit(s) parsed as conventional.
- 1 linked issue(s) detected in commits.
- [#3](https://github.com/3) (referenced 1 time(s))
-- total releases: 2 --
Expand Down
45 changes: 45 additions & 0 deletions git-cliff-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,54 @@ pub struct ChangelogConfig {
pub output: Option<PathBuf>,
}

/// Processing steps for the commits
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessingStep {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestions:

  1. Make the enum variant names and config keys consistent and explicit about what the slot does. Prefer either explicit Verb + Object action names or neutral stage names so the intent is clear. Example mappings:
  • CommitParsers -> ParseCommits
  • CommitPreprocessors -> ModifyCommits
  • IntoConventional -> ConventionalizeCommits or NormalizeCommits
  • LinkParsers -> ParseLinks
  • SplitCommits -> SplitCommits
  1. Rename CommitPreprocessors to a name that reflects it being an ordering slot rather than implying it always runs "before" something. Options:
  • If message transformations/modification are the main behavior: TransformCommits or ModifyCommits.

Copy link
Author

@dslemusp dslemusp Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thx for the suggestions, I think it is indeed nice to have consistent naming. However I think the naming is more a fundamental change as I am using the current naming definitions from the configuration (see https://git-cliff.org/docs/configuration/git). I can indeed modify the names with the caveat of using multiple names for the same (e.g. ModifyCommits in place of CommitPreprocessors for this step https://git-cliff.org/docs/configuration/git#commit_preprocessors).
It seems that @orhun agreed on using the same names as in the config.toml (#1201 (comment)).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, I think it makes more sense to use the names that we are already familiar.

/// Split commits on newlines, treating each line as an individual commit.
SplitCommits,
/// An array of regex based parsers to modify commit messages prior to
/// further processing.
CommitPreprocessors,
/// Try to parse commits according to the conventional commits
/// specification.
IntoConventional,
/// An array of regex based parsers for extracting data from the commit
/// message.
CommitParsers,
/// An array of regex based parsers to extract links from the commit message
/// and add them to the commit's context.
LinkParsers,
}

/// Processing order for the changelog.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessingOrder {
/// The order in which the changelog should be processed.
pub order: Vec<ProcessingStep>,
}

impl Default for ProcessingOrder {
/// Returns the default processing order.
fn default() -> Self {
Self {
order: vec![
ProcessingStep::CommitPreprocessors,
ProcessingStep::SplitCommits,
ProcessingStep::IntoConventional,
ProcessingStep::CommitParsers,
ProcessingStep::LinkParsers,
],
}
}
}

/// Git configuration
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct GitConfig {
/// Defines the processing order of the commits.
#[serde(default)]
pub processing_order: ProcessingOrder,
/// Parse commits according to the conventional commits specification.
pub conventional_commits: bool,
/// Require all commits to be conventional.
Expand Down
1 change: 1 addition & 0 deletions git-cliff-core/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fn generate_changelog() -> Result<()> {
output: None,
};
let git_config = GitConfig {
processing_order: Default::default(),
conventional_commits: true,
require_conventional: false,
filter_unconventional: true,
Expand Down
Loading