Compare commits

...
2 Commits
Author SHA1 Message Date
vhaudiquet 9238aa961f chlog: fall back to the changelog history when no version tag exists
CI / build (push) Successful in 2m56s
CI / test (push) Skipped
CI / snap (push) Successful in 4m30s
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.
2026-09-20 00:38:15 +02:00
vhaudiquet d1056fbbbf ui: ellipsize fake-terminal pane lines wider than the terminal
Overflowing log lines in the pkh deb / pkh build rolling pane used to
wrap onto a second line, corrupting the pane layout. Truncate them to
the terminal width (minus the pane prefix) by display width and append
an ellipsis instead; lines are left whole when the terminal size is
unknown.
2026-09-20 00:15:37 +02:00
3 changed files with 422 additions and 32 deletions
+1
View File
@@ -34,6 +34,7 @@ ssh2 = "0.9.5"
gpgme = "0.11"
serde_yaml = "0.9"
lazy_static = "1.4.0"
unicode-width = "0.2"
[dev-dependencies]
test-log = "0.2.19"
+276 -27
View File
@@ -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 {
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<Vec<String>, Box<dyn std::error::Error>> {
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<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
+145 -5
View File
@@ -19,6 +19,7 @@ use std::time::{Duration, Instant};
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::context::{LineSink, Stream};
use crate::logfmt::{Action, Classifier, GenericClassifier};
@@ -411,19 +412,65 @@ fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) {
}
}
/// Render the pane content with per-kind colors
/// Render the pane content with per-kind colors, ellipsizing lines that are
/// wider than the terminal so they do not overflow onto a wrapped line
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String {
let max_width = terminal_width().map(|w| w.saturating_sub(PANE_PREFIX_WIDTH));
render_pane_with_width(lines, max_width)
}
/// [`render_pane`] with the available pane width injected (in display
/// columns); `None` means the terminal size is unknown and lines are kept whole
fn render_pane_with_width(lines: &VecDeque<(Kind, String)>, max_width: Option<usize>) -> String {
lines
.iter()
.map(|(kind, text)| match kind {
Kind::Normal => format!(" │ {text}"),
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
Kind::Error => format!(" │ {}", text.as_str().red()),
.map(|(kind, text)| {
let text = match max_width {
Some(width) => ellipsize(text, width),
None => text.clone(),
};
match kind {
Kind::Normal => format!(" │ {text}"),
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
Kind::Error => format!(" │ {}", text.as_str().red()),
}
})
.collect::<Vec<_>>()
.join("\n")
}
/// Display width of the ` │ ` prefix rendered before each pane line
const PANE_PREFIX_WIDTH: usize = 4;
/// Width of the terminal in columns, or `None` when it cannot be determined
fn terminal_width() -> Option<usize> {
crossterm::terminal::size()
.ok()
.map(|(cols, _)| cols as usize)
}
/// Ellipsize `text` to at most `max_width` display columns, keeping its head
/// and appending `…` when it does not fit
fn ellipsize(text: &str, max_width: usize) -> String {
if UnicodeWidthStr::width(text) <= max_width {
return text.to_string();
}
// Reserve one column for the ellipsis itself
let budget = max_width.saturating_sub(1);
let mut out = String::new();
let mut width = 0;
for ch in text.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + w > budget {
break;
}
out.push(ch);
width += w;
}
out.push('…');
out
}
/// Status bar style while no determinate progress is known
///
/// The target lives on the first line and the current phase/message on its own
@@ -516,3 +563,96 @@ extern "C" fn on_sigint(_sig: libc::c_int) {
libc::_exit(130);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ellipsize_keeps_short_lines() {
assert_eq!(ellipsize("short", 10), "short");
assert_eq!(ellipsize("exactly10!", 10), "exactly10!");
}
#[test]
fn ellipsize_truncates_long_lines_to_the_width_budget() {
let out = ellipsize("a very long build line that overflows", 20);
assert_eq!(UnicodeWidthStr::width(out.as_str()), 20);
assert!(out.ends_with('…'));
assert!(out.starts_with("a very long build"));
}
#[test]
fn ellipsize_never_exceeds_the_budget_with_wide_characters() {
let out = ellipsize("wíth émojis 🎉 and 文字 mixing", 12);
assert!(UnicodeWidthStr::width(out.as_str()) <= 12);
assert!(out.ends_with('…'));
}
#[test]
fn ellipsize_degenerate_width_still_terminates() {
assert_eq!(ellipsize("overflowing", 0), "…");
assert_eq!(ellipsize("overflowing", 1), "…");
}
#[test]
fn pane_lines_are_ellipsized_but_keep_their_prefix_and_color() {
let mut lines = VecDeque::new();
lines.push_back((
Kind::Normal,
"gcc -DHAVE_CONFIG_H -I. -I.. -g -O2 -c hello.c".to_string(),
));
lines.push_back((
Kind::Error,
"an error much too long for the pane".to_string(),
));
let rendered = render_pane_with_width(&lines, Some(20));
let rendered = rendered.lines().collect::<Vec<_>>();
assert_eq!(rendered.len(), 2);
// The injected budget is the text width; every rendered line stays
// within the simulated terminal width (prefix + budget)
for line in &rendered {
let plain = strip_ansi(line);
assert!(
UnicodeWidthStr::width(plain.as_str()) <= 20 + PANE_PREFIX_WIDTH,
"{plain}"
);
}
assert!(strip_ansi(rendered[0]).starts_with(" │ gcc -DHAVE_CONFIG_H"));
// The error keeps its color wrapping around the ellipsized text
assert!(rendered[1].contains('\x1b'), "{rendered:?}");
assert!(strip_ansi(rendered[1]).starts_with(" │ an error much too l…"));
}
#[test]
fn pane_lines_are_kept_whole_without_a_known_terminal_size() {
let mut lines = VecDeque::new();
lines.push_back((
Kind::Normal,
"a line that would overflow a narrow pane".to_string(),
));
let rendered = render_pane_with_width(&lines, None);
assert!(rendered.contains("a line that would overflow a narrow pane"));
}
/// Best-effort ANSI escape stripper, enough for the assertions above
fn strip_ansi(line: &str) -> String {
let mut out = String::new();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
for esc in chars.by_ref() {
if esc.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(ch);
}
}
out
}
}