git_checks/
release_branch.rs1use 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#[derive(Builder, Debug, Clone)]
42#[builder(field(private))]
43pub struct ReleaseBranch {
44 #[builder(setter(into))]
48 branch: String,
49 #[builder(setter(into))]
56 disallowed_commit: CommitId,
57 #[builder(default = "false")]
62 required: bool,
63}
64
65impl ReleaseBranch {
66 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 if is_eligible && !self.required {
107 result.add_warning(format!("Eligible for the {} branch.", self.branch));
108 } 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 #[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}