[O] Explicit delete missing question, local backups

This commit is contained in:
2026-05-10 13:07:15 +00:00
parent d9726c4235
commit 13f2118267
12 changed files with 692 additions and 10 deletions
+2
View File
@@ -62,6 +62,8 @@ pub struct MirrorConfig {
pub repo_blacklist: Option<String>,
#[serde(default = "default_true")]
pub create_missing: bool,
#[serde(default = "default_true")]
pub delete_missing: bool,
#[serde(default)]
pub visibility: Visibility,
#[serde(default)]
+64
View File
@@ -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<Vec<String>> {
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<bool> {
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)
+55 -2
View File
@@ -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<bool>) -> Result<bool> {
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<bool>) -> Result<bool> {
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::<Vec<_>>()
.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",
+289 -5
View File
@@ -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<String, RemoteRefState>>,
current_refs: &BTreeMap<String, RemoteRefState>,
) -> 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<String, RemoteRefState>>,
@@ -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<String, RemoteRefState>,
) -> 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<String, RemoteRefState>>,
current_refs: &BTreeMap<String, RemoteRefState>,
) -> 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<BranchUpdate>),
PullRequest(BranchConflict),
@@ -1342,6 +1489,140 @@ fn conflict_pr_base_branch(branch: &str) -> Option<String> {
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<String> {
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<String, RemoteRefState>,
stamp: &str,
) -> Vec<RefBackup> {
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<String, RemoteRefState>,
current_refs: &BTreeMap<String, RemoteRefState>,
stamp: &str,
) -> Vec<RefBackup> {
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<String, RemoteRefState>,
stamp: &str,
) -> Vec<RefBackup> {
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<String, RemoteRefState>>,
current_refs: &BTreeMap<String, RemoteRefState>,
) -> RepoDeletionDecision {
if !mirror.delete_missing {
return RepoDeletionDecision::None;
}
let Some(previous_refs) = previous_refs else {
return RepoDeletionDecision::None;
};