Compare commits

...

5 Commits

  1. 226
      Cargo.lock
  2. 1
      Cargo.toml
  3. 23
      LICENSE.txt
  4. 106
      README.md
  5. 51
      src/action_init.rs
  6. 1
      src/action_log.rs
  7. 117
      src/action_pack.rs
  8. 23
      src/config.rs
  9. 4
      src/git.rs
  10. 57
      src/main.rs
  11. 350
      src/store.rs

226
Cargo.lock generated

@ -11,6 +11,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anstream" name = "anstream"
version = "0.6.20" version = "0.6.20"
@ -85,18 +94,47 @@ version = "2.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394"
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.3" version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" 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]] [[package]]
name = "clap" name = "clap"
version = "4.5.47" version = "4.5.47"
@ -129,6 +167,7 @@ name = "clpack"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono",
"clap", "clap",
"colored", "colored",
"faccess", "faccess",
@ -156,6 +195,12 @@ dependencies = [
"windows-sys 0.48.0", "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]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.25.0" version = "0.25.0"
@ -220,6 +265,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "find-msvc-tools"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d"
[[package]] [[package]]
name = "fuzzy-matcher" name = "fuzzy-matcher"
version = "0.3.7" version = "0.3.7"
@ -256,6 +307,30 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 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]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.11.1" version = "2.11.1"
@ -296,6 +371,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 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]] [[package]]
name = "libc" name = "libc"
version = "0.2.175" version = "0.2.175"
@ -351,6 +436,15 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -461,6 +555,12 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.20" version = "1.0.20"
@ -514,6 +614,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.3.18" version = "0.3.18"
@ -707,6 +813,65 @@ dependencies = [
"wit-bindgen", "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]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@ -729,12 +894,71 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.1.3" version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.48.0" version = "0.48.0"
@ -790,7 +1014,7 @@ version = "0.53.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.1.3",
"windows_aarch64_gnullvm 0.53.0", "windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0", "windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0", "windows_i686_gnu 0.53.0",

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

@ -0,0 +1,23 @@
Copyright 2025 Ondřej Hruška <ondra@ondrovo.com>
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. The name of the author may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

@ -0,0 +1,106 @@
# clpack
clpack = ChangeLog pack
This is a command line tool for keeping a changelog.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and can be customized.
This tool aims to make change logging as streamlined as possible to encourage developers to do it
automatically for every bugfix and feature. This can be enforced as part of a CI pipeline.
The entry format is kept simple and readable, so entries can be added manually as well - e.g. if some
contributors can't or don't want to use this tool.
_The tool is meant to be used with Git, but it is not required - it's just more convenient,
as it can pre-fill some information from branch names._
## Advantages over keeping the changelog manually
- **No merge conflicts to resolve**
- Each changelog entry (feature, bugfix) lives in its own file until packed into a release.
- **Support for multiple release channels**
- Normally, you have just one, default, release channel for stable releases - e.g from branch `main` or `master`.
- However, if you use separate branches for beta, preview, EAP, LTS etc., it is now possible to keep a separate
changelog file for each, which will be accurate even if fixes are backported, cherry-picked, merged from other
branches etc.
- **Issue numbers are extracted from branch names**
- Branches must follow a common pattern (configurable) for this to work, e.g. `1234-awful-crash`,
`SW-1234-make-ui-more-pretty`
- GitLab & YouTrack format supported by default
- **Uniform formatting & sections** - a template is pre-generated so developers just fill in the blanks for each
changelog entry
- **Automatic release dating** - date in configurable format may be added to your changelog file
- **Fully configurable** but also works out of the box for most projects
## Building
clpack uses stable rust. Compile with `cargo build --release`.
The binary is intended to be called `cl` in your path.
## "Getting started"
1. Run `cl init`. Inspect and customize the config file `clpack.toml` as needed.
2. To log a change, on your feature branch, use `cl add` or just `cl` for convenience
3. To pack changelog entries for a release, run `cl pack`.
- If you use release branches with a common naming scheme, like `rel/3.14`, clpack is able to parse the version
and use it as a suggestion when asking for version number. The pattern matching is based on regex and is configurable.
Changelog is written into `CHANGELOG.md`. This can be customized as well.
## Minimal setup
The changelog file is not required if you are happy with the defaults.
1. Create folder called changelog in your project
2. Use `cl` in the root of your project. It will use default config and create its subdirectories automatically as needed.
## Adding a changelog entry manually
There is no "vendor lock-in" with clpack. You can simply add changelog entries with your text editor - e.g.
if you use a machine without the tool, or for contributors using exotic systems like Microsoft Windows
(although clpack, in theory, might be compatible).
**Simply add a Markdown file like `my-bugfix.md` into `changelog/entries/`.**
## Changelog entry formatting
Whether you use clpack or do it manually, the actual entry is always a simple markdown file you edit in your preferred editor.
To set the editor to use, use env variable EDITOR, e.g. vim, nano. You can also create an empty entry template using
clpack and later edit it in your IDE, just don't forget.
- Empty lines are discarded
- Lines starting with `#` are considered a section name - e.g. Fixes, Improvements. Keep the section names consistent
across entries, as they will be grouped when packing the changelog for a release. Lines outside any section will go
in the front.
- All other lines will be included in the changelog, without any trimming or changes, and will stay together and in
the same order -> you can write multi-line entries with indentation.
## Working with release channels
Use this if you need to maintain separate release series, e.g. stable, lts, beta, eap, which share some commits
(with changelog entries).
Normally, each channel is on its own branch or branches following a naming scheme, e.g. stable releases. This is not mandatory - if it fits
your needs, you can have multiple channels on the same branch, too - or e.g. create a throwaway release channel for each major release,
so you can keep backporting fixes and making releases from the older branch without merge conflicts in the changelog file.
1. Define channels and branch matching rules in the config file `clpack.toml`.
- If there is no matching rule for a channel, i.e. it is selected manually, use empty string
2. When calling `cl pack` and there is more than one channel configured, the channel is auto-detected from the branch name.
- clpack will ask for channel confirmation, with the auto-detected channel pre-selected
- You may specify the channel directly by using e.g. `cl pack -x beta`
3. Each channel will have its own changelog file, by default called e.g. `CHANGELOG-BETA.md`
## How it works internally
- Each changelog entry is a markdown file in the folder `changelog/entries`
- clpack maintains JSON files in `changelog/channels` with a list of which entries were included in which release
- Changelog entries stay in their files even after making a release, so if you merge a stable branch into a testing
branch, you can create a changelog entry for a testing release, and it will include new fixes from stable as well as
changes made on the testing branch.
- When a large "epoch" release is made, you can delete (`cl flush` - TODO) the contents of the changelog folder.
- Do not delete the folder itself, clpack would complain it is missing.
- If you have a linear release history without multiple channels or backporting, you can do this after every release.

@ -0,0 +1,51 @@
use crate::config::Config;
use crate::store::Store;
use colored::Colorize;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
/// Args for cl_init()
pub struct ClInit {
/// name of the binary, detected from argv/system/env at startup - shown in messages
pub binary_name: String,
/// Root of the project
pub root: PathBuf,
/// Path to the config file to try to read, or to create
pub config_path: PathBuf,
}
/// Init the changelog system
pub fn cl_init(opts: ClInit) -> anyhow::Result<()> {
let mut default_config = Config::default();
if !opts.config_path.exists() {
let mut file = OpenOptions::new()
.write(true)
.create(true)
.open(&opts.config_path)?;
println!(
"Creating clpack config file: {}",
opts.config_path.display()
);
file.write_all(toml::to_string_pretty(&default_config)?.as_bytes())?;
} else {
println!(
"Loading existing config file: {}",
opts.config_path.display()
);
let file_text = std::fs::read_to_string(&opts.config_path)?;
default_config = toml::from_str(&file_text)?;
}
let ctx = crate::AppContext {
binary_name: opts.binary_name,
config: default_config,
root: opts.root,
};
let _ = Store::new(&ctx, true)?;
println!("{}", "Changelog initialized.".green());
Ok(())
}

@ -8,7 +8,6 @@ use colored::Colorize;
/// Perform the action of adding a new log entry /// Perform the action of adding a new log entry
pub(crate) fn cl_log(ctx: AppContext) -> anyhow::Result<()> { pub(crate) fn cl_log(ctx: AppContext) -> anyhow::Result<()> {
let store = Store::new(&ctx, false)?; let store = Store::new(&ctx, false)?;
store.ensure_internal_subdirs_exist()?;
let branch = get_branch_name(&ctx); let branch = get_branch_name(&ctx);
let issue = branch let issue = branch

@ -1,16 +1,123 @@
use crate::AppContext; use crate::AppContext;
use crate::config::ChannelName;
use crate::git::get_branch_name; 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 /// Perform the action of packing changelog entries for a release
pub(crate) fn cl_pack(ctx: AppContext, manual_channel: Option<&str>) -> 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 branch = get_branch_name(&ctx);
let channel = branch.as_ref().map(|b| b.parse_channel(&ctx)).transpose()?.flatten();
let version = branch.as_ref().map(|b| b.parse_version(&ctx)).transpose()?.flatten();
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(),
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!
let version_base = branch
.as_ref()
.map(|b| b.parse_version(&ctx))
.transpose()?
.flatten();
// TODO detect version from git query?
// TODO remove this
eprintln!( eprintln!(
"Branch name: {:?}, channel: {:?}, version: {:?}", "Branch name: {:?}, channel: {:?}, version: {:?}",
branch, channel, version branch, channel_detected, version_base
); );
todo!(); // Ask for the channel
let channel = if ctx.config.channels.len() > 1 {
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);
}
let mut query = inquire::Select::new("Release channel?", channels);
if let Some(index) = starting_index {
query = query.with_starting_cursor(index);
}
query.prompt()?.to_string()
}
} else {
// Just one channel, so use that
ctx.config.default_channel.clone()
};
println!("Channel: {}", channel.green().bold());
let unreleased = store.find_unreleased_changes(&channel)?;
if unreleased.is_empty() {
eprintln!("No unreleased changes.");
return Ok(());
}
println!();
println!("Changes waiting for release:");
for entry in &unreleased {
println!("+ {}", entry.cyan());
}
println!();
// Ask for the version
let mut version = version_base.unwrap_or_default();
loop {
// Ask for full version
version = inquire::Text::new("Version:")
.with_initial_value(&version)
.prompt()?;
if version.is_empty() {
bail!("Cancelled");
}
if store.version_exists(&version) {
println!("{}", "Version already exists, try again or cancel.".red());
} else {
break;
}
}
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(())
} }

@ -2,6 +2,15 @@ use serde::{Deserialize, Serialize};
use smart_default::SmartDefault; use smart_default::SmartDefault;
use std::collections::HashMap; use std::collections::HashMap;
/// e.g. default, stable, eap
pub type ChannelName = String;
/// e.g. 1.2.3
pub type VersionName = String;
/// e.g. SW-1234-stuff-is-broken (without .md)
pub type EntryName = String;
/// Main app configuration file /// Main app configuration file
#[derive(Debug, Serialize, Deserialize, SmartDefault)] #[derive(Debug, Serialize, Deserialize, SmartDefault)]
#[serde(deny_unknown_fields, default)] #[serde(deny_unknown_fields, default)]
@ -29,6 +38,18 @@ pub struct Config {
#[default = "CHANGELOG-{CHANNEL}.md"] #[default = "CHANGELOG-{CHANNEL}.md"]
pub changelog_file_channel: String, 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. /// Changelog sections suggested when creating a new entry.
/// ///
/// Users may also specify a custom section name. /// Users may also specify a custom section name.
@ -57,7 +78,7 @@ pub struct Config {
#[default(HashMap::from([ #[default(HashMap::from([
("default".to_string(), "/^(?:main|master)$/".to_string()) ("default".to_string(), "/^(?:main|master)$/".to_string())
]))] ]))]
pub channels: HashMap<String, String>, pub channels: HashMap<ChannelName, String>,
/// Regex pattern to extract issue number from a branch name. /// Regex pattern to extract issue number from a branch name.
/// There should be one capture group that is the number. /// There should be one capture group that is the number.

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

@ -1,12 +1,10 @@
use crate::action_init::{ClInit, cl_init};
use crate::action_log::cl_log; use crate::action_log::cl_log;
use crate::action_pack::cl_pack; use crate::action_pack::cl_pack;
use crate::config::Config; use crate::config::{ChannelName, Config};
use crate::store::Store;
use anyhow::bail; use anyhow::bail;
use clap::builder::NonEmptyStringValueParser; use clap::builder::NonEmptyStringValueParser;
use colored::Colorize; use colored::Colorize;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::exit; use std::process::exit;
@ -17,6 +15,8 @@ mod git;
mod action_log; mod action_log;
mod action_pack; mod action_pack;
mod action_init;
mod store; mod store;
#[derive(Debug)] #[derive(Debug)]
@ -33,7 +33,7 @@ pub struct AppContext {
fn main() { fn main() {
if let Err(e) = main_try() { if let Err(e) = main_try() {
eprintln!("{}", e.to_string().red().bold()); eprintln!("{}", format!("{:?}", e).red().bold());
exit(1); exit(1);
} }
} }
@ -54,17 +54,20 @@ fn main_try() -> anyhow::Result<()> {
.subcommand( .subcommand(
clap::Command::new("pack") clap::Command::new("pack")
.visible_alias("release") .visible_alias("release")
.about("Create a release changelog entry for the current channel") .about("Pack changelog entries to a changelog section")
.arg( .arg(clap::Arg::new("CHANNEL")
clap::Arg::new("CHANNEL") .short('x')
.help("Channel ID, possible values depend on project config. None for main channel.") .long("channel")
.value_parser(NonEmptyStringValueParser::new()) .value_parser(NonEmptyStringValueParser::new())
.required(false), .required(false)),
),
) )
.subcommand(clap::Command::new("add") .subcommand(clap::Command::new("add")
.visible_alias("log") .visible_alias("log")
.about("Add a changelog entry on the current branch")) .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_required(false) .subcommand_required(false)
.arg(clap::Arg::new("CONFIG") .arg(clap::Arg::new("CONFIG")
.short('c') .short('c')
@ -89,35 +92,14 @@ fn main_try() -> anyhow::Result<()> {
let config_path = root.join(&config_file_name); // if absolute, it is replaced by it let config_path = root.join(&config_file_name); // if absolute, it is replaced by it
if let Some(("init", _)) = args.subcommand() { if let Some(("init", _)) = args.subcommand() {
let mut default_config = Config::default(); return cl_init(ClInit {
if !config_path.exists() {
let mut file = OpenOptions::new()
.write(true)
.create(true)
.open(&config_path)?;
println!("Creating clpack config file");
file.write_all(toml::to_string_pretty(&default_config)?.as_bytes())?;
} else {
println!("Loading existing config file: {}", config_path.display());
let file_text = std::fs::read_to_string(&config_path)?;
default_config = toml::from_str(&file_text)?;
}
let ctx = AppContext {
binary_name, binary_name,
config: default_config,
root, root,
}; config_path,
let _ = Store::new(&ctx, true)?; });
println!("{}", "Changelog initialized.".green());
return Ok(());
} }
// Load and parse config // Load and parse config
let config: Config = if let Ok(config_file_content) = std::fs::read_to_string(&config_path) { let config: Config = if let Ok(config_file_content) = std::fs::read_to_string(&config_path) {
match toml::from_str(&config_file_content) { match toml::from_str(&config_file_content) {
Ok(config) => config, Ok(config) => config,
@ -146,10 +128,11 @@ fn main_try() -> anyhow::Result<()> {
match args.subcommand() { match args.subcommand() {
Some(("pack", subargs)) => { Some(("pack", subargs)) => {
let manual_channel = subargs.get_one::<String>("CHANNEL"); let channel: Option<ChannelName> = subargs.get_one("CHANNEL").cloned();
cl_pack(ctx, manual_channel.map(String::as_str))?; cl_pack(ctx, channel)?;
} }
None | Some(("add", _)) => cl_log(ctx)?, None | Some(("add", _)) => cl_log(ctx)?,
// TODO: status, flush
Some((other, _)) => { Some((other, _)) => {
bail!("Subcommand {other} is not implemented yet"); bail!("Subcommand {other} is not implemented yet");
} }

@ -1,14 +1,26 @@
use crate::AppContext; use crate::AppContext;
use crate::config::{ChannelName, Config, EntryName, VersionName};
use anyhow::bail; use anyhow::bail;
use colored::Colorize;
use faccess::PathExt; use faccess::PathExt;
use std::fs::OpenOptions; use serde::{Deserialize, Serialize};
use std::io::Write; use std::borrow::Cow;
use std::path::PathBuf; use std::collections::HashMap;
use std::fs::{OpenOptions, read_to_string};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
const DIR_ENTRIES: &str = "entries";
const DIR_CHANNELS: &str = "channels";
/// Changelog store struct
pub struct Store<'a> { pub struct Store<'a> {
/// App context, including config
ctx: &'a AppContext, ctx: &'a AppContext,
/// Path to the changelog directory
store_path: PathBuf, store_path: PathBuf,
/// Loaded version history for all channels
versions: HashMap<ChannelName, ChannelReleaseStore>,
} }
impl<'a> Store<'a> { impl<'a> Store<'a> {
@ -36,9 +48,14 @@ impl<'a> Store<'a> {
); );
} }
let store = Self { store_path, ctx }; let mut store = Self {
store_path,
ctx,
versions: HashMap::new(),
};
store.ensure_internal_subdirs_exist()?; store.ensure_internal_subdirs_exist()?;
store.load_versions()?;
Ok(store) Ok(store)
} }
@ -47,7 +64,7 @@ impl<'a> Store<'a> {
/// This is a file in the entries storage /// This is a file in the entries storage
fn make_entry_path(&self, filename: &str) -> PathBuf { fn make_entry_path(&self, filename: &str) -> PathBuf {
self.store_path self.store_path
.join("entries") .join(DIR_ENTRIES)
.join(format!("{filename}.md")) .join(format!("{filename}.md"))
} }
@ -58,9 +75,25 @@ impl<'a> Store<'a> {
path.exists() path.exists()
} }
/// Load release lists for all channels
fn load_versions(&mut self) -> anyhow::Result<()> {
let channels_dir = self.store_path.join(DIR_CHANNELS);
for ch in self.ctx.config.channels.keys() {
let channel_file = channels_dir.join(format!("{}.json", ch));
self.versions.insert(
ch.clone(),
ChannelReleaseStore::load(channel_file, ch.clone())?,
);
}
Ok(())
}
/// Check and create internal subdirs for the clpack system /// Check and create internal subdirs for the clpack system
pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> { pub fn ensure_internal_subdirs_exist(&self) -> anyhow::Result<()> {
self.ensure_subdir_exists("entries")?; self.ensure_subdir_exists(DIR_ENTRIES, true)?;
self.ensure_subdir_exists(DIR_CHANNELS, false)?;
// TODO // TODO
Ok(()) Ok(())
@ -69,7 +102,7 @@ impl<'a> Store<'a> {
/// make sure a subdir exists, creating if needed. /// 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. /// 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); let subdir = self.store_path.join(name);
if !subdir.is_dir() { if !subdir.is_dir() {
@ -87,19 +120,316 @@ impl<'a> Store<'a> {
bail!("Changelog subdir is not writable: {}", subdir.display()); bail!("Changelog subdir is not writable: {}", subdir.display());
} }
if gitkeep {
std::fs::File::create(subdir.join(".gitkeep"))?; std::fs::File::create(subdir.join(".gitkeep"))?;
}
Ok(()) Ok(())
} }
/// Create a changelog entry file and write content to it /// Create a changelog entry file and write content to it
pub fn create_entry(&self, name: String, content: String) -> anyhow::Result<()> { pub fn create_entry(&self, name: EntryName, content: String) -> anyhow::Result<()> {
let path = self.make_entry_path(name.as_str()); let path = self.make_entry_path(name.as_str());
let mut file = OpenOptions::new().write(true).create(true).open(&path)?; let mut file = OpenOptions::new().write(true).create(true).open(&path)?;
eprintln!("Writing to file: {}", path.display()); eprintln!("Writing changelog entry to file: {}", path.display());
file.write_all(content.as_bytes())?; file.write_all(content.as_bytes())?;
Ok(()) Ok(())
} }
/// Check if a version was already released (on any channel) - prevents the user from making a mistake in version naming
pub fn version_exists(&self, version: &str) -> bool {
for v in self.versions.values() {
if v.version_exists(version) {
return true;
}
}
false
}
/// Find unreleased changelog entries on a channel
pub fn find_unreleased_changes(&self, channel: &ChannelName) -> anyhow::Result<Vec<EntryName>> {
let Some(store) = self.versions.get(channel) else {
bail!("Channel {channel} does not exist.");
};
store.find_unreleased_entries(self.store_path.join(DIR_ENTRIES))
}
/// 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 changelog_file = self.ctx.root.join(
if channel == config.default_channel {
Cow::Borrowed(config.changelog_file_default.as_str())
} else {
Cow::Owned(
config
.changelog_file_channel
.replace("{channel}", &channel.to_lowercase())
.replace("{CHANNEL}", &channel.to_uppercase())
.replace("{Channel}", &ucfirst(&channel)),
)
}
.as_ref(),
);
if changelog_file.exists() {
let changelog_file_content = read_to_string(&changelog_file)?;
let old_content = changelog_file_content
.strip_prefix(&config.changelog_header)
.unwrap_or(&changelog_file_content);
let mut outfile = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(changelog_file)?;
outfile.write_all(
format!("{}{}{}", config.changelog_header, rendered, old_content).as_bytes(),
)?;
} else {
let mut outfile = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(changelog_file)?;
outfile.write_all(format!("{}{}", config.changelog_header, rendered).as_bytes())?;
}
store.add_version(release)?;
// Write to the changelog file for this channel
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
fn ucfirst(input: &str) -> String {
let mut c = input.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
}
/// Summary of a release
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Release {
/// Name of the version
pub version: VersionName,
/// List of entries included in this version
pub entries: Vec<EntryName>,
}
impl Release {
/// 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();
for entry in &self.entries {
let entry_file = entries_dir.join(&format!("{entry}.md"));
if !entry_file.exists() || !entry_file.readable() {
bail!(
"Changelog entry file missing or not readable: {}",
entry_file.display()
);
}
let file = OpenOptions::new().read(true).open(&entry_file)?;
let reader = BufReader::new(file);
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 == ' ');
current_section = section.to_string();
} else {
if let Some(buffer) = entries_per_section.get_mut(&current_section) {
buffer.push('\n');
buffer.push_str(&line);
} else {
entries_per_section.insert(current_section.clone(), line);
}
}
}
}
let mut reordered_sections = Vec::<(String, String)>::new();
// First the unlabelled section (this is probably junk, but it was entered by the user, so keep it)
if let Some(unlabelled) = entries_per_section.remove("") {
reordered_sections.push(("".to_string(), unlabelled));
}
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));
}
}
// Leftovers (names authors invented when writing changelog)
for (section_name, content) in entries_per_section {
reordered_sections.push((section_name, content));
}
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### {}\n\n", section_name));
}
buffer.push_str(content.trim_end());
buffer.push_str("\n\n");
}
Ok(buffer)
}
}
/// List of releases, deserialized from a file
type ReleaseList = Vec<Release>;
/// Versions store for one channel
struct ChannelReleaseStore {
/// File where the list of versions is stored
backing_file: PathBuf,
/// Name of the channel, for error messages
channel_name: ChannelName,
/// List of releases, load from the file
releases: ReleaseList,
}
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 - this catches error with write access early
let mut f = OpenOptions::new()
.write(true)
.create(true)
.open(&releases_file)?;
f.write_all("[]".as_bytes())?;
Default::default()
} else {
let channel_json = read_to_string(&releases_file)?;
serde_json::from_str::<ReleaseList>(&channel_json)?
};
Ok(Self {
backing_file: releases_file,
channel_name,
releases,
})
}
/// Check if a version is included in a release
fn version_exists(&self, version: &str) -> bool {
self.releases.iter().any(|rel| rel.version == version)
}
/// Add a version to the channel buffer
/// The release entry, borrowed, is returned for further use
fn add_version(&mut self, release: Release) -> anyhow::Result<()> {
if self.version_exists(&release.version) {
bail!(
"Version {} already exists on channel {}",
release.version,
self.channel_name
);
}
self.releases.push(release);
Ok(())
}
/// Write the versions list contained in this store into the backing file.
fn write_to_file(&self) -> anyhow::Result<()> {
let encoded = serde_json::to_string_pretty(&self.releases)?;
let mut f = OpenOptions::new()
.write(true)
.create(true)
.open(&self.backing_file)?;
f.write_all(encoded.as_bytes())?;
Ok(())
}
/// Find entries not yet included in this release channel
fn find_unreleased_entries(
&self,
entries_dir: impl AsRef<Path>,
) -> anyhow::Result<Vec<EntryName>> {
let mut found = vec![];
for entry in entries_dir.as_ref().read_dir()? {
let entry = entry?;
let fname_os = entry.file_name();
let fname = fname_os.into_string().map_err(|_| {
anyhow::anyhow!("Failed to parse file name: {}", entry.path().display())
})?;
if !entry.metadata()?.is_file() || !fname.ends_with(".md") {
if fname != ".gitkeep" {
eprintln!(
"{}",
format!(
"Unexpected item in changelog entries dir: {}",
entry.path().display()
)
.yellow()
);
}
continue;
}
let basename = fname.strip_suffix(".md").unwrap();
if !self
.releases
.iter()
.map(|rel| &rel.entries)
.flatten()
.any(|entryname| entryname == basename)
{
found.push(basename.to_string());
}
}
Ok(found)
}
} }

Loading…
Cancel
Save