diff --git a/README.md b/README.md index ff0d228..cda6650 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Missing features: - [ ] Try to fetch the correct git branch for series on Debian, or fallback to the archive - [ ] `pkh chlog` - [x] Auto-generate changelog entry - - [ ] Extra flags: backport, non-maintainer upload, no change rebuild, ... + - [x] Extra flags: backport, non-maintainer upload, no change rebuild, ... - [ ] Commit changelog entry - [ ] `pkh build` - [x] Build the source package diff --git a/src/changelog.rs b/src/changelog.rs index 207eea2..aa36da3 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -22,12 +22,36 @@ pub struct GeneratedEntry { pub path: std::path::PathBuf, } +/// The kind of upload a generated changelog entry describes: it selects how +/// the new entry's version is derived from the previous one. Ignored when an +/// explicit version is given. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum EntryKind { + /// A regular upload: the Debian revision is bumped (or the trailing + /// upstream number for native packages) + #[default] + Normal, + /// A non-maintainer upload: `1.0-1` becomes `1.0-1.1` (native `1.0` + /// becomes `1.0+nmu1`) + Nmu, + /// A no-change rebuild: `1.0-1` becomes `1.0-1build1` + Rebuild, + /// An Ubuntu upload: `1.0-1` becomes `1.0-1ubuntu1`. Library-only for + /// now: the pkh CLI has no flag selecting it. + Ubuntu, + /// A backport: `1.0-1` becomes `1.0-1~bpo12+1`, where 12 is the Debian + /// release number of the target series (derived from it: the series + /// must therefore be a numbered Debian release, e.g. not sid) + Backport, +} + /// Automatically generate a changelog entry from a commit history and previous changelog -pub fn generate_entry( +pub async fn generate_entry( changelog_file: &str, cwd: Option<&Path>, user_version: Option<&str>, target_series: Option<&str>, + kind: EntryKind, ) -> Result> { let changelog_path = if let Some(path) = cwd { path.join(changelog_file) @@ -51,16 +75,27 @@ pub fn generate_entry( Err(_e) => Vec::new(), }; + // The series the new entry targets: needed before the version is + // computed, because a backport version carries the target release number + let series = target_series.unwrap_or(¤t_series).to_string(); + // Compute new version if needed, or use user-supplied one let new_version = if let Some(version) = user_version { version.to_string() } else { - // TODO: Pass these flags from CLI - compute_new_version(&old_version, false, false, false)? + match kind { + EntryKind::Normal => compute_new_version(&old_version, Bump::Normal)?, + EntryKind::Nmu => compute_new_version(&old_version, Bump::Nmu)?, + EntryKind::Rebuild => compute_new_version(&old_version, Bump::Rebuild)?, + EntryKind::Ubuntu => compute_new_version(&old_version, Bump::Ubuntu)?, + EntryKind::Backport => { + let number = backport_series_number(&series).await?; + compute_new_version(&old_version, Bump::Backport(number))? + } + } }; let (maintainer_name, maintainer_email) = get_maintainer_info()?; - let series = target_series.unwrap_or(¤t_series).to_string(); let new_entry = format_entry( &package, &new_version, @@ -81,28 +116,72 @@ pub fn generate_entry( }) } -/// Compute the next (most probable) version number of a package, from old version and -/// conditions on changes (is ubuntu upload, is a no change rebuild, is a non-maintainer upload) +/// How the new version is derived from the previous one: the conditions +/// [`compute_new_version`] acts on. The suffixes follow the usual +/// Debian/Ubuntu numbering conventions. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Bump { + /// Regular upload: increment the trailing number + Normal, + /// Ubuntu upload: `1.0-9` becomes `1.0-9ubuntu1` + Ubuntu, + /// Non-maintainer upload: `1.0-1` becomes `1.0-1.1`, native `1.0` + /// becomes `1.0+nmu1` + Nmu, + /// No-change rebuild: `1.0-1` becomes `1.0-1build1` + Rebuild, + /// Backport to the Debian release whose series number is carried + /// (`"12"` for bookworm): `1.0-1` becomes `1.0-1~bpo12+1` + Backport(String), +} + +/// Compute the next (most probable) version number of a package, from the +/// old version and the kind of upload the entry describes fn compute_new_version( old_version: &str, - is_ubuntu: bool, - is_rebuild: bool, - is_nmu: bool, + bump: Bump, ) -> Result> { - if is_ubuntu { - return increment_suffix(old_version, "ubuntu"); - } - if is_rebuild { - return increment_suffix(old_version, "build"); - } - if is_nmu { - if !old_version.contains('-') { - return increment_suffix(old_version, "+nmu"); - } else { - return increment_suffix(old_version, "."); + match bump { + Bump::Ubuntu => increment_suffix(old_version, "ubuntu"), + Bump::Rebuild => increment_suffix(old_version, "build"), + Bump::Nmu => { + if old_version.contains('-') { + increment_suffix(old_version, ".") + } else { + increment_suffix(old_version, "+nmu") + } } + // A re-backport of the same release reuses its `~bpoNN+M` counter, + // incrementing M: increment_suffix appends a fresh `~bpoNN+1` when + // the version carries no such suffix yet (including when it is a + // backport of another release, whose counter is left untouched) + Bump::Backport(number) => increment_suffix(old_version, &format!("~bpo{}+", number)), + Bump::Normal => increment_suffix(old_version, ""), } - increment_suffix(old_version, "") +} + +/// The Debian release number (e.g. `"12"` for bookworm) that backport +/// versions are numbered after (`~bpo12+1`), derived from the target series; +/// a backport suite name (`bookworm-backports`) is accepted too. Errors when +/// the series has no numeric Debian release number (sid, an Ubuntu series, +/// UNRELEASED, unknown): the numbering cannot be derived for it. +async fn backport_series_number(series: &str) -> Result> { + let base = series + .strip_suffix("-backports-sloppy") + .or_else(|| series.strip_suffix("-backports")) + .unwrap_or(series); + crate::distro_info::get_debian_series_number(base) + .await? + .filter(|number| !number.is_empty() && number.chars().all(|c| c.is_ascii_digit())) + .ok_or_else(|| { + format!( + "Could not determine the Debian release number of series '{series}', \ + needed to number the backport version (as in 1.0-1~bpo12+1). \ + Target a numbered Debian release (e.g. --series bookworm) \ + or pass the version explicitly with --version." + ) + .into() + }) } /// Increment a version number by 1, for a given suffix @@ -515,8 +594,8 @@ mod tests { .unwrap(); } - #[test] - fn test_generate_entry() { + #[tokio::test] + async fn test_generate_entry() { let temp_dir = TempDir::new().unwrap(); let repo_dir = temp_dir.path(); setup_repo(repo_dir); @@ -546,7 +625,15 @@ mod tests { std::env::set_var("DEBFULLNAME", "Maintainer Maintainer"); std::env::set_var("DEBEMAIL", "maintainer@maintainer.com"); } - generate_entry("debian/changelog", Some(repo_dir), None, None).unwrap(); + generate_entry( + "debian/changelog", + Some(repo_dir), + None, + None, + EntryKind::Normal, + ) + .await + .unwrap(); unsafe { std::env::remove_var("DEBFULLNAME"); std::env::remove_var("DEBEMAIL"); @@ -568,78 +655,87 @@ mod tests { fn test_compute_new_version() { // Debian upload assert_eq!( - compute_new_version("15.2.0-8", false, false, false).unwrap(), + compute_new_version("15.2.0-8", Bump::Normal).unwrap(), "15.2.0-9" ); assert_eq!( - compute_new_version("15.2.0-9", false, false, false).unwrap(), + compute_new_version("15.2.0-9", Bump::Normal).unwrap(), "15.2.0-10" ); // Ubuntu upload assert_eq!( - compute_new_version("15.2.0-9", true, false, false).unwrap(), + compute_new_version("15.2.0-9", Bump::Ubuntu).unwrap(), "15.2.0-9ubuntu1" ); assert_eq!( - compute_new_version("15.2.0-9ubuntu1", true, false, false).unwrap(), + compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(), "15.2.0-9ubuntu2" ); // No change rebuild assert_eq!( - compute_new_version("15.2.0-9", false, true, false).unwrap(), + compute_new_version("15.2.0-9", Bump::Rebuild).unwrap(), "15.2.0-9build1" ); assert_eq!( - compute_new_version("15.2.0-9build1", false, true, false).unwrap(), + compute_new_version("15.2.0-9build1", Bump::Rebuild).unwrap(), "15.2.0-9build2" ); // Rebuild of Ubuntu version assert_eq!( - compute_new_version("15.2.0-9ubuntu1", false, true, false).unwrap(), + compute_new_version("15.2.0-9ubuntu1", Bump::Rebuild).unwrap(), "15.2.0-9ubuntu1build1" ); // NMU // Native + assert_eq!(compute_new_version("1.0", Bump::Nmu).unwrap(), "1.0+nmu1"); assert_eq!( - compute_new_version("1.0", false, false, true).unwrap(), - "1.0+nmu1" - ); - assert_eq!( - compute_new_version("1.0+nmu1", false, false, true).unwrap(), + compute_new_version("1.0+nmu1", Bump::Nmu).unwrap(), "1.0+nmu2" ); // Non-native + assert_eq!(compute_new_version("1.0-1", Bump::Nmu).unwrap(), "1.0-1.1"); assert_eq!( - compute_new_version("1.0-1", false, false, true).unwrap(), - "1.0-1.1" - ); - assert_eq!( - compute_new_version("1.0-1.1", false, false, true).unwrap(), + compute_new_version("1.0-1.1", Bump::Nmu).unwrap(), "1.0-1.2" ); // NMU of NMU? assert_eq!( - compute_new_version("1.0-1.2", false, false, true).unwrap(), + compute_new_version("1.0-1.2", Bump::Nmu).unwrap(), "1.0-1.3" ); + // Backport + assert_eq!( + compute_new_version("1.0-1", Bump::Backport("12".to_string())).unwrap(), + "1.0-1~bpo12+1" + ); + // Re-backporting the same source reuses the counter + assert_eq!( + compute_new_version("1.0-1~bpo12+1", Bump::Backport("12".to_string())).unwrap(), + "1.0-1~bpo12+2" + ); + // Native packages backport too + assert_eq!( + compute_new_version("1.0", Bump::Backport("12".to_string())).unwrap(), + "1.0~bpo12+1" + ); + // A version carrying another release's counter gains a fresh one + assert_eq!( + compute_new_version("1.0-1~bpo11+1", Bump::Backport("12".to_string())).unwrap(), + "1.0-1~bpo11+1~bpo12+1" + ); + // Native package uploads + assert_eq!(compute_new_version("1.0", Bump::Normal).unwrap(), "1.1"); + assert_eq!(compute_new_version("1.0.5", Bump::Normal).unwrap(), "1.0.6"); assert_eq!( - compute_new_version("1.0", false, false, false).unwrap(), - "1.1" - ); - assert_eq!( - compute_new_version("1.0.5", false, false, false).unwrap(), - "1.0.6" - ); - assert_eq!( - compute_new_version("20241126", false, false, false).unwrap(), + compute_new_version("20241126", Bump::Normal).unwrap(), "20241127" ); } @@ -649,20 +745,89 @@ mod tests { // Date-based versions with a trailing number larger than u32::MAX // must increment normally (they fit in a u64) assert_eq!( - compute_new_version("1.0-20250123123456", false, false, false).unwrap(), + compute_new_version("1.0-20250123123456", Bump::Normal).unwrap(), "1.0-20250123123457" ); // A number that does not even fit in a u64 yields a clear error // instead of panicking - let err = compute_new_version("1.0-99999999999999999999999999", false, false, false); + let err = compute_new_version("1.0-99999999999999999999999999", Bump::Normal); assert!(err.is_err()); // u64::MAX itself cannot be incremented - let err = compute_new_version("1.0-18446744073709551615", false, false, false); + let err = compute_new_version("1.0-18446744073709551615", Bump::Normal); assert!(err.is_err()); } + /// A backport entry is numbered after the Debian release number of the + /// target series (`~bpo12+1` for bookworm). Relies on the host + /// distro-info data mapping bookworm to 12 (see distro_info tests). + #[tokio::test] + async fn test_generate_entry_backport_numbering() { + let temp_dir = TempDir::new().unwrap(); + let repo_dir = temp_dir.path(); + let changelog_path = repo_dir.join("debian/changelog"); + std::fs::create_dir_all(repo_dir.join("debian")).unwrap(); + std::fs::write( + &changelog_path, + "mypackage (1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer Wed, 01 Jan 2020 00:00:00 +0000\n", + ) + .unwrap(); + + unsafe { + std::env::set_var("DEBFULLNAME", "Maintainer Maintainer"); + std::env::set_var("DEBEMAIL", "maintainer@maintainer.com"); + } + let entry = generate_entry( + "debian/changelog", + Some(repo_dir), + None, + Some("bookworm"), + EntryKind::Backport, + ) + .await + .unwrap(); + unsafe { + std::env::remove_var("DEBFULLNAME"); + std::env::remove_var("DEBEMAIL"); + } + + assert_eq!(entry.new_version, "1.0-1~bpo12+1"); + assert_eq!(entry.series, "bookworm"); + let content = std::fs::read_to_string(&changelog_path).unwrap(); + assert!(content.contains("mypackage (1.0-1~bpo12+1) bookworm; urgency=medium")); + } + + /// Backport numbering needs a numeric Debian release number: series + /// without one (Ubuntu series, sid, ...) are rejected before anything + /// is written, pointing at --series/--version instead. + #[tokio::test] + async fn test_generate_entry_backport_requires_numbered_series() { + let temp_dir = TempDir::new().unwrap(); + let repo_dir = temp_dir.path(); + let changelog_path = repo_dir.join("debian/changelog"); + std::fs::create_dir_all(repo_dir.join("debian")).unwrap(); + std::fs::write( + &changelog_path, + "mypackage (1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer Wed, 01 Jan 2020 00:00:00 +0000\n", + ) + .unwrap(); + + let result = generate_entry( + "debian/changelog", + Some(repo_dir), + None, + Some("noble"), + EntryKind::Backport, + ) + .await; + assert!(result.is_err()); + + // Nothing was written: the changelog is untouched + let content = std::fs::read_to_string(&changelog_path).unwrap(); + assert!(content.starts_with("mypackage (1.0-1) unstable")); + } + #[test] fn test_get_maintainer_info() { // Test with env vars diff --git a/src/main.rs b/src/main.rs index 0f94e24..e59691f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,8 +7,6 @@ use pkh::context::ContextConfig; extern crate flate2; -use pkh::changelog::generate_entry; - use indicatif_log_bridge::LogWrapper; use log::{error, info}; @@ -178,8 +176,12 @@ fn main() { Command::new("chlog") .about("Auto-generate changelog entry, editing it, committing it afterwards") .arg(arg!(-s --series "Target distribution series").required(false)) - .arg(arg!(--backport "This changelog is for a backport entry").required(false)) - .arg(arg!(-v --version "Target version").required(false)), + .arg(arg!(--backport "Number the entry as a backport of the target series (1.0-1 becomes 1.0-1~bpo12+1)").required(false) + .conflicts_with_all(["nmu", "rebuild"])) + .arg(arg!(--nmu "Number the entry as a non-maintainer upload (1.0-1 becomes 1.0-1.1, native 1.0 becomes 1.0+nmu1)").required(false) + .conflicts_with("rebuild")) + .arg(arg!(--rebuild "Number the entry as a no-change rebuild (1.0-1 becomes 1.0-1build1)").required(false)) + .arg(arg!(-v --version "Target version (overrides the --backport/--nmu/--rebuild numbering)").required(false)), ) .subcommand( Command::new("build") @@ -389,6 +391,23 @@ fn main() { let cwd = current_dir_or_exit(); let version = sub_matches.get_one::("version").map(|s| s.as_str()); let cli_series = sub_matches.get_one::("series").map(|s| s.as_str()); + let kind = if sub_matches + .get_one::("backport") + .copied() + .unwrap_or(false) + { + pkh::changelog::EntryKind::Backport + } else if sub_matches.get_one::("nmu").copied().unwrap_or(false) { + pkh::changelog::EntryKind::Nmu + } else if sub_matches + .get_one::("rebuild") + .copied() + .unwrap_or(false) + { + pkh::changelog::EntryKind::Rebuild + } else { + pkh::changelog::EntryKind::Normal + }; // Determine target series: CLI flag > interactive selector > current changelog series let target_series = if let Some(s) = cli_series { @@ -418,12 +437,13 @@ fn main() { } }; - let entry = match generate_entry( + let entry = match rt.block_on(pkh::changelog::generate_entry( "debian/changelog", Some(&cwd), version, target_series.as_deref(), - ) { + kind, + )) { Ok(entry) => entry, Err(e) => { error!("{}", e); @@ -434,6 +454,7 @@ fn main() { "Found package: {}, version: {}", entry.package, entry.previous_version ); + println!("New version: {}", entry.new_version); println!("Added new changelog entry to {}", entry.path.display()); let editor = match std::env::var("EDITOR") {