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:
2026-09-29 12:02:43 +02:00
parent e7cad0b1ca
commit babddad505
3 changed files with 264 additions and 16 deletions
+5
View File
@@ -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`:
+251 -15
View File
@@ -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());
};
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<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
View File
@@ -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) => {