An Ubuntu upload of a package sitting at X-2build1 produced X-2build1ubuntu1: the blind append misrepresents the lineage and, sorting below the proper X-2ubuntu1, could never supersede it. A real change on top of a rebuild replaces the marker instead, so the trailing buildN is now stripped before the ubuntu counter is appended or incremented: X-2build1 becomes X-2ubuntu1, X-2ubuntu1build1 becomes X-2ubuntu2.
1480 lines
55 KiB
Rust
1480 lines
55 KiB
Rust
use chrono::Local;
|
|
use git2::{Oid, Repository, Sort};
|
|
use regex::Regex;
|
|
use std::fs::File;
|
|
use std::io::{Read, Write};
|
|
use std::path::Path;
|
|
|
|
/// Outcome of a successful [`generate_entry`] call: everything the CLI
|
|
/// renders for the user, and everything a library consumer needs to chain
|
|
/// further steps.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct GeneratedEntry {
|
|
/// Source package name from the previous changelog entry.
|
|
pub package: String,
|
|
/// Version the changelog carried before the new entry was prepended.
|
|
pub previous_version: String,
|
|
/// Version of the freshly added entry.
|
|
pub new_version: String,
|
|
/// Distribution series the new entry targets.
|
|
pub series: String,
|
|
/// The changelog file that was updated.
|
|
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 version numbering follows the vendor of the
|
|
/// target series — the Debian convention (`1.0-1` becomes `1.0-2`) for
|
|
/// Debian series, the Ubuntu one for Ubuntu series (`1.0-1` becomes
|
|
/// `1.0-1ubuntu1`, an already-Ubuntu `1.0-1ubuntu1` becomes
|
|
/// `1.0-1ubuntu2`). Unresolvable series (UNRELEASED, unknown) number
|
|
/// the Debian way.
|
|
#[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 regardless of the target series: `1.0-1` becomes
|
|
/// `1.0-1ubuntu1`. Library-only: the CLI has no flag selecting it,
|
|
/// and targeting an Ubuntu series already picks this numbering.
|
|
Ubuntu,
|
|
/// A backport: numbered after the vendor of the target series —
|
|
/// Debian's backports scheme (`1.0-1` becomes `1.0-1~bpo12+1`) or
|
|
/// Ubuntu's per-release SRU scheme (`3.1-1ubuntu2` becomes
|
|
/// `3.1-1ubuntu2~24.04.1`). The release number is derived from the
|
|
/// target series, which must therefore be a numbered release (e.g.
|
|
/// not sid).
|
|
Backport,
|
|
}
|
|
|
|
/// Automatically generate a changelog entry from a commit history and previous changelog
|
|
pub async fn generate_entry(
|
|
changelog_file: &str,
|
|
cwd: Option<&Path>,
|
|
user_version: Option<&str>,
|
|
target_series: Option<&str>,
|
|
kind: EntryKind,
|
|
) -> Result<GeneratedEntry, Box<dyn std::error::Error>> {
|
|
let changelog_path = if let Some(path) = cwd {
|
|
path.join(changelog_file)
|
|
} else {
|
|
Path::new(changelog_file).to_path_buf()
|
|
};
|
|
|
|
// Parse existing changelog to get current (old) version
|
|
let (package, old_version, current_series) = parse_changelog_header(&changelog_path)?;
|
|
|
|
// Open git repo, and find commits since last version tag
|
|
let repo_path = if let Some(path) = cwd {
|
|
path.to_path_buf()
|
|
} else {
|
|
std::env::current_dir()?
|
|
};
|
|
let commits = match Repository::open(&repo_path) {
|
|
Ok(repo) => {
|
|
let repo_changelog = changelog_in_repo(&repo, &changelog_path);
|
|
get_commits_since_version(&repo, &old_version, repo_changelog.as_deref())?
|
|
}
|
|
// If there is no git repo (e.g. package downloaded from archive),
|
|
// we just generate an empty list of changes
|
|
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 {
|
|
match kind {
|
|
EntryKind::Normal => {
|
|
let bump = normal_bump_for_series(&series).await;
|
|
compute_new_version(&old_version, bump)?
|
|
}
|
|
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 suffix = backport_suffix_for_series(&series).await?;
|
|
compute_new_version(&old_version, Bump::Backport(suffix))?
|
|
}
|
|
}
|
|
};
|
|
|
|
let (maintainer_name, maintainer_email) = get_maintainer_info()?;
|
|
let new_entry = format_entry(
|
|
&package,
|
|
&new_version,
|
|
&series,
|
|
&commits,
|
|
&maintainer_name,
|
|
&maintainer_email,
|
|
);
|
|
|
|
prepend_to_file(&changelog_path, &new_entry)?;
|
|
|
|
Ok(GeneratedEntry {
|
|
package,
|
|
previous_version: old_version,
|
|
new_version,
|
|
series,
|
|
path: changelog_path,
|
|
})
|
|
}
|
|
|
|
/// 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`; a trailing
|
|
/// no-change-rebuild marker is dropped first (`1.0-9build1` 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 release whose version suffix is carried
|
|
/// (`"~bpo12+"` for Debian bookworm, `"~24.04."` for Ubuntu noble):
|
|
/// `1.0-1` becomes `1.0-1~bpo12+1`, `3.1-1ubuntu2` becomes
|
|
/// `3.1-1ubuntu2~24.04.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,
|
|
bump: Bump,
|
|
) -> Result<String, Box<dyn std::error::Error>> {
|
|
match bump {
|
|
Bump::Ubuntu => increment_suffix(strip_build_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 suffix counter,
|
|
// incrementing the trailing number: increment_suffix appends the
|
|
// suffix with a fresh 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(suffix) => increment_suffix(old_version, &suffix),
|
|
Bump::Normal => increment_suffix(old_version, ""),
|
|
}
|
|
}
|
|
|
|
/// The version an Ubuntu upload is numbered from: a trailing
|
|
/// no-change-rebuild marker is dropped, because a real change on top of a
|
|
/// rebuild replaces the marker rather than appending to it — `X-2build1`
|
|
/// becomes `X-2ubuntu1`, where an appended `X-2build1ubuntu1` would
|
|
/// misrepresent the lineage and sort below `X-2ubuntu1`
|
|
fn strip_build_suffix(version: &str) -> &str {
|
|
let stem = version.trim_end_matches(|c: char| c.is_ascii_digit());
|
|
match stem.strip_suffix("build") {
|
|
Some(base) if stem.len() < version.len() => &version[..base.len()],
|
|
_ => version,
|
|
}
|
|
}
|
|
|
|
/// The version bump a regular ([`EntryKind::Normal`]) upload gets, derived
|
|
/// from the vendor of the target series: Ubuntu series number their uploads
|
|
/// the Ubuntu way (`1.0-1` becomes `1.0-1ubuntu1`), everything else —
|
|
/// Debian series, but also UNRELEASED and series no distro-info data knows —
|
|
/// numbers the Debian way (`1.0-1` becomes `1.0-2`).
|
|
async fn normal_bump_for_series(series: &str) -> Bump {
|
|
match crate::distro_info::get_dist_from_series(series).await {
|
|
Ok(dist) if dist == "ubuntu" => Bump::Ubuntu,
|
|
_ => Bump::Normal,
|
|
}
|
|
}
|
|
|
|
/// The version suffix that backport versions are numbered with, derived
|
|
/// from the vendor of the target series: Debian backports follow the
|
|
/// backports.debian.org scheme (`~bpo12+1`), Ubuntu backports the
|
|
/// per-release SRU scheme of the Ubuntu version-strings documentation
|
|
/// (`3.1-1ubuntu2~22.04.1` for a development version backported to 22.04).
|
|
/// A backport suite name (`bookworm-backports`) is accepted too. Errors
|
|
/// when the series has no usable release number (Debian sid or
|
|
/// experimental, unknown series): the numbering cannot be derived for it.
|
|
async fn backport_suffix_for_series(series: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
let base = series
|
|
.strip_suffix("-backports-sloppy")
|
|
.or_else(|| series.strip_suffix("-backports"))
|
|
.unwrap_or(series);
|
|
let unnumbered = || {
|
|
format!(
|
|
"Could not determine the release number of series '{series}', \
|
|
needed to number the backport version (Debian ~bpo12+1, \
|
|
Ubuntu ~24.04.1). Target a released Debian or Ubuntu series \
|
|
or pass the version explicitly with --version."
|
|
)
|
|
};
|
|
let Some((dist, number)) = crate::distro_info::get_series_release_number(base).await? else {
|
|
return Err(unnumbered().into());
|
|
};
|
|
match dist.as_str() {
|
|
// backports.debian.org numbers with the plain integer release number
|
|
"debian" if !number.is_empty() && number.chars().all(|c| c.is_ascii_digit()) => {
|
|
Ok(format!("~bpo{}+", number))
|
|
}
|
|
// Ubuntu numbers per release as YY.MM; the trailing `.N` counts
|
|
// the per-release SRU uploads
|
|
"ubuntu"
|
|
if number.split('.').count() == 2
|
|
&& number
|
|
.split('.')
|
|
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit())) =>
|
|
{
|
|
Ok(format!("~{}.", number))
|
|
}
|
|
_ => Err(unnumbered().into()),
|
|
}
|
|
}
|
|
|
|
/// Increment a version number by 1, for a given suffix
|
|
fn increment_suffix(version: &str, suffix: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
// If suffix is empty, we just look for trailing digits
|
|
// If suffix is not empty, we look for suffix followed by digits
|
|
|
|
let pattern = if suffix.is_empty() {
|
|
r"(\d+)$".to_string()
|
|
} else {
|
|
format!(r"{}(\d+)$", regex::escape(suffix))
|
|
};
|
|
|
|
let re = Regex::new(&pattern).unwrap();
|
|
|
|
if let Some(caps) = re.captures(version) {
|
|
let num_str = caps.get(1).unwrap().as_str();
|
|
// Parse as u64 so that large trailing numbers (e.g. date-based
|
|
// versions like '1.0-20250123123456', which do not fit in a u32)
|
|
// still increment normally
|
|
let num: u64 = num_str.parse().map_err(|_| {
|
|
format!(
|
|
"Cannot increment version '{version}': trailing number '{num_str}' \
|
|
is too large to be incremented. Specify a version explicitly instead."
|
|
)
|
|
})?;
|
|
let range = caps.get(1).unwrap().range();
|
|
let new_num = num.checked_add(1).ok_or_else(|| {
|
|
format!(
|
|
"Cannot increment version '{version}': trailing number {num} \
|
|
is too large to be incremented. Specify a version explicitly instead."
|
|
)
|
|
})?;
|
|
let mut new_ver = version.to_string();
|
|
new_ver.replace_range(range, &new_num.to_string());
|
|
return Ok(new_ver);
|
|
}
|
|
|
|
// If pattern not found, append suffix + "1"
|
|
// But if suffix is empty, we default to appending "-1" (standard Debian revision start)
|
|
if suffix.is_empty() {
|
|
Ok(format!("{}-1", version))
|
|
} else {
|
|
Ok(format!("{}{}{}", version, suffix, 1))
|
|
}
|
|
}
|
|
|
|
/// Parse a changelog file first entry header
|
|
/// Returns (package, version, series) tuple from the last modification entry
|
|
pub fn parse_changelog_header(
|
|
path: &Path,
|
|
) -> Result<(String, String, String), Box<dyn std::error::Error>> {
|
|
let entry = crate::debian::parse_changelog_entry(path)?;
|
|
Ok((entry.source, entry.version.full(), entry.distribution))
|
|
}
|
|
|
|
/// What a new changelog entry may target as series, derived from the
|
|
/// current changelog ([`series_candidates`]).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SeriesCandidates {
|
|
/// Offer `options` with `default` preselected; when the selection
|
|
/// cannot be made (cancelled, no interactive user) `fallback` — the
|
|
/// changelog's current series — is used instead.
|
|
Choose {
|
|
/// Selector labels: each series name, or `<suite> (<series>)`
|
|
/// for a series aliased by a changelog suite name (Debian's
|
|
/// 'unstable (sid)').
|
|
options: Vec<String>,
|
|
/// Changelog distribution each label of `options` maps to,
|
|
/// parallel to it: an aliased label selects its suite name —
|
|
/// what a changelog distribution field expects — while every
|
|
/// other label selects itself.
|
|
values: Vec<String>,
|
|
/// Preselected label.
|
|
default: String,
|
|
/// Series to fall back to when nothing can be selected.
|
|
fallback: String,
|
|
},
|
|
/// Nothing to choose: the series list was unavailable, keep the
|
|
/// current series.
|
|
Keep(String),
|
|
}
|
|
|
|
/// Derive the candidate series for a new changelog entry from the changelog
|
|
/// at `changelog_path`.
|
|
///
|
|
/// An UNRELEASED entry offers itself as a pinned first option (selecting it
|
|
/// keeps the changelog unreleased) on top of the current vendor's series
|
|
/// list, defaulting to the development series; any other series resolves
|
|
/// through the series list of its own distribution. A changelog suite name
|
|
/// (Debian's 'unstable') identifies the same series as its alias's codename
|
|
/// ('sid') and resolves to that dist's list. `None` when the changelog
|
|
/// cannot be parsed (no default to derive at all).
|
|
pub async fn series_candidates(changelog_path: &Path) -> Option<SeriesCandidates> {
|
|
let (_package, _version, current) = parse_changelog_header(changelog_path).ok()?;
|
|
|
|
if crate::distro_info::is_unreleased(¤t) {
|
|
// Vendors keep original casing ("Ubuntu"), while the series data
|
|
// keys are lowercase
|
|
let dist = crate::build::env::current_vendor().to_lowercase();
|
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
|
Ok(series_list) => {
|
|
let (labels, series_values, _) = selector_options(&dist, &series_list, "");
|
|
let mut options = vec![crate::distro_info::UNRELEASED.to_string()];
|
|
let mut values = vec![crate::distro_info::UNRELEASED.to_string()];
|
|
options.extend(labels);
|
|
values.extend(series_values);
|
|
// Default to the development series (the first real entry),
|
|
// not to the pinned UNRELEASED entry itself
|
|
let default = if options.len() > 1 {
|
|
options[1].clone()
|
|
} else {
|
|
current.clone()
|
|
};
|
|
Some(SeriesCandidates::Choose {
|
|
options,
|
|
values,
|
|
default,
|
|
fallback: current,
|
|
})
|
|
}
|
|
Err(_) => Some(SeriesCandidates::Keep(current)),
|
|
}
|
|
} else {
|
|
// The changelog may target a suite name instead of a series
|
|
// codename: Debian conventionally writes 'unstable' where the
|
|
// series data carries 'sid'. The two identify the same series:
|
|
// the alias resolves to its codename's dist for the lookup.
|
|
let resolved = match crate::distro_info::get_dist_from_series(¤t).await {
|
|
Ok(dist) => Some((dist, current.clone())),
|
|
Err(_) => crate::distro_info::resolve_suite_alias(¤t),
|
|
};
|
|
match resolved {
|
|
Some((dist, canonical)) => {
|
|
match crate::distro_info::get_ordered_series_name(&dist).await {
|
|
// Even an empty list goes through the selector: its
|
|
// fallback prints and takes the default, like it always has
|
|
Ok(series_list) => {
|
|
let (options, values, default) =
|
|
selector_options(&dist, &series_list, &canonical);
|
|
Some(SeriesCandidates::Choose {
|
|
options,
|
|
values,
|
|
// A stale alias whose codename left the series
|
|
// list offers the raw name instead
|
|
default: default.unwrap_or_else(|| canonical.clone()),
|
|
fallback: current,
|
|
})
|
|
}
|
|
Err(_) => Some(SeriesCandidates::Keep(current)),
|
|
}
|
|
}
|
|
None => Some(SeriesCandidates::Keep(current)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The selector entries for a dist's series list: (labels, changelog
|
|
/// targets, label of `current`'s entry). A series aliased by a
|
|
/// changelog suite name (Debian's 'unstable' for 'sid') is offered as
|
|
/// `<suite> (<series>)` but targets the suite name — what a changelog
|
|
/// distribution field expects — while every other series is offered,
|
|
/// and targeted, under its own name. `current` may be a name that is
|
|
/// not in the list at all (e.g. UNRELEASED), in which case no default
|
|
/// is returned.
|
|
fn selector_options(
|
|
dist: &str,
|
|
series: &[String],
|
|
current: &str,
|
|
) -> (Vec<String>, Vec<String>, Option<String>) {
|
|
let mut labels = Vec::with_capacity(series.len());
|
|
let mut values = Vec::with_capacity(series.len());
|
|
let mut default = None;
|
|
for s in series {
|
|
let (label, value) = match crate::distro_info::series_suite_alias(dist, s) {
|
|
Some(suite) => (format!("{suite} ({s})"), suite),
|
|
None => (s.clone(), s.clone()),
|
|
};
|
|
if s == current {
|
|
default = Some(label.clone());
|
|
}
|
|
labels.push(label);
|
|
values.push(value);
|
|
}
|
|
(labels, values, default)
|
|
}
|
|
|
|
/// The changelog distribution a selected series-selector label maps to
|
|
/// ([`SeriesCandidates::Choose`]): an aliased entry ('unstable (sid)')
|
|
/// targets its suite name, any other label targets itself, and a
|
|
/// free-typed series the selector does not offer is its own target.
|
|
pub fn selected_series(options: &[String], values: &[String], selected: String) -> String {
|
|
options
|
|
.iter()
|
|
.position(|o| *o == selected)
|
|
.and_then(|idx| values.get(idx).cloned())
|
|
.unwrap_or(selected)
|
|
}
|
|
|
|
/// Parse a changelog file footer to extract maintainer information
|
|
/// Returns (name, email) tuple from the last modification entry
|
|
pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn std::error::Error>> {
|
|
let entry = crate::debian::parse_changelog_entry(path)?;
|
|
Ok((entry.maintainer_name, entry.maintainer_email))
|
|
}
|
|
|
|
/*
|
|
* Obtain all commit messages as a list since the previous release, to
|
|
* format into a changelog entry
|
|
*/
|
|
fn get_commits_since_version(
|
|
repo: &Repository,
|
|
version: &str,
|
|
changelog_path: Option<&Path>,
|
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|
// Preferred boundary: the tag of the current (previous) version
|
|
if let Some(tag_id) = find_version_tag(repo, version) {
|
|
return commit_summaries_since(repo, tag_id);
|
|
}
|
|
|
|
// No tag: fall back to the last commit that modified the changelog
|
|
// itself, whose commit introduces the previous entry. Its changes
|
|
// since then are the new entry's content — so entries stay correct in
|
|
// repositories that commit their changelogs without tagging them.
|
|
let Some(rel_path) = changelog_path else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let Some(boundary) = last_commit_touching(repo, rel_path)? else {
|
|
// The changelog was never committed; nothing to walk from
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
// Prefer a tag of the version the committed changelog carries: it may
|
|
// point past the changelog commit itself (e.g. a later release commit)
|
|
let boundary_id = version_at_commit(repo, &boundary, rel_path)
|
|
.and_then(|committed_version| find_version_tag(repo, &committed_version))
|
|
.unwrap_or_else(|| boundary.id());
|
|
commit_summaries_since(repo, boundary_id)
|
|
}
|
|
|
|
/// The commit names a Debian version may be tagged under in a repository
|
|
fn version_tag_names(version: &str) -> Vec<String> {
|
|
vec![
|
|
format!("debian/{}", version),
|
|
format!("debian/v{}", version),
|
|
format!("v{}", version),
|
|
version.to_string(),
|
|
]
|
|
}
|
|
|
|
/// Find the commit a version tag points at, probing the usual tag formats
|
|
fn find_version_tag(repo: &Repository, version: &str) -> Option<Oid> {
|
|
for tag_name in version_tag_names(version) {
|
|
if let Ok(r) = repo.revparse_single(&tag_name) {
|
|
// A matching name resolves either to a commit (lightweight
|
|
// tag) or to a tag object (annotated tag)
|
|
if let Some(commit) = r.as_commit() {
|
|
return Some(commit.id());
|
|
} else if let Some(tag) = r.as_tag() {
|
|
return Some(tag.target_id());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The commit summaries from HEAD down to (excluding) `boundary`
|
|
fn commit_summaries_since(
|
|
repo: &Repository,
|
|
boundary: Oid,
|
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
|
let mut revwalk = repo.revwalk()?;
|
|
revwalk.set_sorting(Sort::TIME)?;
|
|
revwalk.push_head()?;
|
|
revwalk.hide(boundary)?;
|
|
|
|
let mut commits = Vec::new();
|
|
for id in revwalk {
|
|
let id = id?;
|
|
let commit = repo.find_commit(id)?;
|
|
let message = commit.message().unwrap_or("").trim();
|
|
let summary = message.lines().next().unwrap_or("").to_string();
|
|
if !summary.is_empty() {
|
|
commits.push(summary);
|
|
}
|
|
}
|
|
|
|
Ok(commits)
|
|
}
|
|
|
|
/// The changelog path relative to the repository root, used to walk the
|
|
/// history of the file. `None` when the changelog lives outside the
|
|
/// repository working directory.
|
|
fn changelog_in_repo(repo: &Repository, changelog_path: &Path) -> Option<std::path::PathBuf> {
|
|
let workdir = repo.workdir()?.canonicalize().ok()?;
|
|
let absolute = if changelog_path.is_absolute() {
|
|
changelog_path.to_path_buf()
|
|
} else {
|
|
std::env::current_dir().ok()?.join(changelog_path)
|
|
};
|
|
let absolute = absolute.canonicalize().ok()?;
|
|
absolute
|
|
.strip_prefix(&workdir)
|
|
.ok()
|
|
.map(|p| p.to_path_buf())
|
|
}
|
|
|
|
/// The last commit that modified the file at `rel_path`, newest first
|
|
fn last_commit_touching<'a>(
|
|
repo: &'a Repository,
|
|
rel_path: &Path,
|
|
) -> Result<Option<git2::Commit<'a>>, Box<dyn std::error::Error>> {
|
|
let mut revwalk = repo.revwalk()?;
|
|
revwalk.set_sorting(Sort::TIME)?;
|
|
if repo.head().is_err() {
|
|
// Unborn HEAD: the repository has no commits yet
|
|
return Ok(None);
|
|
}
|
|
revwalk.push_head()?;
|
|
|
|
for id in revwalk {
|
|
let commit = repo.find_commit(id?)?;
|
|
if commit_touches_path(repo, &commit, rel_path)? {
|
|
return Ok(Some(commit));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
/// Whether `commit` modified the file at `rel_path` relative to its first
|
|
/// parent (a root commit counts when it adds the file)
|
|
fn commit_touches_path(
|
|
repo: &Repository,
|
|
commit: &git2::Commit<'_>,
|
|
rel_path: &Path,
|
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
|
let new_tree = commit.tree()?;
|
|
let parent_tree = if commit.parent_count() > 0 {
|
|
Some(commit.parent(0)?.tree()?)
|
|
} else {
|
|
None
|
|
};
|
|
let mut opts = git2::DiffOptions::new();
|
|
opts.pathspec(rel_path.to_string_lossy().to_string());
|
|
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
|
|
Ok(diff.deltas().len() > 0)
|
|
}
|
|
|
|
/// The version recorded by the changelog content as of `commit`
|
|
fn version_at_commit(
|
|
repo: &Repository,
|
|
commit: &git2::Commit<'_>,
|
|
rel_path: &Path,
|
|
) -> Option<String> {
|
|
let blob = commit
|
|
.tree()
|
|
.ok()?
|
|
.get_path(rel_path)
|
|
.ok()
|
|
.and_then(|entry| repo.find_blob(entry.id()).ok())?;
|
|
let content = std::str::from_utf8(blob.content()).ok()?;
|
|
let entry = crate::debian::parse_changelog_entry_from_str(content).ok()?;
|
|
Some(entry.version.full())
|
|
}
|
|
|
|
/*
|
|
* Create a changelog entry from information, i.e. format that information
|
|
* into a changelog entry
|
|
*/
|
|
fn format_entry(
|
|
package: &str,
|
|
version: &str,
|
|
series: &str,
|
|
changes: &[String],
|
|
maintainer_name: &str,
|
|
maintainer_email: &str,
|
|
) -> String {
|
|
let mut entry = String::new();
|
|
|
|
// Header: package, version and distribution series
|
|
entry.push_str(&format!(
|
|
"{} ({}) {}; urgency=medium\n\n",
|
|
package, version, series
|
|
));
|
|
|
|
// Changes
|
|
for change in changes {
|
|
entry.push_str(&format!(" * {}\n", change));
|
|
}
|
|
if changes.is_empty() {
|
|
entry.push_str(" * \n");
|
|
}
|
|
|
|
// Footer: date, maintainer
|
|
let date = Local::now().format("%a, %d %b %Y %H:%M:%S %z").to_string();
|
|
entry.push_str(&format!(
|
|
"\n -- {} <{}> {}\n\n",
|
|
maintainer_name, maintainer_email, date
|
|
));
|
|
|
|
entry
|
|
}
|
|
|
|
/*
|
|
* Add content to the beginning of a file
|
|
*/
|
|
fn prepend_to_file(path: &Path, content: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut file = File::open(path)?;
|
|
let mut existing_content = String::new();
|
|
file.read_to_string(&mut existing_content)?;
|
|
|
|
let mut file = File::create(path)?;
|
|
file.write_all(content.as_bytes())?;
|
|
file.write_all(existing_content.as_bytes())?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Discover the maintainer identity for changelog entries and package
|
|
/// scaffolding: `$DEBFULLNAME`/`$DEBEMAIL` when both are set, else the git
|
|
/// configuration (`user.name`/`user.email`).
|
|
///
|
|
/// Returns `(name, email)`, with a pointed error telling the user how to
|
|
/// configure the missing piece.
|
|
pub fn get_maintainer_info() -> Result<(String, String), Box<dyn std::error::Error>> {
|
|
// From environment variables
|
|
if let (Ok(name), Ok(email)) = (std::env::var("DEBFULLNAME"), std::env::var("DEBEMAIL")) {
|
|
return Ok((name, email));
|
|
}
|
|
|
|
// From git config
|
|
let config = git2::Config::open_default().map_err(|e| {
|
|
format!(
|
|
"Could not determine maintainer information. \
|
|
Neither $DEBFULLNAME/$DEBEMAIL nor git configuration is available: {}. \
|
|
Set the DEBFULLNAME and DEBEMAIL environment variables, \
|
|
or configure git with `git config --global user.name` and `git config --global user.email`.",
|
|
e
|
|
)
|
|
})?;
|
|
let name = config.get_string("user.name").map_err(|e| {
|
|
format!(
|
|
"Could not find git 'user.name' configuration: {}. \
|
|
Set it with `git config --global user.name \"Your Name\"` \
|
|
or define the DEBFULLNAME environment variable.",
|
|
e
|
|
)
|
|
})?;
|
|
let email = config.get_string("user.email").map_err(|e| {
|
|
format!(
|
|
"Could not find git 'user.email' configuration: {}. \
|
|
Set it with `git config --global user.email \"you@example.com\"` \
|
|
or define the DEBEMAIL environment variable.",
|
|
e
|
|
)
|
|
})?;
|
|
Ok((name, email))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::process::Command;
|
|
use tempfile::TempDir;
|
|
|
|
/// An UNRELEASED changelog offers UNRELEASED pinned first, the
|
|
/// development series as the default, and itself as the fallback.
|
|
#[tokio::test]
|
|
async fn series_candidates_unreleased_pins_entry_and_defaults_to_dev() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("changelog");
|
|
std::fs::write(
|
|
&path,
|
|
"hello (1.0-1) UNRELEASED; urgency=medium\n\n * Something.\n\n \
|
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
match series_candidates(&path).await {
|
|
Some(SeriesCandidates::Choose {
|
|
options,
|
|
values,
|
|
default,
|
|
fallback,
|
|
}) => {
|
|
assert_eq!(options[0], "UNRELEASED");
|
|
assert_eq!(values[0], "UNRELEASED");
|
|
assert!(options.len() > 1, "the vendor series list is offered");
|
|
assert_eq!(default, options[1]);
|
|
assert_eq!(
|
|
selected_series(&options, &values, default.clone()),
|
|
values[1]
|
|
);
|
|
assert_eq!(fallback, "UNRELEASED");
|
|
}
|
|
other => panic!("expected Choose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A released changelog offers its distribution's series with the
|
|
/// current one preselected. Uses a series of the host vendor so the
|
|
/// test only relies on the local distro-info data.
|
|
#[tokio::test]
|
|
async fn series_candidates_released_defaults_to_current_series() {
|
|
let dist = crate::build::env::current_vendor().to_lowercase();
|
|
let vendor_series = crate::distro_info::get_ordered_series_name(&dist)
|
|
.await
|
|
.expect("the host vendor's series data resolves");
|
|
// Any released series of the vendor works; the changelog names it.
|
|
let current = vendor_series.last().expect("non-empty series list");
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("changelog");
|
|
std::fs::write(
|
|
&path,
|
|
format!(
|
|
"hello (1.0-1) {current}; urgency=medium\n\n * Something.\n\n \
|
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
match series_candidates(&path).await {
|
|
Some(SeriesCandidates::Choose {
|
|
options,
|
|
values,
|
|
default,
|
|
fallback,
|
|
}) => {
|
|
// The current series is preselected through its label, and
|
|
// selecting it targets the name the changelog already carries
|
|
let idx = values
|
|
.iter()
|
|
.position(|v| v == current)
|
|
.expect("the current series is offered");
|
|
assert_eq!(default, options[idx]);
|
|
assert_eq!(
|
|
selected_series(&options, &values, options[idx].clone()),
|
|
*current
|
|
);
|
|
assert_eq!(fallback, *current);
|
|
}
|
|
other => panic!("expected Choose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A changelog targeting Debian's 'unstable' suite — the conventional
|
|
/// Debian development distribution, which the series data knows as the
|
|
/// codename 'sid' — resolves to the Debian series list: the selector
|
|
/// offers the aliased entry as 'unstable (sid)', preselected, and
|
|
/// selecting it targets 'unstable' itself.
|
|
#[tokio::test]
|
|
async fn series_candidates_suite_alias_matches_unstable_and_sid() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("changelog");
|
|
std::fs::write(
|
|
&path,
|
|
"hello (1.0-1) unstable; urgency=medium\n\n * Something.\n\n \
|
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
match series_candidates(&path).await {
|
|
Some(SeriesCandidates::Choose {
|
|
options,
|
|
values,
|
|
default,
|
|
fallback,
|
|
}) => {
|
|
let idx = options
|
|
.iter()
|
|
.position(|o| o == "unstable (sid)")
|
|
.expect("sid is offered as its suite alias");
|
|
assert_eq!(values[idx], "unstable");
|
|
assert_eq!(default, "unstable (sid)");
|
|
assert_eq!(fallback, "unstable");
|
|
// Selecting the aliased entry targets the suite name
|
|
assert_eq!(
|
|
selected_series(&options, &values, options[idx].clone()),
|
|
"unstable"
|
|
);
|
|
// A free-typed series the selector does not offer is its own
|
|
// target
|
|
assert_eq!(
|
|
selected_series(&options, &values, "trixie".to_string()),
|
|
"trixie"
|
|
);
|
|
}
|
|
other => panic!("expected Choose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A changelog naming the codename ('sid') resolves to the same
|
|
/// selector entry as the suite alias ('unstable'): the two identify
|
|
/// the same series.
|
|
#[tokio::test]
|
|
async fn series_candidates_sid_defaults_to_the_suite_alias_label() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("changelog");
|
|
std::fs::write(
|
|
&path,
|
|
"hello (1.0-1) sid; urgency=medium\n\n * Something.\n\n \
|
|
-- A B <a@b.c> Mon, 01 Jan 2024 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
match series_candidates(&path).await {
|
|
Some(SeriesCandidates::Choose {
|
|
options,
|
|
values,
|
|
default,
|
|
fallback,
|
|
}) => {
|
|
assert_eq!(default, "unstable (sid)");
|
|
assert_eq!(
|
|
selected_series(&options, &values, default.clone()),
|
|
"unstable"
|
|
);
|
|
assert_eq!(fallback, "sid");
|
|
}
|
|
other => panic!("expected Choose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Without a parsable changelog there is no candidate at all.
|
|
#[tokio::test]
|
|
async fn series_candidates_none_without_changelog() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
assert!(
|
|
series_candidates(&dir.path().join("changelog"))
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
/// Serializes the tests that mutate the process-global DEBFULLNAME /
|
|
/// DEBEMAIL variables: run in parallel, they otherwise race each
|
|
/// other's identity reads and assertions.
|
|
static IDENTITY_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
|
|
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
|
|
|
|
fn setup_repo(dir: &Path) {
|
|
Command::new("git")
|
|
.arg("init")
|
|
.current_dir(dir)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.arg("config")
|
|
.arg("user.email")
|
|
.arg("you@example.com")
|
|
.current_dir(dir)
|
|
.output()
|
|
.unwrap();
|
|
Command::new("git")
|
|
.arg("config")
|
|
.arg("user.name")
|
|
.arg("Your Name")
|
|
.current_dir(dir)
|
|
.output()
|
|
.unwrap();
|
|
}
|
|
|
|
fn commit(dir: &Path, message: &str) {
|
|
Command::new("git")
|
|
.arg("commit")
|
|
.arg("--allow-empty")
|
|
.arg("-m")
|
|
.arg(message)
|
|
.current_dir(dir)
|
|
.output()
|
|
.unwrap();
|
|
}
|
|
|
|
fn tag(dir: &Path, name: &str) {
|
|
Command::new("git")
|
|
.arg("tag")
|
|
.arg(name)
|
|
.current_dir(dir)
|
|
.output()
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_generate_entry() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let repo_dir = temp_dir.path();
|
|
setup_repo(repo_dir);
|
|
|
|
// Create initial changelog
|
|
let changelog_path = repo_dir.join("debian/changelog");
|
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
|
let initial_content = "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n";
|
|
std::fs::write(&changelog_path, initial_content).unwrap();
|
|
|
|
// Commit and tag
|
|
Command::new("git")
|
|
.arg("add")
|
|
.arg(".")
|
|
.current_dir(repo_dir)
|
|
.output()
|
|
.unwrap();
|
|
commit(repo_dir, "Initial commit");
|
|
tag(repo_dir, "debian/0.1.0-1");
|
|
|
|
// New commits
|
|
commit(repo_dir, "Fix bug A");
|
|
commit(repo_dir, "Add feature B");
|
|
|
|
// Generate entry
|
|
let _identity = IDENTITY_LOCK.lock().await;
|
|
unsafe {
|
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
|
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
|
}
|
|
generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
None,
|
|
EntryKind::Normal,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
unsafe {
|
|
std::env::remove_var("DEBFULLNAME");
|
|
std::env::remove_var("DEBEMAIL");
|
|
}
|
|
|
|
// Verify content
|
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
|
println!("{}", content);
|
|
|
|
assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium"));
|
|
assert!(content.contains("* Fix bug A"));
|
|
assert!(content.contains("* Add feature B"));
|
|
assert!(content.contains(" -- Maintainer Maintainer <maintainer@maintainer.com> "));
|
|
// Should still contain old content
|
|
assert!(content.contains("mypackage (0.1.0-1) unstable; urgency=medium"));
|
|
}
|
|
|
|
/// Without any version tag, the history of the changelog itself
|
|
/// delimits the entry: the commits since the changelog was last
|
|
/// committed are the changes, and the changelog commit is not one.
|
|
#[tokio::test]
|
|
async fn test_generate_entry_without_tags() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let repo_dir = temp_dir.path();
|
|
setup_repo(repo_dir);
|
|
|
|
let changelog_path = repo_dir.join("debian/changelog");
|
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
|
let initial_content = "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n";
|
|
std::fs::write(&changelog_path, initial_content).unwrap();
|
|
|
|
// Commit the changelog, but do not tag it
|
|
Command::new("git")
|
|
.arg("add")
|
|
.arg(".")
|
|
.current_dir(repo_dir)
|
|
.output()
|
|
.unwrap();
|
|
commit(repo_dir, "Initial commit");
|
|
|
|
commit(repo_dir, "Fix bug A");
|
|
commit(repo_dir, "Add feature B");
|
|
|
|
let _identity = IDENTITY_LOCK.lock().await;
|
|
unsafe {
|
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
|
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
|
}
|
|
generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
None,
|
|
EntryKind::Normal,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
unsafe {
|
|
std::env::remove_var("DEBFULLNAME");
|
|
std::env::remove_var("DEBEMAIL");
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
|
assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium"));
|
|
assert!(content.contains("* Fix bug A"));
|
|
assert!(content.contains("* Add feature B"));
|
|
assert!(!content.contains("* Initial commit"));
|
|
}
|
|
|
|
/// A changelog that was never committed has no revision to walk the
|
|
/// history from: the entry is generated without commit summaries.
|
|
#[tokio::test]
|
|
async fn test_generate_entry_uncommitted_changelog() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let repo_dir = temp_dir.path();
|
|
setup_repo(repo_dir);
|
|
|
|
let changelog_path = repo_dir.join("debian/changelog");
|
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
|
std::fs::write(&changelog_path, "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n").unwrap();
|
|
commit(repo_dir, "Some change");
|
|
|
|
let _identity = IDENTITY_LOCK.lock().await;
|
|
unsafe {
|
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
|
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
|
}
|
|
generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
None,
|
|
EntryKind::Normal,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
unsafe {
|
|
std::env::remove_var("DEBFULLNAME");
|
|
std::env::remove_var("DEBEMAIL");
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
|
assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium"));
|
|
assert!(!content.contains("* Some change"));
|
|
}
|
|
|
|
/// An uncommitted newer entry on top of the changelog (e.g. UNRELEASED
|
|
/// from a previous run) does not hide the previous version: the tag of
|
|
/// the last *committed* version is still used as the boundary.
|
|
#[tokio::test]
|
|
async fn test_generate_entry_tag_from_committed_version() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let repo_dir = temp_dir.path();
|
|
setup_repo(repo_dir);
|
|
|
|
let changelog_path = repo_dir.join("debian/changelog");
|
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
|
let initial_content = "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n";
|
|
std::fs::write(&changelog_path, initial_content).unwrap();
|
|
|
|
Command::new("git")
|
|
.arg("add")
|
|
.arg(".")
|
|
.current_dir(repo_dir)
|
|
.output()
|
|
.unwrap();
|
|
commit(repo_dir, "Initial commit");
|
|
tag(repo_dir, "debian/0.1.0-1");
|
|
|
|
commit(repo_dir, "Fix bug A");
|
|
commit(repo_dir, "Add feature B");
|
|
|
|
// Leave an uncommitted newer entry on top
|
|
let mut unreleased = String::from(
|
|
"mypackage (0.1.0-2) UNRELEASED; urgency=medium\n\n * WIP.\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
|
);
|
|
unreleased.push_str(initial_content);
|
|
std::fs::write(&changelog_path, unreleased).unwrap();
|
|
|
|
let _identity = IDENTITY_LOCK.lock().await;
|
|
unsafe {
|
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
|
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
|
}
|
|
generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
None,
|
|
EntryKind::Normal,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
unsafe {
|
|
std::env::remove_var("DEBFULLNAME");
|
|
std::env::remove_var("DEBEMAIL");
|
|
}
|
|
|
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
|
assert!(content.contains("mypackage (0.1.0-3) UNRELEASED; urgency=medium"));
|
|
assert!(content.contains("* Fix bug A"));
|
|
assert!(content.contains("* Add feature B"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_new_version() {
|
|
// Debian upload
|
|
assert_eq!(
|
|
compute_new_version("15.2.0-8", Bump::Normal).unwrap(),
|
|
"15.2.0-9"
|
|
);
|
|
assert_eq!(
|
|
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", Bump::Ubuntu).unwrap(),
|
|
"15.2.0-9ubuntu1"
|
|
);
|
|
assert_eq!(
|
|
compute_new_version("15.2.0-9ubuntu1", Bump::Ubuntu).unwrap(),
|
|
"15.2.0-9ubuntu2"
|
|
);
|
|
// Ubuntu upload on top of a rebuild drops the buildN marker:
|
|
// appending would give 15.2.0-9build1ubuntu1, which sorts below
|
|
// the proper 15.2.0-9ubuntu1
|
|
assert_eq!(
|
|
compute_new_version("15.2.0-9build1", Bump::Ubuntu).unwrap(),
|
|
"15.2.0-9ubuntu1"
|
|
);
|
|
assert_eq!(
|
|
compute_new_version("15.2.0-9ubuntu1build1", Bump::Ubuntu).unwrap(),
|
|
"15.2.0-9ubuntu2"
|
|
);
|
|
// Native packages
|
|
assert_eq!(
|
|
compute_new_version("15.2.0build1", Bump::Ubuntu).unwrap(),
|
|
"15.2.0ubuntu1"
|
|
);
|
|
|
|
// No change rebuild
|
|
assert_eq!(
|
|
compute_new_version("15.2.0-9", Bump::Rebuild).unwrap(),
|
|
"15.2.0-9build1"
|
|
);
|
|
assert_eq!(
|
|
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", 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+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.1", Bump::Nmu).unwrap(),
|
|
"1.0-1.2"
|
|
);
|
|
|
|
// NMU of NMU?
|
|
assert_eq!(
|
|
compute_new_version("1.0-1.2", Bump::Nmu).unwrap(),
|
|
"1.0-1.3"
|
|
);
|
|
|
|
// Backport, Debian scheme
|
|
assert_eq!(
|
|
compute_new_version("1.0-1", Bump::Backport("~bpo12+".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("~bpo12+".to_string())).unwrap(),
|
|
"1.0-1~bpo12+2"
|
|
);
|
|
// Native packages backport too
|
|
assert_eq!(
|
|
compute_new_version("1.0", Bump::Backport("~bpo12+".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("~bpo12+".to_string())).unwrap(),
|
|
"1.0-1~bpo11+1~bpo12+1"
|
|
);
|
|
|
|
// Backport, Ubuntu scheme (Ubuntu version-strings documentation)
|
|
assert_eq!(
|
|
compute_new_version("3.1-1ubuntu2", Bump::Backport("~22.04.".to_string())).unwrap(),
|
|
"3.1-1ubuntu2~22.04.1"
|
|
);
|
|
// Subsequent per-release SRU uploads increment the counter
|
|
assert_eq!(
|
|
compute_new_version(
|
|
"3.1-1ubuntu2~22.04.1",
|
|
Bump::Backport("~22.04.".to_string())
|
|
)
|
|
.unwrap(),
|
|
"3.1-1ubuntu2~22.04.2"
|
|
);
|
|
// Native packages
|
|
assert_eq!(
|
|
compute_new_version("3.1", Bump::Backport("~22.04.".to_string())).unwrap(),
|
|
"3.1~22.04.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("20241126", Bump::Normal).unwrap(),
|
|
"20241127"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_new_version_large_trailing_number() {
|
|
// 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", 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", Bump::Normal);
|
|
assert!(err.is_err());
|
|
|
|
// u64::MAX itself cannot be incremented
|
|
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 <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let _identity = IDENTITY_LOCK.lock().await;
|
|
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 usable release number: Debian's rolling
|
|
/// series (sid) carry none and 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 <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let result = generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
Some("sid"),
|
|
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"));
|
|
}
|
|
|
|
/// Ubuntu backports follow the per-release SRU scheme of the Ubuntu
|
|
/// version-strings documentation: the development release's version
|
|
/// with ~YY.MM.1 appended, independently of what the target release
|
|
/// carries. Relies on the host distro-info data mapping noble to
|
|
/// 24.04 (see the distro_info tests).
|
|
#[tokio::test]
|
|
async fn test_generate_entry_backport_ubuntu_numbering() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let repo_dir = temp_dir.path();
|
|
setup_repo(repo_dir);
|
|
let changelog_path = repo_dir.join("debian/changelog");
|
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
|
std::fs::write(
|
|
&changelog_path,
|
|
"mypackage (3.1-1ubuntu2) questing; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let entry = generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
Some("noble"),
|
|
EntryKind::Backport,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(entry.new_version, "3.1-1ubuntu2~24.04.1");
|
|
assert_eq!(entry.series, "noble");
|
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
|
assert!(content.contains("mypackage (3.1-1ubuntu2~24.04.1) noble; urgency=medium"));
|
|
}
|
|
|
|
/// A regular upload is numbered after the vendor of its target series:
|
|
/// a Debian-style version uploaded to an Ubuntu series gains the
|
|
/// ubuntu1 suffix, and re-bumping the now-Ubuntu changelog increments
|
|
/// that counter instead of the revision. Relies on the host distro-info
|
|
/// data listing noble (see the distro_info tests). The git repo
|
|
/// provides the maintainer identity: DEBFULLNAME/DEBEMAIL are
|
|
/// process-global and other tests mutate them in parallel.
|
|
#[tokio::test]
|
|
async fn test_generate_entry_ubuntu_series_numbering() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let repo_dir = temp_dir.path();
|
|
setup_repo(repo_dir);
|
|
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 <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let entry = generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
Some("noble"),
|
|
EntryKind::Normal,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(entry.new_version, "1.0-1ubuntu1");
|
|
assert_eq!(entry.series, "noble");
|
|
|
|
// Re-bumping the now-Ubuntu changelog increments the counter
|
|
let entry = generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
Some("noble"),
|
|
EntryKind::Normal,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(entry.new_version, "1.0-1ubuntu2");
|
|
}
|
|
|
|
/// An Ubuntu upload of a package whose changelog carries a rebuild
|
|
/// version drops the buildN marker: 1.0-1build1 numbers the next entry
|
|
/// 1.0-1ubuntu1 (1.0-1build1ubuntu1 would sort below 1.0-1ubuntu1).
|
|
/// Relies on the host distro-info data listing noble (see the
|
|
/// distro_info tests). The git repo provides the maintainer identity:
|
|
/// DEBFULLNAME/DEBEMAIL are process-global and other tests mutate them
|
|
/// in parallel.
|
|
#[tokio::test]
|
|
async fn test_generate_entry_ubuntu_series_numbering_after_rebuild() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let repo_dir = temp_dir.path();
|
|
setup_repo(repo_dir);
|
|
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-1build1) noble; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let entry = generate_entry(
|
|
"debian/changelog",
|
|
Some(repo_dir),
|
|
None,
|
|
Some("noble"),
|
|
EntryKind::Normal,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(entry.new_version, "1.0-1ubuntu1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_maintainer_info() {
|
|
let _identity = IDENTITY_LOCK.blocking_lock();
|
|
// Test with env vars
|
|
unsafe {
|
|
std::env::set_var("DEBFULLNAME", "Env Name");
|
|
std::env::set_var("DEBEMAIL", "env@example.com");
|
|
}
|
|
|
|
let (name, email) = get_maintainer_info().unwrap();
|
|
assert_eq!(name, "Env Name");
|
|
assert_eq!(email, "env@example.com");
|
|
|
|
unsafe {
|
|
std::env::remove_var("DEBFULLNAME");
|
|
std::env::remove_var("DEBEMAIL");
|
|
}
|
|
}
|
|
}
|