chlog: cap the no-tag history search with --limit
Without a version tag, the boundary of a new entry was found by walking the whole history and diffing each commit's tree against the changelog path. On large repositories that per-commit tree diff takes 10+ minutes. Add --limit (default 500, 0 = unlimited) capping that walk: when no changelog commit is found within the last N commits, the oldest visited commit becomes the boundary — the version tag probe is skipped there, since it could jump the boundary arbitrarily far back and defeat the cap — and a warning explains the fallback. A found version tag still takes precedence and is unaffected by the limit.
This commit is contained in:
@@ -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 <n>`, `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`:
|
||||
|
||||
+248
-12
@@ -60,6 +60,7 @@ pub async fn generate_entry(
|
||||
user_version: Option<&str>,
|
||||
target_series: Option<&str>,
|
||||
kind: EntryKind,
|
||||
history_limit: usize,
|
||||
) -> Result<GeneratedEntry, Box<dyn std::error::Error>> {
|
||||
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<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) {
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
// Prefer a tag of the version the committed changelog carries: it may
|
||||
// point past the changelog commit itself (e.g. a later release commit)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<std::pa
|
||||
.map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// The last commit that modified the file at `rel_path`, newest first
|
||||
/// The last commit that modified the file at `rel_path`, newest first,
|
||||
/// walking at most `history_limit` commits (0 = the whole history)
|
||||
fn last_commit_touching<'a>(
|
||||
repo: &'a Repository,
|
||||
rel_path: &Path,
|
||||
) -> Result<Option<git2::Commit<'a>>, Box<dyn std::error::Error>> {
|
||||
history_limit: usize,
|
||||
) -> Result<Boundary<'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);
|
||||
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 <m@e.com> 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 <m@e.com> 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 <m@e.com> 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();
|
||||
|
||||
+8
-1
@@ -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 <version> "Target version (overrides the --backport/--nmu/--rebuild numbering)").required(false)),
|
||||
.arg(arg!(-v --version <version> "Target version (overrides the --backport/--nmu/--rebuild numbering)").required(false))
|
||||
.arg(arg!(--limit <n> "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::<usize>("limit")
|
||||
.copied()
|
||||
.unwrap_or(500);
|
||||
let cwd = current_dir_or_exit();
|
||||
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
|
||||
let cli_series = sub_matches.get_one::<String>("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) => {
|
||||
|
||||
Reference in New Issue
Block a user