git_checks/
release_branch.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 ReleaseBranchError {
15    #[error(
16        "failed to get the merge-base for {} against a release branch {}: {}",
17        commit,
18        base,
19        output
20    )]
21    MergeBase {
22        commit: CommitId,
23        base: CommitId,
24        output: String,
25    },
26}
27
28impl ReleaseBranchError {
29    fn merge_base(commit: CommitId, base: CommitId, output: &[u8]) -> Self {
30        ReleaseBranchError::MergeBase {
31            commit,
32            base,
33            output: String::from_utf8_lossy(output).into(),
34        }
35    }
36}
37
38/// A check which checks for release branch eligibility.
39///
40/// By default, only warnings are produced.
41#[derive(Builder, Debug, Clone)]
42#[builder(field(private))]
43pub struct ReleaseBranch {
44    /// The branch name of the release being checked for.
45    ///
46    /// Configuration: Required
47    #[builder(setter(into))]
48    branch: String,
49    /// The first commit on the target branch not allowed on the release branch.
50    ///
51    /// This is usually the first commit on the main integration branch after the release branch
52    /// forked from it.
53    ///
54    /// Configuration: Required
55    #[builder(setter(into))]
56    disallowed_commit: CommitId,
57    /// Whether the check should error or just warn.
58    ///
59    /// Configuration: Optional
60    /// Default: `false`
61    #[builder(default = "false")]
62    required: bool,
63}
64
65impl ReleaseBranch {
66    /// Create a new builder.
67    pub fn builder() -> ReleaseBranchBuilder {
68        Default::default()
69    }
70}
71
72impl BranchCheck for ReleaseBranch {
73    fn name(&self) -> &str {
74        "release-branch"
75    }
76
77    fn check(
78        &self,
79        ctx: &CheckGitContext,
80        commit: &CommitId,
81    ) -> Result<CheckResult, Box<dyn Error>> {
82        let merge_base = ctx
83            .git()
84            .arg("merge-base")
85            .arg("--all")
86            .arg(commit.as_str())
87            .arg(self.disallowed_commit.as_str())
88            .output()
89            .map_err(|err| GitError::subcommand("merge-base", err))?;
90        if !merge_base.status.success() {
91            return Err(ReleaseBranchError::merge_base(
92                commit.clone(),
93                self.disallowed_commit.clone(),
94                &merge_base.stderr,
95            )
96            .into());
97        }
98        let merge_bases = String::from_utf8_lossy(&merge_base.stdout);
99        let is_eligible = merge_bases
100            .lines()
101            .all(|merge_base| merge_base != self.disallowed_commit.as_str());
102
103        let mut result = CheckResult::new();
104
105        // Indicate that the branch is eligible (if it is required, say nothing).
106        if is_eligible && !self.required {
107            result.add_warning(format!("Eligible for the {} branch.", self.branch));
108        // Error out if the branch is not eligible, but is required.
109        } else if !is_eligible && self.required {
110            result.add_error(format!(
111                "This branch is ineligible for the {} branch; it needs to \
112                 be based on a commit before {}.",
113                self.branch, self.disallowed_commit,
114            ));
115        }
116
117        Ok(result)
118    }
119}
120
121#[cfg(feature = "config")]
122pub(crate) mod config {
123    use git_checks_config::{register_checks, BranchCheckConfig, IntoCheck};
124    use git_workarea::CommitId;
125    use serde::Deserialize;
126    #[cfg(test)]
127    use serde_json::json;
128
129    #[cfg(test)]
130    use crate::test;
131    use crate::ReleaseBranch;
132
133    /// Configuration for the `ReleaseBranch` check.
134    ///
135    /// The `branch` key is a string which defaults to `release`. This is the name of the branch
136    /// which contains the version which is being checked. The `disallowed_commit` is a string with
137    /// the full hash of the first commit which happened on the original branch after the release
138    /// branch was created. The `required` key is a boolean defaulting to `false` which indicates
139    /// whether the check is a hard failure or not.
140    ///
141    /// This check is registered as a branch check with the name `"branch_check"`.
142    ///
143    /// # Example
144    ///
145    /// ```json
146    /// {
147    ///     "branch": "v1.x",
148    ///     "disallowed_commit": "post-branch commit hash",
149    ///     "required": true
150    /// }
151    /// ```
152    #[derive(Deserialize, Debug)]
153    pub struct ReleaseBranchConfig {
154        branch: String,
155        disallowed_commit: String,
156        #[serde(default)]
157        required: Option<bool>,
158    }
159
160    impl IntoCheck for ReleaseBranchConfig {
161        type Check = ReleaseBranch;
162
163        fn into_check(self) -> Self::Check {
164            let mut builder = ReleaseBranch::builder();
165
166            builder
167                .branch(self.branch)
168                .disallowed_commit(CommitId::new(self.disallowed_commit));
169
170            if let Some(required) = self.required {
171                builder.required(required);
172            }
173
174            builder
175                .build()
176                .expect("configuration mismatch for `ReleaseBranch`")
177        }
178    }
179
180    register_checks! {
181        ReleaseBranchConfig {
182            "release_branch" => BranchCheckConfig,
183        },
184    }
185
186    #[test]
187    fn test_release_branch_config_empty() {
188        let json = json!({});
189        let err = serde_json::from_value::<ReleaseBranchConfig>(json).unwrap_err();
190        test::check_missing_json_field(err, "branch");
191    }
192
193    #[test]
194    fn test_release_branch_config_branch_is_required() {
195        let exp_disallowed_commit = "post-branch commit hash";
196        let json = json!({
197            "disallowed_commit": exp_disallowed_commit,
198        });
199        let err = serde_json::from_value::<ReleaseBranchConfig>(json).unwrap_err();
200        test::check_missing_json_field(err, "branch");
201    }
202
203    #[test]
204    fn test_release_branch_config_disallowed_commit_is_required() {
205        let exp_branch = "v1.x";
206        let json = json!({
207            "branch": exp_branch,
208        });
209        let err = serde_json::from_value::<ReleaseBranchConfig>(json).unwrap_err();
210        test::check_missing_json_field(err, "disallowed_commit");
211    }
212
213    #[test]
214    fn test_release_branch_config_minimum_fields() {
215        let exp_branch = "v1.x";
216        let exp_disallowed_commit = "post-branch commit hash";
217        let json = json!({
218            "branch": exp_branch,
219            "disallowed_commit": exp_disallowed_commit,
220        });
221        let check: ReleaseBranchConfig = serde_json::from_value(json).unwrap();
222
223        assert_eq!(check.branch, exp_branch);
224        assert_eq!(check.disallowed_commit, exp_disallowed_commit);
225        assert_eq!(check.required, None);
226
227        let check = check.into_check();
228
229        assert_eq!(check.branch, exp_branch);
230        assert_eq!(
231            check.disallowed_commit,
232            CommitId::new(exp_disallowed_commit),
233        );
234        assert!(!check.required);
235    }
236
237    #[test]
238    fn test_release_branch_config_all_fields() {
239        let exp_branch = "v1.x";
240        let exp_disallowed_commit = "post-branch commit hash";
241        let json = json!({
242            "branch": exp_branch,
243            "disallowed_commit": exp_disallowed_commit,
244            "required": true,
245        });
246        let check: ReleaseBranchConfig = serde_json::from_value(json).unwrap();
247
248        assert_eq!(check.branch, exp_branch);
249        assert_eq!(check.disallowed_commit, exp_disallowed_commit);
250        assert_eq!(check.required, Some(true));
251
252        let check = check.into_check();
253
254        assert_eq!(check.branch, exp_branch);
255        assert_eq!(
256            check.disallowed_commit,
257            CommitId::new(exp_disallowed_commit),
258        );
259        assert!(check.required);
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use git_checks_core::BranchCheck;
266    use git_workarea::CommitId;
267
268    use crate::builders::ReleaseBranchBuilder;
269    use crate::test::*;
270    use crate::ReleaseBranch;
271
272    const RELEASE_BRANCH: &str = "3a22ca19fda09183da2faab60819ff6807568acd";
273    const POST_RELEASE_COMMIT: &str = "d02f015907371738253a22b9a7fec78607a969b2";
274    const POST_RELEASE_BRANCH: &str = "a61fd3759b61a4a1f740f3fe656bc42151cefbdd";
275    const POST_RELEASE_BRANCH_MERGE: &str = "c58dd19d9976722d82aa6bc6a52a2a01a52bd9e8";
276
277    #[test]
278    fn test_release_branch_builder_default() {
279        assert!(ReleaseBranch::builder().build().is_err());
280    }
281
282    #[test]
283    fn test_release_branch_builder_branch_is_required() {
284        assert!(ReleaseBranch::builder()
285            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT))
286            .build()
287            .is_err());
288    }
289
290    #[test]
291    fn test_release_branch_builder_commit_is_required() {
292        assert!(ReleaseBranch::builder().branch("release").build().is_err());
293    }
294
295    #[test]
296    fn test_release_branch_builder_minimum_fields() {
297        assert!(ReleaseBranch::builder()
298            .branch("release")
299            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT))
300            .build()
301            .is_ok());
302    }
303
304    #[test]
305    fn test_release_branch_name_branch() {
306        let check = ReleaseBranch::builder()
307            .branch("release")
308            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT))
309            .build()
310            .unwrap();
311        assert_eq!(BranchCheck::name(&check), "release-branch");
312    }
313
314    fn make_release_branch_check() -> ReleaseBranchBuilder {
315        let mut builder = ReleaseBranch::builder();
316        builder
317            .branch("release")
318            .disallowed_commit(CommitId::new(POST_RELEASE_COMMIT));
319        builder
320    }
321
322    #[test]
323    fn test_release_branch_ok() {
324        let check = make_release_branch_check().build().unwrap();
325        let result = run_branch_check("test_release_branch_ok", RELEASE_BRANCH, check);
326        test_result_warnings(result, &["Eligible for the release branch."]);
327    }
328
329    #[test]
330    fn test_release_branch_ok_required() {
331        let check = make_release_branch_check().required(true).build().unwrap();
332        run_branch_check_ok("test_release_branch_ok_required", RELEASE_BRANCH, check);
333    }
334
335    #[test]
336    fn test_post_release_branch() {
337        let check = make_release_branch_check().build().unwrap();
338        run_branch_check_ok("test_post_release_branch", POST_RELEASE_BRANCH, check);
339    }
340
341    #[test]
342    fn test_post_release_branch_required() {
343        let check = make_release_branch_check().required(true).build().unwrap();
344        let result = run_branch_check(
345            "test_post_release_branch_required",
346            POST_RELEASE_BRANCH,
347            check,
348        );
349        test_result_errors(result, &[
350            "This branch is ineligible for the release branch; it needs to be based on a commit \
351             before d02f015907371738253a22b9a7fec78607a969b2.",
352        ]);
353    }
354
355    #[test]
356    fn test_post_release_branch_merge() {
357        let check = make_release_branch_check().build().unwrap();
358        run_branch_check_ok(
359            "test_post_release_branch_merge",
360            POST_RELEASE_BRANCH_MERGE,
361            check,
362        );
363    }
364
365    #[test]
366    fn test_post_release_branch_merge_required() {
367        let check = make_release_branch_check().required(true).build().unwrap();
368        let result = run_branch_check(
369            "test_post_release_branch_merge_required",
370            POST_RELEASE_BRANCH_MERGE,
371            check,
372        );
373        test_result_errors(result, &[
374            "This branch is ineligible for the release branch; it needs to be based on a commit \
375             before d02f015907371738253a22b9a7fec78607a969b2.",
376        ]);
377    }
378}