From 82a641b8c93c30b29cae5dc9db46c52711593eb3 Mon Sep 17 00:00:00 2001 From: Azalea Date: Sun, 10 May 2026 13:07:15 +0000 Subject: [PATCH] [O] Explicit delete missing question, local backups --- README.md | 2 +- src/config.rs | 2 + src/git.rs | 64 +++++++ src/interactive.rs | 57 +++++- src/sync.rs | 294 +++++++++++++++++++++++++++++- tests/e2e/sequential.rs | 46 +++++ tests/unit/config.rs | 30 +++ tests/unit/git.rs | 38 ++++ tests/unit/interactive.rs | 52 +++++- tests/unit/interactive_test_io.rs | 60 +++++- tests/unit/sync.rs | 53 ++++++ tests/unit/webhook.rs | 4 + 12 files changed, 692 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index af14e90..bcb8247 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ Conflict resolution strategies are configured per mirror group: When a previously opened conflict pull request is merged, the next sync sees the merged branch as the winning tip, pushes it to the other endpoints, and closes stale `refray/conflicts/...` pull requests for that branch. -Repository and branch deletion are propagated only when it is safe to infer intent. If a repository existed on every endpoint in the previous successful sync, then disappears from one endpoint while the remaining endpoints still have the previous synced refs, `refray` deletes it from the remaining endpoints instead of recreating it. If the repository was deleted everywhere, `refray` removes its saved sync state. If the repository was deleted on one endpoint but changed elsewhere, it is treated as a conflict and skipped. +Repository and branch deletion are propagated only when it is safe to infer intent, and `refray` writes local backup refs and bundle files under the work-dir `backups/` directory before propagating those deletions. If a repository existed on every endpoint in the previous successful sync, then disappears from one endpoint while the remaining endpoints still have the previous synced refs, `refray` deletes it from the remaining endpoints instead of recreating it when `delete_missing = true`. If `delete_missing = false`, that missing repository is not treated as a deletion and normal missing-repository handling applies. If the repository was deleted everywhere, `refray` removes its saved sync state after creating a local backup from the mirror cache. If the repository was deleted on one endpoint but changed elsewhere, it is treated as a conflict and skipped. Branch deletion follows the same rule at branch scope: if a branch existed on every endpoint in the previous successful sync, then disappears from one endpoint while the remaining endpoints still have the previous tip, `refray` deletes it from the remaining endpoints instead of recreating it. If the branch was deleted on one endpoint but changed elsewhere, it is treated as a conflict and skipped. diff --git a/src/config.rs b/src/config.rs index a4470ae..a99a3fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -62,6 +62,8 @@ pub struct MirrorConfig { pub repo_blacklist: Option, #[serde(default = "default_true")] pub create_missing: bool, + #[serde(default = "default_true")] + pub delete_missing: bool, #[serde(default)] pub visibility: Visibility, #[serde(default)] diff --git a/src/git.rs b/src/git.rs index 640567d..c6af95b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -52,6 +52,13 @@ pub struct BranchUpdate { pub force: bool, } +#[derive(Clone, Debug)] +pub struct RefBackup { + pub refname: String, + pub sha: String, + pub description: String, +} + #[derive(Clone, Debug)] pub struct BranchRebaseDecision { pub branch: String, @@ -420,6 +427,63 @@ impl GitMirror { Ok(()) } + pub fn backup_refs(&self, backups: &[RefBackup]) -> Result> { + let mut refs = Vec::new(); + for backup in backups { + crate::logln!( + " {} {}", + style("backup").cyan().bold(), + style(&backup.description).dim() + ); + self.run(["update-ref", &backup.refname, &backup.sha])?; + refs.push(backup.refname.clone()); + } + Ok(refs) + } + + pub fn create_bundle(&self, path: &Path, refs: &[String]) -> Result { + if refs.is_empty() { + return Ok(false); + } + if self.dry_run { + crate::logln!( + " {} git bundle create {} {}", + style("dry-run").yellow().bold(), + style(path.display()).dim(), + style(refs.join(" ")).dim() + ); + return Ok(false); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + + let output = self + .command() + .arg("bundle") + .arg("create") + .arg(path) + .args(refs) + .output() + .with_context(|| "failed to run git bundle create")?; + if !output.status.success() { + let stdout = self + .redactor + .redact(&String::from_utf8_lossy(&output.stdout)); + let stderr = self + .redactor + .redact(&String::from_utf8_lossy(&output.stderr)); + return Err(GitCommandError::new("git bundle create", stdout, stderr).into()); + } + crate::logln!( + " {} {}", + style("backup bundle").cyan().bold(), + style(path.display()).dim() + ); + Ok(true) + } + fn push_branch_update(&self, remote: &RemoteSpec, update: &BranchUpdate) -> Result<()> { let refspec = if update.force { format!("+{}:refs/heads/{}", update.sha, update.branch) diff --git a/src/interactive.rs b/src/interactive.rs index 4ec2588..eb50181 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -115,6 +115,9 @@ fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<( let endpoints = prompt_sync_group_endpoints_styled(config, theme, &[])?; let sync_visibility = prompt_sync_visibility_styled(theme, None)?; let repo_filters = prompt_repo_filters_styled(theme, None)?; + print_deletion_backup_notice_styled(); + let create_missing = prompt_create_missing_styled(theme, None)?; + let delete_missing = prompt_delete_missing_styled(theme, None)?; let conflict_resolution = prompt_conflict_resolution_styled(theme, None)?; config.upsert_mirror(MirrorConfig { name: next_mirror_name(config), @@ -122,7 +125,8 @@ fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<( sync_visibility, repo_whitelist: repo_filters.whitelist, repo_blacklist: repo_filters.blacklist, - create_missing: true, + create_missing, + delete_missing, visibility: Visibility::Private, conflict_resolution, }); @@ -447,6 +451,8 @@ fn edit_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result< let existing_sync_visibility = config.mirrors[index].sync_visibility.clone(); let existing_repo_whitelist = config.mirrors[index].repo_whitelist.clone(); let existing_repo_blacklist = config.mirrors[index].repo_blacklist.clone(); + let existing_create_missing = config.mirrors[index].create_missing; + let existing_delete_missing = config.mirrors[index].delete_missing; let existing_conflict_resolution = config.mirrors[index].conflict_resolution.clone(); let endpoints = prompt_sync_group_endpoints_styled(config, theme, &existing)?; let sync_visibility = prompt_sync_visibility_styled(theme, Some(&existing_sync_visibility))?; @@ -455,12 +461,17 @@ fn edit_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result< blacklist: existing_repo_blacklist, }; let repo_filters = prompt_repo_filters_styled(theme, Some(&existing_repo_filters))?; + print_deletion_backup_notice_styled(); + let create_missing = prompt_create_missing_styled(theme, Some(existing_create_missing))?; + let delete_missing = prompt_delete_missing_styled(theme, Some(existing_delete_missing))?; let conflict_resolution = prompt_conflict_resolution_styled(theme, Some(&existing_conflict_resolution))?; config.mirrors[index].endpoints = endpoints; config.mirrors[index].sync_visibility = sync_visibility; config.mirrors[index].repo_whitelist = repo_filters.whitelist; config.mirrors[index].repo_blacklist = repo_filters.blacklist; + config.mirrors[index].create_missing = create_missing; + config.mirrors[index].delete_missing = delete_missing; config.mirrors[index].conflict_resolution = conflict_resolution; prompt_webhook_setup_styled(config, theme)?; println!( @@ -817,6 +828,31 @@ fn prompt_repo_pattern_styled( Ok(parse_repo_pattern(&value)) } +fn print_deletion_backup_notice_styled() { + println!(); + println!( + "{} {}", + style("Deletion backups").cyan().bold(), + style("refray keeps a local backup before propagating repository or branch deletes").dim() + ); +} + +fn prompt_create_missing_styled(theme: &ColorfulTheme, existing: Option) -> Result { + Confirm::with_theme(theme) + .with_prompt("Create repositories that are missing from an endpoint?") + .default(existing.unwrap_or(true)) + .interact() + .map_err(Into::into) +} + +fn prompt_delete_missing_styled(theme: &ColorfulTheme, existing: Option) -> Result { + Confirm::with_theme(theme) + .with_prompt("When a previously synced repository is deleted from one endpoint, delete it everywhere?") + .default(existing.unwrap_or(true)) + .interact() + .map_err(Into::into) +} + fn validate_repo_pattern(value: &str) -> std::result::Result<(), String> { let Some(pattern) = parse_repo_pattern(value) else { return Ok(()); @@ -913,10 +949,11 @@ fn sync_group_summary(config: &Config, mirror: &MirrorConfig) -> String { .collect::>() .join(" <-> "); format!( - "{} ({}, {}, {})", + "{} ({}, {}, {}, {})", endpoints, sync_visibility_label(&mirror.sync_visibility), repo_filter_label(mirror), + repo_lifecycle_label(mirror), conflict_resolution_label(&mirror.conflict_resolution) ) } @@ -938,6 +975,22 @@ fn repo_filter_label(mirror: &MirrorConfig) -> String { } } +fn repo_lifecycle_label(mirror: &MirrorConfig) -> String { + format!( + "missing: {}, deletes: {}", + if mirror.create_missing { + "create" + } else { + "skip" + }, + if mirror.delete_missing { + "propagate" + } else { + "keep" + } + ) +} + fn conflict_resolution_label(strategy: &ConflictResolutionStrategy) -> &'static str { match strategy { ConflictResolutionStrategy::Fail => "conflicts: fail", diff --git a/src/sync.rs b/src/sync.rs index 7feb5ba..9f1d0fa 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, mpsc}; use std::thread; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; use console::style; @@ -13,7 +14,7 @@ use crate::config::{ RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config, }; use crate::git::{ - BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RemoteSpec, + BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RefBackup, RemoteSpec, is_disabled_repository_error, ls_remote_refs, safe_remote_name, }; use crate::logging; @@ -523,6 +524,13 @@ enum RepoStateUpdate { Remove, } +fn mirror_repo_path(context: &RepoSyncContext<'_>, repo_name: &str) -> PathBuf { + context + .work_dir + .join(safe_remote_name(&context.mirror.name)) + .join(format!("{}.git", safe_remote_name(repo_name))) +} + fn sync_repo( context: &RepoSyncContext<'_>, repo_name: &str, @@ -573,14 +581,18 @@ fn sync_repo( return Ok(RepoSyncOutcome::default()); } - let path = context - .work_dir - .join(safe_remote_name(&context.mirror.name)) - .join(format!("{}.git", safe_remote_name(repo_name))); + let path = mirror_repo_path(context, repo_name); let mirror_repo = GitMirror::open(path, context.redactor.clone(), context.dry_run)?; mirror_repo.configure_remotes(&initial_remotes)?; let cached_ref_state = cached_ref_state(&mirror_repo, &initial_remotes)?; + backup_branches_deleted_everywhere( + context, + &mirror_repo, + repo_name, + detailed_repo_ref_state(previous_repo_refs).or(cached_ref_state.as_ref()), + &initial_ref_state, + )?; for remote in &initial_remotes { if let Err(error) = mirror_repo.fetch_remote(remote) { if is_disabled_repository_error(&error) { @@ -635,6 +647,7 @@ fn sync_repo( let result = push_repo_refs( context, &mirror_repo, + repo_name, &remotes, repos, detailed_repo_ref_state(previous_repo_refs).or(cached_ref_state.as_ref()), @@ -682,6 +695,7 @@ fn handle_repo_deletion( style(repo_name).cyan(), deleted_remotes.join("+") ); + backup_deleted_repo(context, repo_name, repos, previous_refs, current_refs)?; Ok(Some(RepoSyncOutcome { state_update: (!context.dry_run).then_some(RepoStateUpdate::Remove), })) @@ -697,6 +711,7 @@ fn handle_repo_deletion( deleted_remotes.join("+"), target_remotes.join("+") ); + backup_deleted_repo(context, repo_name, repos, previous_refs, current_refs)?; delete_repos(context, repo_name, repos, &target_remotes)?; Ok(Some(RepoSyncOutcome { state_update: (!context.dry_run).then_some(RepoStateUpdate::Remove), @@ -720,6 +735,65 @@ fn handle_repo_deletion( } } +fn backup_deleted_repo( + context: &RepoSyncContext<'_>, + repo_name: &str, + repos: &[EndpointRepo], + previous_refs: Option<&BTreeMap>, + current_refs: &BTreeMap, +) -> Result<()> { + if context.dry_run { + crate::logln!( + " {} {} {}", + style("dry-run").yellow().bold(), + style("would create local backup for deleted repo").dim(), + style(repo_name).cyan() + ); + return Ok(()); + } + + let path = mirror_repo_path(context, repo_name); + if repos.is_empty() && !path.exists() { + bail!( + "cannot back up deleted repo {} because local mirror cache {} is missing", + repo_name, + path.display() + ); + } + + let mirror_repo = GitMirror::open(path, context.redactor.clone(), false)?; + if !repos.is_empty() { + let remotes = remote_specs(context, repos)?; + mirror_repo.configure_remotes(&remotes)?; + for remote in &remotes { + mirror_repo.fetch_remote(remote).with_context(|| { + format!("failed to fetch {} for deletion backup", remote.display) + })?; + } + } + + let stamp = backup_stamp()?; + let refs_to_backup = if current_refs.is_empty() { + previous_refs.unwrap_or(current_refs) + } else { + current_refs + }; + let backups = repo_ref_backups(repo_name, refs_to_backup, &stamp); + if backups.is_empty() { + crate::logln!( + " {} {} has no refs to bundle before deletion", + style("backup").yellow().bold(), + style(repo_name).cyan() + ); + return Ok(()); + } + + let refs = mirror_repo.backup_refs(&backups)?; + let bundle_path = backup_dir(context, repo_name).join(format!("repo-{stamp}.bundle")); + mirror_repo.create_bundle(&bundle_path, &refs)?; + Ok(()) +} + fn delete_repos( context: &RepoSyncContext<'_>, repo_name: &str, @@ -874,6 +948,7 @@ fn remote_specs(context: &RepoSyncContext<'_>, repos: &[EndpointRepo]) -> Result fn push_repo_refs( context: &RepoSyncContext<'_>, mirror_repo: &GitMirror, + repo_name: &str, remotes: &[RemoteSpec], repos: &[EndpointRepo], previous_refs: Option<&BTreeMap>, @@ -972,6 +1047,13 @@ fn push_repo_refs( { if !branch_deletions.is_empty() { print_branch_deletions(&branch_deletions); + backup_deleted_branches( + context, + mirror_repo, + repo_name, + &branch_deletions, + current_refs, + )?; mirror_repo.delete_branches(remotes, &branch_deletions)?; } if !cleanup_branches.is_empty() { @@ -994,6 +1076,13 @@ fn push_repo_refs( } if !branch_deletions.is_empty() { print_branch_deletions(&branch_deletions); + backup_deleted_branches( + context, + mirror_repo, + repo_name, + &branch_deletions, + current_refs, + )?; mirror_repo.delete_branches(remotes, &branch_deletions)?; } if !branches_to_push.is_empty() { @@ -1031,6 +1120,64 @@ fn push_repo_refs( }) } +fn backup_deleted_branches( + context: &RepoSyncContext<'_>, + mirror_repo: &GitMirror, + repo_name: &str, + deletions: &[BranchDeletion], + current_refs: &BTreeMap, +) -> Result<()> { + if context.dry_run { + crate::logln!( + " {} {} deleted branch backup{}", + style("dry-run").yellow().bold(), + style("would create").dim(), + if deletions.len() == 1 { "" } else { "s" } + ); + return Ok(()); + } + + let stamp = backup_stamp()?; + let backups = branch_ref_backups(deletions, current_refs, &stamp); + if backups.is_empty() { + bail!("cannot back up branch deletion because no target branch refs were available"); + } + let refs = mirror_repo.backup_refs(&backups)?; + let bundle_path = backup_dir(context, repo_name).join(format!("branches-{stamp}.bundle")); + mirror_repo.create_bundle(&bundle_path, &refs)?; + Ok(()) +} + +fn backup_branches_deleted_everywhere( + context: &RepoSyncContext<'_>, + mirror_repo: &GitMirror, + repo_name: &str, + previous_refs: Option<&BTreeMap>, + current_refs: &BTreeMap, +) -> Result<()> { + let Some(previous_refs) = previous_refs else { + return Ok(()); + }; + let stamp = backup_stamp()?; + let backups = branches_deleted_everywhere_backups(previous_refs, current_refs, &stamp); + if backups.is_empty() { + return Ok(()); + } + if context.dry_run { + crate::logln!( + " {} {} branch backup{} for refs deleted everywhere", + style("dry-run").yellow().bold(), + style("would create").dim(), + if backups.len() == 1 { "" } else { "s" } + ); + return Ok(()); + } + let refs = mirror_repo.backup_refs(&backups)?; + let bundle_path = backup_dir(context, repo_name).join(format!("branches-{stamp}.bundle")); + mirror_repo.create_bundle(&bundle_path, &refs)?; + Ok(()) +} + enum BranchConflictResolution { Rebased(Vec), PullRequest(BranchConflict), @@ -1342,6 +1489,140 @@ fn conflict_pr_base_branch(branch: &str) -> Option { decode_hex_component(encoded) } +fn backup_dir(context: &RepoSyncContext<'_>, repo_name: &str) -> PathBuf { + context + .work_dir + .join("backups") + .join(safe_remote_name(&context.mirror.name)) + .join(safe_remote_name(repo_name)) +} + +fn backup_stamp() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .with_context(|| "system clock is before UNIX_EPOCH")?; + Ok(format!("{}-{:09}", now.as_secs(), now.subsec_nanos())) +} + +fn branch_ref_backups( + deletions: &[BranchDeletion], + current_refs: &BTreeMap, + stamp: &str, +) -> Vec { + let mut backups = Vec::new(); + let mut seen = BTreeSet::new(); + for deletion in deletions { + for remote in &deletion.target_remotes { + let Some(sha) = current_refs + .get(remote) + .and_then(|refs| refs.branches.get(&deletion.branch)) + else { + continue; + }; + if !seen.insert((deletion.branch.clone(), sha.clone())) { + continue; + } + backups.push(RefBackup { + refname: format!( + "refs/refray-backups/branches/{}/{}/{}", + hex_component(&deletion.branch), + stamp, + hex_component(remote) + ), + sha: sha.clone(), + description: format!( + "branch {} from {} before propagated deletion", + deletion.branch, remote + ), + }); + } + } + backups +} + +fn branches_deleted_everywhere_backups( + previous_refs: &BTreeMap, + current_refs: &BTreeMap, + stamp: &str, +) -> Vec { + let mut branches = BTreeSet::new(); + for refs in previous_refs.values() { + branches.extend( + refs.branches + .keys() + .filter(|branch| !is_internal_conflict_branch(branch)) + .cloned(), + ); + } + + let mut backups = Vec::new(); + for branch in branches { + if current_refs + .values() + .any(|refs| refs.branches.contains_key(&branch)) + { + continue; + } + let mut seen_shas = BTreeSet::new(); + for (remote, refs) in previous_refs { + let Some(sha) = refs.branches.get(&branch) else { + continue; + }; + if !seen_shas.insert(sha.clone()) { + continue; + } + backups.push(RefBackup { + refname: format!( + "refs/refray-backups/branches/{}/{}/deleted-everywhere-{}", + hex_component(&branch), + stamp, + hex_component(remote) + ), + sha: sha.clone(), + description: format!( + "branch {branch} from {remote} before all endpoints pruned it" + ), + }); + } + } + backups +} + +fn repo_ref_backups( + repo_name: &str, + refs_by_remote: &BTreeMap, + stamp: &str, +) -> Vec { + let mut backups = Vec::new(); + for (remote, refs) in refs_by_remote { + for (branch, sha) in &refs.branches { + backups.push(RefBackup { + refname: format!( + "refs/refray-backups/repos/{}/{}/heads/{}", + stamp, + hex_component(remote), + hex_component(branch) + ), + sha: sha.clone(), + description: format!("repo {repo_name} branch {branch} from {remote}"), + }); + } + for (tag, sha) in &refs.tags { + backups.push(RefBackup { + refname: format!( + "refs/refray-backups/repos/{}/{}/tags/{}", + stamp, + hex_component(remote), + hex_component(tag) + ), + sha: sha.clone(), + description: format!("repo {repo_name} tag {tag} from {remote}"), + }); + } + } + backups +} + fn hex_component(value: &str) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut output = String::with_capacity(value.len() * 2); @@ -1504,6 +1785,9 @@ fn repo_deletion_decision( previous_refs: Option<&BTreeMap>, current_refs: &BTreeMap, ) -> RepoDeletionDecision { + if !mirror.delete_missing { + return RepoDeletionDecision::None; + } let Some(previous_refs) = previous_refs else { return RepoDeletionDecision::None; }; diff --git a/tests/e2e/sequential.rs b/tests/e2e/sequential.rs index 2537336..f722c8e 100644 --- a/tests/e2e/sequential.rs +++ b/tests/e2e/sequential.rs @@ -341,6 +341,7 @@ name = "all" sync_visibility = "all" repo_whitelist = '{}' create_missing = {} +delete_missing = true visibility = "public" conflict_resolution = "{}" @@ -615,6 +616,7 @@ namespace = "{}" source.wait_branch_absent(&repo, "delete-me")?; self.sync_repo(&repo, [])?; self.assert_branch_absent_everywhere(&repo, "delete-me")?; + self.assert_backup_bundle_contains(&repo, "refs/refray-backups/branches/")?; Ok(()) } @@ -629,6 +631,7 @@ namespace = "{}" source.wait_repo_absent(&repo)?; self.sync_repo(&repo, [])?; self.assert_repo_absent_everywhere(&repo)?; + self.assert_backup_bundle_contains(&repo, "refs/refray-backups/repos/")?; Ok(()) } @@ -1090,6 +1093,29 @@ namespace = "{}" }) } + fn assert_backup_bundle_contains(&self, repo: &str, marker: &str) -> Result<()> { + let bundles = self.backup_bundles_for_repo(repo)?; + for bundle in &bundles { + let output = Command::new("git") + .args(["bundle", "list-heads", bundle.to_str().unwrap()]) + .output() + .context("failed to run git bundle list-heads")?; + if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(marker) { + return Ok(()); + } + } + bail!( + "no local backup bundle for {repo} contained {marker}; checked {:?}", + bundles + ) + } + + fn backup_bundles_for_repo(&self, repo: &str) -> Result> { + let mut bundles = Vec::new(); + collect_backup_bundles(&self.cache_home, repo, &mut bundles)?; + Ok(bundles) + } + fn assert_conflict_branch_exists(&self, repo: &str) -> Result<()> { retry("conflict branch", || { for refs in self.refs_by_provider(repo)?.values() { @@ -1951,6 +1977,26 @@ fn assert_output_success(output: Output, label: &str, redactor: &Redactor) -> Re ) } +fn collect_backup_bundles(dir: &Path, repo: &str, output: &mut Vec) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_backup_bundles(&path, repo, output)?; + continue; + } + if path.extension().and_then(|value| value.to_str()) == Some("bundle") + && path.to_string_lossy().contains(repo) + { + output.push(path); + } + } + Ok(()) +} + fn retry(label: &str, mut action: impl FnMut() -> Result<()>) -> Result<()> { let mut last_error = None; for _ in 0..30 { diff --git a/tests/unit/config.rs b/tests/unit/config.rs index f61c84c..2bfeb31 100644 --- a/tests/unit/config.rs +++ b/tests/unit/config.rs @@ -25,6 +25,7 @@ fn parses_value_tokens() { repo_whitelist = "^important-|-mirror$" repo_blacklist = "-archive$" create_missing = true + delete_missing = false visibility = "private" conflict_resolution = "auto_rebase_pull_request" @@ -57,6 +58,7 @@ fn parses_value_tokens() { config.mirrors[0].repo_blacklist, Some("-archive$".to_string()) ); + assert!(!config.mirrors[0].delete_missing); let webhook = config.webhook.unwrap(); assert!(webhook.install); assert_eq!(webhook.url, "https://mirror.example.test/webhook"); @@ -92,6 +94,30 @@ fn config_defaults_jobs() { assert_eq!(config.jobs, DEFAULT_JOBS); } +#[test] +fn mirror_defaults_to_deleting_missing_repos_for_existing_configs() { + let config: Config = toml::from_str( + r#" + [[mirrors]] + name = "personal" + create_missing = true + + [[mirrors.endpoints]] + site = "github" + kind = "user" + namespace = "alice" + + [[mirrors.endpoints]] + site = "gitea" + kind = "user" + namespace = "alice" + "#, + ) + .unwrap(); + + assert!(config.mirrors[0].delete_missing); +} + #[test] fn validation_rejects_unknown_sites_and_single_endpoint_groups() { let config = Config { @@ -108,6 +134,7 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -137,6 +164,7 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -241,6 +269,7 @@ fn validation_rejects_duplicate_mirror_endpoints() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -287,6 +316,7 @@ fn mirror_config() -> MirrorConfig { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, } diff --git a/tests/unit/git.rs b/tests/unit/git.rs index f0bd3fe..b28b698 100644 --- a/tests/unit/git.rs +++ b/tests/unit/git.rs @@ -275,6 +275,44 @@ fn delete_branches_removes_branch_from_target_remotes() { assert!(!fixture.remote_ref_exists(&fixture.remote_b, "refs/heads/main")); } +#[test] +fn backup_refs_create_restorable_bundle_before_branch_delete() { + let fixture = GitFixture::new(); + let expected = fixture.commit("base", "base", 1_700_000_000); + fixture.push_head(&fixture.remote_a, "main"); + fixture.push_head(&fixture.remote_b, "main"); + + let mirror = fixture.mirror(); + fixture.fetch_all(&mirror); + let backup_ref = "refs/refray-backups/branches/main/test/a".to_string(); + mirror + .backup_refs(&[RefBackup { + refname: backup_ref.clone(), + sha: expected.clone(), + description: "branch main before delete".to_string(), + }]) + .unwrap(); + let bundle = fixture._temp.path().join("branch-backup.bundle"); + mirror + .create_bundle(&bundle, std::slice::from_ref(&backup_ref)) + .unwrap(); + + mirror + .delete_branches( + &fixture.remotes(), + &[BranchDeletion { + branch: "main".to_string(), + deleted_remotes: vec!["a".to_string()], + target_remotes: vec!["b".to_string()], + }], + ) + .unwrap(); + + let heads = git_output(None, ["bundle", "list-heads", bundle.to_str().unwrap()]); + assert!(heads.contains(&expected)); + assert!(heads.contains(&backup_ref)); +} + #[test] fn tag_decisions_mirror_matching_or_missing_tags_and_skip_divergent_tags() { let fixture = GitFixture::new(); diff --git a/tests/unit/interactive.rs b/tests/unit/interactive.rs index 340d4c2..9d5a861 100644 --- a/tests/unit/interactive.rs +++ b/tests/unit/interactive.rs @@ -14,6 +14,8 @@ fn wizard_builds_sync_group_from_profile_urls() { "", "", "", + "", + "", "n", "4", ] @@ -46,6 +48,7 @@ fn wizard_builds_sync_group_from_profile_urls() { assert_eq!(config.mirrors[0].endpoints[1].namespace, "azalea"); assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::All); assert!(config.mirrors[0].create_missing); + assert!(config.mirrors[0].delete_missing); assert_eq!(config.mirrors[0].visibility, Visibility::Private); assert_eq!( config.mirrors[0].conflict_resolution, @@ -54,6 +57,9 @@ fn wizard_builds_sync_group_from_profile_urls() { let output = String::from_utf8(output).unwrap(); assert!(output.contains("1. github.com/hykilpikonna <-> gitea.example.test/azalea")); + assert!(output.contains("Deletion backups: refray keeps a local backup")); + assert!(output.contains("Create repositories that are missing from an endpoint?")); + assert!(output.contains("delete it everywhere?")); assert!(output.contains("Add another sync group")); assert!(output.contains("Edit an existing group")); assert!(output.contains("Delete an existing group")); @@ -77,6 +83,8 @@ fn wizard_can_build_three_way_sync() { "", "", "", + "", + "", "n", "4", ] @@ -92,6 +100,35 @@ fn wizard_can_build_three_way_sync() { assert_eq!(config.sites.len(), 3); } +#[test] +fn wizard_can_disable_missing_repo_creation_and_repo_delete_propagation() { + let input = [ + "https://github.com/alice", + "gh-token", + "", + "https://gitea.example.test/alice", + "gt-token", + "", + "n", + "", + "", + "n", + "n", + "", + "n", + "4", + ] + .join("\n") + + "\n"; + let mut reader = Cursor::new(input.as_bytes()); + let mut output = Vec::new(); + + let config = run_config_wizard_with_io(Config::default(), &mut reader, &mut output).unwrap(); + + assert!(!config.mirrors[0].create_missing); + assert!(!config.mirrors[0].delete_missing); +} + #[test] fn wizard_can_enable_webhooks() { let input = [ @@ -105,6 +142,8 @@ fn wizard_can_enable_webhooks() { "", "", "", + "", + "", "y", "https://mirror.example.test/webhook", "y", @@ -159,6 +198,8 @@ fn wizard_reuses_existing_credentials_for_same_instance() { "", "", "", + "", + "", "n", "4", ] @@ -214,6 +255,7 @@ fn wizard_starts_existing_config_at_sync_group_menu() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -243,6 +285,7 @@ fn wizard_can_ask_to_run_full_sync_after_config() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -319,6 +362,7 @@ fn wizard_edits_existing_sync_group_from_menu() { repo_whitelist: Some("^important-".to_string()), repo_blacklist: Some("-archive$".to_string()), create_missing: false, + delete_missing: true, visibility: Visibility::Public, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -337,6 +381,8 @@ fn wizard_edits_existing_sync_group_from_menu() { "^public-", "-skip$", "", + "", + "", "n", "4", ] @@ -356,6 +402,7 @@ fn wizard_edits_existing_sync_group_from_menu() { assert_eq!(mirror.endpoints[1].site, "gitlab"); assert_eq!(mirror.endpoints[1].namespace, "bob"); assert!(!mirror.create_missing); + assert!(mirror.delete_missing); assert_eq!(mirror.sync_visibility, SyncVisibility::Public); assert_eq!(mirror.repo_whitelist, Some("^public-".to_string())); assert_eq!(mirror.repo_blacklist, Some("-skip$".to_string())); @@ -406,12 +453,13 @@ fn wizard_prefills_existing_sync_group_when_editing() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], webhook: None, }; - let input = ["2", "1", "", "", "", "", "n", "", "", "", "n", "4"].join("\n") + "\n"; + let input = ["2", "1", "", "", "", "", "n", "", "", "", "", "", "n", "4"].join("\n") + "\n"; let mut reader = Cursor::new(input.as_bytes()); let mut output = Vec::new(); @@ -470,6 +518,7 @@ fn wizard_deletes_existing_sync_group_from_menu() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -529,6 +578,7 @@ fn wizard_can_go_back_from_delete_menu() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], diff --git a/tests/unit/interactive_test_io.rs b/tests/unit/interactive_test_io.rs index cc09d3c..3e53c22 100644 --- a/tests/unit/interactive_test_io.rs +++ b/tests/unit/interactive_test_io.rs @@ -65,6 +65,9 @@ where let endpoints = prompt_sync_group_endpoints(reader, writer, config, &[])?; let sync_visibility = prompt_sync_visibility(reader, writer, None)?; let repo_filters = prompt_repo_filters(reader, writer, None)?; + write_deletion_backup_notice(writer)?; + let create_missing = prompt_create_missing(reader, writer, None)?; + let delete_missing = prompt_delete_missing(reader, writer, None)?; let conflict_resolution = prompt_conflict_resolution(reader, writer, None)?; config.upsert_mirror(MirrorConfig { name: next_mirror_name(config), @@ -72,7 +75,8 @@ where sync_visibility, repo_whitelist: repo_filters.whitelist, repo_blacklist: repo_filters.blacklist, - create_missing: true, + create_missing, + delete_missing, visibility: Visibility::Private, conflict_resolution, }); @@ -276,6 +280,8 @@ where whitelist: config.mirrors[index - 1].repo_whitelist.clone(), blacklist: config.mirrors[index - 1].repo_blacklist.clone(), }; + let existing_create_missing = config.mirrors[index - 1].create_missing; + let existing_delete_missing = config.mirrors[index - 1].delete_missing; let existing_conflict_resolution = config.mirrors[index - 1].conflict_resolution.clone(); let endpoints = prompt_sync_group_endpoints(reader, writer, config, &existing)?; @@ -283,6 +289,11 @@ where prompt_sync_visibility(reader, writer, Some(&existing_sync_visibility))?; let repo_filters = prompt_repo_filters(reader, writer, Some(&existing_repo_filters))?; + write_deletion_backup_notice(writer)?; + let create_missing = + prompt_create_missing(reader, writer, Some(existing_create_missing))?; + let delete_missing = + prompt_delete_missing(reader, writer, Some(existing_delete_missing))?; let conflict_resolution = prompt_conflict_resolution( reader, writer, @@ -292,6 +303,8 @@ where config.mirrors[index - 1].sync_visibility = sync_visibility; config.mirrors[index - 1].repo_whitelist = repo_filters.whitelist; config.mirrors[index - 1].repo_blacklist = repo_filters.blacklist; + config.mirrors[index - 1].create_missing = create_missing; + config.mirrors[index - 1].delete_missing = delete_missing; config.mirrors[index - 1].conflict_resolution = conflict_resolution; prompt_webhook_setup(reader, writer, config)?; writeln!(writer, "updated sync group {index}")?; @@ -572,6 +585,51 @@ where Ok(parse_repo_pattern(&value)) } +fn write_deletion_backup_notice(writer: &mut W) -> Result<()> +where + W: Write, +{ + writeln!( + writer, + "Deletion backups: refray keeps a local backup before propagating repository or branch deletes." + )?; + Ok(()) +} + +fn prompt_create_missing( + reader: &mut R, + writer: &mut W, + existing: Option, +) -> Result +where + R: BufRead, + W: Write, +{ + prompt_bool( + reader, + writer, + "Create repositories that are missing from an endpoint?", + existing.unwrap_or(true), + ) +} + +fn prompt_delete_missing( + reader: &mut R, + writer: &mut W, + existing: Option, +) -> Result +where + R: BufRead, + W: Write, +{ + prompt_bool( + reader, + writer, + "When a previously synced repository is deleted from one endpoint, delete it everywhere?", + existing.unwrap_or(true), + ) +} + fn sync_visibility_value(sync_visibility: &SyncVisibility) -> &'static str { match sync_visibility { SyncVisibility::All => "all", diff --git a/tests/unit/sync.rs b/tests/unit/sync.rs index d6edea2..ce7c114 100644 --- a/tests/unit/sync.rs +++ b/tests/unit/sync.rs @@ -174,6 +174,29 @@ fn branch_deletion_decisions_ignore_internal_conflict_branches() { assert!(blocked.is_empty()); } +#[test] +fn branches_deleted_everywhere_are_backed_up_before_prune() { + let mut previous = BTreeMap::new(); + previous.insert( + "github".to_string(), + remote_ref_state("a", &[("main", "111")]), + ); + previous.insert( + "gitea".to_string(), + remote_ref_state("b", &[("main", "111")]), + ); + + let backups = branches_deleted_everywhere_backups(&previous, &BTreeMap::new(), "stamp"); + + assert_eq!(backups.len(), 1); + assert_eq!(backups[0].sha, "111"); + assert!( + backups[0] + .refname + .starts_with("refs/refray-backups/branches/") + ); +} + #[test] fn repo_deletion_decision_propagates_previous_synced_repo_deletion() { let mirror = test_mirror(); @@ -208,6 +231,35 @@ fn repo_deletion_decision_propagates_previous_synced_repo_deletion() { ); } +#[test] +fn repo_deletion_decision_is_disabled_by_mirror_policy() { + let mut mirror = test_mirror(); + mirror.delete_missing = false; + let mut previous = BTreeMap::new(); + previous.insert( + remote_key("github"), + remote_ref_state("a", &[("main", "111")]), + ); + previous.insert( + remote_key("gitea"), + remote_ref_state("b", &[("main", "111")]), + ); + let mut current = BTreeMap::new(); + current.insert( + remote_key("gitea"), + 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 repo_deletion_decision_conflicts_when_remaining_repo_changed() { let mirror = test_mirror(); @@ -475,6 +527,7 @@ fn test_mirror() -> MirrorConfig { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: crate::config::Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, } diff --git a/tests/unit/webhook.rs b/tests/unit/webhook.rs index ef145a8..3785845 100644 --- a/tests/unit/webhook.rs +++ b/tests/unit/webhook.rs @@ -115,6 +115,7 @@ fn matches_jobs_by_provider_and_namespace() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -142,6 +143,7 @@ fn matching_jobs_respects_repo_name_filters() { repo_whitelist: Some("^important-".to_string()), repo_blacklist: Some("-archive$".to_string()), create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }; @@ -360,6 +362,7 @@ fn uninstall_webhooks_skips_blocked_provider_access() { repo_whitelist: None, repo_blacklist: None, create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }], @@ -713,6 +716,7 @@ fn filtered_mirror() -> MirrorConfig { repo_whitelist: Some("^important-".to_string()), repo_blacklist: Some("-archive$".to_string()), create_missing: true, + delete_missing: true, visibility: Visibility::Private, conflict_resolution: ConflictResolutionStrategy::Fail, }