git_checks/
fast_forward.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 FastForwardError {
15    #[error(
16        "failed to get the merge-base for {} against a target branch {} ({:?}): {}",
17        commit,
18        base,
19        code,
20        output
21    )]
22    MergeBase {
23        commit: CommitId,
24        base: CommitId,
25        code: Option<i32>,
26        output: String,
27    },
28}
29
30impl FastForwardError {
31    fn merge_base(commit: CommitId, base: CommitId, code: Option<i32>, output: &[u8]) -> Self {
32        Self::MergeBase {
33            commit,
34            base,
35            code,
36            output: String::from_utf8_lossy(output).into(),
37        }
38    }
39}
40
41/// A check which checks for fast-forward merge statuses.
42///
43/// Note that this check is fundamentally temporally bound because the state of the target ref can
44/// change the state of this check.
45///
46/// By default, only warnings are produced.
47#[derive(Builder, Debug, Clone)]
48#[builder(field(private))]
49pub struct FastForward {
50    /// The branch name of the release being checked for.
51    ///
52    /// Configuration: Required
53    #[builder(setter(into))]
54    branch: CommitId,
55    /// Whether the check should error or just warn.
56    ///
57    /// Configuration: Optional
58    /// Default: `false`
59    #[builder(default = "false")]
60    required: bool,
61}
62
63impl FastForward {
64    /// Create a new builder.
65    pub fn builder() -> FastForwardBuilder {
66        Default::default()
67    }
68}
69
70impl BranchCheck for FastForward {
71    fn name(&self) -> &str {
72        "fast-forward"
73    }
74
75    fn check(
76        &self,
77        ctx: &CheckGitContext,
78        commit: &CommitId,
79    ) -> Result<CheckResult, Box<dyn Error>> {
80        let merge_base = ctx
81            .git()
82            .arg("merge-base")
83            .arg("--is-ancestor")
84            .arg(self.branch.as_str())
85            .arg(commit.as_str())
86            .output()
87            .map_err(|err| GitError::subcommand("merge-base", err))?;
88        let ok = match merge_base.status.code() {
89            Some(0) => true,
90            Some(1) => false,
91            code => {
92                return Err(FastForwardError::merge_base(
93                    commit.clone(),
94                    self.branch.clone(),
95                    code,
96                    &merge_base.stderr,
97                )
98                .into());
99            },
100        };
101
102        let mut result = CheckResult::new();
103
104        // Indicate that the branch is eligible for fast-forward merges.
105        if !ok {
106            if self.required {
107                result.add_error(format!(
108                    "This branch is ineligible for the fast-forward merging into the `{}` branch; \
109                     it needs to be rebased.",
110                    self.branch,
111                ));
112            } else {
113                result.add_warning(format!(
114                    "Not eligible for fast-forward merging into `{}`.",
115                    self.branch,
116                ));
117            }
118        }
119
120        Ok(result)
121    }
122}
123
124#[cfg(feature = "config")]
125pub(crate) mod config {
126    use git_checks_config::{register_checks, BranchCheckConfig, IntoCheck};
127    use git_workarea::CommitId;
128    use serde::Deserialize;
129    #[cfg(test)]
130    use serde_json::json;
131
132    #[cfg(test)]
133    use crate::test;
134    use crate::FastForward;
135
136    /// Configuration for the `FastForward` check.
137    ///
138    /// The `branch` key is a string which is the name of the branch which is the target of
139    /// merging. The `required` key is a boolean defaulting to `false` which indicates whether the
140    /// check is a hard failure or not.
141    ///
142    /// This check is registered as a branch check with the name `"fast_forward"`.
143    ///
144    /// # Example
145    ///
146    /// ```json
147    /// {
148    ///     "branch": "master",
149    ///     "required": true
150    /// }
151    /// ```
152    #[derive(Deserialize, Debug)]
153    pub struct FastForwardConfig {
154        branch: String,
155        #[serde(default)]
156        required: Option<bool>,
157    }
158
159    impl IntoCheck for FastForwardConfig {
160        type Check = FastForward;
161
162        fn into_check(self) -> Self::Check {
163            let mut builder = FastForward::builder();
164
165            builder.branch(CommitId::new(self.branch));
166
167            if let Some(required) = self.required {
168                builder.required(required);
169            }
170
171            builder
172                .build()
173                .expect("configuration mismatch for `FastForward`")
174        }
175    }
176
177    register_checks! {
178        FastForwardConfig {
179            "fast_forward" => BranchCheckConfig,
180        },
181    }
182
183    #[test]
184    fn test_fast_forward_config_empty() {
185        let json = json!({});
186        let err = serde_json::from_value::<FastForwardConfig>(json).unwrap_err();
187        test::check_missing_json_field(err, "branch");
188    }
189
190    #[test]
191    fn test_fast_forward_config_branch_is_required() {
192        let json = json!({});
193        let err = serde_json::from_value::<FastForwardConfig>(json).unwrap_err();
194        test::check_missing_json_field(err, "branch");
195    }
196
197    #[test]
198    fn test_fast_forward_config_minimum_fields() {
199        let exp_branch = CommitId::new("v1.x");
200        let json = json!({
201            "branch": exp_branch.as_str(),
202        });
203        let check: FastForwardConfig = serde_json::from_value(json).unwrap();
204
205        assert_eq!(check.branch, exp_branch.as_str());
206        assert_eq!(check.required, None);
207
208        let check = check.into_check();
209
210        assert_eq!(check.branch, exp_branch);
211        assert!(!check.required);
212    }
213
214    #[test]
215    fn test_fast_forward_config_all_fields() {
216        let exp_branch = CommitId::new("v1.x");
217        let json = json!({
218            "branch": exp_branch.as_str(),
219            "required": true,
220        });
221        let check: FastForwardConfig = serde_json::from_value(json).unwrap();
222
223        assert_eq!(check.branch, exp_branch.as_str());
224        assert_eq!(check.required, Some(true));
225
226        let check = check.into_check();
227
228        assert_eq!(check.branch, exp_branch);
229        assert!(check.required);
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use git_checks_core::BranchCheck;
236    use git_workarea::CommitId;
237
238    use crate::builders::FastForwardBuilder;
239    use crate::test::*;
240    use crate::FastForward;
241
242    const RELEASE_BRANCH: &str = "3a22ca19fda09183da2faab60819ff6807568acd";
243    const NON_FF_TOPIC: &str = "a61fd3759b61a4a1f740f3fe656bc42151cefbdd";
244    const FF_TOPIC: &str = "969b794ea82ce51d1555852de33bfcb63dfec969";
245
246    #[test]
247    fn test_fast_forward_builder_default() {
248        assert!(FastForward::builder().build().is_err());
249    }
250
251    #[test]
252    fn test_fast_forward_builder_branch_is_required() {
253        assert!(FastForward::builder().build().is_err());
254    }
255
256    #[test]
257    fn test_fast_forward_builder_minimum_fields() {
258        assert!(FastForward::builder()
259            .branch(CommitId::new("release"))
260            .build()
261            .is_ok());
262    }
263
264    #[test]
265    fn test_fast_forward_name_branch() {
266        let check = FastForward::builder()
267            .branch(CommitId::new("release"))
268            .build()
269            .unwrap();
270        assert_eq!(BranchCheck::name(&check), "fast-forward");
271    }
272
273    fn make_fast_forward_check() -> FastForwardBuilder {
274        let mut builder = FastForward::builder();
275        builder.branch(CommitId::new(RELEASE_BRANCH));
276        builder
277    }
278
279    #[test]
280    fn test_fast_forward_ok() {
281        let check = make_fast_forward_check().build().unwrap();
282        run_branch_check_ok("test_fast_forward_ok", FF_TOPIC, check);
283    }
284
285    #[test]
286    fn test_fast_forward_ok_required() {
287        let check = make_fast_forward_check().required(true).build().unwrap();
288        run_branch_check_ok("test_fast_forward_ok_required", FF_TOPIC, check);
289    }
290
291    #[test]
292    fn test_fast_forward_bad() {
293        let check = make_fast_forward_check().build().unwrap();
294        let result = run_branch_check("test_fast_forward_bad", NON_FF_TOPIC, check);
295        test_result_warnings(
296            result,
297            &["Not eligible for fast-forward merging into \
298               `3a22ca19fda09183da2faab60819ff6807568acd`."],
299        );
300    }
301
302    #[test]
303    fn test_fast_forward_bad_required() {
304        let check = make_fast_forward_check().required(true).build().unwrap();
305        let result = run_branch_check("test_fast_forward_bad_required", NON_FF_TOPIC, check);
306        test_result_errors(
307            result,
308            &[
309                "This branch is ineligible for the fast-forward merging into the \
310                 `3a22ca19fda09183da2faab60819ff6807568acd` branch; it needs to be rebased.",
311            ],
312        );
313    }
314}