use derive_builder::Builder;
use git_checks_core::impl_prelude::*;
use thiserror::Error;
#[derive(Debug, Error)]
enum BadCommitsError {
#[error("failed to list topic refs from {} to {}: {}", base, commit, output)]
RevList {
commit: CommitId,
base: CommitId,
output: String,
},
}
impl BadCommitsError {
fn rev_list(commit: CommitId, base: CommitId, output: &[u8]) -> Self {
BadCommitsError::RevList {
commit,
base,
output: String::from_utf8_lossy(output).into(),
}
}
}
#[derive(Builder, Debug, Clone)]
#[builder(field(private))]
pub struct BadCommits {
#[builder(private)]
#[builder(setter(name = "_bad_commits"))]
bad_commits: Vec<CommitId>,
}
impl BadCommitsBuilder {
pub fn bad_commits<I>(&mut self, bad_commits: I) -> &mut Self
where
I: IntoIterator,
I::Item: Into<CommitId>,
{
self.bad_commits = Some(bad_commits.into_iter().map(Into::into).collect());
self
}
}
impl BadCommits {
pub fn builder() -> BadCommitsBuilder {
BadCommitsBuilder::default()
}
}
impl Check for BadCommits {
fn name(&self) -> &str {
"bad-commits"
}
fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
let mut result = CheckResult::new();
if self.bad_commits.contains(&commit.sha1) {
result
.add_error(format!(
"commit {} is a known-bad commit that was removed from the server.",
commit.sha1,
))
.add_alert(
format!("commit {} was pushed to the server.", commit.sha1),
true,
);
}
Ok(result)
}
}
impl TopicCheck for BadCommits {
fn name(&self) -> &str {
"bad-commits-topic"
}
fn check(&self, ctx: &CheckGitContext, topic: &Topic) -> Result<CheckResult, Box<dyn Error>> {
let rev_list = ctx
.git()
.arg("rev-list")
.arg("--reverse")
.arg("--topo-order")
.arg(topic.sha1.as_str())
.arg(&format!("^{}", topic.base))
.output()
.map_err(|err| GitError::subcommand("rev-list", err))?;
if !rev_list.status.success() {
return Err(BadCommitsError::rev_list(
topic.sha1.clone(),
topic.base.clone(),
&rev_list.stderr,
)
.into());
}
let refs = String::from_utf8_lossy(&rev_list.stdout);
Ok(refs
.lines()
.map(CommitId::new)
.fold(CheckResult::new(), |mut result, commit| {
if self.bad_commits.contains(&commit) {
result
.add_error(format!(
"commit {} is a known-bad commit that was removed from the server.",
commit,
))
.add_alert(format!("commit {} was pushed to the server.", commit), true);
}
result
}))
}
}
#[cfg(feature = "config")]
pub(crate) mod config {
use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
use git_workarea::CommitId;
use serde::Deserialize;
#[cfg(test)]
use serde_json::json;
#[cfg(test)]
use crate::test;
use crate::BadCommits;
#[derive(Deserialize, Debug)]
pub struct BadCommitsConfig {
bad_commits: Vec<String>,
}
impl IntoCheck for BadCommitsConfig {
type Check = BadCommits;
fn into_check(self) -> Self::Check {
BadCommits::builder()
.bad_commits(self.bad_commits.into_iter().map(CommitId::new))
.build()
.expect("configuration mismatch for `BadCommits`")
}
}
register_checks! {
BadCommitsConfig {
"bad_commits" => CommitCheckConfig,
"bad_commits/topic" => TopicCheckConfig,
},
}
#[test]
fn test_bad_commits_config_empty() {
let json = json!({});
let err = serde_json::from_value::<BadCommitsConfig>(json).unwrap_err();
test::check_missing_json_field(err, "bad_commits");
}
#[test]
fn test_bad_commits_config_minimum_fields() {
let commit1: String = "commit hash 1".into();
let json = json!({
"bad_commits": [commit1],
});
let check: BadCommitsConfig = serde_json::from_value(json).unwrap();
itertools::assert_equal(&check.bad_commits, &[commit1.clone()]);
let check = check.into_check();
itertools::assert_equal(&check.bad_commits, &[CommitId::new(commit1)]);
}
}
#[cfg(test)]
mod tests {
use git_checks_core::TopicCheck;
use git_workarea::CommitId;
use crate::test::*;
use crate::BadCommits;
const NO_EXIST_COMMIT: &str = "0000000000000000000000000000000000000000";
const GOOD_COMMIT: &str = "7b0c51ed98a23a32718ed7014d6d4a813423f1bd";
const BAD_COMMIT: &str = "029a00428913ee915ce5ee7250c023abfbc2aca3";
const BAD_TOPIC: &str = "3d535904b40868dcba6465cf2c3ce4358501880a";
#[test]
fn test_bad_commits_builder_default() {
assert!(BadCommits::builder().build().is_err());
}
#[test]
fn test_bad_commits_builder_minimum_fields() {
assert!(BadCommits::builder()
.bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
.build()
.is_ok());
}
#[test]
fn test_bad_commits_name_topic() {
let check = BadCommits::builder()
.bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
assert_eq!(TopicCheck::name(&check), "bad-commits-topic");
}
#[test]
fn test_bad_commits_good_commit() {
let check = BadCommits::builder()
.bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
run_check_ok("test_bad_commits_good_commit", GOOD_COMMIT, check);
}
#[test]
fn test_bad_commits_no_bad_commit() {
let check = BadCommits::builder()
.bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
run_check_ok("test_bad_commits_no_bad_commit", BAD_TOPIC, check);
}
#[test]
fn test_bad_commits_already_in_history() {
let check = BadCommits::builder()
.bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
run_check_ok("test_bad_commits_already_in_history", BAD_TOPIC, check);
}
#[test]
fn test_bad_commits_not_already_in_history() {
let check = BadCommits::builder()
.bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
let result = run_check("test_bad_commits_not_already_in_history", BAD_TOPIC, check);
assert_eq!(result.warnings().len(), 0);
assert_eq!(result.alerts().len(), 1);
assert_eq!(
result.alerts()[0],
"commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
);
assert_eq!(result.errors().len(), 1);
assert_eq!(
result.errors()[0],
"commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
removed from the server.",
);
assert!(!result.temporary());
assert!(!result.allowed());
assert!(!result.pass());
}
#[test]
fn test_bad_commits_topic_good_commit() {
let check = BadCommits::builder()
.bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
run_topic_check_ok("test_bad_commits_topic_good_commit", GOOD_COMMIT, check);
}
#[test]
fn test_bad_commits_topic_no_bad_commit() {
let check = BadCommits::builder()
.bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
run_topic_check_ok("test_bad_commits_topic_no_bad_commit", BAD_TOPIC, check);
}
#[test]
fn test_bad_commits_topic_already_in_history() {
let check = BadCommits::builder()
.bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
run_topic_check_ok(
"test_bad_commits_topic_already_in_history",
BAD_TOPIC,
check,
);
}
#[test]
fn test_bad_commits_topic_not_already_in_history() {
let check = BadCommits::builder()
.bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
.build()
.unwrap();
let result = run_topic_check(
"test_bad_commits_topic_not_already_in_history",
BAD_TOPIC,
check,
);
assert_eq!(result.warnings().len(), 0);
assert_eq!(result.alerts().len(), 1);
assert_eq!(
result.alerts()[0],
"commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
);
assert_eq!(result.errors().len(), 1);
assert_eq!(
result.errors()[0],
"commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
removed from the server.",
);
assert!(!result.temporary());
assert!(!result.allowed());
assert!(!result.pass());
}
}