git_checks/
bad_commits.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use 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/// Check for commits which should not be in the history.
34#[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    /// The set of bad commits to deny.
44    ///
45    /// Full commit hashes should be used. These are not passed through `git rev-parse`.
46    ///
47    /// Configuration: Required
48    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    /// Create a new builder.
60    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    /// Configuration for the `BadCommits` check.
146    ///
147    /// The `bad_commits` field is required and is a list of strings. Full hashes must be used.
148    ///
149    /// This check is registered as a commit check with the name `"bad_commits"` and as a topic
150    /// check with the name `"bad_commits/topic"`. It is recommended to use the topic variant due
151    /// to its better performance.
152    ///
153    /// # Example
154    ///
155    /// ```json
156    /// {
157    ///     "bad_commits": [
158    ///         "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
159    ///         "abadcafeabadcafeabadcafeabadcafeabadcafeabadcafe"
160    ///     ]
161    /// }
162    /// ```
163    #[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            // This commit should never exist.
266            .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            // This commit is in the shared history.
276            .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            // This commit is on the branch being brought in.
286            .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            // This commit should never exist.
321            .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            // This commit is in the shared history.
331            .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            // This commit is on the topic being brought in.
345            .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}