1use derive_builder::Builder;
10use git_checks_core::impl_prelude::*;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14enum BadCommitsError {
15 #[error("failed to list topic refs from {} to {}: {}", base, commit, output)]
16 RevList {
17 commit: CommitId,
18 base: CommitId,
19 output: String,
20 },
21}
22
23impl BadCommitsError {
24 fn rev_list(commit: CommitId, base: CommitId, output: &[u8]) -> Self {
25 BadCommitsError::RevList {
26 commit,
27 base,
28 output: String::from_utf8_lossy(output).into(),
29 }
30 }
31}
32
33#[derive(Builder, Debug, Clone)]
35#[builder(field(private))]
36pub struct BadCommits {
37 #[builder(private)]
38 #[builder(setter(name = "_bad_commits"))]
39 bad_commits: Vec<CommitId>,
40}
41
42impl BadCommitsBuilder {
43 pub fn bad_commits<I>(&mut self, bad_commits: I) -> &mut Self
49 where
50 I: IntoIterator,
51 I::Item: Into<CommitId>,
52 {
53 self.bad_commits = Some(bad_commits.into_iter().map(Into::into).collect());
54 self
55 }
56}
57
58impl BadCommits {
59 pub fn builder() -> BadCommitsBuilder {
61 Default::default()
62 }
63}
64
65impl Check for BadCommits {
66 fn name(&self) -> &str {
67 "bad-commits"
68 }
69
70 fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
71 let mut result = CheckResult::new();
72
73 if self.bad_commits.contains(&commit.sha1) {
74 result
75 .add_error(format!(
76 "commit {} is a known-bad commit that was removed from the server.",
77 commit.sha1,
78 ))
79 .add_alert(
80 format!("commit {} was pushed to the server.", commit.sha1),
81 true,
82 );
83 }
84
85 Ok(result)
86 }
87}
88
89impl TopicCheck for BadCommits {
90 fn name(&self) -> &str {
91 "bad-commits-topic"
92 }
93
94 fn check(&self, ctx: &CheckGitContext, topic: &Topic) -> Result<CheckResult, Box<dyn Error>> {
95 let rev_list = ctx
96 .git()
97 .arg("rev-list")
98 .arg("--reverse")
99 .arg("--topo-order")
100 .arg(topic.sha1.as_str())
101 .arg(format!("^{}", topic.base))
102 .output()
103 .map_err(|err| GitError::subcommand("rev-list", err))?;
104 if !rev_list.status.success() {
105 return Err(BadCommitsError::rev_list(
106 topic.sha1.clone(),
107 topic.base.clone(),
108 &rev_list.stderr,
109 )
110 .into());
111 }
112
113 let refs = String::from_utf8_lossy(&rev_list.stdout);
114
115 Ok(refs
116 .lines()
117 .map(CommitId::new)
118 .fold(CheckResult::new(), |mut result, commit| {
119 if self.bad_commits.contains(&commit) {
120 result
121 .add_error(format!(
122 "commit {commit} is a known-bad commit that was removed from the server.",
123 ))
124 .add_alert(format!("commit {commit} was pushed to the server."), true);
125 }
126
127 result
128 }))
129 }
130}
131
132#[cfg(feature = "config")]
133pub(crate) mod config {
134 use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
135 use git_workarea::CommitId;
136 use serde::Deserialize;
137 #[cfg(test)]
138 use serde_json::json;
139
140 #[cfg(test)]
141 use crate::test;
142 use crate::BadCommits;
143
144 #[derive(Deserialize, Debug)]
163 pub struct BadCommitsConfig {
164 bad_commits: Vec<String>,
165 }
166
167 impl IntoCheck for BadCommitsConfig {
168 type Check = BadCommits;
169
170 fn into_check(self) -> Self::Check {
171 BadCommits::builder()
172 .bad_commits(self.bad_commits.into_iter().map(CommitId::new))
173 .build()
174 .expect("configuration mismatch for `BadCommits`")
175 }
176 }
177
178 register_checks! {
179 BadCommitsConfig {
180 "bad_commits" => CommitCheckConfig,
181 "bad_commits/topic" => TopicCheckConfig,
182 },
183 }
184
185 #[test]
186 fn test_bad_commits_config_empty() {
187 let json = json!({});
188 let err = serde_json::from_value::<BadCommitsConfig>(json).unwrap_err();
189 test::check_missing_json_field(err, "bad_commits");
190 }
191
192 #[test]
193 fn test_bad_commits_config_minimum_fields() {
194 let commit1: String = "commit hash 1".into();
195 let json = json!({
196 "bad_commits": [commit1],
197 });
198 let check: BadCommitsConfig = serde_json::from_value(json).unwrap();
199
200 itertools::assert_equal(&check.bad_commits, std::slice::from_ref(&commit1));
201
202 let check = check.into_check();
203
204 itertools::assert_equal(&check.bad_commits, &[CommitId::new(commit1)]);
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use git_checks_core::{Check, TopicCheck};
211 use git_workarea::CommitId;
212
213 use crate::test::*;
214 use crate::BadCommits;
215
216 const NO_EXIST_COMMIT: &str = "0000000000000000000000000000000000000000";
217 const GOOD_COMMIT: &str = "7b0c51ed98a23a32718ed7014d6d4a813423f1bd";
218 const BAD_COMMIT: &str = "029a00428913ee915ce5ee7250c023abfbc2aca3";
219 const BAD_TOPIC: &str = "3d535904b40868dcba6465cf2c3ce4358501880a";
220
221 #[test]
222 fn test_bad_commits_builder_default() {
223 assert!(BadCommits::builder().build().is_err());
224 }
225
226 #[test]
227 fn test_bad_commits_builder_minimum_fields() {
228 assert!(BadCommits::builder()
229 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
230 .build()
231 .is_ok());
232 }
233
234 #[test]
235 fn test_bad_commits_name_commit() {
236 let check = BadCommits::builder()
237 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
238 .build()
239 .unwrap();
240 assert_eq!(Check::name(&check), "bad-commits");
241 }
242
243 #[test]
244 fn test_bad_commits_name_topic() {
245 let check = BadCommits::builder()
246 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
247 .build()
248 .unwrap();
249 assert_eq!(TopicCheck::name(&check), "bad-commits-topic");
250 }
251
252 #[test]
253 fn test_bad_commits_good_commit() {
254 let check = BadCommits::builder()
255 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
256 .build()
257 .unwrap();
258 run_check_ok("test_bad_commits_good_commit", GOOD_COMMIT, check);
259 }
260
261 #[test]
262 fn test_bad_commits_no_bad_commit() {
263 let check = BadCommits::builder()
264 .bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
266 .build()
267 .unwrap();
268 run_check_ok("test_bad_commits_no_bad_commit", BAD_TOPIC, check);
269 }
270
271 #[test]
272 fn test_bad_commits_already_in_history() {
273 let check = BadCommits::builder()
274 .bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
276 .build()
277 .unwrap();
278 run_check_ok("test_bad_commits_already_in_history", BAD_TOPIC, check);
279 }
280
281 #[test]
282 fn test_bad_commits_not_already_in_history() {
283 let check = BadCommits::builder()
284 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
286 .build()
287 .unwrap();
288 let result = run_check("test_bad_commits_not_already_in_history", BAD_TOPIC, check);
289
290 assert_eq!(result.warnings().len(), 0);
291 assert_eq!(result.alerts().len(), 1);
292 assert_eq!(
293 result.alerts()[0],
294 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
295 );
296 assert_eq!(result.errors().len(), 1);
297 assert_eq!(
298 result.errors()[0],
299 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
300 removed from the server.",
301 );
302 assert!(!result.temporary());
303 assert!(!result.allowed());
304 assert!(!result.pass());
305 }
306
307 #[test]
308 fn test_bad_commits_topic_good_commit() {
309 let check = BadCommits::builder()
310 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
311 .build()
312 .unwrap();
313 run_topic_check_ok("test_bad_commits_topic_good_commit", GOOD_COMMIT, check);
314 }
315
316 #[test]
317 fn test_bad_commits_topic_no_bad_commit() {
318 let check = BadCommits::builder()
319 .bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
321 .build()
322 .unwrap();
323 run_topic_check_ok("test_bad_commits_topic_no_bad_commit", BAD_TOPIC, check);
324 }
325
326 #[test]
327 fn test_bad_commits_topic_already_in_history() {
328 let check = BadCommits::builder()
329 .bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
331 .build()
332 .unwrap();
333 run_topic_check_ok(
334 "test_bad_commits_topic_already_in_history",
335 BAD_TOPIC,
336 check,
337 );
338 }
339
340 #[test]
341 fn test_bad_commits_topic_not_already_in_history() {
342 let check = BadCommits::builder()
343 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
345 .build()
346 .unwrap();
347 let result = run_topic_check(
348 "test_bad_commits_topic_not_already_in_history",
349 BAD_TOPIC,
350 check,
351 );
352
353 assert_eq!(result.warnings().len(), 0);
354 assert_eq!(result.alerts().len(), 1);
355 assert_eq!(
356 result.alerts()[0],
357 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
358 );
359 assert_eq!(result.errors().len(), 1);
360 assert_eq!(
361 result.errors()[0],
362 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
363 removed from the server.",
364 );
365 assert!(!result.temporary());
366 assert!(!result.allowed());
367 assert!(!result.pass());
368 }
369}