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 {} is a known-bad commit that was removed from the server.",
123 commit,
124 ))
125 .add_alert(format!("commit {} was pushed to the server.", commit), true);
126 }
127
128 result
129 }))
130 }
131}
132
133#[cfg(feature = "config")]
134pub(crate) mod config {
135 use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
136 use git_workarea::CommitId;
137 use serde::Deserialize;
138 #[cfg(test)]
139 use serde_json::json;
140
141 #[cfg(test)]
142 use crate::test;
143 use crate::BadCommits;
144
145 #[derive(Deserialize, Debug)]
164 pub struct BadCommitsConfig {
165 bad_commits: Vec<String>,
166 }
167
168 impl IntoCheck for BadCommitsConfig {
169 type Check = BadCommits;
170
171 fn into_check(self) -> Self::Check {
172 BadCommits::builder()
173 .bad_commits(self.bad_commits.into_iter().map(CommitId::new))
174 .build()
175 .expect("configuration mismatch for `BadCommits`")
176 }
177 }
178
179 register_checks! {
180 BadCommitsConfig {
181 "bad_commits" => CommitCheckConfig,
182 "bad_commits/topic" => TopicCheckConfig,
183 },
184 }
185
186 #[test]
187 fn test_bad_commits_config_empty() {
188 let json = json!({});
189 let err = serde_json::from_value::<BadCommitsConfig>(json).unwrap_err();
190 test::check_missing_json_field(err, "bad_commits");
191 }
192
193 #[test]
194 fn test_bad_commits_config_minimum_fields() {
195 let commit1: String = "commit hash 1".into();
196 let json = json!({
197 "bad_commits": [commit1],
198 });
199 let check: BadCommitsConfig = serde_json::from_value(json).unwrap();
200
201 itertools::assert_equal(&check.bad_commits, &[commit1.clone()]);
202
203 let check = check.into_check();
204
205 itertools::assert_equal(&check.bad_commits, &[CommitId::new(commit1)]);
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use git_checks_core::{Check, TopicCheck};
212 use git_workarea::CommitId;
213
214 use crate::test::*;
215 use crate::BadCommits;
216
217 const NO_EXIST_COMMIT: &str = "0000000000000000000000000000000000000000";
218 const GOOD_COMMIT: &str = "7b0c51ed98a23a32718ed7014d6d4a813423f1bd";
219 const BAD_COMMIT: &str = "029a00428913ee915ce5ee7250c023abfbc2aca3";
220 const BAD_TOPIC: &str = "3d535904b40868dcba6465cf2c3ce4358501880a";
221
222 #[test]
223 fn test_bad_commits_builder_default() {
224 assert!(BadCommits::builder().build().is_err());
225 }
226
227 #[test]
228 fn test_bad_commits_builder_minimum_fields() {
229 assert!(BadCommits::builder()
230 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
231 .build()
232 .is_ok());
233 }
234
235 #[test]
236 fn test_bad_commits_name_commit() {
237 let check = BadCommits::builder()
238 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
239 .build()
240 .unwrap();
241 assert_eq!(Check::name(&check), "bad-commits");
242 }
243
244 #[test]
245 fn test_bad_commits_name_topic() {
246 let check = BadCommits::builder()
247 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
248 .build()
249 .unwrap();
250 assert_eq!(TopicCheck::name(&check), "bad-commits-topic");
251 }
252
253 #[test]
254 fn test_bad_commits_good_commit() {
255 let check = BadCommits::builder()
256 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
257 .build()
258 .unwrap();
259 run_check_ok("test_bad_commits_good_commit", GOOD_COMMIT, check);
260 }
261
262 #[test]
263 fn test_bad_commits_no_bad_commit() {
264 let check = BadCommits::builder()
265 .bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
267 .build()
268 .unwrap();
269 run_check_ok("test_bad_commits_no_bad_commit", BAD_TOPIC, check);
270 }
271
272 #[test]
273 fn test_bad_commits_already_in_history() {
274 let check = BadCommits::builder()
275 .bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
277 .build()
278 .unwrap();
279 run_check_ok("test_bad_commits_already_in_history", BAD_TOPIC, check);
280 }
281
282 #[test]
283 fn test_bad_commits_not_already_in_history() {
284 let check = BadCommits::builder()
285 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
287 .build()
288 .unwrap();
289 let result = run_check("test_bad_commits_not_already_in_history", BAD_TOPIC, check);
290
291 assert_eq!(result.warnings().len(), 0);
292 assert_eq!(result.alerts().len(), 1);
293 assert_eq!(
294 result.alerts()[0],
295 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
296 );
297 assert_eq!(result.errors().len(), 1);
298 assert_eq!(
299 result.errors()[0],
300 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
301 removed from the server.",
302 );
303 assert!(!result.temporary());
304 assert!(!result.allowed());
305 assert!(!result.pass());
306 }
307
308 #[test]
309 fn test_bad_commits_topic_good_commit() {
310 let check = BadCommits::builder()
311 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
312 .build()
313 .unwrap();
314 run_topic_check_ok("test_bad_commits_topic_good_commit", GOOD_COMMIT, check);
315 }
316
317 #[test]
318 fn test_bad_commits_topic_no_bad_commit() {
319 let check = BadCommits::builder()
320 .bad_commits([NO_EXIST_COMMIT].iter().copied().map(CommitId::new))
322 .build()
323 .unwrap();
324 run_topic_check_ok("test_bad_commits_topic_no_bad_commit", BAD_TOPIC, check);
325 }
326
327 #[test]
328 fn test_bad_commits_topic_already_in_history() {
329 let check = BadCommits::builder()
330 .bad_commits([FILLER_COMMIT].iter().copied().map(CommitId::new))
332 .build()
333 .unwrap();
334 run_topic_check_ok(
335 "test_bad_commits_topic_already_in_history",
336 BAD_TOPIC,
337 check,
338 );
339 }
340
341 #[test]
342 fn test_bad_commits_topic_not_already_in_history() {
343 let check = BadCommits::builder()
344 .bad_commits([BAD_COMMIT].iter().copied().map(CommitId::new))
346 .build()
347 .unwrap();
348 let result = run_topic_check(
349 "test_bad_commits_topic_not_already_in_history",
350 BAD_TOPIC,
351 check,
352 );
353
354 assert_eq!(result.warnings().len(), 0);
355 assert_eq!(result.alerts().len(), 1);
356 assert_eq!(
357 result.alerts()[0],
358 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
359 );
360 assert_eq!(result.errors().len(), 1);
361 assert_eq!(
362 result.errors()[0],
363 "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is a known-bad commit that was \
364 removed from the server.",
365 );
366 assert!(!result.temporary());
367 assert!(!result.allowed());
368 assert!(!result.pass());
369 }
370}