[+] Track repo deletions

This commit is contained in:
2026-05-08 07:01:32 +00:00
parent bc6509ad59
commit e43e555b37
5 changed files with 569 additions and 29 deletions
+48 -7
View File
@@ -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<GitlabGroup> {
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<String> {
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)
)
+237 -19
View File
@@ -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::<BTreeSet<_>>();
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::<BTreeSet<_>>();
if repo_names.is_empty() {
if let Some(retry_repo_names) = retry_repo_names {
@@ -314,11 +319,20 @@ fn sync_group(
for result in receiver {
match result {
Ok(success) => {
if let Some(refs) = success.outcome.ref_update {
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) => {
let scope = format!("{}/{}", mirror.name, failure.repo_name);
@@ -481,7 +495,12 @@ struct RepoSyncContext<'a> {
#[derive(Default)]
struct RepoSyncOutcome {
ref_update: Option<BTreeMap<String, RemoteRefState>>,
state_update: Option<RepoStateUpdate>,
}
enum RepoStateUpdate {
Set(BTreeMap<String, RemoteRefState>),
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<String, RemoteRefState>>,
current_refs: &BTreeMap<String, RemoteRefState>,
) -> Result<Option<RepoSyncOutcome>> {
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<String> {
@@ -1332,6 +1468,88 @@ fn branch_deletion_decisions(
(deletions, conflicts, blocked)
}
#[derive(Debug, Eq, PartialEq)]
enum RepoDeletionDecision {
None,
DeletedEverywhere {
deleted_remotes: Vec<String>,
},
Propagate {
deleted_remotes: Vec<String>,
target_remotes: Vec<String>,
},
Conflict {
deleted_remotes: Vec<String>,
changed_remotes: Vec<String>,
},
}
fn repo_deletion_decision(
mirror: &MirrorConfig,
repos: &[EndpointRepo],
previous_refs: Option<&BTreeMap<String, RemoteRefState>>,
current_refs: &BTreeMap<String, RemoteRefState>,
) -> RepoDeletionDecision {
let Some(previous_refs) = previous_refs else {
return RepoDeletionDecision::None;
};
let remote_names = mirror
.endpoints
.iter()
.map(remote_name_for_endpoint)
.collect::<Vec<_>>();
if remote_names.is_empty() {
return RepoDeletionDecision::None;
}
let present_remotes = repos
.iter()
.map(remote_name_for_endpoint_repo)
.collect::<BTreeSet<_>>();
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::<Vec<_>>();
let target_remotes = remote_names
.iter()
.filter(|remote| present_remotes.contains(remote.as_str()))
.cloned()
.collect::<Vec<_>>();
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::<Vec<_>>();
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,
+17
View File
@@ -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<String> {
self.repos
.get(group)
.map(|repos| repos.keys().cloned().collect())
.unwrap_or_default()
}
pub(super) fn repo(
&self,
group: &str,
+99
View File
@@ -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(
+165
View File
@@ -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),
&current,
);
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),
&current,
);
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),
&current,
);
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<RemoteSpec> {
},
]
}
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,
},
}
}