Skip to main content

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 {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    /// Configuration for the `BadCommits` check.
145    ///
146    /// The `bad_commits` field is required and is a list of strings. Full hashes must be used.
147    ///
148    /// This check is registered as a commit check with the name `"bad_commits"` and as a topic
149    /// check with the name `"bad_commits/topic"`. It is recommended to use the topic variant due
150    /// to its better performance.
151    ///
152    /// # Example
153    ///
154    /// ```json
155    /// {
156    ///     "bad_commits": [
157    ///         "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
158    ///         "abadcafeabadcafeabadcafeabadcafeabadcafeabadcafe"
159    ///     ]
160    /// }
161    /// ```
162    #[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            // This commit should never exist.
265            .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            // This commit is in the shared history.
275            .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            // This commit is on the branch being brought in.
285            .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            // This commit should never exist.
320            .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            // This commit is in the shared history.
330            .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            // This commit is on the topic being brought in.
344            .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}