diff --git a/README.md b/README.md index 74b564c..aeace56 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ pkh put --ppa user/hello_xxx git push xxx user-fork ``` +`pkh chlog` walks the commit history back to the previous changelog entry. +Without a version tag, that search is capped at the last 500 commits +(`--limit `, `0` = unlimited): beyond the cap, the entry simply starts +at the oldest commit searched, which keeps the run fast on large histories. + ### Incremental builds (build sessions) Every `pkh deb` build records a **session** under `/var/tmp/pkh/sessions`: diff --git a/src/changelog.rs b/src/changelog.rs index 604c6cb..4471e00 100644 --- a/src/changelog.rs +++ b/src/changelog.rs @@ -60,6 +60,7 @@ pub async fn generate_entry( user_version: Option<&str>, target_series: Option<&str>, kind: EntryKind, + history_limit: usize, ) -> Result> { let changelog_path = if let Some(path) = cwd { path.join(changelog_file) @@ -79,7 +80,12 @@ pub async fn generate_entry( 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())? + get_commits_since_version( + &repo, + &old_version, + repo_changelog.as_deref(), + history_limit, + )? } // If there is no git repo (e.g. package downloaded from archive), // we just generate an empty list of changes @@ -459,6 +465,7 @@ fn get_commits_since_version( repo: &Repository, version: &str, changelog_path: Option<&Path>, + history_limit: usize, ) -> Result, Box> { // Preferred boundary: the tag of the current (previous) version if let Some(tag_id) = find_version_tag(repo, version) { @@ -472,17 +479,44 @@ fn get_commits_since_version( let Some(rel_path) = changelog_path else { return Ok(Vec::new()); }; - let Some(boundary) = last_commit_touching(repo, rel_path)? else { + match last_commit_touching(repo, rel_path, history_limit)? { // The changelog was never committed; nothing to walk from - return Ok(Vec::new()); - }; + Boundary::None => Ok(Vec::new()), + // The walk hit the limit without finding a changelog commit: + // cap the entry at the oldest visited commit rather than keep + // diffing trees over the whole history (which on large repos can + // take minutes). The version_at_commit tag probe is skipped: it + // could jump the boundary arbitrarily far back. + Boundary::Capped(oldest_visited) => { + log::warn!( + "No changelog commit found within the last {} commits; \ + the entry only covers the commits since then.", + history_limit + ); + commit_summaries_since(repo, oldest_visited) + } + Boundary::Found(boundary) => { + // 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) + } + } +} - // 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 result of searching the history for the last commit that modified +/// the changelog +enum Boundary<'a> { + /// That commit was found + Found(git2::Commit<'a>), + /// The walk visited `history_limit` commits without a match; carries + /// the id of the oldest visited commit + Capped(git2::Oid), + /// The changelog was never committed (or the history is empty) + None, } /// The commit names a Debian version may be tagged under in a repository @@ -552,26 +586,36 @@ fn changelog_in_repo(repo: &Repository, changelog_path: &Path) -> Option( repo: &'a Repository, rel_path: &Path, -) -> Result>, Box> { + history_limit: usize, +) -> 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); + return Ok(Boundary::None); } revwalk.push_head()?; + let mut visited = 0; for id in revwalk { let commit = repo.find_commit(id?)?; if commit_touches_path(repo, &commit, rel_path)? { - return Ok(Some(commit)); + return Ok(Boundary::Found(commit)); + } + if history_limit > 0 { + visited += 1; + if visited >= history_limit { + // The oldest commit visited so far is the walk boundary + return Ok(Boundary::Capped(commit.id())); + } } } - Ok(None) + Ok(Boundary::None) } /// Whether `commit` modified the file at `rel_path` relative to its first @@ -919,6 +963,23 @@ mod tests { .unwrap(); } + /// A commit with an explicit, strictly increasing timestamp: the + /// revwalks sort by commit time, which is otherwise ambiguous when + /// several commits land within the same second + fn commit_dated(dir: &Path, message: &str, seconds: u64) { + let date = format!("{} +0000", 1_700_000_000 + seconds); + Command::new("git") + .arg("commit") + .arg("--allow-empty") + .arg("-m") + .arg(message) + .env("GIT_AUTHOR_DATE", &date) + .env("GIT_COMMITTER_DATE", &date) + .current_dir(dir) + .output() + .unwrap(); + } + fn tag(dir: &Path, name: &str) { Command::new("git") .arg("tag") @@ -966,6 +1027,7 @@ mod tests { None, None, EntryKind::Normal, + 0, ) .await .unwrap(); @@ -1023,6 +1085,7 @@ mod tests { None, None, EntryKind::Normal, + 0, ) .await .unwrap(); @@ -1062,6 +1125,7 @@ mod tests { None, None, EntryKind::Normal, + 0, ) .await .unwrap(); @@ -1119,6 +1183,7 @@ mod tests { None, None, EntryKind::Normal, + 0, ) .await .unwrap(); @@ -1133,6 +1198,171 @@ mod tests { assert!(content.contains("* Add feature B")); } + /// A deep history of commits that never touch the changelog caps the + /// boundary search at `--limit`: the entry covers only the commits + /// after the oldest visited one, instead of walking the whole history. + #[tokio::test] + async fn test_generate_entry_history_limit_capped() { + 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(); + Command::new("git") + .arg("add") + .arg(".") + .current_dir(repo_dir) + .output() + .unwrap(); + commit_dated(repo_dir, "Initial commit", 0); + for i in 1..=10 { + commit_dated(repo_dir, &format!("Fix bug {i}"), i); + } + + 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, + 5, + ) + .await + .unwrap(); + unsafe { + std::env::remove_var("DEBFULLNAME"); + std::env::remove_var("DEBEMAIL"); + } + + // Boundary = oldest of the 5 visited commits ("Fix bug 6"): the + // entry covers the 4 commits after it, and nothing older + 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 7")); + assert!(content.contains("* Fix bug 10")); + assert!(!content.contains("* Fix bug 6")); + assert!(!content.contains("* Fix bug 5")); + assert!(!content.contains("* Initial commit")); + } + + /// an unbounded walk: the boundary is still the changelog commit. + #[tokio::test] + async fn test_generate_entry_history_limit_above_depth() { + 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(); + Command::new("git") + .arg("add") + .arg(".") + .current_dir(repo_dir) + .output() + .unwrap(); + commit_dated(repo_dir, "Initial commit", 0); + for i in 1..=10 { + commit_dated(repo_dir, &format!("Fix bug {i}"), i); + } + + 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, + 100, + ) + .await + .unwrap(); + unsafe { + std::env::remove_var("DEBFULLNAME"); + std::env::remove_var("DEBEMAIL"); + } + + // The changelog commit ("Initial commit") is the boundary: all + // later commits are in the entry, the boundary itself is not + 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 1")); + assert!(content.contains("* Fix bug 10")); + assert!(!content.contains("* Initial commit")); + } + + /// A limit of 0 means unlimited: the walk reaches the deep changelog + /// commit like today's behavior. + #[tokio::test] + async fn test_generate_entry_history_limit_zero_unlimited() { + 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(); + Command::new("git") + .arg("add") + .arg(".") + .current_dir(repo_dir) + .output() + .unwrap(); + commit_dated(repo_dir, "Initial commit", 0); + for i in 1..=10 { + commit_dated(repo_dir, &format!("Fix bug {i}"), i); + } + + 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, + 0, + ) + .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 1")); + assert!(content.contains("* Fix bug 10")); + assert!(!content.contains("* Initial commit")); + } + #[test] fn test_compute_new_version() { // Debian upload @@ -1303,6 +1533,7 @@ mod tests { None, Some("bookworm"), EntryKind::Backport, + 0, ) .await .unwrap(); @@ -1338,6 +1569,7 @@ mod tests { None, Some("sid"), EntryKind::Backport, + 0, ) .await; assert!(result.is_err()); @@ -1371,6 +1603,7 @@ mod tests { None, Some("noble"), EntryKind::Backport, + 0, ) .await .unwrap(); @@ -1407,6 +1640,7 @@ mod tests { None, Some("noble"), EntryKind::Normal, + 0, ) .await .unwrap(); @@ -1420,6 +1654,7 @@ mod tests { None, Some("noble"), EntryKind::Normal, + 0, ) .await .unwrap(); @@ -1452,6 +1687,7 @@ mod tests { None, Some("noble"), EntryKind::Normal, + 0, ) .await .unwrap(); diff --git a/src/main.rs b/src/main.rs index af45059..fbe8024 100644 --- a/src/main.rs +++ b/src/main.rs @@ -328,7 +328,9 @@ fn main() { .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)), + .arg(arg!(-v --version "Target version (overrides the --backport/--nmu/--rebuild numbering)").required(false)) + .arg(arg!(--limit "Cap how far back the history is searched for the previous changelog entry when no version tag exists (0 = unlimited)").required(false) + .value_parser(clap::value_parser!(usize)).default_value("500")), ) .subcommand( Command::new("build") @@ -661,6 +663,10 @@ fn main() { info!("Done."); } Some(("chlog", sub_matches)) => { + let history_limit = sub_matches + .get_one::("limit") + .copied() + .unwrap_or(500); 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()); @@ -719,6 +725,7 @@ fn main() { version, target_series.as_deref(), kind, + history_limit, )) { Ok(entry) => entry, Err(e) => {