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 integration: crate::landing::Integration,
260}
261
262impl Ctx {
263 pub fn resolve(
272 target: &Utf8PathBuf,
273 repo_flag: Option<&str>,
274 forge_flag: Option<&str>,
275 required_check: Option<&str>,
276 ) -> Result<Self, RkError> {
277 if !target.is_dir() {
278 return Err(RkError::missing(
279 Diagnostic::new(
280 Reason::TargetNotFound,
281 format!("target {target} is not a directory; nothing was run"),
282 )
283 .expected("an existing repository to set up"),
284 ));
285 }
286 let forge_flag = forge_flag
287 .map(|name| {
288 detect::Forge::parse(name).ok_or_else(|| {
289 RkError::Usage(format!(
290 "unknown forge '{name}'; the forges are: github, gitlab"
291 ))
292 })
293 })
294 .transpose()?;
295 let detected = detect::detect(target.as_std_path());
296 let config = crate::config::load(target.as_std_path())?;
297 let record = crate::landing::manifest::load(target)?;
298 let resolved = crate::profile::Params::resolve(
302 target,
303 &crate::profile::Inputs {
304 forge: forge_flag.map(Forge::as_str),
305 repo: repo_flag,
306 ..crate::profile::Inputs::default()
307 },
308 config.as_ref(),
309 record.as_ref(),
310 crate::profile::Purpose::Preview,
311 )?;
312 let declared_forge = resolved.forge().map(str::to_owned);
313 let forge = declared_forge.as_deref().and_then(Forge::parse);
314 let repo = resolved.repo().to_owned();
315 let repo = if repo == crate::projection::REPO_PLACEHOLDER {
316 String::new()
317 } else {
318 repo
319 };
320 let cli = PathBuf::new();
324 let answers = config
325 .as_ref()
326 .map_or_else(crate::config::Setup::default, |held| held.setup.clone());
327 let required_check = required_check.map(str::to_owned).or_else(|| {
331 Some(answers.required_check.clone())
332 .filter(|name| !name.is_empty() && forge == Some(Forge::Github))
333 });
334 let required_workflow = Some(answers.required_workflow.clone())
335 .filter(|name| !name.is_empty() && forge == Some(Forge::Github));
336 let bot_app_id = Some(answers.bot.app_id.clone()).filter(|id| !id.is_empty());
337 let trunk = resolved.trunk().to_owned();
338 let integration = record
343 .as_ref()
344 .map_or_else(crate::landing::manifest::integration_forge, |held| {
345 held.git.integration
346 });
347 let stated = config.as_ref().map(|held| &held.protection);
348 let protection = effective_protection(stated, integration);
349 Ok(Self {
350 target: target.clone(),
351 repo,
352 forge,
353 host: detected.host,
354 required_check,
355 required_workflow,
356 cli,
357 tech: resolved.driver().and_then(|driver| {
358 ["rust", "python", "bash"]
359 .into_iter()
360 .find(|known| *known == driver)
361 }),
362 trunk_ruleset: protection.trunk_ruleset(&trunk),
363 safety_ruleset: protection.safety_ruleset(&trunk),
364 tag_ruleset: protection.tag_ruleset.clone(),
365 lines_ruleset: protection.lines_ruleset.clone(),
366 title_check: protection.title_check.clone(),
367 protection,
368 integration,
369 trunk,
370 line_prefix: resolved.line_prefix().to_owned(),
371 profile: resolved.profile().clone(),
372 capabilities: resolved.capabilities().clone(),
373 declared_forge,
374 retired_branches: answers.retired_branches,
375 release_lines: answers.release_lines,
376 excluded_steps: answers.excluded_steps,
377 bot_app_id,
378 })
379 }
380
381 pub fn require_cli(&mut self, steps: &[&crate::setup::steps::StepSpec]) -> Result<(), RkError> {
396 let Some(forge) = self.forge else {
397 return Ok(());
398 };
399 let calls = steps.iter().any(|step| {
400 step.forge_cli.contains(&forge) && crate::commands::setup::stance(self, step).acts()
401 });
402 if calls && self.cli.as_os_str().is_empty() {
403 self.cli = resolve_cli(forge)?;
404 }
405 Ok(())
406 }
407
408 #[doc(hidden)]
413 #[must_use]
414 pub fn for_tests(
415 target: Utf8PathBuf,
416 repo: String,
417 forge: Forge,
418 cli: PathBuf,
419 tech: Option<&'static str>,
420 ) -> Self {
421 let defaults = crate::config::Protection::default();
422 Self {
423 integration: crate::landing::Integration::Forge,
426 target,
427 repo,
428 forge: Some(forge),
429 declared_forge: Some(forge.as_str().to_owned()),
430 profile: ProfileSnapshot {
431 technologies: tech.into_iter().map(str::to_owned).collect(),
432 forge: Some(forge.as_str().to_owned()),
433 release: crate::profile::ReleaseIntent {
434 mode: ReleaseMode::Automatic,
435 driver: tech.map(str::to_owned),
436 style: Some(crate::landing::Style::Trunk),
437 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
438 },
439 },
440 capabilities: CapabilityRequests {
441 nix_packaging: false,
442 reporting_policy: true,
443 scorecard: false,
444 code_scanning: None,
445 },
446 host: None,
447 required_check: None,
448 required_workflow: None,
449 cli,
450 tech,
451 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
452 line_prefix: crate::config::LINE_PREFIX_DEFAULT.to_owned(),
453 retired_branches: crate::config::Setup::default().retired_branches,
454 release_lines: false,
455 excluded_steps: std::collections::BTreeMap::new(),
456 bot_app_id: None,
457 trunk_ruleset: format!("{}-protection", crate::config::TRUNK_DEFAULT),
458 safety_ruleset: format!("{}-safety", crate::config::TRUNK_DEFAULT),
459 tag_ruleset: defaults.tag_ruleset.clone(),
460 lines_ruleset: defaults.lines_ruleset.clone(),
461 title_check: defaults.title_check.clone(),
462 protection: defaults,
463 }
464 }
465
466 #[must_use]
468 pub const fn has_adapter(&self) -> bool {
469 self.forge.is_some()
470 }
471
472 pub fn adapter(&self) -> Result<Forge, RkError> {
480 self.forge.ok_or_else(|| {
481 let named = self.declared_forge.as_deref();
482 let message = named.map_or_else(
483 || "the profile names no forge, and this operation acts on one".to_owned(),
484 |name| {
485 format!(
486 "the profile names the forge {name}, which this release has no adapter for"
487 )
488 },
489 );
490 RkError::refusal(
491 Diagnostic::new(Reason::PrerequisiteUnmet, message)
492 .expected("a profile naming github or gitlab")
493 .action("set profile.forge in .release-kit/config.toml, or pass --forge <github|gitlab>")
494 .target_state("unchanged"),
495 )
496 })
497 }
498
499 #[must_use]
501 pub const fn automatic_release(&self) -> bool {
502 matches!(self.profile.release.mode, ReleaseMode::Automatic)
503 }
504
505 #[must_use]
507 pub fn driver(&self) -> Option<&str> {
508 self.profile.release.driver.as_deref()
509 }
510
511 #[must_use]
513 pub fn declared_forge(&self) -> Option<&str> {
514 self.declared_forge.as_deref()
515 }
516
517 #[must_use]
519 pub const fn reporting_policy(&self) -> bool {
520 self.capabilities.reporting_policy
521 }
522
523 #[must_use]
525 pub fn trunk(&self) -> &str {
526 &self.trunk
527 }
528
529 #[must_use]
531 pub fn line_prefix(&self) -> &str {
532 &self.line_prefix
533 }
534
535 #[must_use]
537 pub fn retired_branches(&self) -> &[String] {
538 &self.retired_branches
539 }
540
541 #[must_use]
543 pub const fn release_lines(&self) -> bool {
544 self.release_lines
545 }
546
547 #[must_use]
551 pub fn excluded(&self, step: &str) -> Option<&str> {
552 self.excluded_steps.get(step).map(String::as_str)
553 }
554
555 #[must_use]
557 pub fn excluded_count(&self) -> usize {
558 self.excluded_steps.len()
559 }
560
561 #[must_use]
563 pub fn bot_app_id(&self) -> Option<&str> {
564 self.bot_app_id.as_deref()
565 }
566
567 #[must_use]
569 pub fn trunk_ruleset(&self) -> &str {
570 &self.trunk_ruleset
571 }
572
573 #[must_use]
575 pub fn safety_ruleset(&self) -> &str {
576 &self.safety_ruleset
577 }
578
579 #[must_use]
581 pub fn tag_ruleset(&self) -> &str {
582 &self.tag_ruleset
583 }
584
585 #[must_use]
587 pub fn lines_ruleset(&self) -> &str {
588 &self.lines_ruleset
589 }
590
591 #[must_use]
593 pub fn title_check(&self) -> &str {
594 &self.title_check
595 }
596
597 #[must_use]
599 pub const fn integration(&self) -> crate::landing::Integration {
600 self.integration
601 }
602
603 fn trunk_rules(&self) -> String {
606 compose_rules(
607 &self.protection,
608 &crate::config::REQUEST_RULES,
609 self.required_check.as_deref().unwrap_or_default(),
610 &self.title_check,
611 )
612 }
613
614 fn safety_rules(&self) -> String {
616 compose_rules(
617 &self.protection,
618 &crate::config::SAFETY_RULES,
619 self.required_check.as_deref().unwrap_or_default(),
620 &self.title_check,
621 )
622 }
623
624 #[must_use]
626 pub const fn protection(&self) -> &crate::config::Protection {
627 &self.protection
628 }
629
630 #[must_use]
633 pub fn self_hosted_gitlab(&self) -> bool {
634 self.forge == Some(Forge::Gitlab)
635 && self
636 .host
637 .as_deref()
638 .is_some_and(|host| host != "gitlab.com")
639 }
640
641 #[must_use]
644 #[allow(
645 clippy::too_many_lines,
646 reason = "one pass builds the whole environment a step receives, and splitting it would separate a variable from the value it carries"
647 )]
648 pub fn child_env(&self, step: &str) -> Vec<(OsString, OsString)> {
649 let mut env: Vec<(OsString, OsString)> = vec![
650 (
651 "RK_FORGE".into(),
652 self.forge.map_or("", Forge::as_str).into(),
653 ),
654 ("RK_REPO".into(), self.repo.clone().into()),
655 ("RK_TRUNK_BRANCH".into(), self.trunk.clone().into()),
656 ("RK_LINE_PREFIX".into(), self.line_prefix.clone().into()),
657 ("RK_TRUNK_RULESET".into(), self.trunk_ruleset.clone().into()),
658 (
659 "RK_SAFETY_RULESET".into(),
660 self.safety_ruleset.clone().into(),
661 ),
662 ("RK_TAG_RULESET".into(), self.tag_ruleset.clone().into()),
663 ("RK_LINES_RULESET".into(), self.lines_ruleset.clone().into()),
664 ("RK_TITLE_CHECK".into(), self.title_check.clone().into()),
665 (
669 "RK_TAG_PATTERN".into(),
670 self.protection.tag_pattern.clone().into(),
671 ),
672 (
673 "RK_REVIEW_COUNT".into(),
674 self.protection
675 .required_approving_review_count
676 .to_string()
677 .into(),
678 ),
679 (
680 "RK_DISMISS_STALE_REVIEWS".into(),
681 bool_word(self.protection.dismiss_stale_reviews_on_push).into(),
682 ),
683 (
684 "RK_CODE_OWNER_REVIEW".into(),
685 bool_word(self.protection.require_code_owner_review).into(),
686 ),
687 (
688 "RK_LAST_PUSH_APPROVAL".into(),
689 bool_word(self.protection.require_last_push_approval).into(),
690 ),
691 (
692 "RK_MERGE_METHODS".into(),
693 json_list(&self.protection.allowed_merge_methods).into(),
694 ),
695 (
696 "RK_STRICT_CHECKS".into(),
697 bool_word(self.protection.strict_required_status_checks).into(),
698 ),
699 (
700 "RK_BYPASS_ACTORS".into(),
701 github_bypass_actors(&self.protection.bypass_actors)
702 .to_string()
703 .into(),
704 ),
705 (
706 "RK_SQUASH_TITLE_SOURCE".into(),
707 self.protection.github.squash_title_source.clone().into(),
708 ),
709 (
710 "RK_SQUASH_BODY_SOURCE".into(),
711 self.protection.github.squash_body_source.clone().into(),
712 ),
713 (
714 "RK_GITLAB_MERGE_METHOD".into(),
715 self.protection.gitlab.merge_method.clone().into(),
716 ),
717 (
718 "RK_GITLAB_SQUASH_OPTION".into(),
719 self.protection.gitlab.squash_option.clone().into(),
720 ),
721 (
722 "RK_GITLAB_SQUASH_TEMPLATE".into(),
723 self.protection.gitlab.squash_commit_template.clone().into(),
724 ),
725 (
726 "RK_GITLAB_PUSH_LEVEL".into(),
727 self.protection.gitlab.push_access_level.to_string().into(),
728 ),
729 ("RK_TRUNK_RULES".into(), self.trunk_rules().into()),
737 ("RK_SAFETY_RULES".into(), self.safety_rules().into()),
738 (
739 "RK_GITLAB_MERGE_LEVEL".into(),
740 self.protection.gitlab.merge_access_level.to_string().into(),
741 ),
742 ("GH_PAGER".into(), "".into()),
743 ("GLAB_PAGER".into(), "".into()),
744 ];
745 if let Some(check) = &self.required_check
746 && self.forge == Some(Forge::Github)
747 && matches!(step, "protect-trunk" | "protections-check")
748 {
749 env.push(("RK_REQUIRED_CHECK".into(), check.clone().into()));
750 }
751 for name in PASSTHROUGH {
752 if let Some(value) = std::env::var_os(name) {
753 env.push((name.into(), value));
754 }
755 }
756 if let Some(dir) = self.cli_override_dir() {
760 let mut paths: Vec<PathBuf> = vec![dir];
761 if let Some(existing) = std::env::var_os("PATH") {
762 paths.extend(std::env::split_paths(&existing));
763 }
764 if let Ok(joined) = std::env::join_paths(paths) {
765 env.retain(|(name, _)| name != "PATH");
766 env.push(("PATH".into(), joined));
767 }
768 }
769 if step == "bot-secrets" {
770 for name in SECRET_VARS {
771 if let Some(value) = secrets::value_of(name) {
772 env.push((name.into(), value));
773 }
774 }
775 }
776 env
777 }
778
779 fn cli_override_dir(&self) -> Option<PathBuf> {
781 let overridden = std::env::var_os(match self.forge? {
782 Forge::Github => "RK_GH_BIN",
783 Forge::Gitlab => "RK_GLAB_BIN",
784 })?;
785 Path::new(&overridden).parent().map(Path::to_path_buf)
786 }
787
788 #[must_use]
796 pub fn secret_values() -> Vec<Zeroizing<Vec<u8>>> {
797 SECRET_VARS
798 .iter()
799 .filter_map(|name| secrets::value_of(name))
800 .map(|value| Zeroizing::new(value.into_encoded_bytes()))
801 .collect()
802 }
803}
804
805pub fn resolve_cli(forge: Forge) -> Result<PathBuf, RkError> {
815 let override_var = match forge {
816 Forge::Github => "RK_GH_BIN",
817 Forge::Gitlab => "RK_GLAB_BIN",
818 };
819 if let Some(overridden) = std::env::var_os(override_var).filter(|v| !v.is_empty()) {
820 let path = PathBuf::from(&overridden);
821 if !path.is_file() {
822 return Err(RkError::refusal(
823 Diagnostic::new(
824 Reason::PrerequisiteUnmet,
825 format!(
826 "{override_var} names {}, which does not exist",
827 path.display()
828 ),
829 )
830 .expected("the override to name the forge CLI binary"),
831 ));
832 }
833 if path.file_name().is_none_or(|name| name != forge.cli()) {
838 return Err(RkError::refusal(
839 Diagnostic::new(
840 Reason::PrerequisiteUnmet,
841 format!(
842 "{override_var} must name a binary called {}, and {} is not one",
843 forge.cli(),
844 path.display()
845 ),
846 )
847 .expected(format!(
848 "an override whose file name is {}, so scripts and observations run one binary",
849 forge.cli()
850 )),
851 ));
852 }
853 return Ok(path);
854 }
855 let name = forge.cli();
856 let found = std::env::var_os("PATH").and_then(|path| {
857 std::env::split_paths(&path)
858 .map(|dir| dir.join(name))
859 .find(|candidate| candidate.is_file())
860 });
861 found.ok_or_else(|| {
862 RkError::refusal(
863 Diagnostic::new(
864 Reason::PrerequisiteUnmet,
865 format!(
866 "{name} is not on PATH, and a step this run acts on calls it on {}",
867 forge.as_str()
868 ),
869 )
870 .expected(format!("the {name} CLI installed and authenticated"))
871 .action(format!("install {name}, then run {name} auth login")),
872 )
873 })
874}
875
876#[cfg(test)]
877mod tests {
878 #[test]
885 fn each_ruleset_composes_its_half_of_the_owned_rule_key() {
886 let kinds = |text: &str| -> Vec<String> {
887 let parsed: Vec<serde_json::Value> =
888 serde_json::from_str(text).expect("the rules parse");
889 parsed
890 .iter()
891 .filter_map(|rule| rule["type"].as_str())
892 .map(str::to_owned)
893 .collect()
894 };
895 let mut policy = crate::config::Protection::default();
896 let request =
897 super::compose_rules(&policy, &crate::config::REQUEST_RULES, "gate", "pr-title");
898 assert_eq!(kinds(&request), ["pull_request", "required_status_checks"]);
899 let safety =
900 super::compose_rules(&policy, &crate::config::SAFETY_RULES, "gate", "pr-title");
901 assert_eq!(kinds(&safety), ["deletion", "non_fast_forward"]);
902
903 let parsed: Vec<serde_json::Value> =
904 serde_json::from_str(&request).expect("the rules parse");
905 let checks = parsed
906 .iter()
907 .find(|rule| rule["type"] == "required_status_checks")
908 .expect("the check rule");
909 assert_eq!(
910 checks["parameters"]["required_status_checks"],
911 serde_json::json!([{ "context": "gate" }, { "context": "pr-title" }])
912 );
913
914 policy.bypass_actors = vec![crate::config::LOCAL_GITHUB_BYPASS.into()];
917 assert_eq!(
918 kinds(&super::compose_rules(
919 &policy,
920 &crate::config::REQUEST_RULES,
921 "gate",
922 "pr-title"
923 )),
924 ["pull_request", "required_status_checks"]
925 );
926 assert_eq!(
927 kinds(&super::compose_rules(
928 &policy,
929 &crate::config::SAFETY_RULES,
930 "gate",
931 "pr-title"
932 )),
933 ["deletion", "non_fast_forward"]
934 );
935 }
936
937 #[test]
950 fn the_effective_policy_follows_the_recorded_authority() {
951 use crate::landing::Integration;
952 let silent = super::effective_protection(None, Integration::Forge);
953 assert!(
954 silent
955 .owned_trunk_rules
956 .contains(&"pull_request".to_owned())
957 );
958 assert_eq!(silent.gitlab.push_access_level, 0);
959
960 let silent = super::effective_protection(None, Integration::Local);
961 assert_eq!(
962 silent.owned_trunk_rules,
963 crate::config::Protection::default().owned_trunk_rules,
964 "local integration retains the release request's atomic check"
965 );
966 assert_eq!(
967 silent.bypass_actors,
968 [crate::config::LOCAL_GITHUB_BYPASS.to_owned()]
969 );
970 assert_eq!(
971 silent.gitlab.push_access_level, 40,
972 "zero would close the trunk to the push this mode ends in"
973 );
974
975 let mut stated = crate::config::Protection::default();
976 stated.gitlab.push_access_level = 0;
977 stated.owned_trunk_rules = vec!["deletion".into()];
978 let held = super::effective_protection(Some(&stated), Integration::Local);
979 assert_eq!(held.gitlab.push_access_level, 0, "a stated value wins");
980 assert_eq!(held.owned_trunk_rules, ["deletion".to_owned()]);
981
982 let legacy = crate::config::Protection {
983 owned_trunk_rules: vec!["deletion".into(), "non_fast_forward".into()],
984 gitlab: crate::config::Gitlab {
985 push_access_level: 40,
986 ..crate::config::Gitlab::default()
987 },
988 ..crate::config::Protection::default()
989 };
990 let migrated = super::effective_protection(Some(&legacy), Integration::Local);
991 assert_eq!(
992 migrated,
993 crate::config::local_protection(),
994 "setup cannot reinstall the legacy policy while upgrade remains able to read it"
995 );
996 }
997}