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
22 changes: 22 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ footer = """
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
# Format the rendered changelog as Markdown (only applies to Markdown output).
# format = true
# An array of regex based postprocessors to modify the changelog.
postprocessors = [
# Replace the placeholder `<REPO>` with a URL.
Expand Down
2 changes: 2 additions & 0 deletions git-cliff-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ indexmap = { version = "2.13.0", features = ["serde"] }
toml = "0.9.8"
next_version = "0.3.2"
semver = "1.0.27"
pulldown-cmark = { version = "0.13.4", default-features = false }
pulldown-cmark-to-cmark = "22.0.1"
humantime-serde = "1.1.1"
document-features = { version = "0.2.12", optional = true }
reqwest = { workspace = true, optional = true }
Expand Down
123 changes: 76 additions & 47 deletions git-cliff-core/src/changelog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,64 +587,61 @@ impl<'a> Changelog<'a> {
pub fn generate<W: Write + ?Sized>(&self, out: &mut W) -> Result<()> {
crate::set_progress_message!("Generating and writing the changelog");
tracing::debug!("Generating changelog");

let mut output = self.render()?;
if self.config.changelog.format {
output = crate::markdown::format_markdown(&output)?;
}

let write_result = write!(out, "{output}");
if let Err(e) = write_result {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e.into());
}
}

Ok(())
}

/// Renders the changelog (header + releases + footer) into a string.
///
/// With `format` disabled this produces byte-for-byte the same output that
/// used to be written directly to the writer.
fn render(&self) -> Result<String> {
let postprocessors = self.config.changelog.postprocessors.clone();
let mut output = String::new();

if let Some(header_template) = &self.header_template {
let write_result = writeln!(
out,
"{}",
header_template.render(
&Releases {
releases: &self.releases,
},
Some(&self.additional_context),
&postprocessors,
)?
);
if let Err(e) = write_result {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e.into());
}
}
output.push_str(&header_template.render(
&Releases {
releases: &self.releases,
},
Some(&self.additional_context),
&postprocessors,
)?);
output.push('\n');
}

for release in &self.releases {
let write_result = write!(
out,
"{}",
self.body_template.render(
&release,
Some(&self.additional_context),
&postprocessors
)?
);
if let Err(e) = write_result {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e.into());
}
}
output.push_str(&self.body_template.render(
&release,
Some(&self.additional_context),
&postprocessors,
)?);
}

if let Some(footer_template) = &self.footer_template {
let write_result = writeln!(
out,
"{}",
footer_template.render(
&Releases {
releases: &self.releases,
},
Some(&self.additional_context),
&postprocessors,
)?
);
if let Err(e) = write_result {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e.into());
}
}
output.push_str(&footer_template.render(
&Releases {
releases: &self.releases,
},
Some(&self.additional_context),
&postprocessors,
)?);
output.push('\n');
}

Ok(())
Ok(output)
}

/// Generates a changelog and prepends it to the given changelog.
Expand Down Expand Up @@ -814,6 +811,7 @@ mod test {
replace_command: None,
}],
render_always: false,
format: false,
output: None,
},
git: GitConfig {
Expand Down Expand Up @@ -1477,6 +1475,36 @@ mod test {
Ok(())
}

#[test]
fn changelog_generator_format() -> Result<()> {
let (config, releases) = get_test_data();

// Formatting disabled: the output is exactly what the templates render.
let plain = {
let changelog = Changelog::new(releases.clone(), config.clone(), None)?;
let mut out = Vec::new();
changelog.generate(&mut out)?;
String::from_utf8(out).expect("output should be valid utf-8")
};

// Formatting enabled: the output is the rendered changelog run through
// the Markdown formatter.
let mut formatted_config = config;
formatted_config.changelog.format = true;
let formatted = {
let changelog = Changelog::new(releases, formatted_config, None)?;
let mut out = Vec::new();
changelog.generate(&mut out)?;
String::from_utf8(out).expect("output should be valid utf-8")
};

assert_eq!(formatted, crate::markdown::format_markdown(&plain)?);
// The fixture output isn't already normalized, so formatting changes it.
assert_ne!(plain, formatted);

Ok(())
}

#[test]
fn changelog_generator_split_commits() -> Result<()> {
let (mut config, mut releases) = get_test_data();
Expand Down Expand Up @@ -1661,6 +1689,7 @@ chore(deps): fix broken deps
trim: true,
postprocessors: Vec::new(),
render_always: false,
format: false,
output: None,
},
git: GitConfig {
Expand Down
7 changes: 7 additions & 0 deletions git-cliff-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ pub struct ChangelogConfig {
pub trim: bool,
/// Always render the body template.
pub render_always: bool,
/// Format the rendered changelog as Markdown.
///
/// Only takes effect when the output is Markdown (stdout or a `.md`
/// file). Defaults to `false`, in which case the output is left exactly
/// as the templates rendered it.
#[serde(default)]
pub format: bool,
/// Changelog postprocessors.
pub postprocessors: Vec<TextProcessor>,
/// Output file path.
Expand Down
3 changes: 3 additions & 0 deletions git-cliff-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ pub enum Error {
/// string.
#[error("UTF-8 error: `{0}`")]
Utf8Error(#[from] std::str::Utf8Error),
/// Error that may occur while formatting the changelog as Markdown.
#[error("Markdown format error: `{0}`")]
MarkdownFormatError(#[from] pulldown_cmark_to_cmark::Error),
/// Error variant that represents errors coming out of libgit2.
#[cfg(feature = "repo")]
#[error("Git error: `{0}`")]
Expand Down
2 changes: 2 additions & 0 deletions git-cliff-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ pub mod contributor;
pub mod embed;
/// Error handling.
pub mod error;
/// Markdown post-processing.
pub mod markdown;
/// Commit processing pipeline.
pub mod process;
/// Common release type.
Expand Down
105 changes: 105 additions & 0 deletions git-cliff-core/src/markdown.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! Markdown post-processing for the generated changelog.

use pulldown_cmark::{Options, Parser};
use pulldown_cmark_to_cmark::cmark_with_options;

use crate::error::Result;

/// Normalizes a Markdown string by round-tripping it through a parser and
/// re-emitter.
///
/// This tidies up formatting that is tedious to get right in a Tera template
/// (heading styles, list markers, blank lines between blocks) without the user
/// having to fiddle with `{%-` / `trim` everywhere. The GitHub-flavored
/// extensions git-cliff templates commonly use (tables, strikethrough, task
/// lists, footnotes) are enabled so they survive the round-trip.
///
/// The formatter options are intentionally conservative: it doesn't reflow
/// text or rewrite links, so template output that is already valid Markdown
/// keeps its structure.
pub fn format_markdown(input: &str) -> Result<String> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_FOOTNOTES);

let parser = Parser::new_ext(input, options);
let mut formatted = String::with_capacity(input.len());
// Keep `-` as the bullet marker to match git-cliff's default templates and
// avoid churning existing changelogs from `-` to `*`.
let format_options = pulldown_cmark_to_cmark::Options {
list_token: '-',
..Default::default()
};
cmark_with_options(parser, &mut formatted, format_options)?;

// `cmark` doesn't emit a trailing newline, but changelogs conventionally
// end with one. Preserve whatever the input had at the boundary.
if input.ends_with('\n') && !formatted.ends_with('\n') {
formatted.push('\n');
}
Ok(formatted)
}

#[cfg(test)]
mod test {
use super::*;

#[test]
fn normalizes_messy_markdown() -> Result<()> {
// Valid but sloppy: a setext heading, missing blank line before a
// heading, and runs of blank lines between blocks.
let input = "\
Changelog
=========


## 1.0.0
### Features

- first change
- second change



some text
";
let formatted = format_markdown(input)?;
let expected = "\
# Changelog

## 1.0.0

### Features

- first change
- second change

some text
";
assert_eq!(expected, formatted);
// Formatting is idempotent: a second pass changes nothing.
assert_eq!(formatted, format_markdown(&formatted)?);
Ok(())
}

#[test]
fn keeps_clean_markdown_stable() -> Result<()> {
let input = "\
# Changelog

## 1.0.0

### Features

- add a thing

### Bug Fixes

- fix a thing
";
assert_eq!(input, format_markdown(input)?);
Ok(())
}
}
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 @@ -35,6 +35,7 @@ fn generate_changelog() -> Result<()> {
footer: Some(String::from("eoc - end of changelog")),
trim: true,
render_always: false,
format: false,
postprocessors: [].to_vec(),
output: None,
};
Expand Down
15 changes: 15 additions & 0 deletions git-cliff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,21 @@ pub fn write_changelog<W: io::Write>(
.output
.clone()
.or(changelog.config.changelog.output.clone());
// Markdown formatting only makes sense for Markdown output. Detect it from
// the file extension (stdout and extension-less paths are treated as
// Markdown, matching git-cliff's default output).
if changelog.config.changelog.format {
let is_markdown = output.as_ref().is_none_or(|path| {
path.extension()
.is_none_or(|ext| ext.eq_ignore_ascii_case("md"))
});
if !is_markdown {
tracing::warn!(
"`changelog.format` is enabled but the output is not Markdown; skipping formatting"
);
changelog.config.changelog.format = false;
}
}
if args.bump.is_some() || args.bumped_version {
let current_version = changelog.releases.first().and_then(|release| {
release.version.clone().or_else(|| {
Expand Down
Loading
Loading