chlog: fall back to the changelog history when no version tag exists
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:
+275
-26
@@ -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 {
|
||||
/// 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(tid)?;
|
||||
} else {
|
||||
// Tag not found...
|
||||
// We return an empty list.
|
||||
// TODO: Can we do better?
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user