Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9238aa961f | ||
|
|
d1056fbbbf |
@@ -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"
|
||||
|
||||
+275
-26
@@ -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 {
|
||||
/// 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(tid)?;
|
||||
} else {
|
||||
// Tag not found...
|
||||
// We return an empty list.
|
||||
// TODO: Can we do better?
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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
|
||||
|
||||
+142
-2
@@ -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 {
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user