diff --git a/src/changelog.rs b/src/changelog.rs index ed32e66..1d96b1b 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -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, ) -> Result, Box> { - 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 { + 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 = 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 { + 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, Box> { + 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 { + 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>, Box> { + 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> { + 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 { + 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 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 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 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 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