implement changelog packing, more config options, better formatting & flexibility

cl-status
Ondřej Hruška 3 days ago
parent e6ff1520ee
commit 55dc83adc6
  1. 226
      Cargo.lock
  2. 1
      Cargo.toml
  3. 53
      src/action_pack.rs
  4. 9
      src/config.rs
  5. 4
      src/git.rs
  6. 24
      src/main.rs
  7. 60
      src/store.rs

226
Cargo.lock generated

@ -11,6 +11,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.20"
@ -85,18 +94,47 @@ version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65193589c6404eb80b450d618eaf9a2cafaaafd57ecce47370519ef674a7bd44"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "chrono"
version = "0.4.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link 0.2.0",
]
[[package]]
name = "clap"
version = "4.5.47"
@ -129,6 +167,7 @@ name = "clpack"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"colored",
"faccess",
@ -156,6 +195,12 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "crossterm"
version = "0.25.0"
@ -220,6 +265,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d"
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
@ -256,6 +307,30 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
[[package]]
name = "iana-time-zone"
version = "0.1.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.11.1"
@ -296,6 +371,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.175"
@ -351,6 +436,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@ -461,6 +555,12 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.20"
@ -514,6 +614,12 @@ dependencies = [
"serde",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
@ -707,6 +813,65 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi"
version = "0.3.9"
@ -729,12 +894,71 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-core"
version = "0.62.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.2.0",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-result"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f"
dependencies = [
"windows-link 0.2.0",
]
[[package]]
name = "windows-strings"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda"
dependencies = [
"windows-link 0.2.0",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
@ -790,7 +1014,7 @@ version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
dependencies = [
"windows-link",
"windows-link 0.1.3",
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",

@ -16,6 +16,7 @@ regex = "1"
anyhow = "1"
colored = "3"
faccess = "0.2"
chrono = "0.4"
# input

@ -1,19 +1,32 @@
use crate::AppContext;
use crate::config::ChannelName;
use crate::git::get_branch_name;
use crate::store::{Release, Store};
use anyhow::bail;
use colored::Colorize;
/// Perform the action of packing changelog entries for a release
pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> {
pub(crate) fn cl_pack(ctx: AppContext, channel: Option<ChannelName>) -> anyhow::Result<()> {
let mut store = Store::new(&ctx, false)?;
let branch = get_branch_name(&ctx);
let channel_detected = branch
let (channel_detected, channel_explicit) = match channel {
Some(ch) => (Some(ch), true), // passed via flag already
None => (
branch
.as_ref()
.map(|b| b.parse_channel(&ctx))
.transpose()?
.flatten();
.flatten(),
false,
),
};
if let Some(ch) = &channel_detected
&& !ctx.config.channels.contains_key(ch)
{
bail!("No such channel: {ch}");
}
// If the branch is named rel/3.40, this can extract 3.40.
// TODO try to get something better from git!
@ -33,7 +46,10 @@ pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> {
// Ask for the channel
let channel = if ctx.config.channels.len() > 1 {
let channels = ctx.config.channels.values().collect::<Vec<_>>();
if channel_explicit {
channel_detected.unwrap()
} else {
let channels = ctx.config.channels.keys().collect::<Vec<_>>();
let mut starting_index = None;
if let Some(channel) = channel_detected {
starting_index = channels.iter().position(|ch| *ch == &channel);
@ -43,6 +59,7 @@ pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> {
query = query.with_starting_cursor(index);
}
query.prompt()?.to_string()
}
} else {
// Just one channel, so use that
ctx.config.default_channel.clone()
@ -57,9 +74,9 @@ pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> {
}
println!();
println!("Changes waiting for release:\n");
println!("Changes waiting for release:");
for entry in &unreleased {
println!("+ {entry}\n");
println!("+ {}", entry.cyan());
}
println!();
@ -82,11 +99,25 @@ pub(crate) fn cl_pack(ctx: AppContext) -> anyhow::Result<()> {
}
}
store.create_release(
channel,
Release {
let release = Release {
version,
entries: unreleased,
},
)
};
let rendered = store.render_release(&release)?;
println!("\n\nPreview:\n\n{}\n", rendered);
if !inquire::Confirm::new("Continue - write to changelog file?")
.with_default(true)
.prompt()?
{
eprintln!("{}", "Cancelled.".red());
return Ok(());
}
store.create_release(channel, release)?;
println!("{}", "Changelog written.".green());
Ok(())
}

@ -38,9 +38,18 @@ pub struct Config {
#[default = "CHANGELOG-{CHANNEL}.md"]
pub changelog_file_channel: String,
/// Title of the changelog file, stripped and put back in front
#[default = "# Changelog\n\n"]
pub changelog_header: String,
/// Pattern for release header
#[default = "[{VERSION}] - {DATE}"]
pub release_header: String,
/// Date format (see patterns supported by the Chrono crate: https://docs.rs/chrono/latest/chrono/format/strftime/index.html )
#[default = "%Y-%m-%d"]
pub date_format: String,
/// Changelog sections suggested when creating a new entry.
///
/// Users may also specify a custom section name.

@ -98,6 +98,10 @@ impl BranchName {
/// Try to detect a release channel from this branch name (e.g. stable, EAP)
pub fn parse_channel(&self, ctx: &AppContext) -> anyhow::Result<Option<String>> {
for (channel_id, template) in &ctx.config.channels {
if template.is_empty() {
// Channel only for manual choosing
continue;
}
if let Some(pat_s) = as_regex_pattern(template) {
let pat = match regex::Regex::new(pat_s) {
Ok(pat) => pat,

@ -1,7 +1,7 @@
use crate::action_init::{ClInit, cl_init};
use crate::action_log::cl_log;
use crate::action_pack::cl_pack;
use crate::config::Config;
use crate::config::{ChannelName, Config};
use anyhow::bail;
use clap::builder::NonEmptyStringValueParser;
use colored::Colorize;
@ -33,7 +33,7 @@ pub struct AppContext {
fn main() {
if let Err(e) = main_try() {
eprintln!("{}", e.to_string().red().bold());
eprintln!("{}", format!("{:?}", e).red().bold());
exit(1);
}
}
@ -54,15 +54,20 @@ fn main_try() -> anyhow::Result<()> {
.subcommand(
clap::Command::new("pack")
.visible_alias("release")
.about("Pack changelog entries to a changelog section"),
.about("Pack changelog entries to a changelog section")
.arg(clap::Arg::new("CHANNEL")
.short('x')
.long("channel")
.value_parser(NonEmptyStringValueParser::new())
.required(false)),
)
.subcommand(clap::Command::new("add")
.visible_alias("log")
.about("Add a changelog entry on the current branch"))
.subcommand(clap::Command::new("flush")
.about("Remove all changelog entries that were already released on all channels - clean up the changelog dir. Use e.g. when making a major release where all channel branches are merged."))
.subcommand(clap::Command::new("status")
.about("Show changelog entries currently waiting for release on the current channel"))
// .subcommand(clap::Command::new("flush")
// .about("Remove all changelog entries that were already released on all channels - clean up the changelog dir. Use e.g. when making a major release where all channel branches are merged."))
// .subcommand(clap::Command::new("status")
// .about("Show changelog entries currently waiting for release on the current channel"))
.subcommand_required(false)
.arg(clap::Arg::new("CONFIG")
.short('c')
@ -122,8 +127,9 @@ fn main_try() -> anyhow::Result<()> {
// eprintln!("AppCtx: {:?}", ctx);
match args.subcommand() {
Some(("pack", _)) => {
cl_pack(ctx)?;
Some(("pack", subargs)) => {
let channel: Option<ChannelName> = subargs.get_one("CHANNEL").cloned();
cl_pack(ctx, channel)?;
}
None | Some(("add", _)) => cl_log(ctx)?,
// TODO: status, flush

@ -1,5 +1,5 @@
use crate::AppContext;
use crate::config::{ChannelName, EntryName, VersionName};
use crate::config::{ChannelName, Config, EntryName, VersionName};
use anyhow::bail;
use colored::Colorize;
use faccess::PathExt;
@ -92,8 +92,8 @@ impl<'a> Store<'a> {
/// Check and create internal subdirs for the clpack system
pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> {
self.ensure_subdir_exists(DIR_ENTRIES)?;
self.ensure_subdir_exists(DIR_CHANNELS)?;
self.ensure_subdir_exists(DIR_ENTRIES, true)?;
self.ensure_subdir_exists(DIR_CHANNELS, false)?;
// TODO
Ok(())
@ -102,7 +102,7 @@ impl<'a> Store<'a> {
/// make sure a subdir exists, creating if needed.
///
/// Note there is no lock so there can be a TOCTOU bug later if someone deletes the path - must be checked and handled.
fn ensure_subdir_exists(&self, name: &str) -> anyhow::Result<()> {
fn ensure_subdir_exists(&self, name: &str, gitkeep: bool) -> anyhow::Result<()> {
let subdir = self.store_path.join(name);
if !subdir.is_dir() {
@ -120,7 +120,9 @@ impl<'a> Store<'a> {
bail!("Changelog subdir is not writable: {}", subdir.display());
}
if gitkeep {
std::fs::File::create(subdir.join(".gitkeep"))?;
}
Ok(())
}
@ -157,12 +159,13 @@ impl<'a> Store<'a> {
/// Create a release entry, write it to the releases buffer and to the file.
pub fn create_release(&mut self, channel: ChannelName, release: Release) -> anyhow::Result<()> {
let rendered = self.render_release(&release)?;
let Some(store) = self.versions.get_mut(&channel) else {
bail!("Channel {channel} does not exist.");
};
let config = &self.ctx.config;
let rendered = release.render(self.store_path.join(DIR_ENTRIES), &config.sections)?;
let changelog_file = self.ctx.root.join(
if channel == config.default_channel {
@ -209,6 +212,12 @@ impl<'a> Store<'a> {
store.write_to_file()?;
Ok(())
}
/// Render a release
pub fn render_release(&self, release: &Release) -> anyhow::Result<String> {
let config = &self.ctx.config;
release.render(self.store_path.join(DIR_ENTRIES), &config)
}
}
/// Uppercase first char of a string
@ -230,12 +239,8 @@ pub struct Release {
}
impl Release {
/// Render the entry into a Markdown fragment, using h2 (##) as the title.
pub fn render(
&self,
entries_dir: impl AsRef<Path>,
predefined_sections: &[String],
) -> anyhow::Result<String> {
/// Render the entry into a Markdown fragment, using h2 (##) as the title, h3 (###) for sections
pub fn render(&self, entries_dir: impl AsRef<Path>, config: &Config) -> anyhow::Result<String> {
let mut entries_per_section = HashMap::<String, String>::new();
let entries_dir = entries_dir.as_ref();
let unnamed = "".to_string();
@ -256,6 +261,9 @@ impl Release {
let mut current_section = unnamed.clone();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if line.trim().starts_with('#') {
// It is a section name
let section = line.trim_matches(|c| c == '#' || c == ' ');
@ -278,7 +286,7 @@ impl Release {
reordered_sections.push(("".to_string(), unlabelled));
}
for section_name in [unnamed].iter().chain(predefined_sections.iter()) {
for section_name in [unnamed].iter().chain(config.sections.iter()) {
if let Some(content) = entries_per_section.remove(section_name) {
reordered_sections.push((section_name.clone(), content));
}
@ -288,14 +296,21 @@ impl Release {
reordered_sections.push((section_name, content));
}
let mut buffer = String::new();
let date = chrono::Local::now();
let mut buffer = format!(
"## {}\n",
config
.release_header
.replace("{VERSION}", &self.version)
.replace("{DATE}", &date.format(&config.date_format).to_string())
);
for (section_name, content) in reordered_sections {
if !section_name.is_empty() {
buffer.push_str(&format!("## {}\n", section_name));
buffer.push_str(&format!("\n### {}\n\n", section_name));
}
buffer.push_str(&content);
buffer.push('\n');
buffer.push_str(content.trim_end());
buffer.push_str("\n\n");
}
Ok(buffer)
@ -318,13 +333,18 @@ struct ChannelReleaseStore {
impl ChannelReleaseStore {
/// Load from a versions file
fn load(releases_file: PathBuf, channel_name: ChannelName) -> anyhow::Result<Self> {
println!(
"Loading versions for channel {} from: {}",
channel_name,
releases_file.display()
);
let releases = if !releases_file.exists() {
// File did not exist yet, create it
// File did not exist yet, create it - this catches error with write access early
let mut f = OpenOptions::new()
.write(true)
.create(true)
.open(&releases_file)?;
f.write_all("{}".as_bytes())?;
f.write_all("[]".as_bytes())?;
Default::default()
} else {
let channel_json = read_to_string(&releases_file)?;
@ -384,6 +404,7 @@ impl ChannelReleaseStore {
})?;
if !entry.metadata()?.is_file() || !fname.ends_with(".md") {
if fname != ".gitkeep" {
eprintln!(
"{}",
format!(
@ -392,6 +413,7 @@ impl ChannelReleaseStore {
)
.yellow()
);
}
continue;
}
@ -404,7 +426,7 @@ impl ChannelReleaseStore {
.flatten()
.any(|entryname| entryname == basename)
{
found.push(fname);
found.push(basename.to_string());
}
}

Loading…
Cancel
Save