git_checks/
bad_commit.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 BadCommitError {
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 BadCommitError {
24    fn rev_list(commit: CommitId, base: CommitId, output: &[u8]) -> Self {
25        BadCommitError::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 BadCommit {
37    /// The commit to deny.
38    ///
39    /// Configuration: Required
40    #[builder(setter(into))]
41    commit: CommitId,
42    /// The reason the commit is bad.
43    ///
44    /// Configuration: Required
45    #[builder(setter(into))]
46    reason: String,
47}
48
49impl BadCommit {
50    /// Create a new builder.
51    pub fn builder() -> BadCommitBuilder {
52        Default::default()
53    }
54
55    fn apply(&self, result: &mut CheckResult) {
56        result
57            .add_error(format!(
58                "commit {} is not allowed {}.",
59                self.commit, self.reason,
60            ))
61            .add_alert(
62                format!("commit {} was pushed to the server.", self.commit),
63                true,
64            );
65    }
66}
67
68impl Check for BadCommit {
69    fn name(&self) -> &str {
70        "bad-commit"
71    }
72
73    fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
74        let mut result = CheckResult::new();
75
76        if self.commit == commit.sha1 {
77            self.apply(&mut result);
78        }
79
80        Ok(result)
81    }
82}
83
84impl TopicCheck for BadCommit {
85    fn name(&self) -> &str {
86        "bad-commit-topic"
87    }
88
89    fn check(&self, ctx: &CheckGitContext, topic: &Topic) -> Result<CheckResult, Box<dyn Error>> {
90        let rev_list = ctx
91            .git()
92            .arg("rev-list")
93            .arg("--reverse")
94            .arg("--topo-order")
95            .arg(topic.sha1.as_str())
96            .arg(format!("^{}", topic.base))
97            .output()
98            .map_err(|err| GitError::subcommand("rev-list", err))?;
99        if !rev_list.status.success() {
100            return Err(BadCommitError::rev_list(
101                topic.sha1.clone(),
102                topic.base.clone(),
103                &rev_list.stderr,
104            )
105            .into());
106        }
107
108        let refs = String::from_utf8_lossy(&rev_list.stdout);
109
110        let mut result = CheckResult::new();
111        if refs.lines().any(|rev| rev == self.commit.as_str()) {
112            self.apply(&mut result)
113        }
114        Ok(result)
115    }
116}
117
118#[cfg(feature = "config")]
119pub(crate) mod config {
120    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
121    use git_workarea::CommitId;
122    use serde::Deserialize;
123    #[cfg(test)]
124    use serde_json::json;
125
126    #[cfg(test)]
127    use crate::test;
128    use crate::BadCommit;
129
130    /// Configuration for the `BadCommit` check.
131    ///
132    /// The `commit` and `reason` fields are required and are both strings. The commit must be a
133    /// full commit hash.
134    ///
135    /// This check is registered as a commit check with the name `"bad_commit"` and as a topic
136    /// check with the name `"bad_commit/topic"`.
137    ///
138    /// # Example
139    ///
140    /// ```json
141    /// {
142    ///     "commit": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
143    ///     "reason": "it's a bad commit"
144    /// }
145    /// ```
146    #[derive(Deserialize, Debug)]
147    pub struct BadCommitConfig {
148        commit: String,
149        reason: String,
150    }
151
152    impl IntoCheck for BadCommitConfig {
153        type Check = BadCommit;
154
155        fn into_check(self) -> Self::Check {
156            let mut builder = BadCommit::builder();
157            builder.commit(CommitId::new(self.commit));
158            builder.reason(self.reason);
159            builder
160                .build()
161                .expect("configuration mismatch for `BadCommit`")
162        }
163    }
164
165    register_checks! {
166        BadCommitConfig {
167            "bad_commit" => CommitCheckConfig,
168            "bad_commit/topic" => TopicCheckConfig,
169        },
170    }
171
172    #[test]
173    fn test_bad_commit_config_empty() {
174        let json = json!({});
175        let err = serde_json::from_value::<BadCommitConfig>(json).unwrap_err();
176        test::check_missing_json_field(err, "commit");
177    }
178
179    #[test]
180    fn test_bad_commit_config_commit_is_required() {
181        let reason = "because";
182        let json = json!({
183            "reason": reason,
184        });
185        let err = serde_json::from_value::<BadCommitConfig>(json).unwrap_err();
186        test::check_missing_json_field(err, "commit");
187    }
188
189    #[test]
190    fn test_bad_commit_config_reason_is_required() {
191        let commit = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
192        let json = json!({
193            "commit": commit,
194        });
195        let err = serde_json::from_value::<BadCommitConfig>(json).unwrap_err();
196        test::check_missing_json_field(err, "reason");
197    }
198
199    #[test]
200    fn test_bad_commit_config_minimum_fields() {
201        let commit = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
202        let reason = "because";
203        let json = json!({
204            "commit": commit,
205            "reason": reason,
206        });
207        let check: BadCommitConfig = serde_json::from_value(json).unwrap();
208
209        assert_eq!(check.commit, commit);
210        assert_eq!(check.reason, reason);
211
212        let check = check.into_check();
213
214        assert_eq!(check.commit, CommitId::new(commit));
215        assert_eq!(check.reason, reason);
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use git_checks_core::{Check, TopicCheck};
222    use git_workarea::CommitId;
223
224    use crate::test::*;
225    use crate::BadCommit;
226
227    const NO_EXIST_COMMIT: &str = "0000000000000000000000000000000000000000";
228    const GOOD_COMMIT: &str = "7b0c51ed98a23a32718ed7014d6d4a813423f1bd";
229    const BAD_COMMIT: &str = "029a00428913ee915ce5ee7250c023abfbc2aca3";
230    const BAD_TOPIC: &str = "3d535904b40868dcba6465cf2c3ce4358501880a";
231
232    #[test]
233    fn test_bad_commit_builder_default() {
234        assert!(BadCommit::builder().build().is_err());
235    }
236
237    #[test]
238    fn test_bad_commit_builder_reason_is_required() {
239        assert!(BadCommit::builder()
240            .commit(CommitId::new(NO_EXIST_COMMIT))
241            .build()
242            .is_err());
243    }
244
245    #[test]
246    fn test_bad_commit_builder_commit_is_required() {
247        assert!(BadCommit::builder().reason("because").build().is_err());
248    }
249
250    #[test]
251    fn test_bad_commit_builder_minimum_fields() {
252        assert!(BadCommit::builder()
253            .commit(CommitId::new(NO_EXIST_COMMIT))
254            .reason("because")
255            .build()
256            .is_ok());
257    }
258
259    #[test]
260    fn test_bad_commit_name_commit() {
261        let check = BadCommit::builder()
262            .commit(CommitId::new(NO_EXIST_COMMIT))
263            .reason("because")
264            .build()
265            .unwrap();
266        assert_eq!(Check::name(&check), "bad-commit");
267    }
268
269    #[test]
270    fn test_bad_commit_name_topic() {
271        let check = BadCommit::builder()
272            .commit(CommitId::new(NO_EXIST_COMMIT))
273            .reason("because")
274            .build()
275            .unwrap();
276        assert_eq!(TopicCheck::name(&check), "bad-commit-topic");
277    }
278
279    #[test]
280    fn test_bad_commit_good_commit() {
281        let check = BadCommit::builder()
282            .commit(CommitId::new(BAD_COMMIT))
283            .reason("because")
284            .build()
285            .unwrap();
286        run_check_ok("test_bad_commit_good_commit", GOOD_COMMIT, check);
287    }
288
289    #[test]
290    fn test_bad_commit_no_bad_commit() {
291        let check = BadCommit::builder()
292            // This commit should never exist.
293            .commit(CommitId::new(NO_EXIST_COMMIT))
294            .reason("because")
295            .build()
296            .unwrap();
297        run_check_ok("test_bad_commit_no_bad_commit", BAD_TOPIC, check);
298    }
299
300    #[test]
301    fn test_bad_commit_already_in_history() {
302        let check = BadCommit::builder()
303            // This commit is in the shared history.
304            .commit(CommitId::new(FILLER_COMMIT))
305            .reason("because")
306            .build()
307            .unwrap();
308        run_check_ok("test_bad_commit_already_in_history", BAD_TOPIC, check);
309    }
310
311    #[test]
312    fn test_bad_commit_not_already_in_history() {
313        let check = BadCommit::builder()
314            // This commit is on the branch being brought in.
315            .commit(CommitId::new(BAD_COMMIT))
316            .reason("because")
317            .build()
318            .unwrap();
319        let result = run_check("test_bad_commit_not_already_in_history", BAD_TOPIC, check);
320
321        assert_eq!(result.warnings().len(), 0);
322        assert_eq!(result.alerts().len(), 1);
323        assert_eq!(
324            result.alerts()[0],
325            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
326        );
327        assert_eq!(result.errors().len(), 1);
328        assert_eq!(
329            result.errors()[0],
330            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is not allowed because.",
331        );
332        assert!(!result.temporary());
333        assert!(!result.allowed());
334        assert!(!result.pass());
335    }
336
337    #[test]
338    fn test_bad_commit_topic_good_commit() {
339        let check = BadCommit::builder()
340            .commit(CommitId::new(BAD_COMMIT))
341            .reason("because")
342            .build()
343            .unwrap();
344        run_topic_check_ok("test_bad_commit_topic_good_commit", GOOD_COMMIT, check);
345    }
346
347    #[test]
348    fn test_bad_commit_topic_no_bad_commit() {
349        let check = BadCommit::builder()
350            // This commit should never exist.
351            .commit(CommitId::new(NO_EXIST_COMMIT))
352            .reason("because")
353            .build()
354            .unwrap();
355        run_topic_check_ok("test_bad_commit_topic_no_bad_commit", BAD_TOPIC, check);
356    }
357
358    #[test]
359    fn test_bad_commit_topic_already_in_history() {
360        let check = BadCommit::builder()
361            // This commit is in the shared history.
362            .commit(CommitId::new(FILLER_COMMIT))
363            .reason("because")
364            .build()
365            .unwrap();
366        run_topic_check_ok("test_bad_commit_topic_already_in_history", BAD_TOPIC, check);
367    }
368
369    #[test]
370    fn test_bad_commit_topic_not_already_in_history() {
371        let check = BadCommit::builder()
372            // This commit is on the topic being brought in.
373            .commit(CommitId::new(BAD_COMMIT))
374            .reason("because")
375            .build()
376            .unwrap();
377        let result = run_topic_check(
378            "test_bad_commit_topic_not_already_in_history",
379            BAD_TOPIC,
380            check,
381        );
382
383        assert_eq!(result.warnings().len(), 0);
384        assert_eq!(result.alerts().len(), 1);
385        assert_eq!(
386            result.alerts()[0],
387            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 was pushed to the server.",
388        );
389        assert_eq!(result.errors().len(), 1);
390        assert_eq!(
391            result.errors()[0],
392            "commit 029a00428913ee915ce5ee7250c023abfbc2aca3 is not allowed because.",
393        );
394        assert!(!result.temporary());
395        assert!(!result.allowed());
396        assert!(!result.pass());
397    }
398}