diff --git a/README.md b/README.md index 120d17b..068ac19 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,15 @@ Created becasue github is so unusable and [unreliable](https://red-squares.cian. - **read-write mirrors**: Make changes from any provider, and the changes will sync to the others - **webhook support**: Sync right after push, reduce potential divergence window - **conflict handling**: Rebase or open pull requests when two platforms diverge -- **tracks deletions**: Delete branches/repos across platforms when they are deleted from one platform +- **tracks deletions**: Branches/repo deletions sync across platforms (with backup) - **selective sync**: Sync subset of repos by regex white/black list, or by private/public visibility +- **multithreaded**: Process multiple repos simultaneously! Supported platforms: GitHub, GitLab, Gitea, Forgejo > [!NOTE] -> Meow +> My cat made this codebase, meow + ## Install @@ -177,6 +179,10 @@ To move installed hooks to a new public URL, use `webhook update`. It removes ho refray webhook update https://new.example.com/webhook ``` +## Issues and Pull Requests + +Issues and pull requests are not mirrored. + ## Sync Semantics Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints. @@ -245,6 +251,3 @@ REFRAY_E2E_ALLOW_DESTRUCTIVE=1 \ By default cleanup only deletes repositories named `refray-e2e-*`. To start by deleting every owned repository visible to the configured accounts, set `REFRAY_E2E_CLEAR_ALL_REPOS=DELETE_ALL_OWNED_REPOS`. Provider skips (`REFRAY_E2E_SKIP_GITHUB`, `REFRAY_E2E_SKIP_GITLAB`, `REFRAY_E2E_SKIP_GITEA`, `REFRAY_E2E_SKIP_FORGEJO`) and `REFRAY_E2E_ALLOW_PARTIAL=1` are available for local debugging, but the full support check should run with all four providers. -## Issues and Pull Requests - -Issues and pull requests are not mirrored. diff --git a/docs/screenshot-serve.png b/docs/screenshot-serve.png new file mode 100644 index 0000000..ad631db Binary files /dev/null and b/docs/screenshot-serve.png differ diff --git a/src/sync.rs b/src/sync.rs index 9745293..cbb816b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -11,7 +11,7 @@ use regex::Regex; use crate::config::{ Config, ConflictResolutionStrategy, DEFAULT_JOBS, EndpointConfig, MirrorConfig, NamespaceKind, - RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config, + ProviderKind, RepoNameFilter, SyncVisibility, Visibility, default_work_dir, validate_config, }; use crate::git::{ BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RefBackup, RemoteSpec, @@ -530,7 +530,13 @@ fn ensure_missing_repos( let description = template.and_then(|repo| repo.description); let expected_private = matches!(create_visibility, Visibility::Private); let create_jobs = missing.into_iter().enumerate().collect::>(); - let mut created = crate::parallel::map(create_jobs, context.jobs, |(index, endpoint)| { + let created = crate::parallel::map(create_jobs, context.jobs, |(index, endpoint)| { + let site = context.config.site(&endpoint.site).unwrap(); + if site.provider == ProviderKind::Gitlab && !is_supported_gitlab_project_path(repo_name) { + log_invalid_gitlab_project_name_skip(repo_name, &endpoint); + return Ok(None); + } + crate::logln!( " {} {} {}", style("create").green().bold(), @@ -538,16 +544,27 @@ fn ensure_missing_repos( style(format!("on {}", endpoint.label())).dim() ); - let site = context.config.site(&endpoint.site).unwrap(); let client = ProviderClient::new(site)?; - let created = client - .create_repo( - &endpoint, - repo_name, - &create_visibility, - description.as_deref(), - ) - .with_context(|| format!("failed to create {} on {}", repo_name, endpoint.label()))?; + let created = match client.create_repo( + &endpoint, + repo_name, + &create_visibility, + description.as_deref(), + ) { + Ok(created) => created, + Err(error) + if site.provider == ProviderKind::Gitlab + && is_gitlab_invalid_project_name_error(&error) => + { + log_invalid_gitlab_project_name_skip(repo_name, &endpoint); + return Ok(None); + } + Err(error) => { + return Err(error).with_context(|| { + format!("failed to create {} on {}", repo_name, endpoint.label()) + }); + } + }; if created.private != expected_private { crate::logln!( " {} created {} on {}, but provider reported a different visibility than requested", @@ -556,14 +573,15 @@ fn ensure_missing_repos( style(endpoint.label()).dim() ); } - Ok(( + Ok(Some(( index, EndpointRepo { endpoint, repo: created, }, - )) + ))) })?; + let mut created = created.into_iter().flatten().collect::>(); created.sort_by_key(|(index, _)| *index); let created = created .into_iter() @@ -586,6 +604,50 @@ fn visibility_for_created_repo(mirror: &MirrorConfig, template: Option<&RemoteRe .unwrap_or_else(|| mirror.visibility.clone()) } +fn is_supported_gitlab_project_path(name: &str) -> bool { + if name.is_empty() + || matches!(name.chars().next(), Some('-' | '_' | '.')) + || matches!(name.chars().last(), Some('-' | '_' | '.')) + { + return false; + } + + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".git") || lower.ends_with(".atom") { + return false; + } + + name.chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) +} + +fn log_invalid_gitlab_project_name_skip(repo_name: &str, endpoint: &EndpointConfig) { + crate::logln!( + " {} {} {}", + style("skip").yellow().bold(), + style(repo_name).cyan(), + style(format!( + "on {}: invalid GitLab project name/path", + endpoint.label() + )) + .dim() + ); +} + +fn is_gitlab_invalid_project_name_error(error: &anyhow::Error) -> bool { + let text = error + .chain() + .map(ToString::to_string) + .collect::>() + .join("\n") + .to_ascii_lowercase(); + text.contains("400 bad request") + && (text.contains("project_namespace.path") + || text.contains("can only include non-accented letters") + || text.contains("must not start with") + || text.contains("must start with a letter")) +} + struct RepoSyncContext<'a> { config: &'a Config, mirror: &'a MirrorConfig, diff --git a/tests/unit/sync.rs b/tests/unit/sync.rs index 082b0f6..97ac1a5 100644 --- a/tests/unit/sync.rs +++ b/tests/unit/sync.rs @@ -532,6 +532,39 @@ fn created_repo_visibility_falls_back_to_config_without_template() { ); } +#[test] +fn gitlab_invalid_project_name_errors_are_skippable() { + let error = anyhow::Error::msg( + r#"POST https://gitlab.com/api/v4/projects returned 400 Bad Request: {"message":{"project_namespace.path":["can only include non-accented letters, digits, '_', '-' and '.'. It must not start with '-', '_', or '.'."],"name":["can contain only letters, digits, emoji, '_', '.', '+', dashes, or spaces. It must start with a letter, digit, emoji, or '_'."]}}"#, + ); + + assert!(is_gitlab_invalid_project_name_error(&error)); +} + +#[test] +fn gitlab_project_path_validation_matches_create_constraints() { + for name in ["Kairos", "needLe", "amaoke.app", "repo_1", "repo-1"] { + assert!(is_supported_gitlab_project_path(name), "{name}"); + } + + for name in [ + "", + ".github", + "_private", + "-draft", + "repo.", + "repo_", + "repo-", + "repo.git", + "repo.atom", + "has space", + "has+plus", + "荞麦main", + ] { + assert!(!is_supported_gitlab_project_path(name), "{name}"); + } +} + fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState { RemoteRefState { hash: hash.to_string(),