1use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15
16use camino::Utf8PathBuf;
17use zeroize::Zeroizing;
18
19use super::secrets;
20use crate::detect::{self, Forge};
21use crate::diagnostic::{Diagnostic, Reason};
22use crate::error::RkError;
23use crate::profile::{CapabilityRequests, ProfileSnapshot, ReleaseMode};
24
25const fn bool_word(value: bool) -> &'static str {
32 if value { "true" } else { "false" }
33}
34
35fn json_list(values: &[String]) -> String {
38 let inner: Vec<String> = values.iter().map(|value| format!("\"{value}\"")).collect();
39 format!("[{}]", inner.join(", "))
40}
41
42pub(super) fn github_bypass_actors(values: &[String]) -> serde_json::Value {
48 serde_json::Value::Array(
49 values
50 .iter()
51 .filter_map(|value| match value.as_str() {
52 crate::config::LOCAL_GITHUB_BYPASS => Some(serde_json::json!({
53 "actor_id": 5,
54 "actor_type": "RepositoryRole",
55 "bypass_mode": "always",
56 })),
57 _ => None,
58 })
59 .collect(),
60 )
61}
62
63fn compose_rules(
76 protection: &crate::config::Protection,
77 wanted: &[&str],
78 required_check: &str,
79 title_check: &str,
80) -> String {
81 let rules: Vec<serde_json::Value> = protection
82 .owned_trunk_rules
83 .iter()
84 .filter(|rule| wanted.iter().any(|kind| kind == &rule.as_str()))
85 .map(|rule| match rule.as_str() {
86 "pull_request" => serde_json::json!({
87 "type": "pull_request",
88 "parameters": {
89 "required_approving_review_count": protection.required_approving_review_count,
90 "dismiss_stale_reviews_on_push": protection.dismiss_stale_reviews_on_push,
91 "require_code_owner_review": protection.require_code_owner_review,
92 "require_last_push_approval": protection.require_last_push_approval,
93 "required_review_thread_resolution": false,
94 "require_extra_approval_for_unattributed_changes": false,
95 "allowed_merge_methods": protection.allowed_merge_methods,
96 }
97 }),
98 "required_status_checks" => serde_json::json!({
99 "type": "required_status_checks",
100 "parameters": {
101 "do_not_enforce_on_create": true,
102 "strict_required_status_checks_policy":
103 protection.strict_required_status_checks,
104 "required_status_checks": [
105 { "context": required_check },
106 { "context": title_check },
107 ],
108 }
109 }),
110 other => serde_json::json!({ "type": other }),
111 })
112 .collect();
113 serde_json::to_string_pretty(&rules).unwrap_or_else(|_| "[]".to_owned())
116}
117
118fn effective_protection(
140 stated: Option<&crate::config::Protection>,
141 integration: crate::landing::Integration,
142) -> crate::config::Protection {
143 if let Some(stated) = stated {
144 if integration == crate::landing::Integration::Local
145 && crate::config::legacy_local_protection(stated)
146 {
147 let mut migrated = stated.clone();
148 let current = crate::config::local_protection();
149 migrated.bypass_actors = current.bypass_actors;
150 migrated.owned_trunk_rules = current.owned_trunk_rules;
151 migrated.gitlab.push_access_level = current.gitlab.push_access_level;
152 return migrated;
153 }
154 return stated.clone();
155 }
156 if integration == crate::landing::Integration::Local {
157 return crate::config::local_protection();
158 }
159 crate::config::Protection::default()
160}
161
162const PASSTHROUGH: [&str; 11] = [
166 "PATH",
167 "HOME",
168 "XDG_CONFIG_HOME",
169 "GH_TOKEN",
170 "GITHUB_TOKEN",
171 "GH_HOST",
172 "GH_CONFIG_DIR",
173 "GLAB_TOKEN",
174 "GITLAB_TOKEN",
175 "GITLAB_HOST",
176 "GLAB_CONFIG_DIR",
177];
178
179pub use super::secrets::VALUE_VARS as SECRET_VARS;
185
186#[derive(Debug, Clone)]
188pub struct Ctx {
189 pub target: Utf8PathBuf,
191 pub repo: String,
193 pub forge: Option<Forge>,
198 pub declared_forge: Option<String>,
201 pub profile: ProfileSnapshot,
203 pub capabilities: CapabilityRequests,
205 pub host: Option<String>,
207 pub required_check: Option<String>,
209 pub required_workflow: Option<String>,
214 pub cli: PathBuf,
216 pub tech: Option<&'static str>,
218 trunk: String,
221 line_prefix: String,
224 retired_branches: Vec<String>,
227 release_lines: bool,
230 excluded_steps: std::collections::BTreeMap<String, String>,
233 bot_app_id: Option<String>,
236 trunk_ruleset: String,
238 safety_ruleset: String,
241 tag_ruleset: String,
243 lines_ruleset: String,
245 title_check: String,
247 protection: crate::config::Protection,
255 landed: Option<Vec<String>>,
258 integration: crate::landing::Integration,
263}
264
265impl Ctx {
266 pub fn resolve(
275 target: &Utf8PathBuf,
276 repo_flag: Option<&str>,
277 forge_flag: Option<&str>,
278 required_check: Option<&str>,
279 ) -> Result<Self, RkError> {
280 if !target.is_dir() {
281 return Err(RkError::missing(
282 Diagnostic::new(
283 Reason::TargetNotFound,
284 format!("target {target} is not a directory; nothing was run"),
285 )
286 .expected("an existing repository to set up"),
287 ));
288 }
289 let forge_flag = forge_flag
290 .map(|name| {
291 detect::Forge::parse(name).ok_or_else(|| {
292 RkError::Usage(format!(
293 "unknown forge '{name}'; the forges are: github, gitlab"
294 ))
295 })
296 })
297 .transpose()?;
298 let detected = detect::detect(target.as_std_path());
299 let config = crate::config::load(target.as_std_path())?;
300 let record = crate::landing::manifest::load(target)?;
301 let resolved = crate::profile::Params::resolve(
305 target,
306 &crate::profile::Inputs {
307 forge: forge_flag.map(Forge::as_str),
308 repo: repo_flag,
309 ..crate::profile::Inputs::default()
310 },
311 config.as_ref(),
312 record.as_ref(),
313 crate::profile::Purpose::Preview,
314 )?;
315 let declared_forge = resolved.forge().map(str::to_owned);
316 let forge = declared_forge.as_deref().and_then(Forge::parse);
317 let repo = resolved.repo().to_owned();
318 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
319 String::new()
320 } else {
321 repo
322 };
323 let cli = PathBuf::new();
327 let answers = config
328 .as_ref()
329 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
330 let required_check = required_check.map(str::to_owned).or_else(|| {
334 Some(answers.required_check.clone())
335 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
336 });
337 let required_workflow = Some(answers.required_workflow.clone())
338 .filter(|name| !name.is_empty() && forge == Some(Forge::Github));
339 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
340 let trunk = resolved.trunk().to_owned();
341 let integration = record
346 .as_ref()
347 .map_or_else(crate::landing::manifest::integration_forge, |held| {
348 held.git.integration
349 });
350 let landed = record.as_ref().map(|held| {
351 held.files
352 .iter()
353 .map(|file| file.destination.clone())
354 .collect()
355 });
356 let stated = config.as_ref().map(|held| &held.protection);
357 let protection = effective_protection(stated, integration);
358 Ok(Self {
359 target: target.clone(),
360 repo,
361 forge,
362 host: detected.host,
363 required_check,
364 required_workflow,
365 cli,
366 tech: resolved.driver().and_then(|driver| {
367 ["rust", "python", "bash"]
368 .into_iter()
369 .find(|known| *known == driver)
370 }),
371 trunk_ruleset: protection.trunk_ruleset(&trunk),
372 safety_ruleset: protection.safety_ruleset(&trunk),
373 tag_ruleset: protection.tag_ruleset.clone(),
374 lines_ruleset: protection.lines_ruleset.clone(),
375 title_check: protection.title_check.clone(),
376 protection,
377 integration,
378 landed,
379 trunk,
380 line_prefix: resolved.line_prefix().to_owned(),
381 profile: resolved.profile().clone(),
382 capabilities: resolved.capabilities().clone(),
383 declared_forge,
384 retired_branches: answers.retired_branches,
385 release_lines: answers.release_lines,
386 excluded_steps: answers.excluded_steps,
387 bot_app_id,
388 })
389 }
390
391 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
406 let Some(forge) = self.forge else {
407 return Ok(());
408 };
409 let calls = steps.iter().any(|step| {
410 step.forge_cli.contains(&forge) && crate::commands::setup::stance(self, step).acts()
411 });
412 if calls && self.cli.as_os_str().is_empty() {
413 self.cli = resolve_cli(forge)?;
414 }
415 Ok(())
416 }
417
418 #[doc(hidden)]
423 #[must_use]
424 pub fn for_tests(
425 target: Utf8PathBuf,
426 repo: String,
427 forge: Forge,
428 cli: PathBuf,
429 tech: Option<&'static str>,
430 ) -> Self {
431 let defaults = crate::config::Protection::default();
432 Self {
433 integration: crate::landing::Integration::Forge,
436 landed: None,
437 target,
438 repo,
439 forge: Some(forge),
440 declared_forge: Some(forge.as_str().to_owned()),
441 profile: ProfileSnapshot {
442 technologies: tech.into_iter().map(str::to_owned).collect(),
443 forge: Some(forge.as_str().to_owned()),
444 release: crate::profile::ReleaseIntent {
445 mode: ReleaseMode::Automatic,
446 driver: tech.map(str::to_owned),
447 style: Some(crate::landing::Style::Trunk),
448 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
449 },
450 },
451 capabilities: CapabilityRequests {
452 nix_packaging: false,
453 reporting_policy: true,
454 scorecard: false,
455 code_scanning: None,
456 },
457 host: None,
458 required_check: None,
459 required_workflow: None,
460 cli,
461 tech,
462 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
463 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
464 retired_branches: crate::config::Setup::default().retired_branches,
465 release_lines: false,
466 excluded_steps: std::collections::BTreeMap::new(),
467 bot_app_id: None,
468 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
469 safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
470 tag_ruleset: defaults.tag_ruleset.clone(),
471 lines_ruleset: defaults.lines_ruleset.clone(),
472 title_check: defaults.title_check.clone(),
473 protection: defaults,
474 }
475 }
476
477 #[must_use]
480 pub fn landed_destinations(&self) -> Option<&[String]> {
481 self.landed.as_deref()
482 }
483
484 #[must_use]
486 pub const fn has_adapter(&self) -> bool {
487 self.forge.is_some()
488 }
489
490 pub fn adapter(&self) -> Result<Forge, RkError> {
498 self.forge.ok_or_else(|| {
499 let named = self.declared_forge.as_deref();
500 let message = named.map_or_else(
501 || "the profile names no forge, and this operation acts on one".to_owned(),
502 |name| {
503 format!(
504 "the profile names the forge {name}, which this release has no adapter for"
505 )
506 },
507 );
508 RkError::refusal(
509 Diagnostic::new(Reason::PrerequisiteUnmet, message)
510 .expected("a profile naming github or gitlab")
511 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
512 .target_state("unchanged"),
513 )
514 })
515 }
516
517 #[must_use]
519 pub const fn automatic_release(&self) -> bool {
520 matches!(self.profile.release.mode, ReleaseMode::Automatic)
521 }
522
523 #[must_use]
525 pub fn driver(&self) -> Option<&str> {
526 self.profile.release.driver.as_deref()
527 }
528
529 #[must_use]
531 pub fn declared_forge(&self) -> Option<&str> {
532 self.declared_forge.as_deref()
533 }
534
535 #[must_use]
537 pub const fn reporting_policy(&self) -> bool {
538 self.capabilities.reporting_policy
539 }
540
541 #[must_use]
543 pub fn trunk(&self) -> &str {
544 &self.trunk
545 }
546
547 #[must_use]
549 pub fn line_prefix(&self) -> &str {
550 &self.line_prefix
551 }
552
553 #[must_use]
555 pub fn retired_branches(&self) -> &[String] {
556 &self.retired_branches
557 }
558
559 #[must_use]
561 pub const fn release_lines(&self) -> bool {
562 self.release_lines
563 }
564
565 #[must_use]
569 pub fn excluded(&self, step: &str) -> Option<&str> {
570 self.excluded_steps.get(step).map(String::as_str)
571 }
572
573 #[must_use]
575 pub fn excluded_count(&self) -> usize {
576 self.excluded_steps.len()
577 }
578
579 #[must_use]
581 pub fn bot_app_id(&self) -> Option<&str> {
582 self.bot_app_id.as_deref()
583 }
584
585 #[must_use]
587 pub fn trunk_ruleset(&self) -> &str {
588 &self.trunk_ruleset
589 }
590
591 #[must_use]
593 pub fn safety_ruleset(&self) -> &str {
594 &self.safety_ruleset
595 }
596
597 #[must_use]
599 pub fn tag_ruleset(&self) -> &str {
600 &self.tag_ruleset
601 }
602
603 #[must_use]
605 pub fn lines_ruleset(&self) -> &str {
606 &self.lines_ruleset
607 }
608
609 #[must_use]
611 pub fn title_check(&self) -> &str {
612 &self.title_check
613 }
614
615 #[must_use]
617 pub const fn integration(&self) -> crate::landing::Integration {
618 self.integration
619 }
620
621 fn trunk_rules(&self) -> String {
624 compose_rules(
625 &self.protection,
626 &crate::config::REQUEST_RULES,
627 self.required_check.as_deref().unwrap_or_default(),
628 &self.title_check,
629 )
630 }
631
632 fn safety_rules(&self) -> String {
634 compose_rules(
635 &self.protection,
636 &crate::config::SAFETY_RULES,
637 self.required_check.as_deref().unwrap_or_default(),
638 &self.title_check,
639 )
640 }
641
642 #[must_use]
644 pub const fn protection(&self) -> &crate::config::Protection {
645 &self.protection
646 }
647
648 #[must_use]
651 pub fn self_hosted_gitlab(&self) -> bool {
652 self.forge == Some(Forge::Gitlab)
653 && self
654 .host
655 .as_deref()
656 .is_some_and(|host| host != "gitlab.com")
657 }
658
659 #[must_use]
662 #[allow(
663 clippy::too_many_lines,
664 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
665 )]
666 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
667 let mut env: Vec<(OsString, OsString)> = vec![
668 (
669 "RK_FORGE".into(),
670 self.forge.map_or("", Forge::as_str).into(),
671 ),
672 ("RK_REPO".into(), self.repo.clone().into()),
673 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
674 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
675 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
676 (
677 "RK_SAFETY_RULESET".into(),
678 self.safety_ruleset.clone().into(),
679 ),
680 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
681 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
682 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
683 (
687 "RK_TAG_PATTERN".into(),
688 self.protection.tag_pattern.clone().into(),
689 ),
690 (
691 "RK_REVIEW_COUNT".into(),
692 self.protection
693 .required_approving_review_count
694 .to_string()
695 .into(),
696 ),
697 (
698 "RK_DISMISS_STALE_REVIEWS".into(),
699 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
700 ),
701 (
702 "RK_CODE_OWNER_REVIEW".into(),
703 bool_word(self.protection.require_code_owner_review).into(),
704 ),
705 (
706 "RK_LAST_PUSH_APPROVAL".into(),
707 bool_word(self.protection.require_last_push_approval).into(),
708 ),
709 (
710 "RK_MERGE_METHODS".into(),
711 json_list(&self.protection.allowed_merge_methods).into(),
712 ),
713 (
714 "RK_STRICT_CHECKS".into(),
715 bool_word(self.protection.strict_required_status_checks).into(),
716 ),
717 (
718 "RK_BYPASS_ACTORS".into(),
719 github_bypass_actors(&self.protection.bypass_actors)
720 .to_string()
721 .into(),
722 ),
723 (
724 "RK_SQUASH_TITLE_SOURCE".into(),
725 self.protection.github.squash_title_source.clone().into(),
726 ),
727 (
728 "RK_SQUASH_BODY_SOURCE".into(),
729 self.protection.github.squash_body_source.clone().into(),
730 ),
731 (
732 "RK_GITLAB_MERGE_METHOD".into(),
733 self.protection.gitlab.merge_method.clone().into(),
734 ),
735 (
736 "RK_GITLAB_SQUASH_OPTION".into(),
737 self.protection.gitlab.squash_option.clone().into(),
738 ),
739 (
740 "RK_GITLAB_SQUASH_TEMPLATE".into(),
741 self.protection.gitlab.squash_commit_template.clone().into(),
742 ),
743 (
744 "RK_GITLAB_PUSH_LEVEL".into(),
745 self.protection.gitlab.push_access_level.to_string().into(),
746 ),
747 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
755 ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
756 (
757 "RK_GITLAB_MERGE_LEVEL".into(),
758 self.protection.gitlab.merge_access_level.to_string().into(),
759 ),
760 ("GH_PAGER".into(), "".into()),
761 ("GLAB_PAGER".into(), "".into()),
762 ];
763 if let Some(check) = &self.required_check
764 && self.forge == Some(Forge::Github)
765 && matches!(step, "protect-trunk" | "protections-check")
766 {
767 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
768 }
769 for name in PASSTHROUGH {
770 if let Some(value) = std::env::var_os(name) {
771 env.push((name.into(), value));
772 }
773 }
774 if let Some(dir) = self.cli_override_dir() {
778 let mut paths: Vec<PathBuf> = vec![dir];
779 if let Some(existing) = std::env::var_os("PATH") {
780 paths.extend(std::env::split_paths(&existing));
781 }
782 if let Ok(joined) = std::env::join_paths(paths) {
783 env.retain(|(name, _)| name != "PATH");
784 env.push(("PATH".into(), joined));
785 }
786 }
787 if step == "bot-secrets" {
788 for name in SECRET_VARS {
789 if let Some(value) = secrets::value_of(name) {
790 env.push((name.into(), value));
791 }
792 }
793 }
794 env
795 }
796
797 fn cli_override_dir(&self) -> Option<PathBuf> {
799 let overridden = std::env::var_os(match self.forge? {
800 Forge::Github => "RK_GH_BIN",
801 Forge::Gitlab => "RK_GLAB_BIN",
802 })?;
803 Path::new(&overridden).parent().map(Path::to_path_buf)
804 }
805
806 #[must_use]
814 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
815 SECRET_VARS
816 .iter()
817 .filter_map(|name| secrets::value_of(name))
818 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
819 .collect()
820 }
821}
822
823pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
833 let override_var = match forge {
834 Forge::Github => "RK_GH_BIN",
835 Forge::Gitlab => "RK_GLAB_BIN",
836 };
837 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
838 let path = PathBuf::from(&overridden);
839 if !path.is_file() {
840 return Err(RkError::refusal(
841 Diagnostic::new(
842 Reason::PrerequisiteUnmet,
843 format!(
844 "{override_var} names {}, which does not exist",
845 path.display()
846 ),
847 )
848 .expected("the override to name the forge CLI binary"),
849 ));
850 }
851 if path.file_name().is_none_or(|name| name != forge.cli()) {
856 return Err(RkError::refusal(
857 Diagnostic::new(
858 Reason::PrerequisiteUnmet,
859 format!(
860 "{override_var} must name a binary called {}, and {} is not one",
861 forge.cli(),
862 path.display()
863 ),
864 )
865 .expected(format!(
866 "an override whose file name is {}, so scripts and observations run one binary",
867 forge.cli()
868 )),
869 ));
870 }
871 return Ok(path);
872 }
873 let name = forge.cli();
874 let found = std::env::var_os("PATH").and_then(|path| {
875 std::env::split_paths(&path)
876 .map(|dir| dir.join(name))
877 .find(|candidate| candidate.is_file())
878 });
879 found.ok_or_else(|| {
880 RkError::refusal(
881 Diagnostic::new(
882 Reason::PrerequisiteUnmet,
883 format!(
884 "{name} is not on PATH, and a step this run acts on calls it on {}",
885 forge.as_str()
886 ),
887 )
888 .expected(format!("the {name} CLI installed and authenticated"))
889 .action(format!("install {name}, then run {name} auth login")),
890 )
891 })
892}
893
894#[cfg(test)]
895mod tests {
896 #[test]
903 fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
904 let kinds = |text: &str| -> Vec<String> {
905 let parsed: Vec<serde_json::Value> =
906 serde_json::from_str(text).expect("the rules parse");
907 parsed
908 .iter()
909 .filter_map(|rule| rule["type"].as_str())
910 .map(str::to_owned)
911 .collect()
912 };
913 let mut policy = crate::config::Protection::default();
914 let request =
915 super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
916 assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
917 let safety =
918 super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
919 assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
920
921 let parsed: Vec<serde_json::Value> =
922 serde_json::from_str(&request).expect("the rules parse");
923 let checks = parsed
924 .iter()
925 .find(|rule| rule["type"] == "required_status_checks")
926 .expect("the check rule");
927 assert_eq!(
928 checks["parameters"]["required_status_checks"],
929 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
930 );
931
932 policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
935 assert_eq!(
936 kinds(&super::compose_rules(
937 &policy,
938 &crate::config::REQUEST_RULES,
939 "gate",
940 "pr-title"
941 )),
942 ["pull_request", "required_status_checks"]
943 );
944 assert_eq!(
945 kinds(&super::compose_rules(
946 &policy,
947 &crate::config::SAFETY_RULES,
948 "gate",
949 "pr-title"
950 )),
951 ["deletion", "non_fast_forward"]
952 );
953 }
954
955 #[test]
968 fn the_effective_policy_follows_the_recorded_authority() {
969 use crate::landing::Integration;
970 let silent = super::effective_protection(None, Integration::Forge);
971 assert!(
972 silent
973 .owned_trunk_rules
974 .contains(&"pull_request".to_owned())
975 );
976 assert_eq!(silent.gitlab.push_access_level, 0);
977
978 let silent = super::effective_protection(None, Integration::Local);
979 assert_eq!(
980 silent.owned_trunk_rules,
981 crate::config::Protection::default().owned_trunk_rules,
982 "local integration retains the release request's atomic check"
983 );
984 assert_eq!(
985 silent.bypass_actors,
986 [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
987 );
988 assert_eq!(
989 silent.gitlab.push_access_level, 40,
990 "zero would close the trunk to the push this mode ends in"
991 );
992
993 let mut stated = crate::config::Protection::default();
994 stated.gitlab.push_access_level = 0;
995 stated.owned_trunk_rules = vec!["deletion".into()];
996 let held = super::effective_protection(Some(&stated), Integration::Local);
997 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
998 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
999
1000 let legacy = crate::config::Protection {
1001 owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
1002 gitlab: crate::config::Gitlab {
1003 push_access_level: 40,
1004 ..crate::config::Gitlab::default()
1005 },
1006 ..crate::config::Protection::default()
1007 };
1008 let migrated = super::effective_protection(Some(&legacy), Integration::Local);
1009 assert_eq!(
1010 migrated,
1011 crate::config::local_protection(),
1012 "setup cannot reinstall the legacy policy while upgrade remains able to read it"
1013 );
1014 }
1015}