use breezyshim::error::Error;
use breezyshim::graph::Graph;
use breezyshim::prelude::*;
use breezyshim::revisionid::RevisionId;
use debian_changelog::{ChangeLog, Entry as ChangeLogEntry};
use lazy_regex::regex;
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct ChangelogBehaviour {
#[serde(rename = "update")]
pub update_changelog: bool,
pub explanation: String,
}
#[cfg(feature = "svp")]
impl From<ChangelogBehaviour> for svp_client::ChangelogBehaviour {
fn from(b: ChangelogBehaviour) -> Self {
svp_client::ChangelogBehaviour {
update_changelog: b.update_changelog,
explanation: b.explanation,
}
}
}
impl From<ChangelogBehaviour> for (bool, String) {
fn from(b: ChangelogBehaviour) -> Self {
(b.update_changelog, b.explanation)
}
}
impl From<&ChangelogBehaviour> for (bool, String) {
fn from(b: &ChangelogBehaviour) -> Self {
(b.update_changelog, b.explanation.clone())
}
}
const DEFAULT_BACKLOG: usize = 50;
fn gbp_conf_has_dch_section(tree: &dyn Tree, debian_path: &std::path::Path) -> bool {
let gbp_conf_path = debian_path.join("gbp.conf");
let gbp_conf_text = match tree.get_file_text(gbp_conf_path.as_path()) {
Ok(text) => text,
Err(Error::NoSuchFile(_)) => return false,
Err(e) => panic!("Unexpected error reading gbp.conf: {:?}", e),
};
let mut parser = configparser::ini::Ini::new();
parser
.read(String::from_utf8_lossy(gbp_conf_text.as_slice()).to_string())
.unwrap();
parser.sections().contains(&"dch".to_string())
}
pub fn guess_update_changelog(
tree: &dyn WorkingTree,
debian_path: &std::path::Path,
mut cl: Option<ChangeLog>,
) -> Option<ChangelogBehaviour> {
if debian_path != std::path::Path::new("debian") {
return Some(ChangelogBehaviour{
update_changelog: true,
explanation: "assuming changelog needs to be updated since gbp dch only supports a debian directory in the root of the repository".to_string(),
});
}
let changelog_path = debian_path.join("changelog");
if cl.is_none() {
match tree.get_file(changelog_path.as_path()) {
Ok(f) => {
cl = Some(ChangeLog::read(f).unwrap());
}
Err(Error::NoSuchFile(_)) => {
log::debug!("No changelog found");
}
Err(e) => {
panic!("Unexpected error reading changelog: {:?}", e);
}
}
}
if let Some(ref cl) = cl {
if debian_changelog::is_unreleased_inaugural(cl) {
return Some(ChangelogBehaviour {
update_changelog: false,
explanation: "assuming changelog does not need to be updated since it is the inaugural unreleased entry".to_string()
});
}
if let Some(first_entry) = cl.iter().next() {
for line in first_entry.change_lines() {
if line.contains("generated at release time") {
return Some(ChangelogBehaviour {
update_changelog: false,
explanation:
"last changelog entry warns changelog is generated at release time"
.to_string(),
});
}
}
}
}
if let Some(ret) = guess_update_changelog_from_tree(tree, debian_path, cl) {
Some(ret)
} else {
guess_update_changelog_from_branch(&tree.branch(), debian_path, None)
}
}
pub fn guess_update_changelog_from_tree(
tree: &dyn Tree,
debian_path: &std::path::Path,
cl: Option<ChangeLog>,
) -> Option<ChangelogBehaviour> {
if gbp_conf_has_dch_section(tree, debian_path) {
return Some(ChangelogBehaviour {
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since there is a [dch] section in gbp.conf.".to_string()
});
}
if let Some(cl) = cl {
if let Some(entry) = cl.iter().next() {
if all_sha_prefixed(&entry) {
return Some(ChangelogBehaviour {
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since all entries in last changelog entry are prefixed by git shas.".to_string()
});
}
}
}
None
}
fn greedy_revisions(graph: &Graph, revid: &RevisionId, length: usize) -> (Vec<RevisionId>, bool) {
let mut ret = vec![];
let mut it = match graph.iter_lefthand_ancestry(revid, None) {
Ok(iter) => iter,
Err(_) => return (ret, true),
};
while ret.len() < length {
ret.push(match it.next() {
None => break,
Some(Ok(rev)) => rev,
Some(Err(Error::RevisionNotPresent(_))) => {
if !ret.is_empty() {
ret.pop();
}
return (ret, true);
}
Some(Err(e)) => {
panic!("Error iterating through ancestry: {:?}", e);
}
});
}
(ret, false)
}
#[derive(Debug, Default)]
struct ChangelogStats {
mixed: usize,
changelog_only: usize,
other_only: usize,
dch_references: usize,
unreleased_references: usize,
}
fn changelog_stats(
branch: &dyn Branch,
history: usize,
debian_path: &std::path::Path,
) -> ChangelogStats {
let mut ret = ChangelogStats::default();
let branch_lock = branch.lock_read();
let graph = branch.repository().get_graph();
let (revids, _truncated) = greedy_revisions(&graph, &branch.last_revision(), history);
let mut revs = vec![];
for (_revid, rev) in branch.repository().iter_revisions(revids) {
if rev.is_none() {
continue;
}
let rev = rev.unwrap();
if rev.message.contains("Git-Dch: ") || rev.message.contains("Gbp-Dch: ") {
ret.dch_references += 1;
}
revs.push(rev);
}
for (rev, delta) in revs.iter().zip(
branch
.repository()
.get_revision_deltas(revs.as_slice(), None),
) {
let filenames: Vec<_> = delta
.added
.iter()
.filter_map(|a| a.path.1.as_ref())
.chain(delta.removed.iter().filter_map(|r| r.path.0.as_ref()))
.chain(delta.renamed.iter().filter_map(|r| r.path.0.as_ref()))
.chain(delta.renamed.iter().filter_map(|r| r.path.1.as_ref()))
.chain(delta.modified.iter().filter_map(|m| m.path.0.as_ref()))
.cloned()
.collect();
if !filenames.iter().any(|f| f.starts_with(debian_path)) {
continue;
}
let cl_path = debian_path.join("changelog");
if filenames.contains(&cl_path) {
let revtree = branch.repository().revision_tree(&rev.revision_id).unwrap();
match revtree.get_file_lines(cl_path.as_path()) {
Err(Error::NoSuchFile(_p)) => {}
Err(e) => {
panic!("Error reading changelog: {}", e);
}
Ok(cl_lines) => {
if String::from_utf8_lossy(cl_lines[0].as_slice()).contains("UNRELEASED") {
ret.unreleased_references += 1;
}
}
}
if filenames.len() > 1 {
ret.mixed += 1;
} else {
ret.changelog_only += 1;
}
} else {
ret.other_only += 1;
}
}
std::mem::drop(branch_lock);
ret
}
pub fn guess_update_changelog_from_branch(
branch: &dyn Branch,
debian_path: &std::path::Path,
history: Option<usize>,
) -> Option<ChangelogBehaviour> {
let history = history.unwrap_or(DEFAULT_BACKLOG);
let stats = changelog_stats(branch, history, debian_path);
log::debug!("Branch history analysis: changelog_only: {}, other_only: {}, mixed: {}, dch_references: {}, unreleased_references: {}",
stats.changelog_only, stats.other_only, stats.mixed, stats.dch_references,
stats.unreleased_references);
if stats.dch_references > 0 {
return Some(ChangelogBehaviour {
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since there are Gbp-Dch stanzas in commit messages".to_string()
});
}
if stats.changelog_only == 0 {
return Some(ChangelogBehaviour {
update_changelog: true,
explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string()
});
}
if stats.unreleased_references == 0 {
return Some(ChangelogBehaviour {
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since it never uses UNRELEASED entries".to_string()
});
}
if stats.mixed == 0 && stats.changelog_only > 0 && stats.other_only > 0 {
return Some(ChangelogBehaviour {
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since changelog entries are always updated in separate commits.".to_string()
});
}
if stats.changelog_only > stats.mixed && stats.other_only > stats.mixed {
return Some(ChangelogBehaviour{
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since changelog entries are usually updated in separate commits.".to_string()
});
}
None
}
pub fn all_sha_prefixed(cb: &ChangeLogEntry) -> bool {
let mut sha_prefixed = 0;
for change in cb.change_lines() {
if !change.starts_with("* ") {
continue;
}
if regex!(r"\* \[[0-9a-f]{7}\] ").is_match(change.as_str()) {
sha_prefixed += 1;
} else {
return false;
}
}
sha_prefixed > 0
}
#[cfg(test)]
mod tests {
use super::*;
use breezyshim::controldir::{create_standalone_workingtree, ControlDirFormat};
use std::path::Path;
pub const COMMITTER: &str = "Test User <test@example.com>";
fn make_changelog(entries: Vec<String>) -> String {
format!(
r###"lintian-brush (0.1) UNRELEASED; urgency=medium
{}
-- Jelmer Vernooij <jelmer@debian.org> Sat, 13 Oct 2018 11:21:39 +0100
"###,
entries
.iter()
.map(|x| format!(" * {}\n", x))
.collect::<Vec<_>>()
.concat()
)
}
#[test]
fn test_no_gbp_conf() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
assert_eq!(
Some(ChangelogBehaviour{
update_changelog: true,
explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
}),
guess_update_changelog(&tree, Path::new("debian"), None),
);
}
#[test]
fn test_custom_path() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
assert_eq!(
Some(ChangelogBehaviour{
update_changelog: true,
explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
}),
guess_update_changelog(&tree, Path::new("debian"), None),
);
assert_eq!(
Some(ChangelogBehaviour{
update_changelog: true,
explanation: "assuming changelog needs to be updated since gbp dch only supports a debian directory in the root of the repository".to_string(),
}),
guess_update_changelog(&tree, Path::new(""), None),
);
assert_eq!(
Some(ChangelogBehaviour{
update_changelog: true,
explanation: "assuming changelog needs to be updated since gbp dch only supports a debian directory in the root of the repository".to_string(),
}),
guess_update_changelog(&tree, Path::new("lala/debian"), None),
);
}
#[test]
fn test_gbp_conf_dch() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
std::fs::create_dir(td.path().join("debian")).unwrap();
std::fs::write(
td.path().join("debian/gbp.conf"),
r#"[dch]
pristine-tar = False
"#,
)
.unwrap();
tree.add(&[Path::new("debian"), Path::new("debian/gbp.conf")])
.unwrap();
assert_eq!(Some(ChangelogBehaviour{
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since there is a [dch] section in gbp.conf.".to_string(),
}),
guess_update_changelog(&tree, Path::new("debian"), None)
);
}
#[test]
fn test_changelog_sha_prefixed() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
std::fs::create_dir(td.path().join("debian")).unwrap();
std::fs::write(
td.path().join("debian/changelog"),
r#"blah (0.20.1) unstable; urgency=medium
[ Somebody ]
* [ebb7c31] do a thing
* [629746a] do another thing that actually requires us to wrap lines
and then
[ Somebody Else ]
* [b02b435] do another thing
-- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
"#,
)
.unwrap();
tree.add(&[Path::new("debian"), Path::new("debian/changelog")])
.unwrap();
assert_eq!(
Some(ChangelogBehaviour{
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since all entries in last changelog entry are prefixed by git shas.".to_string(),
}),
guess_update_changelog(&tree, Path::new("debian"), None)
);
}
#[test]
fn test_empty() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
assert_eq!(
Some(ChangelogBehaviour{
update_changelog: true,
explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
}),
guess_update_changelog(&tree, Path::new("debian"), None)
);
}
#[test]
fn test_update_with_change() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
std::fs::write(td.path().join("upstream"), b"upstream").unwrap();
std::fs::create_dir(td.path().join("debian")).unwrap();
std::fs::write(
td.path().join("debian/changelog"),
make_changelog(vec!["initial release".to_string()]),
)
.unwrap();
std::fs::write(td.path().join("debian/control"), b"initial").unwrap();
tree.add(&[
Path::new("upstream"),
Path::new("debian"),
Path::new("debian/changelog"),
Path::new("debian/control"),
])
.unwrap();
tree.build_commit()
.message("initial release")
.committer(COMMITTER)
.commit()
.unwrap();
let mut changelog_entries = vec!["initial release".to_string()];
for i in 0..20 {
std::fs::write(td.path().join("upstream"), format!("upstream {}", i)).unwrap();
changelog_entries.push(format!("next entry {}", i));
std::fs::write(
td.path().join("debian/changelog"),
make_changelog(changelog_entries.clone()),
)
.unwrap();
std::fs::write(td.path().join("debian/control"), format!("next {}", i)).unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("Next")
.commit()
.unwrap();
}
assert_eq!(Some(ChangelogBehaviour {
update_changelog: true,
explanation: "Assuming changelog needs to be updated, since it is always changed together with other files in the tree.".to_string(),
}), guess_update_changelog(&tree, Path::new("debian"), None));
}
#[test]
fn test_changelog_updated_separately() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
std::fs::create_dir(td.path().join("debian")).unwrap();
std::fs::write(
td.path().join("debian/changelog"),
make_changelog(vec!["initial release".to_string()]),
)
.unwrap();
std::fs::write(td.path().join("debian/control"), b"initial").unwrap();
tree.add(&[
Path::new("debian"),
Path::new("debian/changelog"),
Path::new("debian/control"),
])
.unwrap();
tree.build_commit()
.message("initial release")
.committer(COMMITTER)
.commit()
.unwrap();
let mut changelog_entries = vec!["initial release".to_string()];
for i in 0..20 {
changelog_entries.push(format!("next entry {}", i));
std::fs::write(
td.path().join("debian/control"),
format!("next {}", i).as_bytes(),
)
.unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("Next")
.commit()
.unwrap();
}
std::fs::write(
td.path().join("debian/changelog"),
make_changelog(changelog_entries.clone()),
)
.unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("Next")
.commit()
.unwrap();
changelog_entries.push("final entry".to_string());
std::fs::write(td.path().join("debian/control"), b"more").unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("Next")
.commit()
.unwrap();
std::fs::write(
td.path().join("debian/changelog"),
make_changelog(changelog_entries),
)
.unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("Next")
.commit()
.unwrap();
assert_eq!(Some(ChangelogBehaviour{
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since changelog entries are usually updated in separate commits.".to_string(),
}), guess_update_changelog(&tree, Path::new("debian"), None));
}
#[test]
fn test_has_dch_in_messages() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
tree.build_commit()
.message("Git-Dch: ignore\n")
.allow_pointless(true)
.committer(COMMITTER)
.commit()
.unwrap();
assert_eq!(Some(ChangelogBehaviour{
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since there are Gbp-Dch stanzas in commit messages".to_string(),
}), guess_update_changelog(&tree, Path::new("debian"), None));
}
#[test]
fn test_inaugural_unreleased() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
std::fs::create_dir(td.path().join("debian")).unwrap();
std::fs::write(
td.path().join("debian/changelog"),
r#"blah (0.20.1) UNRELEASED; urgency=medium
* Initial release. Closes: #123123
-- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
"#,
)
.unwrap();
tree.add(&[Path::new("debian"), Path::new("debian/changelog")])
.unwrap();
assert_eq!(Some(ChangelogBehaviour{
update_changelog: false,
explanation: "assuming changelog does not need to be updated since it is the inaugural unreleased entry".to_string(),
}), guess_update_changelog(&tree, Path::new("debian"), None));
}
#[test]
fn test_last_entry_warns_generated() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
std::fs::create_dir(td.path().join("debian")).unwrap();
std::fs::write(
td.path().join("debian/changelog"),
r#"blah (0.20.1) UNRELEASED; urgency=medium
* WIP (generated at release time: please do not add entries below).
-- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
blah (0.20.1) unstable; urgency=medium
* Initial release. Closes: #123123
-- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
"#,
)
.unwrap();
tree.add(&[Path::new("debian"), Path::new("debian/changelog")])
.unwrap();
assert_eq!(
Some(ChangelogBehaviour {
update_changelog: false,
explanation: "last changelog entry warns changelog is generated at release time"
.to_string()
}),
guess_update_changelog(&tree, Path::new("debian"), None)
);
}
#[test]
fn test_never_unreleased() {
let td = tempfile::tempdir().unwrap();
let tree = create_standalone_workingtree(td.path(), &ControlDirFormat::default()).unwrap();
std::fs::create_dir(td.path().join("debian")).unwrap();
std::fs::write(td.path().join("debian/control"), b"foo").unwrap();
std::fs::write(
td.path().join("debian/changelog"),
r#"blah (0.20.1) unstable; urgency=medium
* Initial release. Closes: #123123
-- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
"#,
)
.unwrap();
tree.add(&[
(Path::new("debian")),
(Path::new("debian/control")),
(Path::new("debian/changelog")),
])
.unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("rev1")
.commit()
.unwrap();
std::fs::write(td.path().join("debian/control"), b"bar").unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("rev2")
.commit()
.unwrap();
std::fs::write(td.path().join("debian/control"), b"bla").unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("rev2")
.commit()
.unwrap();
std::fs::write(
td.path().join("debian/changelog"),
r#"blah (0.21.1) unstable; urgency=medium
* Next release.
-- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
blah (0.20.1) unstable; urgency=medium
* Initial release. Closes: #123123
-- Joe User <joe@example.com> Tue, 19 Nov 2019 15:29:47 +0100
"#,
)
.unwrap();
tree.build_commit()
.committer(COMMITTER)
.message("rev2")
.commit()
.unwrap();
assert_eq!(Some(ChangelogBehaviour{
update_changelog: false,
explanation: "Assuming changelog does not need to be updated, since it never uses UNRELEASED entries".to_string()
}), guess_update_changelog(&tree, Path::new("debian"), None));
}
}