[O] Cleanup codebase
This commit is contained in:
+15
-456
@@ -7,7 +7,6 @@ use std::thread;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use console::style;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::{Config, EndpointConfig, MirrorConfig, default_work_dir, validate_config};
|
||||
use crate::git::{
|
||||
@@ -18,8 +17,20 @@ use crate::logging;
|
||||
use crate::provider::{EndpointRepo, ProviderClient, repos_by_name};
|
||||
use crate::webhook;
|
||||
|
||||
const FAILURE_STATE_FILE: &str = "failed-repos.toml";
|
||||
const REF_STATE_FILE: &str = "ref-state.toml";
|
||||
mod output;
|
||||
mod state;
|
||||
|
||||
use self::output::{
|
||||
print_branch_decisions, print_branch_deletions, print_failure, print_failure_summary,
|
||||
print_tag_decisions, short_sha,
|
||||
};
|
||||
use self::state::{
|
||||
FailureState, RefState, RemoteRefState, SyncFailure, load_failure_state, load_ref_state,
|
||||
save_failure_state, save_ref_state,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use self::state::{FailedRepo, failure_state_path};
|
||||
|
||||
pub const DEFAULT_JOBS: usize = 4;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -125,217 +136,6 @@ pub fn sync_all(config: &Config, options: SyncOptions) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SyncFailure {
|
||||
scope: String,
|
||||
error: String,
|
||||
retry: Option<FailedRepo>,
|
||||
}
|
||||
|
||||
impl SyncFailure {
|
||||
fn group(scope: String, error: anyhow::Error) -> Self {
|
||||
Self {
|
||||
scope,
|
||||
error: format_error(&error),
|
||||
retry: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn repo(group: String, repo: String, error: anyhow::Error) -> Self {
|
||||
Self {
|
||||
scope: format!("{group}/{repo}"),
|
||||
error: format_error(&error),
|
||||
retry: Some(FailedRepo { group, repo }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
|
||||
struct FailedRepo {
|
||||
group: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
struct FailureState {
|
||||
#[serde(default)]
|
||||
repos: Vec<FailedRepo>,
|
||||
}
|
||||
|
||||
impl FailureState {
|
||||
fn from_failures(failures: &[SyncFailure]) -> Self {
|
||||
let repos = failures
|
||||
.iter()
|
||||
.filter_map(|failure| failure.retry.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
Self { repos }
|
||||
}
|
||||
|
||||
fn repos_by_group(&self) -> BTreeMap<String, BTreeSet<String>> {
|
||||
let mut output = BTreeMap::<String, BTreeSet<String>>::new();
|
||||
for failure in &self.repos {
|
||||
output
|
||||
.entry(failure.group.clone())
|
||||
.or_default()
|
||||
.insert(failure.repo.clone());
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
|
||||
fn load_failure_state(work_dir: &Path) -> Result<FailureState> {
|
||||
let path = failure_state_path(work_dir);
|
||||
if !path.exists() {
|
||||
return Ok(FailureState::default());
|
||||
}
|
||||
let contents =
|
||||
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
fn save_failure_state(work_dir: &Path, state: &FailureState) -> Result<()> {
|
||||
let path = failure_state_path(work_dir);
|
||||
if state.repos.is_empty() {
|
||||
if path.exists() {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("failed to remove {}", path.display()))?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
let contents = toml::to_string_pretty(state)?;
|
||||
fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn failure_state_path(work_dir: &Path) -> PathBuf {
|
||||
work_dir.join(FAILURE_STATE_FILE)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
struct RemoteRefState {
|
||||
hash: String,
|
||||
refs: usize,
|
||||
#[serde(default)]
|
||||
branches: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
tags: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl From<RemoteRefSnapshot> for RemoteRefState {
|
||||
fn from(value: RemoteRefSnapshot) -> Self {
|
||||
Self {
|
||||
hash: value.hash,
|
||||
refs: value.refs,
|
||||
branches: value.branches,
|
||||
tags: value.tags,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&RemoteRefState> for RemoteRefSnapshot {
|
||||
fn from(value: &RemoteRefState) -> Self {
|
||||
Self {
|
||||
hash: value.hash.clone(),
|
||||
refs: value.refs,
|
||||
branches: value.branches.clone(),
|
||||
tags: value.tags.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
struct RefState {
|
||||
#[serde(default)]
|
||||
repos: BTreeMap<String, BTreeMap<String, BTreeMap<String, RemoteRefState>>>,
|
||||
}
|
||||
|
||||
impl RefState {
|
||||
fn repo_matches(
|
||||
&self,
|
||||
group: &str,
|
||||
repo: &str,
|
||||
refs: &BTreeMap<String, RemoteRefState>,
|
||||
) -> bool {
|
||||
self.repos.get(group).and_then(|repos| repos.get(repo)) == Some(refs)
|
||||
}
|
||||
|
||||
fn set_repo(&mut self, group: &str, repo: &str, refs: BTreeMap<String, RemoteRefState>) {
|
||||
self.repos
|
||||
.entry(group.to_string())
|
||||
.or_default()
|
||||
.insert(repo.to_string(), refs);
|
||||
}
|
||||
|
||||
fn repo(&self, group: &str, repo: &str) -> Option<&BTreeMap<String, RemoteRefState>> {
|
||||
self.repos.get(group).and_then(|repos| repos.get(repo))
|
||||
}
|
||||
}
|
||||
|
||||
fn load_ref_state(work_dir: &Path) -> Result<RefState> {
|
||||
let path = ref_state_path(work_dir);
|
||||
if !path.exists() {
|
||||
return Ok(RefState::default());
|
||||
}
|
||||
let contents =
|
||||
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||
toml::from_str(&contents).with_context(|| format!("failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
fn save_ref_state(work_dir: &Path, state: &RefState) -> Result<()> {
|
||||
let path = ref_state_path(work_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
}
|
||||
let contents = toml::to_string_pretty(state)?;
|
||||
fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
|
||||
}
|
||||
|
||||
fn ref_state_path(work_dir: &Path) -> PathBuf {
|
||||
work_dir.join(REF_STATE_FILE)
|
||||
}
|
||||
|
||||
fn print_failure(scope: &str, error: &anyhow::Error) {
|
||||
crate::logln!(
|
||||
" {} {} {}",
|
||||
style("fail").red().bold(),
|
||||
style(scope).cyan(),
|
||||
style(error_headline(error)).dim()
|
||||
);
|
||||
}
|
||||
|
||||
fn print_failure_summary(failures: &[SyncFailure]) {
|
||||
crate::logln!();
|
||||
crate::logln!(
|
||||
"{} {}",
|
||||
style("Failures").red().bold(),
|
||||
style(format!("({})", failures.len())).dim()
|
||||
);
|
||||
for (index, failure) in failures.iter().enumerate() {
|
||||
crate::logln!(" {}. {}", index + 1, style(&failure.scope).cyan().bold());
|
||||
for line in failure.error.lines() {
|
||||
crate::logln!(" {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error_headline(error: &anyhow::Error) -> String {
|
||||
format_error(error)
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or("unknown error")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn format_error(error: &anyhow::Error) -> String {
|
||||
format!("{error:#}")
|
||||
}
|
||||
|
||||
struct GroupSyncContext<'a> {
|
||||
config: &'a Config,
|
||||
options: &'a SyncOptions,
|
||||
@@ -1135,246 +935,5 @@ struct RepoRefSyncResult {
|
||||
had_conflicts: bool,
|
||||
}
|
||||
|
||||
fn print_branch_decisions(branches: &[crate::git::BranchDecision]) {
|
||||
crate::logln!(
|
||||
" {} {}",
|
||||
style("branches").cyan().bold(),
|
||||
style(format!("({})", branches.len())).dim()
|
||||
);
|
||||
for branch in branches {
|
||||
crate::logln!(
|
||||
" {} {} {}",
|
||||
style(&branch.branch).cyan(),
|
||||
style(format!("@{}", short_sha(&branch.sha))).dim(),
|
||||
style(format!(
|
||||
"{} -> {}",
|
||||
branch.source_remotes.join("+"),
|
||||
branch.target_remotes.join("+")
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_branch_deletions(deletions: &[BranchDeletion]) {
|
||||
crate::logln!(
|
||||
" {} {}",
|
||||
style("deleted branches").red().bold(),
|
||||
style(format!("({})", deletions.len())).dim()
|
||||
);
|
||||
for deletion in deletions {
|
||||
crate::logln!(
|
||||
" {} {}",
|
||||
style(&deletion.branch).cyan(),
|
||||
style(format!(
|
||||
"deleted on {} -> {}",
|
||||
deletion.deleted_remotes.join("+"),
|
||||
deletion.target_remotes.join("+")
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_tag_decisions(tags: &[crate::git::TagDecision]) {
|
||||
crate::logln!(
|
||||
" {} {}",
|
||||
style("tags").cyan().bold(),
|
||||
style(format!("({})", tags.len())).dim()
|
||||
);
|
||||
for tag in tags {
|
||||
crate::logln!(
|
||||
" {} {} {}",
|
||||
style(&tag.tag).cyan(),
|
||||
style(format!("@{}", short_sha(&tag.sha))).dim(),
|
||||
style(format!(
|
||||
"{} -> {}",
|
||||
tag.source_remotes.join("+"),
|
||||
tag.target_remotes.join("+")
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn short_sha(sha: &str) -> &str {
|
||||
sha.get(..12).unwrap_or(sha)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failure_state_persists_repo_failures_by_group() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let failures = vec![
|
||||
SyncFailure::repo(
|
||||
"sync-1".to_string(),
|
||||
"repo-a".to_string(),
|
||||
anyhow::anyhow!("a"),
|
||||
),
|
||||
SyncFailure::repo(
|
||||
"sync-1".to_string(),
|
||||
"repo-a".to_string(),
|
||||
anyhow::anyhow!("a again"),
|
||||
),
|
||||
SyncFailure::repo(
|
||||
"sync-2".to_string(),
|
||||
"repo-b".to_string(),
|
||||
anyhow::anyhow!("b"),
|
||||
),
|
||||
SyncFailure::group(
|
||||
"mirror group sync-3".to_string(),
|
||||
anyhow::anyhow!("list failed"),
|
||||
),
|
||||
];
|
||||
let state = FailureState::from_failures(&failures);
|
||||
|
||||
save_failure_state(temp.path(), &state).unwrap();
|
||||
let loaded = load_failure_state(temp.path()).unwrap();
|
||||
let by_group = loaded.repos_by_group();
|
||||
|
||||
assert_eq!(by_group["sync-1"].len(), 1);
|
||||
assert!(by_group["sync-1"].contains("repo-a"));
|
||||
assert_eq!(by_group["sync-2"].len(), 1);
|
||||
assert!(by_group["sync-2"].contains("repo-b"));
|
||||
assert!(!by_group.contains_key("sync-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_failure_state_removes_retry_file() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state = FailureState {
|
||||
repos: vec![FailedRepo {
|
||||
group: "sync-1".to_string(),
|
||||
repo: "repo-a".to_string(),
|
||||
}],
|
||||
};
|
||||
save_failure_state(temp.path(), &state).unwrap();
|
||||
assert!(failure_state_path(temp.path()).exists());
|
||||
|
||||
save_failure_state(temp.path(), &FailureState::default()).unwrap();
|
||||
|
||||
assert!(!failure_state_path(temp.path()).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ref_state_persists_and_requires_exact_remote_ref_match() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let mut refs = BTreeMap::new();
|
||||
refs.insert(
|
||||
"github_alice".to_string(),
|
||||
remote_ref_state("abc", &[("main", "111")]),
|
||||
);
|
||||
refs.insert(
|
||||
"gitea_alice".to_string(),
|
||||
remote_ref_state("def", &[("main", "111")]),
|
||||
);
|
||||
let mut state = RefState::default();
|
||||
state.set_repo("sync-1", "repo-a", refs.clone());
|
||||
|
||||
save_ref_state(temp.path(), &state).unwrap();
|
||||
let loaded = load_ref_state(temp.path()).unwrap();
|
||||
|
||||
assert!(loaded.repo_matches("sync-1", "repo-a", &refs));
|
||||
|
||||
let mut changed_hash = refs.clone();
|
||||
changed_hash.insert(
|
||||
"github_alice".to_string(),
|
||||
remote_ref_state("changed", &[("main", "111")]),
|
||||
);
|
||||
assert!(!loaded.repo_matches("sync-1", "repo-a", &changed_hash));
|
||||
|
||||
let mut missing_remote = refs;
|
||||
missing_remote.remove("gitea_alice");
|
||||
assert!(!loaded.repo_matches("sync-1", "repo-a", &missing_remote));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_deletion_decisions_propagate_previous_synced_branch_deletion() {
|
||||
let remotes = test_remotes();
|
||||
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 mut current = BTreeMap::new();
|
||||
current.insert("github".to_string(), remote_ref_state("c", &[]));
|
||||
current.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("d", &[("main", "111")]),
|
||||
);
|
||||
|
||||
let (deletions, conflicts, blocked) =
|
||||
branch_deletion_decisions(&remotes, Some(&previous), ¤t);
|
||||
|
||||
assert!(conflicts.is_empty());
|
||||
assert!(blocked.contains("main"));
|
||||
assert_eq!(deletions.len(), 1);
|
||||
assert_eq!(deletions[0].branch, "main");
|
||||
assert_eq!(deletions[0].deleted_remotes, vec!["github".to_string()]);
|
||||
assert_eq!(deletions[0].target_remotes, vec!["gitea".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_deletion_decisions_conflict_when_branch_changed_elsewhere() {
|
||||
let remotes = test_remotes();
|
||||
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 mut current = BTreeMap::new();
|
||||
current.insert("github".to_string(), remote_ref_state("c", &[]));
|
||||
current.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("d", &[("main", "222")]),
|
||||
);
|
||||
|
||||
let (deletions, conflicts, blocked) =
|
||||
branch_deletion_decisions(&remotes, Some(&previous), ¤t);
|
||||
|
||||
assert!(deletions.is_empty());
|
||||
assert!(blocked.contains("main"));
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].branch, "main");
|
||||
assert_eq!(conflicts[0].deleted_remotes, vec!["github".to_string()]);
|
||||
assert_eq!(conflicts[0].changed_remotes, vec!["gitea".to_string()]);
|
||||
}
|
||||
|
||||
fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState {
|
||||
RemoteRefState {
|
||||
hash: hash.to_string(),
|
||||
refs: branches.len(),
|
||||
branches: branches
|
||||
.iter()
|
||||
.map(|(branch, sha)| ((*branch).to_string(), (*sha).to_string()))
|
||||
.collect(),
|
||||
tags: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_remotes() -> Vec<RemoteSpec> {
|
||||
vec![
|
||||
RemoteSpec {
|
||||
name: "github".to_string(),
|
||||
url: "https://github.invalid/alice/repo.git".to_string(),
|
||||
display: "github:alice:User".to_string(),
|
||||
},
|
||||
RemoteSpec {
|
||||
name: "gitea".to_string(),
|
||||
url: "https://gitea.invalid/alice/repo.git".to_string(),
|
||||
display: "gitea:alice:User".to_string(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user