[F] Name validation

This commit is contained in:
2026-05-10 14:25:28 +00:00
parent acde9f4f67
commit fe1aa19ce3
4 changed files with 116 additions and 18 deletions
+8 -5
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

+75 -13
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>()
.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,
+33
View File
@@ -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(),