diff --git a/src/provider.rs b/src/provider.rs index ca802e5..c68650c 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -106,6 +106,14 @@ impl<'a> ProviderClient<'a> { ) } + pub fn delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { + dispatch_provider!(self.site.provider, + github => self.github_delete_repo(endpoint, repo_name), + gitlab => self.gitlab_delete_repo(endpoint, repo_name), + gitea_like => self.gitea_delete_repo(endpoint, repo_name), + ) + } + pub fn install_webhook( &self, endpoint: &EndpointConfig, @@ -246,6 +254,11 @@ impl<'a> ProviderClient<'a> { }) } + fn github_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { + let url = self.repo_url(endpoint, repo_name, "GitHub")?; + self.delete(&url).map(|_| ()) + } + fn github_install_webhook( &self, endpoint: &EndpointConfig, @@ -398,6 +411,11 @@ impl<'a> ProviderClient<'a> { .map(Into::into) } + fn gitlab_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { + let url = self.gitlab_project_url(endpoint, repo_name); + self.delete(&url).map(|_| ()) + } + fn gitlab_group(&self, namespace: &str) -> Result { let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace)); self.get_json(&url) @@ -574,6 +592,11 @@ impl<'a> ProviderClient<'a> { Ok(None) } + fn gitea_delete_repo(&self, endpoint: &EndpointConfig, repo_name: &str) -> Result<()> { + let url = self.repo_url(endpoint, repo_name, "Gitea/Forgejo")?; + self.delete(&url).map(|_| ()) + } + fn gitea_install_webhook( &self, endpoint: &EndpointConfig, @@ -674,6 +697,22 @@ impl<'a> ProviderClient<'a> { Ok(closed) } + fn repo_url( + &self, + endpoint: &EndpointConfig, + repo_name: &str, + provider: &str, + ) -> Result { + if matches!(endpoint.kind, NamespaceKind::Group) { + bail!("{provider} endpoints use kind 'user' or 'org'"); + } + Ok(format!( + "{}/repos/{}/{repo_name}", + self.site.api_base(), + endpoint.namespace + )) + } + fn repo_hooks_url( &self, endpoint: &EndpointConfig, @@ -707,18 +746,20 @@ impl<'a> ProviderClient<'a> { } fn gitlab_hooks_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String { - let project = format!("{}/{repo_name}", endpoint.namespace); - format!( - "{}/projects/{}/hooks", - self.site.api_base(), - urlencoding(&project) - ) + format!("{}/hooks", self.gitlab_project_url(endpoint, repo_name)) } fn gitlab_merge_requests_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String { + format!( + "{}/merge_requests", + self.gitlab_project_url(endpoint, repo_name) + ) + } + + fn gitlab_project_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String { let project = format!("{}/{repo_name}", endpoint.namespace); format!( - "{}/projects/{}/merge_requests", + "{}/projects/{}", self.site.api_base(), urlencoding(&project) ) diff --git a/src/sync.rs b/src/sync.rs index 9afe1d2..c3b7061 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -178,19 +178,24 @@ fn sync_group( } let mut repos = repos_by_name(all_endpoint_repos); - let all_repo_count = repos.len(); let retry_repo_names = context .retry_failed_repos .and_then(|repos| repos.get(&mirror.name)); - let repo_names = repos + let tracked_repo_names = context.ref_state.repo_names(&mirror.name); + let all_repo_names = repos .keys() + .cloned() + .chain(tracked_repo_names) + .collect::>(); + let all_repo_count = all_repo_names.len(); + let repo_names = all_repo_names + .into_iter() .filter(|name| { context .repo_pattern .is_none_or(|pattern| pattern.is_match(name)) && retry_repo_names.is_none_or(|repos| repos.contains(name.as_str())) }) - .cloned() .collect::>(); if repo_names.is_empty() { if let Some(retry_repo_names) = retry_repo_names { @@ -314,10 +319,19 @@ fn sync_group( for result in receiver { match result { Ok(success) => { - if let Some(refs) = success.outcome.ref_update { - context - .ref_state - .set_repo(&mirror.name, &success.repo_name, refs); + if let Some(update) = success.outcome.state_update { + match update { + RepoStateUpdate::Set(refs) => { + context + .ref_state + .set_repo(&mirror.name, &success.repo_name, refs); + } + RepoStateUpdate::Remove => { + context + .ref_state + .remove_repo(&mirror.name, &success.repo_name); + } + } } } Err(failure) => { @@ -481,7 +495,12 @@ struct RepoSyncContext<'a> { #[derive(Default)] struct RepoSyncOutcome { - ref_update: Option>, + state_update: Option, +} + +enum RepoStateUpdate { + Set(BTreeMap), + Remove, } fn sync_repo( @@ -497,19 +516,31 @@ fn sync_repo( style("Repo").magenta().bold(), style(repo_name).bold() ); + let previous_repo_refs = ref_state.repo(&context.mirror.name, repo_name); if repos.is_empty() { - crate::logln!( - " {} {}", - style("skip").yellow().bold(), - style("repository not found on any endpoint").dim() - ); - return Ok(RepoSyncOutcome::default()); + return handle_repo_deletion( + context, + repo_name, + repos, + previous_repo_refs, + &BTreeMap::new(), + ) + .map(|outcome| outcome.unwrap_or_default()); } let initial_remotes = remote_specs(context, repos)?; let Some(initial_ref_state) = check_remote_refs(context, repo_name, &initial_remotes)? else { return Ok(RepoSyncOutcome::default()); }; + if let Some(outcome) = handle_repo_deletion( + context, + repo_name, + repos, + previous_repo_refs, + &initial_ref_state, + )? { + return Ok(outcome); + } let all_endpoints_present = all_configured_endpoints_present(context.mirror, repos); if !context.dry_run && all_endpoints_present @@ -539,7 +570,7 @@ fn sync_repo( style("up-to-date").green().bold() ); return Ok(RepoSyncOutcome { - ref_update: Some(initial_ref_state), + state_update: Some(RepoStateUpdate::Set(initial_ref_state)), }); } @@ -606,8 +637,7 @@ fn sync_repo( &mirror_repo, &remotes, repos, - detailed_repo_ref_state(ref_state.repo(&context.mirror.name, repo_name)) - .or(cached_ref_state.as_ref()), + detailed_repo_ref_state(previous_repo_refs).or(cached_ref_state.as_ref()), &initial_ref_state, )?; if !context.dry_run && !result.had_conflicts { @@ -620,12 +650,117 @@ fn sync_repo( initial_ref_state }; return Ok(RepoSyncOutcome { - ref_update: Some(refs), + state_update: Some(RepoStateUpdate::Set(refs)), }); } Ok(RepoSyncOutcome::default()) } +fn handle_repo_deletion( + context: &RepoSyncContext<'_>, + repo_name: &str, + repos: &[EndpointRepo], + previous_refs: Option<&BTreeMap>, + current_refs: &BTreeMap, +) -> Result> { + match repo_deletion_decision(context.mirror, repos, previous_refs, current_refs) { + RepoDeletionDecision::None => { + if repos.is_empty() { + crate::logln!( + " {} {}", + style("skip").yellow().bold(), + style("repository not found on any endpoint").dim() + ); + return Ok(Some(RepoSyncOutcome::default())); + } + Ok(None) + } + RepoDeletionDecision::DeletedEverywhere { deleted_remotes } => { + crate::logln!( + " {} {} deleted on {}", + style("deleted repo").red().bold(), + style(repo_name).cyan(), + deleted_remotes.join("+") + ); + Ok(Some(RepoSyncOutcome { + state_update: (!context.dry_run).then_some(RepoStateUpdate::Remove), + })) + } + RepoDeletionDecision::Propagate { + deleted_remotes, + target_remotes, + } => { + crate::logln!( + " {} {} deleted on {} -> {}", + style("deleted repo").red().bold(), + style(repo_name).cyan(), + deleted_remotes.join("+"), + target_remotes.join("+") + ); + delete_repos(context, repo_name, repos, &target_remotes)?; + Ok(Some(RepoSyncOutcome { + state_update: (!context.dry_run).then_some(RepoStateUpdate::Remove), + })) + } + RepoDeletionDecision::Conflict { + deleted_remotes, + changed_remotes, + } => { + crate::logln!( + " {} repo {} was deleted on {} but changed on {} ({})", + style("conflict").yellow().bold(), + style(repo_name).cyan(), + deleted_remotes.join("+"), + changed_remotes.join("+"), + style("skipped").dim() + ); + fail_on_unresolved_conflict(context, "repo deletion conflict")?; + Ok(Some(RepoSyncOutcome::default())) + } + } +} + +fn delete_repos( + context: &RepoSyncContext<'_>, + repo_name: &str, + repos: &[EndpointRepo], + target_remotes: &[String], +) -> Result<()> { + for repo in repos { + let remote_name = remote_name_for_endpoint_repo(repo); + if !target_remotes.contains(&remote_name) { + continue; + } + crate::logln!( + " {} {} {}", + style(if context.dry_run { + "would delete" + } else { + "delete" + }) + .red() + .bold(), + style(repo_name).cyan(), + style(format!("from {}", repo.endpoint.label())).dim() + ); + if context.dry_run { + continue; + } + let site = context.config.site(&repo.endpoint.site).unwrap(); + let client = ProviderClient::new(site)?; + client + .delete_repo(&repo.endpoint, repo_name) + .with_context(|| { + format!( + "failed to delete {} from {}", + repo_name, + repo.endpoint.label() + ) + })?; + } + Ok(()) +} + fn all_configured_endpoints_present(mirror: &MirrorConfig, repos: &[EndpointRepo]) -> bool { let present = repos .iter() @@ -1145,10 +1280,11 @@ fn endpoint_repos_by_remote_name<'a>( } fn remote_name_for_endpoint_repo(endpoint_repo: &EndpointRepo) -> String { - safe_remote_name(&format!( - "{}_{}", - endpoint_repo.endpoint.site, endpoint_repo.endpoint.namespace - )) + remote_name_for_endpoint(&endpoint_repo.endpoint) +} + +fn remote_name_for_endpoint(endpoint: &EndpointConfig) -> String { + safe_remote_name(&format!("{}_{}", endpoint.site, endpoint.namespace)) } fn branch_names(branches: &[crate::git::BranchDecision]) -> BTreeSet { @@ -1332,6 +1468,88 @@ fn branch_deletion_decisions( (deletions, conflicts, blocked) } +#[derive(Debug, Eq, PartialEq)] +enum RepoDeletionDecision { + None, + DeletedEverywhere { + deleted_remotes: Vec, + }, + Propagate { + deleted_remotes: Vec, + target_remotes: Vec, + }, + Conflict { + deleted_remotes: Vec, + changed_remotes: Vec, + }, +} + +fn repo_deletion_decision( + mirror: &MirrorConfig, + repos: &[EndpointRepo], + previous_refs: Option<&BTreeMap>, + current_refs: &BTreeMap, +) -> RepoDeletionDecision { + let Some(previous_refs) = previous_refs else { + return RepoDeletionDecision::None; + }; + let remote_names = mirror + .endpoints + .iter() + .map(remote_name_for_endpoint) + .collect::>(); + if remote_names.is_empty() { + return RepoDeletionDecision::None; + } + + let present_remotes = repos + .iter() + .map(remote_name_for_endpoint_repo) + .collect::>(); + if present_remotes.len() == remote_names.len() { + return RepoDeletionDecision::None; + } + + let deleted_remotes = remote_names + .iter() + .filter(|remote| !present_remotes.contains(remote.as_str())) + .cloned() + .collect::>(); + let target_remotes = remote_names + .iter() + .filter(|remote| present_remotes.contains(remote.as_str())) + .cloned() + .collect::>(); + + if target_remotes.is_empty() { + return RepoDeletionDecision::DeletedEverywhere { deleted_remotes }; + } + + if remote_names + .iter() + .any(|remote| !previous_refs.contains_key(remote)) + { + return RepoDeletionDecision::None; + } + + let changed_remotes = target_remotes + .iter() + .filter(|remote| current_refs.get(remote.as_str()) != previous_refs.get(remote.as_str())) + .cloned() + .collect::>(); + if changed_remotes.is_empty() { + RepoDeletionDecision::Propagate { + deleted_remotes, + target_remotes, + } + } else { + RepoDeletionDecision::Conflict { + deleted_remotes, + changed_remotes, + } + } +} + struct RepoRefSyncResult { pushed: bool, had_conflicts: bool, diff --git a/src/sync/state.rs b/src/sync/state.rs index 1d5c0af..cf98d70 100644 --- a/src/sync/state.rs +++ b/src/sync/state.rs @@ -149,6 +149,23 @@ impl RefState { .insert(repo.to_string(), refs); } + pub(super) fn remove_repo(&mut self, group: &str, repo: &str) { + let Some(repos) = self.repos.get_mut(group) else { + return; + }; + repos.remove(repo); + if repos.is_empty() { + self.repos.remove(group); + } + } + + pub(super) fn repo_names(&self, group: &str) -> BTreeSet { + self.repos + .get(group) + .map(|repos| repos.keys().cloned().collect()) + .unwrap_or_default() + } + pub(super) fn repo( &self, group: &str, diff --git a/tests/unit/provider.rs b/tests/unit/provider.rs index dcea828..98ead83 100644 --- a/tests/unit/provider.rs +++ b/tests/unit/provider.rs @@ -255,6 +255,105 @@ fn uninstall_webhook_deletes_matching_github_hook() { handle.join().unwrap(); } +#[test] +fn delete_repo_deletes_github_repo() { + let (api_url, handle) = one_request_server("204 No Content", "", |request| { + assert!( + request.starts_with("DELETE /repos/alice/repo "), + "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() + .delete_repo( + &EndpointConfig { + site: "github".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + "repo", + ) + .unwrap(); + handle.join().unwrap(); +} + +#[test] +fn delete_repo_deletes_url_encoded_gitlab_project() { + let (api_url, handle) = one_request_server("202 Accepted", "", |request| { + assert!( + request.starts_with("DELETE /projects/parent%2Falice%2Frepo "), + "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() + .delete_repo( + &EndpointConfig { + site: "gitlab".to_string(), + kind: NamespaceKind::Group, + namespace: "parent/alice".to_string(), + }, + "repo", + ) + .unwrap(); + handle.join().unwrap(); +} + +#[test] +fn delete_repo_deletes_gitea_repo() { + let (api_url, handle) = one_request_server("204 No Content", "", |request| { + assert!( + request.starts_with("DELETE /repos/alice/repo "), + "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() + .delete_repo( + &EndpointConfig { + site: "gitea".to_string(), + kind: NamespaceKind::User, + namespace: "alice".to_string(), + }, + "repo", + ) + .unwrap(); + handle.join().unwrap(); +} + #[test] fn open_pull_request_posts_github_pull_when_missing() { let (api_url, handle) = request_server( diff --git a/tests/unit/sync.rs b/tests/unit/sync.rs index f1dc45a..fe702d6 100644 --- a/tests/unit/sync.rs +++ b/tests/unit/sync.rs @@ -174,6 +174,140 @@ fn branch_deletion_decisions_ignore_internal_conflict_branches() { assert!(blocked.is_empty()); } +#[test] +fn repo_deletion_decision_propagates_previous_synced_repo_deletion() { + let mirror = test_mirror(); + let mut previous = BTreeMap::new(); + previous.insert( + "github_alice".to_string(), + remote_ref_state("a", &[("main", "111")]), + ); + previous.insert( + "gitea_alice".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + let mut current = BTreeMap::new(); + current.insert( + "gitea_alice".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + + let decision = repo_deletion_decision( + &mirror, + &[endpoint_repo("gitea")], + Some(&previous), + ¤t, + ); + + assert_eq!( + decision, + RepoDeletionDecision::Propagate { + deleted_remotes: vec!["github_alice".to_string()], + target_remotes: vec!["gitea_alice".to_string()], + } + ); +} + +#[test] +fn repo_deletion_decision_conflicts_when_remaining_repo_changed() { + let mirror = test_mirror(); + let mut previous = BTreeMap::new(); + previous.insert( + "github_alice".to_string(), + remote_ref_state("a", &[("main", "111")]), + ); + previous.insert( + "gitea_alice".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + let mut current = BTreeMap::new(); + current.insert( + "gitea_alice".to_string(), + remote_ref_state("changed", &[("main", "222")]), + ); + + let decision = repo_deletion_decision( + &mirror, + &[endpoint_repo("gitea")], + Some(&previous), + ¤t, + ); + + assert_eq!( + decision, + RepoDeletionDecision::Conflict { + deleted_remotes: vec!["github_alice".to_string()], + changed_remotes: vec!["gitea_alice".to_string()], + } + ); +} + +#[test] +fn repo_deletion_decision_removes_state_when_deleted_everywhere() { + let mirror = test_mirror(); + let mut previous = BTreeMap::new(); + previous.insert( + "github_alice".to_string(), + remote_ref_state("a", &[("main", "111")]), + ); + previous.insert( + "gitea_alice".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + + let decision = repo_deletion_decision(&mirror, &[], Some(&previous), &BTreeMap::new()); + + assert_eq!( + decision, + RepoDeletionDecision::DeletedEverywhere { + deleted_remotes: vec!["github_alice".to_string(), "gitea_alice".to_string()], + } + ); +} + +#[test] +fn repo_deletion_decision_removes_partial_state_when_deleted_everywhere() { + let mirror = test_mirror(); + let mut previous = BTreeMap::new(); + previous.insert( + "gitea_alice".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + + let decision = repo_deletion_decision(&mirror, &[], Some(&previous), &BTreeMap::new()); + + assert_eq!( + decision, + RepoDeletionDecision::DeletedEverywhere { + deleted_remotes: vec!["github_alice".to_string(), "gitea_alice".to_string()], + } + ); +} + +#[test] +fn repo_deletion_decision_ignores_repos_not_previously_synced_everywhere() { + let mirror = test_mirror(); + let mut previous = BTreeMap::new(); + previous.insert( + "gitea_alice".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + let mut current = BTreeMap::new(); + current.insert( + "gitea_alice".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + + let decision = repo_deletion_decision( + &mirror, + &[endpoint_repo("gitea")], + Some(&previous), + ¤t, + ); + + assert_eq!(decision, RepoDeletionDecision::None); +} + #[test] fn conflict_branch_prefixes_are_reversible_not_slug_collisions() { let slash_branch = conflict_pr_branch_prefix("release/foo"); @@ -219,3 +353,34 @@ fn test_remotes() -> Vec { }, ] } + +fn test_mirror() -> MirrorConfig { + MirrorConfig { + name: "sync-1".to_string(), + endpoints: vec![endpoint("github"), endpoint("gitea")], + create_missing: true, + visibility: crate::config::Visibility::Private, + allow_force: false, + conflict_resolution: ConflictResolutionStrategy::Fail, + } +} + +fn endpoint(site: &str) -> EndpointConfig { + EndpointConfig { + site: site.to_string(), + kind: crate::config::NamespaceKind::User, + namespace: "alice".to_string(), + } +} + +fn endpoint_repo(site: &str) -> EndpointRepo { + EndpointRepo { + endpoint: endpoint(site), + repo: crate::provider::RemoteRepo { + name: "repo".to_string(), + clone_url: format!("https://{site}.invalid/alice/repo.git"), + private: true, + description: None, + }, + } +}