From 73d5127ee28e4b559141bbdd57f686ddbe5877a5 Mon Sep 17 00:00:00 2001 From: Azalea Date: Sun, 10 May 2026 15:23:50 +0000 Subject: [PATCH] [F] Fix default branch --- src/provider.rs | 94 ++++++++++++++++++++++++- src/sync.rs | 45 ++++++++++++ tests/e2e/sequential.rs | 51 +++++++++++++- tests/unit/provider.rs | 150 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 335 insertions(+), 5 deletions(-) diff --git a/src/provider.rs b/src/provider.rs index e9350a0..c609f5f 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -172,6 +172,19 @@ impl<'a> ProviderClient<'a> { ) } + pub fn set_default_branch( + &self, + endpoint: &EndpointConfig, + repo_name: &str, + branch: &str, + ) -> Result<()> { + dispatch_provider!(self.site.provider, + github => self.github_set_default_branch(endpoint, repo_name, branch), + gitlab => self.gitlab_set_default_branch(endpoint, repo_name, branch), + gitea_like => self.gitea_set_default_branch(endpoint, repo_name, branch), + ) + } + pub fn install_webhook( &self, endpoint: &EndpointConfig, @@ -317,6 +330,17 @@ impl<'a> ProviderClient<'a> { self.delete(&url).map(|_| ()) } + fn github_set_default_branch( + &self, + endpoint: &EndpointConfig, + repo_name: &str, + branch: &str, + ) -> Result<()> { + let url = self.repo_url(endpoint, repo_name, "GitHub")?; + self.patch_json::(&url, &json!({ "default_branch": branch })) + .map(|_| ()) + } + fn github_install_webhook( &self, endpoint: &EndpointConfig, @@ -441,7 +465,11 @@ impl<'a> ProviderClient<'a> { projects.push(project); } } - Ok(projects.into_iter().map(Into::into).collect()) + Ok(projects + .into_iter() + .filter(|project| !project.is_deletion_scheduled()) + .map(Into::into) + .collect()) } NamespaceKind::Org | NamespaceKind::Group => { let encoded = urlencoding(&endpoint.namespace); @@ -450,7 +478,12 @@ impl<'a> ProviderClient<'a> { self.site.api_base(), encoded ); - self.paged_remote_repos::(&url) + Ok(self + .paged_get::(&url)? + .into_iter() + .filter(|project| !project.is_deletion_scheduled()) + .map(Into::into) + .collect()) } } } @@ -502,6 +535,17 @@ impl<'a> ProviderClient<'a> { self.delete(&url).map(|_| ()) } + fn gitlab_set_default_branch( + &self, + endpoint: &EndpointConfig, + repo_name: &str, + branch: &str, + ) -> Result<()> { + let url = self.gitlab_project_url(endpoint, repo_name); + self.put_json::(&url, &json!({ "default_branch": branch })) + .map(|_| ()) + } + fn gitlab_group(&self, namespace: &str) -> Result { let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace)); self.get_json(&url) @@ -695,6 +739,17 @@ impl<'a> ProviderClient<'a> { self.delete(&url).map(|_| ()) } + fn gitea_set_default_branch( + &self, + endpoint: &EndpointConfig, + repo_name: &str, + branch: &str, + ) -> Result<()> { + let url = self.repo_url(endpoint, repo_name, "Gitea/Forgejo")?; + self.patch_json::(&url, &json!({ "default_branch": branch })) + .map(|_| ()) + } + fn gitea_install_webhook( &self, endpoint: &EndpointConfig, @@ -1163,6 +1218,10 @@ struct GitlabProject { http_url_to_repo: String, visibility: String, description: Option, + marked_for_deletion_at: Option, + marked_for_deletion_on: Option, + #[serde(default)] + pending_delete: bool, } impl GitlabProject { @@ -1190,6 +1249,37 @@ impl GitlabProject { .eq_ignore_ascii_case(other.project_path()), } } + + fn is_deletion_scheduled(&self) -> bool { + self.pending_delete + || self + .marked_for_deletion_at + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self + .marked_for_deletion_on + .as_deref() + .is_some_and(|value| !value.is_empty()) + || is_gitlab_deletion_scheduled_path(&self.name) + || self + .path + .as_deref() + .is_some_and(is_gitlab_deletion_scheduled_path) + || self + .path_with_namespace + .as_deref() + .and_then(|path| path.rsplit('/').next()) + .is_some_and(is_gitlab_deletion_scheduled_path) + } +} + +fn is_gitlab_deletion_scheduled_path(path: &str) -> bool { + let Some((name, project_id)) = path.rsplit_once("-deletion_scheduled-") else { + return false; + }; + !name.is_empty() + && !project_id.is_empty() + && project_id.bytes().all(|byte| byte.is_ascii_digit()) } #[derive(Deserialize)] diff --git a/src/sync.rs b/src/sync.rs index cbb816b..487451b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -38,6 +38,7 @@ use self::state::{ }; const CONFLICT_BRANCH_ROOT: &str = "refray/conflicts/"; +const DEFAULT_BRANCH: &str = "main"; #[derive(Clone, Debug)] pub struct SyncOptions { @@ -842,6 +843,7 @@ fn sync_repo( let Some(refs) = check_remote_refs(context, repo_name, &remotes)? else { return Ok(RepoSyncOutcome::default()); }; + set_default_branch_for_created_repos(context, repo_name, &created_repos, &refs)?; refs } else { initial_ref_state @@ -857,6 +859,48 @@ fn sync_repo( }) } +fn set_default_branch_for_created_repos( + context: &RepoSyncContext<'_>, + repo_name: &str, + created_repos: &[EndpointRepo], + refs: &BTreeMap, +) -> Result<()> { + if created_repos.is_empty() { + return Ok(()); + } + + let targets = created_repos + .iter() + .filter(|repo| { + refs.get(&remote_name_for_endpoint_repo(repo)) + .is_some_and(|refs| refs.branches.contains_key(DEFAULT_BRANCH)) + }) + .cloned() + .collect::>(); + + crate::parallel::map(targets, context.jobs, |repo| { + crate::logln!( + " {} branch {} {}", + style("default").green().bold(), + style(DEFAULT_BRANCH).cyan(), + style(format!("on {}", repo.endpoint.label())).dim() + ); + let site = context.config.site(&repo.endpoint.site).unwrap(); + ProviderClient::new(site)? + .set_default_branch(&repo.endpoint, repo_name, DEFAULT_BRANCH) + .with_context(|| { + format!( + "failed to set default branch for {} on {}", + repo_name, + repo.endpoint.label() + ) + })?; + Ok(()) + })?; + + Ok(()) +} + fn sync_assumed_repo( context: &RepoSyncContext<'_>, repo_name: &str, @@ -972,6 +1016,7 @@ fn sync_assumed_repo( ..RepoSyncOutcome::default() }); }; + set_default_branch_for_created_repos(context, repo_name, &created_repos, &refs)?; refs } else { initial_ref_check.refs diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs index f722c8e..72d5924 100644 --- a/tests/e2e/sequential.rs +++ b/tests/e2e/sequential.rs @@ -380,9 +380,10 @@ namespace = "{}" git(&work, ["tag", "v1.0.0"])?; let remote_url = source.authenticated_repo_url(&repo)?; self.git(&work, ["remote", "add", "origin", &remote_url])?; - self.git( + self.git_retry( &work, ["push", "origin", "HEAD:main", "feature/github", "v1.0.0"], + "initial seed push", )?; source.wait_branch( &repo, @@ -393,6 +394,7 @@ namespace = "{}" source.wait_repo_listed(&repo)?; self.sync_repo(&repo, [])?; self.assert_branch_all_equal_after_optional_resync(&repo, MAIN_BRANCH)?; + self.assert_default_branch_all_except(&repo, MAIN_BRANCH, &source.site_name)?; self.assert_branch_all_equal(&repo, "feature/github")?; self.assert_tag_all_equal(&repo, "v1.0.0")?; @@ -699,7 +701,7 @@ namespace = "{}" )?; let remote_url = provider.authenticated_repo_url(repo)?; self.git(&work, ["remote", "add", "origin", &remote_url])?; - self.git(&work, ["push", "origin", "HEAD:main"])?; + self.git_retry(&work, ["push", "origin", "HEAD:main"], "seed push")?; provider.wait_branch( repo, MAIN_BRANCH, @@ -726,7 +728,11 @@ namespace = "{}" for provider in &self.settings.providers { let remote_url = provider.authenticated_repo_url(repo)?; self.git(&work, ["remote", "add", &provider.site_name, &remote_url])?; - self.git(&work, ["push", &provider.site_name, "HEAD:main"])?; + self.git_retry( + &work, + ["push", &provider.site_name, "HEAD:main"], + "seed-all push", + )?; provider.wait_branch(repo, MAIN_BRANCH, &sha)?; provider.wait_repo_listed(repo)?; provider.unprotect_branch(repo, MAIN_BRANCH)?; @@ -787,6 +793,10 @@ namespace = "{}" assert_output_success(output, "git", &self.redactor) } + fn git_retry(&self, path: &Path, args: [&str; N], label: &str) -> Result<()> { + retry(label, || self.git(path, args)) + } + fn set_repo_whitelist(&self, pattern: &str) -> Result<()> { let contents = fs::read_to_string(&self.config_path) .with_context(|| format!("failed to read {}", self.config_path.display()))?; @@ -1064,6 +1074,30 @@ namespace = "{}" } } + fn assert_default_branch_all_except( + &self, + repo: &str, + branch: &str, + excluded_site: &str, + ) -> Result<()> { + retry("default branch metadata", || { + for provider in &self.settings.providers { + if provider.site_name == excluded_site { + continue; + } + let actual = provider.default_branch(repo)?; + if actual.as_deref() != Some(branch) { + bail!( + "expected default branch {branch} on {} for {repo}, got {:?}", + provider.site_name, + actual + ); + } + } + Ok(()) + }) + } + fn assert_tag_all_equal(&self, repo: &str, tag: &str) -> Result<()> { retry("tag convergence", || { let refs = self.refs_by_provider(repo)?; @@ -1387,6 +1421,17 @@ impl ProviderAccount { } } + fn default_branch(&self, repo: &str) -> Result> { + let value = self + .get_json::(&self.repo_api_url(repo)) + .with_context(|| format!("failed to inspect {} default branch", self.site_name))?; + Ok(value + .get("default_branch") + .and_then(Value::as_str) + .filter(|branch| !branch.is_empty()) + .map(ToOwned::to_owned)) + } + fn wait_repo_present(&self, repo: &str) -> Result<()> { retry("repo present", || { if self.repo_exists(repo)? { diff --git a/tests/unit/provider.rs b/tests/unit/provider.rs index 105eb1b..4bebafa 100644 --- a/tests/unit/provider.rs +++ b/tests/unit/provider.rs @@ -225,6 +225,42 @@ fn list_gitlab_user_repos_merges_authenticated_owned_projects() { handle.join().unwrap(); } +#[test] +fn list_gitlab_group_repos_ignores_deletion_scheduled_projects() { + let projects = r#"[ + {"name":"active","path":"active","path_with_namespace":"maigolabs/active","http_url_to_repo":"https://gitlab.example.test/maigolabs/active.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"}}, + {"name":"Kairos-deletion_scheduled-82068172","path":"Kairos-deletion_scheduled-82068172","path_with_namespace":"maigolabs/Kairos-deletion_scheduled-82068172","http_url_to_repo":"https://gitlab.example.test/maigolabs/Kairos-deletion_scheduled-82068172.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"}}, + {"name":"marked-at","path":"marked-at","path_with_namespace":"maigolabs/marked-at","http_url_to_repo":"https://gitlab.example.test/maigolabs/marked-at.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"},"marked_for_deletion_at":"2026-05-17"}, + {"name":"marked-on","path":"marked-on","path_with_namespace":"maigolabs/marked-on","http_url_to_repo":"https://gitlab.example.test/maigolabs/marked-on.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"},"marked_for_deletion_on":"2026-05-17"}, + {"name":"pending","path":"pending","path_with_namespace":"maigolabs/pending","http_url_to_repo":"https://gitlab.example.test/maigolabs/pending.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"},"pending_delete":true} + ]"#; + let (api_url, handle) = one_request_server("200 OK", projects, |request| { + assert!( + request.starts_with( + "GET /groups/maigolabs/projects?simple=true&include_subgroups=false&per_page=100 " + ), + "request was {request}" + ); + }); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitlab, None) + }; + + let repos = ProviderClient::new(&site) + .unwrap() + .list_repos(&EndpointConfig { + site: "gitlab".to_string(), + kind: NamespaceKind::Group, + namespace: "maigolabs".to_string(), + }) + .unwrap(); + + assert_eq!(repos.len(), 1); + assert_eq!(repos[0].name, "active"); + handle.join().unwrap(); +} + #[test] fn create_gitlab_repo_returns_existing_repo_when_path_is_taken() { let existing = r#"{"name":"repo","path":"repo","path_with_namespace":"alice/repo","http_url_to_repo":"https://gitlab.example.test/alice/repo.git","visibility":"public","description":"existing","namespace":{"path":"alice","full_path":"alice"}}"#; @@ -282,6 +318,82 @@ fn create_gitlab_repo_returns_existing_repo_when_path_is_taken() { handle.join().unwrap(); } +#[test] +fn set_github_default_branch_patches_repo() { + let (api_url, handle) = one_request_server("200 OK", "{}", |request| { + assert!( + request.starts_with("PATCH /repos/alice/repo "), + "request was {request}" + ); + assert!( + request.contains(r#""default_branch":"main""#), + "request was {request}" + ); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: bearer secret"), + "request was {request}" + ); + }); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Github, None) + }; + + ProviderClient::new(&site) + .unwrap() + .set_default_branch( + &EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + "repo", + "main", + ) + .unwrap(); + handle.join().unwrap(); +} + +#[test] +fn set_gitlab_default_branch_updates_project() { + let (api_url, handle) = one_request_server("200 OK", "{}", |request| { + assert!( + request.starts_with("PUT /projects/alice%2Frepo "), + "request was {request}" + ); + assert!( + request.contains(r#""default_branch":"main""#), + "request was {request}" + ); + assert!( + request + .to_ascii_lowercase() + .contains("private-token: secret"), + "request was {request}" + ); + }); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitlab, None) + }; + + ProviderClient::new(&site) + .unwrap() + .set_default_branch( + &EndpointConfig { + site: "gitlab".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + "repo", + "main", + ) + .unwrap(); + handle.join().unwrap(); +} + #[test] fn install_webhook_posts_github_hook_when_missing() { let (api_url, handle) = request_server( @@ -710,6 +822,44 @@ fn create_gitea_repo_returns_existing_repo_on_conflict() { handle.join().unwrap(); } +#[test] +fn set_gitea_default_branch_patches_repo() { + let (api_url, handle) = one_request_server("200 OK", "{}", |request| { + assert!( + request.starts_with("PATCH /repos/alice/repo "), + "request was {request}" + ); + assert!( + request.contains(r#""default_branch":"main""#), + "request was {request}" + ); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: token secret"), + "request was {request}" + ); + }); + let site = SiteConfig { + api_url: Some(api_url), + ..site(ProviderKind::Gitea, None) + }; + + ProviderClient::new(&site) + .unwrap() + .set_default_branch( + &EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + "repo", + "main", + ) + .unwrap(); + handle.join().unwrap(); +} + #[test] fn open_pull_request_posts_github_pull_when_missing() { let (api_url, handle) = request_server(