chlog: fall back to the changelog history when no version tag exists
CI / build (push) Successful in 2m56s
CI / test (push) Skipped
CI / snap (push) Successful in 4m30s

get_commits_since_version silently returned an empty change list when
the previous version carried no tag. Walk the history back to the last
commit that modified the changelog itself and use it as the boundary
instead, so entries stay correct in repositories that commit their
changelogs without tagging them.

Tag detection remains the preferred path. Before falling back to the
changelog commit, the version recorded by the committed changelog is
probed for a tag: an uncommitted newer entry on top (e.g. UNRELEASED
from a previous run) does not hide the previous version's tag.
This commit is contained in:
2026-09-20 00:38:15 +02:00
parent d1056fbbbf
commit 9238aa961f
+276 -27
View File
@@ -77,7 +77,10 @@ pub async fn generate_entry(
std::env::current_dir()?
};
let commits = match Repository::open(&repo_path) {
Ok(repo) => get_commits_since_version(&repo, &old_version)?,
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(),
@@ -362,51 +365,75 @@ pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn s
}
/*
* Obtain all commit messages as a list since a tagged version in a git repository
* 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>> {
let mut revwalk = repo.revwalk()?;
revwalk.set_sorting(Sort::TIME)?;
// 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);
}
// We are looking for debian version numbers
// Depending on the repo, they could be tagged with the following formats
let tag_names = vec![
// 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(),
];
]
}
// Look for the different tag formats in the git repository
let mut tag_id: Option<Oid> = None;
for tag_name in tag_names {
/// 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) {
// If we found either a commit or a tag matching the name,
// we have found our version
// A matching name resolves either to a commit (lightweight
// tag) or to a tag object (annotated tag)
if let Some(commit) = r.as_commit() {
tag_id = Some(commit.id());
break;
return Some(commit.id());
} else if let Some(tag) = r.as_tag() {
tag_id = Some(tag.target_id());
break;
return Some(tag.target_id());
}
}
}
None
}
if let Some(tid) = tag_id {
revwalk.push_head()?;
revwalk.hide(tid)?;
} else {
// Tag not found...
// We return an empty list.
// TODO: Can we do better?
return Ok(Vec::new());
}
/// 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)?;
// Add all commit messages from that tagged version to head in the list
let mut commits = Vec::new();
for id in revwalk {
let id = id?;
@@ -421,6 +448,81 @@ fn get_commits_since_version(
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
@@ -703,6 +805,153 @@ mod tests {
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