1use super::{Config, LOCAL_GITHUB_BYPASS, Protection, invalid, legacy_local_protection};
4use crate::error::RkError;
5use crate::landing::{Integration, MergeForm};
6
7pub struct Floor {
9 pub key: &'static str,
11 pub minimum: &'static str,
13 pub heading: &'static str,
15 accepts: fn(&Protection) -> bool,
16}
17
18const LINEAR_TRUNK: &str = "The trunk takes one validated integration at a time";
21
22const REQUEST: &str = "A forge-integrated trunk merges through a checked request";
24
25const SHARED: &[Floor] = &[
30 Floor {
31 key: "protection.tag_pattern",
32 minimum: "refs/tags/v* or refs/tags/*, covering every published version",
33 heading: "A published version is immutable",
34 accepts: |p| matches!(p.tag_pattern.as_str(), "refs/tags/v*" | "refs/tags/*"),
35 },
36 Floor {
37 key: "protection.gitlab.merge_method",
38 minimum: "ff",
39 heading: LINEAR_TRUNK,
40 accepts: |p| p.gitlab.merge_method == "ff",
41 },
42 Floor {
43 key: "protection.gitlab.squash_commit_template",
44 minimum: "references %{title}",
45 heading: LINEAR_TRUNK,
46 accepts: |p| p.gitlab.squash_commit_template.contains("%{title}"),
51 },
52 Floor {
53 key: "protection.gitlab.merge_access_level",
54 minimum: "at least 30",
55 heading: LINEAR_TRUNK,
56 accepts: |p| p.gitlab.merge_access_level >= 30,
57 },
58];
59
60const FORGE_ONLY: &[Floor] = &[
63 Floor {
64 key: "protection.bypass_actors",
65 minimum: "empty",
66 heading: LINEAR_TRUNK,
67 accepts: |p| p.bypass_actors.is_empty(),
68 },
69 Floor {
70 key: "protection.strict_required_status_checks",
71 minimum: "true",
72 heading: REQUEST,
73 accepts: |p| p.strict_required_status_checks,
74 },
75 Floor {
76 key: "protection.owned_trunk_rules",
77 minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
78 heading: REQUEST,
79 accepts: |p| {
80 [
81 "deletion",
82 "non_fast_forward",
83 "pull_request",
84 "required_status_checks",
85 ]
86 .iter()
87 .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
88 },
89 },
90 Floor {
91 key: "protection.gitlab.push_access_level",
92 minimum: "0 (no direct pushes)",
93 heading: REQUEST,
94 accepts: |p| p.gitlab.push_access_level == 0,
95 },
96 Floor {
97 key: "protection.required_approving_review_count",
98 minimum: "at least 0",
99 heading: REQUEST,
100 accepts: |p| p.required_approving_review_count >= 0,
101 },
102 Floor {
103 key: "protection.dismiss_stale_reviews_on_push",
104 minimum: "false; true is stricter",
105 heading: REQUEST,
106 accepts: |p| {
107 let _ = p.dismiss_stale_reviews_on_push;
108 true
109 },
110 },
111 Floor {
112 key: "protection.require_code_owner_review",
113 minimum: "false; true is stricter",
114 heading: REQUEST,
115 accepts: |p| {
116 let _ = p.require_code_owner_review;
117 true
118 },
119 },
120 Floor {
121 key: "protection.require_last_push_approval",
122 minimum: "false; true is stricter",
123 heading: REQUEST,
124 accepts: |p| {
125 let _ = p.require_last_push_approval;
126 true
127 },
128 },
129];
130
131const LOCAL_ONLY: &[Floor] = &[
140 Floor {
141 key: "protection.bypass_actors",
142 minimum: "exactly [repository-admin]",
143 heading: LINEAR_TRUNK,
144 accepts: |p| p.bypass_actors == [LOCAL_GITHUB_BYPASS] || legacy_local_protection(p),
145 },
146 Floor {
147 key: "protection.strict_required_status_checks",
148 minimum: "true",
149 heading: REQUEST,
150 accepts: |p| p.strict_required_status_checks,
151 },
152 Floor {
153 key: "protection.owned_trunk_rules",
154 minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
155 heading: REQUEST,
156 accepts: |p| {
157 [
158 "deletion",
159 "non_fast_forward",
160 "pull_request",
161 "required_status_checks",
162 ]
163 .iter()
164 .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
165 || legacy_local_protection(p)
166 },
167 },
168 Floor {
169 key: "protection.gitlab.push_access_level",
170 minimum: "at least 40 (maintainers alone); 0 is stricter and closes the trunk entirely",
175 heading: LINEAR_TRUNK,
176 accepts: |p| p.gitlab.push_access_level == 0 || p.gitlab.push_access_level >= 40,
177 },
178 Floor {
179 key: "protection.required_approving_review_count",
180 minimum: "at least 0",
181 heading: REQUEST,
182 accepts: |p| p.required_approving_review_count >= 0,
183 },
184 Floor {
185 key: "protection.dismiss_stale_reviews_on_push",
186 minimum: "false; true is stricter",
187 heading: REQUEST,
188 accepts: |p| {
189 let _ = p.dismiss_stale_reviews_on_push;
190 true
191 },
192 },
193 Floor {
194 key: "protection.require_code_owner_review",
195 minimum: "false; true is stricter",
196 heading: REQUEST,
197 accepts: |p| {
198 let _ = p.require_code_owner_review;
199 true
200 },
201 },
202 Floor {
203 key: "protection.require_last_push_approval",
204 minimum: "false; true is stricter",
205 heading: REQUEST,
206 accepts: |p| {
207 let _ = p.require_last_push_approval;
208 true
209 },
210 },
211];
212
213const SQUASH_FORM: &[Floor] = &[
217 Floor {
218 key: "protection.allowed_merge_methods",
219 minimum: "exactly [squash]",
220 heading: LINEAR_TRUNK,
221 accepts: |p| p.allowed_merge_methods == ["squash"],
222 },
223 Floor {
224 key: "protection.github.squash_title_source",
225 minimum: "PR_TITLE",
226 heading: LINEAR_TRUNK,
227 accepts: |p| p.github.squash_title_source == "PR_TITLE",
228 },
229 Floor {
230 key: "protection.github.squash_body_source",
231 minimum: "PR_BODY",
232 heading: LINEAR_TRUNK,
233 accepts: |p| p.github.squash_body_source == "PR_BODY",
234 },
235 Floor {
236 key: "protection.gitlab.squash_option",
237 minimum: "always",
238 heading: LINEAR_TRUNK,
239 accepts: |p| p.gitlab.squash_option == "always",
240 },
241];
242
243const REBASE_FORM: &[Floor] = &[
248 Floor {
249 key: "protection.allowed_merge_methods",
250 minimum: "exactly [rebase]",
251 heading: LINEAR_TRUNK,
252 accepts: |p| p.allowed_merge_methods == ["rebase"],
253 },
254 Floor {
255 key: "protection.gitlab.squash_option",
256 minimum: "never",
257 heading: LINEAR_TRUNK,
258 accepts: |p| p.gitlab.squash_option == "never",
259 },
260];
261
262#[must_use]
265pub fn floors(integration: Integration, form: MergeForm) -> Vec<&'static Floor> {
266 let extra = match integration {
267 Integration::Forge => FORGE_ONLY,
268 Integration::Local => LOCAL_ONLY,
269 };
270 let merge = match form {
271 MergeForm::Squash => SQUASH_FORM,
272 MergeForm::Rebase => REBASE_FORM,
273 };
274 SHARED.iter().chain(merge).chain(extra).collect()
275}
276
277pub fn check(config: &Config) -> Result<(), RkError> {
296 let integration = config.git.integration.unwrap_or(Integration::Forge);
297 let form = config.git.merge_form.unwrap_or_else(|| {
298 let holds = |table: &[Floor]| {
299 table
300 .iter()
301 .all(|floor| (floor.accepts)(&config.protection))
302 };
303 if !holds(SQUASH_FORM) && holds(REBASE_FORM) {
304 MergeForm::Rebase
305 } else {
306 MergeForm::Squash
307 }
308 });
309 for floor in floors(integration, form) {
310 if !(floor.accepts)(&config.protection) {
311 return Err(invalid(format!(
312 "{}: floor is {}; see rk method invariants ({})",
313 floor.key, floor.minimum, floor.heading
314 )));
315 }
316 }
317 Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322 use super::{check, floors};
323 use crate::config::Config;
324 use crate::landing::{Integration, MergeForm};
325
326 #[test]
327 fn every_floor_names_a_real_invariant_heading() {
328 let chapter = crate::embedded::METHOD
329 .get_file("01-invariants.md")
330 .expect("the invariants ship")
331 .contents_utf8()
332 .expect("prose is utf8");
333 for integration in [Integration::Forge, Integration::Local] {
334 for form in [MergeForm::Squash, MergeForm::Rebase] {
335 for floor in floors(integration, form) {
336 assert!(
337 chapter
338 .lines()
339 .any(|line| line.strip_prefix("## ") == Some(floor.heading)),
340 "{}: {}",
341 floor.key,
342 floor.heading
343 );
344 }
345 }
346 }
347 check(&Config::default()).expect("defaults meet every floor");
348 let mut local = Config::default();
349 local.git.integration = Some(Integration::Local);
350 local.protection = crate::config::local_protection();
351 check(&local).expect("defaults meet every local floor too");
352 }
353
354 #[test]
355 fn a_value_below_a_floor_refuses_naming_the_invariants() {
356 let mut config = Config::default();
357 config.protection.allowed_merge_methods.push("merge".into());
358 let error = check(&config)
359 .expect_err("merge violates squash only")
360 .to_string();
361 for expected in [
362 "protection.allowed_merge_methods",
363 "floor",
364 "squash",
365 "rk method invariants",
366 ] {
367 assert!(error.contains(expected), "{error}");
368 }
369 }
370
371 #[test]
372 fn a_stricter_value_passes_the_floor() {
373 let mut config = Config::default();
374 config.protection.required_approving_review_count = 3;
375 config.protection.dismiss_stale_reviews_on_push = true;
376 config.protection.require_code_owner_review = true;
377 config.protection.require_last_push_approval = true;
378 config.protection.gitlab.merge_access_level = 40;
379 config.protection.tag_pattern = "refs/tags/*".into();
380 config
381 .protection
382 .owned_trunk_rules
383 .push("required_signatures".into());
384 check(&config).expect("a target can be stricter");
385 }
386
387 #[test]
388 fn one_policy_is_judged_by_the_recorded_integration_mode() {
389 let mut config = Config::default();
390 config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
391 config.git.integration = Some(Integration::Forge);
392 let error = check(&config)
393 .expect_err("a forge-integrated trunk needs the request rule")
394 .to_string();
395 assert!(error.contains("protection.owned_trunk_rules"), "{error}");
396 assert!(error.contains("pull_request"), "{error}");
397 config.git.integration = Some(Integration::Local);
398 config.protection.gitlab.push_access_level = 40;
399 check(&config).expect("the legacy local policy remains readable for migration");
400 }
401
402 #[test]
403 fn a_silent_config_is_judged_as_forge_integrated() {
404 let mut config = Config::default();
405 config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
406 config.git.integration = None;
407 check(&config).expect_err("silence is judged by the stricter table");
408 }
409
410 #[test]
411 fn a_local_target_still_refuses_a_developer_wide_trunk_push() {
412 let mut config = Config::default();
413 config.git.integration = Some(Integration::Local);
414 config.protection = crate::config::local_protection();
415 config.protection.gitlab.push_access_level = 30;
416 let error = check(&config)
417 .expect_err("developer level is everyone")
418 .to_string();
419 assert!(
420 error.contains("protection.gitlab.push_access_level"),
421 "{error}"
422 );
423 config.protection.gitlab.push_access_level = 40;
424 check(&config).expect("maintainers alone is a restriction");
425 }
426
427 #[test]
428 fn the_tag_floors_hold_under_both_integration_modes() {
429 for integration in [Integration::Forge, Integration::Local] {
430 let mut config = Config::default();
431 config.git.integration = Some(integration);
432 config.protection.tag_pattern = "refs/tags/release-*".into();
433 let error = check(&config)
434 .expect_err("a pattern missing published versions refuses")
435 .to_string();
436 assert!(error.contains("protection.tag_pattern"), "{error}");
437 }
438 }
439
440 fn rebase_policy(config: &mut Config) {
443 config.protection = crate::config::with_merge_form(&config.protection, MergeForm::Rebase);
444 }
445
446 #[test]
448 fn merge_form_rebase_values_pass_only_under_the_rebase_form() {
449 for integration in [Integration::Forge, Integration::Local] {
450 let mut config = Config::default();
451 config.git.integration = Some(integration);
452 if integration == Integration::Local {
453 config.protection = crate::config::local_protection();
454 }
455 rebase_policy(&mut config);
456 config.git.merge_form = Some(MergeForm::Rebase);
457 check(&config).expect("the rebase form accepts its own values");
458 config.git.merge_form = Some(MergeForm::Squash);
459 let error = check(&config)
460 .expect_err("the squash form refuses the rebase method")
461 .to_string();
462 assert!(
463 error.contains("protection.allowed_merge_methods"),
464 "{error}"
465 );
466 assert!(error.contains("exactly [squash]"), "{error}");
467 assert!(error.contains("rk method invariants"), "{error}");
468 }
469 }
470
471 #[test]
475 fn merge_form_silence_takes_one_forms_whole_values() {
476 let mut config = Config::default();
477 config.git.merge_form = None;
478 check(&config).expect("the squash values pass a silent file");
479 rebase_policy(&mut config);
480 check(&config).expect("the rebase values pass a silent file");
481 config.protection.gitlab.squash_option = "always".into();
482 let error = check(&config)
483 .expect_err("a mix of the two forms is no form")
484 .to_string();
485 assert!(error.contains("exactly [squash]"), "{error}");
486 }
487
488 #[test]
489 fn merge_form_squash_values_refuse_under_the_rebase_form() {
490 let mut config = Config::default();
491 config.git.merge_form = Some(MergeForm::Rebase);
492 let error = check(&config)
493 .expect_err("the rebase form refuses the squash method")
494 .to_string();
495 assert!(
496 error.contains("protection.allowed_merge_methods"),
497 "{error}"
498 );
499 assert!(error.contains("exactly [rebase]"), "{error}");
500 config.protection.allowed_merge_methods = vec!["rebase".into()];
502 let error = check(&config)
503 .expect_err("a squashing GitLab refuses under rebase")
504 .to_string();
505 assert!(error.contains("protection.gitlab.squash_option"), "{error}");
506 assert!(error.contains("never"), "{error}");
507 }
508
509 #[test]
510 fn merge_form_rebase_lifts_the_squash_source_floors() {
511 let mut config = Config::default();
512 config.git.merge_form = Some(MergeForm::Rebase);
513 rebase_policy(&mut config);
514 config.protection.github.squash_title_source = "COMMIT_OR_PR_TITLE".into();
515 config.protection.github.squash_body_source = "BLANK".into();
516 check(&config).expect("no squash source applies under rebase");
517 config.git.merge_form = Some(MergeForm::Squash);
518 config.protection = crate::config::with_merge_form(&config.protection, MergeForm::Squash);
519 let error = check(&config)
520 .expect_err("the squash form floors its title source")
521 .to_string();
522 assert!(
523 error.contains("protection.github.squash_title_source"),
524 "{error}"
525 );
526 }
527
528 #[test]
529 fn merge_form_never_admits_a_second_method() {
530 let mut config = Config::default();
531 config.git.merge_form = Some(MergeForm::Rebase);
532 rebase_policy(&mut config);
533 config.protection.allowed_merge_methods = vec!["rebase".into(), "squash".into()];
534 let error = check(&config)
535 .expect_err("two methods are not one linear form")
536 .to_string();
537 assert!(
538 error.contains("protection.allowed_merge_methods"),
539 "{error}"
540 );
541 }
542}