Compare commits

19 Commits

Author SHA1 Message Date
azalea ae2bd9aaa1 [+] Force push detection & sync
Build executables / Windows x86_64 (push) Has been cancelled
Build executables / macOS universal (push) Has been cancelled
Build executables / Linux arm64 musl static (push) Has been cancelled
Build executables / Linux x86_64 musl static (push) Has been cancelled
2026-05-11 06:04:23 +00:00
azalea 3638d774ea [F] Rebase commit committer should be kept 2026-05-10 21:42:12 +00:00
Azalea 953a677575 [+] MIT License 2026-05-10 21:32:47 +00:00
azalea 9dccd094a2 [+] My mailmap 2026-05-10 21:32:17 +00:00
azalea a24172bc6c [U] Lock 2026-05-10 20:24:05 +00:00
azalea 07c382935d [U] Bump version 2026-05-10 20:23:27 +00:00
azalea dbae958248 [-] Comment out unnecessary detail from readme 2026-05-10 17:04:02 +00:00
azalea 3efacc96dc [U] Revise README for installation and demo info
Updated installation instructions and demo generation details in README.
2026-05-10 12:24:39 -04:00
azalea 80895bdc76 [+] Demo 2026-05-10 16:18:35 +00:00
azalea 73d5127ee2 [F] Fix default branch 2026-05-10 15:23:50 +00:00
azalea fe1aa19ce3 [F] Name validation 2026-05-10 14:25:28 +00:00
azalea acde9f4f67 [O] Fast path for serve 2026-05-10 13:43:32 +00:00
azalea 273a814692 [U] Update logo size in README
Increased logo size from 50% to 70% in README.
2026-05-10 21:14:00 +08:00
azalea 78b18590a5 [O] Increase export res 2026-05-10 13:12:07 +00:00
azalea e104337737 [+] Logo 2026-05-10 13:08:18 +00:00
azalea 82a641b8c9 [O] Explicit delete missing question, local backups 2026-05-10 13:07:15 +00:00
azalea 582ea7c490 [O] Wording 2026-05-10 11:07:08 +00:00
azalea 7a699aee81 [-] Remove unnecessary multiple regex 2026-05-10 10:57:17 +00:00
azalea 04d8aee687 [F] Fix log sync (#6) 2026-05-10 14:14:53 +08:00
25 changed files with 2842 additions and 276 deletions
+1
View File
@@ -0,0 +1 @@
Azalea <noreply@aza.moe> <22280294+hykilpikonna@users.noreply.github.com>
Generated
+1 -1
View File
@@ -1118,7 +1118,7 @@ dependencies = [
[[package]] [[package]]
name = "refray" name = "refray"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "refray" name = "refray"
version = "0.1.0" version = "0.1.1"
edition = "2024" edition = "2024"
authors = ["Azalea"] authors = ["Azalea"]
description = "∞-way read-write git mirroring tool" description = "∞-way read-write git mirroring tool"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Azalea
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+48 -13
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="./docs/refray.png" alt="refray logo" width="70%"/>
</p>
# refray # refray
A tool to keep your repos in sync across all git platforms, while being able to work from everywhere all at once. A tool to keep your repos in sync across all git platforms, while being able to work from everywhere all at once.
@@ -8,17 +12,46 @@ 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 - **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 - **webhook support**: Sync right after push, reduce potential divergence window
- **conflict handling**: Rebase or open pull requests when two platforms diverge - **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 - **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 Supported platforms: GitHub, GitLab, Gitea, Forgejo
> [!NOTE] > [!NOTE]
> Meow > My cat made this codebase, meow
![demo](./docs/demo.webp)
<!--
The demo was rendered from an asciinema cast with capped idle pauses, Sarasa Mono SC, a One Half Dark palette with lighter dark-gray ANSI slots, and a larger font:
```sh
agg --idle-time-limit 1 \
--theme '282C34,DCDFE4,5C6370,E06C75,98C379,E5C07B,61AFEF,C678DD,56B6C2,DCDFE4,7F848E,E06C75,98C379,E5C07B,61AFEF,C678DD,56B6C2,DCDFE4' \
--text-font-family 'Sarasa Mono SC' \
--font-size 24 \
--cols 160 \
--rows 42 \
../out.cast \
demo.gif
ffmpeg -i demo.gif \
-loop 0 \
-c:v libwebp_anim \
-lossless 1 \
-compression_level 6 \
-q:v 100 \
docs/demo.webp
```
--->
## Install ## Install
### Option 1. Install from source ### Option 1. Install with Cargo
1. Install rust cargo if you don't have it: https://rustup.rs 1. Install rust cargo if you don't have it: https://rustup.rs
2. `cargo install refray` 2. `cargo install refray`
@@ -69,8 +102,8 @@ token = { value = "gitea_pat_..." }
[[mirrors]] [[mirrors]]
name = "personal" name = "personal"
sync_visibility = "all" sync_visibility = "all"
repo_whitelist = ["^important-"] repo_whitelist = "^important-"
repo_blacklist = ["-archive$"] repo_blacklist = "-archive$"
create_missing = true create_missing = true
visibility = "private" visibility = "private"
conflict_resolution = "auto_rebase_pull_request" conflict_resolution = "auto_rebase_pull_request"
@@ -173,13 +206,17 @@ To move installed hooks to a new public URL, use `webhook update`. It removes ho
refray webhook update https://new.example.com/webhook refray webhook update https://new.example.com/webhook
``` ```
## Sync Semantics ## 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. Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints.
Set `sync_visibility = "all"`, `"private"`, or `"public"` on a mirror group to choose which repository visibility is included in that group. When `refray` creates a missing repository, it mirrors the visibility of the existing repository it is syncing from; `visibility` is only a fallback when no source visibility is available. Set `sync_visibility = "all"`, `"private"`, or `"public"` on a mirror group to choose which repository visibility is included in that group. When `refray` creates a missing repository, it mirrors the visibility of the existing repository it is syncing from; `visibility` is only a fallback when no source visibility is available.
Set `repo_whitelist = ["..."]` and/or `repo_blacklist = ["..."]` on a mirror group to filter repository names with regular expressions. An empty whitelist includes all repository names, and blacklist matches are excluded after whitelist matches. These name filters are independent from `sync_visibility`; both must match for a repository to be synced. Set `repo_whitelist = "..."` and/or `repo_blacklist = "..."` on a mirror group to filter repository names with regular expressions. Omit `repo_whitelist` to include all repository names, and blacklist matches are excluded after whitelist matches. These name filters are independent from `sync_visibility`; both must match for a repository to be synced.
For every repository name found in any endpoint, `refray` will: For every repository name found in any endpoint, `refray` will:
@@ -203,7 +240,9 @@ 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. 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. Force-pushes are propagated only when `refray` can infer intent from the previous successful sync state. If a branch previously matched everywhere, one endpoint rewrites that branch to a non-descendant tip, and the other endpoints still have the previous tip, `refray` writes local backup refs and a bundle under the work-dir `backups/` directory before force-pushing the rewritten tip to the other endpoints. If multiple endpoints rewrite the branch differently, or another endpoint also advances independently, the branch 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. 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.
@@ -239,8 +278,4 @@ REFRAY_E2E_ALLOW_DESTRUCTIVE=1 \
cargo test --test sequential -- --ignored --test-threads=1 --nocapture cargo test --test sequential -- --ignored --test-threads=1 --nocapture
``` ```
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. 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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

+28 -20
View File
@@ -56,12 +56,14 @@ pub struct MirrorConfig {
pub endpoints: Vec<EndpointConfig>, pub endpoints: Vec<EndpointConfig>,
#[serde(default)] #[serde(default)]
pub sync_visibility: SyncVisibility, pub sync_visibility: SyncVisibility,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_whitelist: Vec<String>, pub repo_whitelist: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_blacklist: Vec<String>, pub repo_blacklist: Option<String>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub create_missing: bool, pub create_missing: bool,
#[serde(default = "default_true")]
pub delete_missing: bool,
#[serde(default)] #[serde(default)]
pub visibility: Visibility, pub visibility: Visibility,
#[serde(default)] #[serde(default)]
@@ -135,8 +137,8 @@ pub enum SyncVisibility {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RepoNameFilter { pub struct RepoNameFilter {
whitelist: Vec<Regex>, whitelist: Option<Regex>,
blacklist: Vec<Regex>, blacklist: Option<Regex>,
} }
impl SyncVisibility { impl SyncVisibility {
@@ -152,35 +154,41 @@ impl SyncVisibility {
impl MirrorConfig { impl MirrorConfig {
pub fn repo_filter(&self) -> Result<RepoNameFilter> { pub fn repo_filter(&self) -> Result<RepoNameFilter> {
Ok(RepoNameFilter { Ok(RepoNameFilter {
whitelist: compile_repo_patterns(&self.name, "repo_whitelist", &self.repo_whitelist)?, whitelist: compile_repo_pattern(&self.name, "repo_whitelist", &self.repo_whitelist)?,
blacklist: compile_repo_patterns(&self.name, "repo_blacklist", &self.repo_blacklist)?, blacklist: compile_repo_pattern(&self.name, "repo_blacklist", &self.repo_blacklist)?,
}) })
} }
} }
impl RepoNameFilter { impl RepoNameFilter {
pub fn matches(&self, repo_name: &str) -> bool { pub fn matches(&self, repo_name: &str) -> bool {
let whitelisted = self.whitelist.is_empty() let whitelisted = self
|| self
.whitelist .whitelist
.iter() .as_ref()
.any(|pattern| pattern.is_match(repo_name)); .is_none_or(|pattern| pattern.is_match(repo_name));
let blacklisted = self let blacklisted = self
.blacklist .blacklist
.iter() .as_ref()
.any(|pattern| pattern.is_match(repo_name)); .is_some_and(|pattern| pattern.is_match(repo_name));
whitelisted && !blacklisted whitelisted && !blacklisted
} }
} }
fn compile_repo_patterns(mirror: &str, field: &str, patterns: &[String]) -> Result<Vec<Regex>> { fn compile_repo_pattern(
patterns mirror: &str,
.iter() field: &str,
.map(|pattern| { pattern: &Option<String>,
) -> Result<Option<Regex>> {
let Some(pattern) = pattern
.as_deref()
.map(str::trim)
.filter(|pattern| !pattern.is_empty())
else {
return Ok(None);
};
Regex::new(pattern) Regex::new(pattern)
.with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'")) .with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'"))
}) .map(Some)
.collect()
} }
fn default_true() -> bool { fn default_true() -> bool {
+180 -4
View File
@@ -52,6 +52,13 @@ pub struct BranchUpdate {
pub force: bool, pub force: bool,
} }
#[derive(Clone, Debug)]
pub struct RefBackup {
pub refname: String,
pub sha: String,
pub description: String,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct BranchRebaseDecision { pub struct BranchRebaseDecision {
pub branch: String, pub branch: String,
@@ -79,6 +86,12 @@ pub struct GitMirror {
dry_run: bool, dry_run: bool,
} }
struct CommitterIdentity {
name: String,
email: String,
date: String,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Redactor { pub struct Redactor {
secrets: Vec<String>, secrets: Vec<String>,
@@ -420,6 +433,63 @@ impl GitMirror {
Ok(()) 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<()> { fn push_branch_update(&self, remote: &RemoteSpec, update: &BranchUpdate) -> Result<()> {
let refspec = if update.force { let refspec = if update.force {
format!("+{}:refs/heads/{}", update.sha, update.branch) format!("+{}:refs/heads/{}", update.sha, update.branch)
@@ -518,6 +588,7 @@ impl GitMirror {
return Ok(format!("dry-run-rebased-{}", short_sha(tip))); return Ok(format!("dry-run-rebased-{}", short_sha(tip)));
} }
let commits = self.replay_commits(base, tip)?;
let worktree = tempfile::TempDir::new().context("failed to create temporary worktree")?; let worktree = tempfile::TempDir::new().context("failed to create temporary worktree")?;
let worktree_path = worktree.path().to_path_buf(); let worktree_path = worktree.path().to_path_buf();
self.run([ self.run([
@@ -525,12 +596,13 @@ impl GitMirror {
"add", "add",
"--detach", "--detach",
worktree_path.to_str().unwrap(), worktree_path.to_str().unwrap(),
tip, onto,
])?; ])?;
let rebase_result = self.worktree_git(&worktree_path, ["rebase", "--onto", onto, base]); let replay_result = self.replay_commits_preserving_committer(&worktree_path, &commits);
if let Err(error) = rebase_result { if let Err(error) = replay_result {
let _ = self.worktree_git(&worktree_path, ["rebase", "--abort"]); let _ = self.worktree_git(&worktree_path, ["cherry-pick", "--abort"]);
let _ = self.worktree_git(&worktree_path, ["reset", "--hard"]);
let _ = self.run([ let _ = self.run([
"worktree", "worktree",
"remove", "remove",
@@ -549,6 +621,66 @@ impl GitMirror {
Ok(rebased.trim().to_string()) Ok(rebased.trim().to_string())
} }
fn replay_commits(&self, base: &str, tip: &str) -> Result<Vec<String>> {
let range = format!("{base}..{tip}");
Ok(self
.output([
"rev-list",
"--reverse",
"--topo-order",
"--no-merges",
&range,
])?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect())
}
fn replay_commits_preserving_committer(
&self,
worktree: &Path,
commits: &[String],
) -> Result<()> {
for commit in commits {
let committer = self.committer_identity(commit)?;
self.worktree_git(worktree, ["cherry-pick", "--no-commit", commit])?;
self.worktree_git_with_committer(
worktree,
["commit", "--no-gpg-sign", "-C", commit],
&committer,
)?;
}
Ok(())
}
fn committer_identity(&self, commit: &str) -> Result<CommitterIdentity> {
let output = self.output(["show", "-s", "--format=%cn%x00%ce%x00%cI", commit])?;
let output = output.trim_end_matches('\n');
let mut parts = output.split('\0');
let name = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("commit {commit} has no committer name"))?;
let email = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("commit {commit} has no committer email"))?;
let date = parts
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| anyhow::anyhow!("commit {commit} has no committer date"))?;
if parts.next().is_some() {
bail!("commit {commit} has unexpected committer metadata");
}
Ok(CommitterIdentity {
name: name.to_string(),
email: email.to_string(),
date: date.to_string(),
})
}
pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> { pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
let status = self let status = self
.command() .command()
@@ -645,6 +777,35 @@ impl GitMirror {
.into()) .into())
} }
} }
fn worktree_git_with_committer<const N: usize>(
&self,
worktree: &Path,
args: [&str; N],
committer: &CommitterIdentity,
) -> Result<()> {
let output = Command::new("git")
.arg("-C")
.arg(worktree)
.args(args)
.env("GIT_COMMITTER_NAME", &committer.name)
.env("GIT_COMMITTER_EMAIL", &committer.email)
.env("GIT_COMMITTER_DATE", &committer.date)
.output()
.with_context(|| "failed to run git")?;
if output.status.success() {
Ok(())
} else {
Err(GitCommandError::new(
"git",
self.redactor
.redact(&String::from_utf8_lossy(&output.stdout)),
self.redactor
.redact(&String::from_utf8_lossy(&output.stderr)),
)
.into())
}
}
} }
fn short_sha(sha: &str) -> &str { fn short_sha(sha: &str) -> &str {
@@ -752,6 +913,13 @@ pub fn is_disabled_repository_error(error: &anyhow::Error) -> bool {
.any(|error| is_disabled_repository_stderr(error.stderr())) .any(|error| is_disabled_repository_stderr(error.stderr()))
} }
pub fn is_missing_repository_error(error: &anyhow::Error) -> bool {
error
.chain()
.filter_map(|cause| cause.downcast_ref::<GitCommandError>())
.any(|error| is_missing_repository_stderr(error.stderr()))
}
fn missing_remotes(all_remote_names: &[String], source_remotes: &[String]) -> Vec<String> { fn missing_remotes(all_remote_names: &[String], source_remotes: &[String]) -> Vec<String> {
all_remote_names all_remote_names
.iter() .iter()
@@ -768,6 +936,14 @@ fn is_disabled_repository_stderr(stderr: &str) -> bool {
|| stderr.contains("dmca takedown") || stderr.contains("dmca takedown")
} }
fn is_missing_repository_stderr(stderr: &str) -> bool {
let stderr = stderr.to_ascii_lowercase();
(stderr.contains("repository") && stderr.contains("not found"))
|| stderr.contains("project you were looking for could not be found")
|| stderr.contains("does not appear to be a git repository")
|| stderr.contains("the requested url returned error: 404")
}
impl Redactor { impl Redactor {
pub fn new(secrets: Vec<String>) -> Self { pub fn new(secrets: Vec<String>) -> Self {
let secrets = secrets let secrets = secrets
+80 -36
View File
@@ -38,8 +38,8 @@ struct ParsedProfileUrl {
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
struct RepoFilterInput { struct RepoFilterInput {
whitelist: Vec<String>, whitelist: Option<String>,
blacklist: Vec<String>, blacklist: Option<String>,
} }
pub fn run_config_wizard(path: &Path) -> Result<ConfigWizardOutcome> { pub fn run_config_wizard(path: &Path) -> Result<ConfigWizardOutcome> {
@@ -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 endpoints = prompt_sync_group_endpoints_styled(config, theme, &[])?;
let sync_visibility = prompt_sync_visibility_styled(theme, None)?; let sync_visibility = prompt_sync_visibility_styled(theme, None)?;
let repo_filters = prompt_repo_filters_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)?; let conflict_resolution = prompt_conflict_resolution_styled(theme, None)?;
config.upsert_mirror(MirrorConfig { config.upsert_mirror(MirrorConfig {
name: next_mirror_name(config), name: next_mirror_name(config),
@@ -122,7 +125,8 @@ fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<(
sync_visibility, sync_visibility,
repo_whitelist: repo_filters.whitelist, repo_whitelist: repo_filters.whitelist,
repo_blacklist: repo_filters.blacklist, repo_blacklist: repo_filters.blacklist,
create_missing: true, create_missing,
delete_missing,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution, 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_sync_visibility = config.mirrors[index].sync_visibility.clone();
let existing_repo_whitelist = config.mirrors[index].repo_whitelist.clone(); let existing_repo_whitelist = config.mirrors[index].repo_whitelist.clone();
let existing_repo_blacklist = config.mirrors[index].repo_blacklist.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 existing_conflict_resolution = config.mirrors[index].conflict_resolution.clone();
let endpoints = prompt_sync_group_endpoints_styled(config, theme, &existing)?; let endpoints = prompt_sync_group_endpoints_styled(config, theme, &existing)?;
let sync_visibility = prompt_sync_visibility_styled(theme, Some(&existing_sync_visibility))?; 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, blacklist: existing_repo_blacklist,
}; };
let repo_filters = prompt_repo_filters_styled(theme, Some(&existing_repo_filters))?; 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 = let conflict_resolution =
prompt_conflict_resolution_styled(theme, Some(&existing_conflict_resolution))?; prompt_conflict_resolution_styled(theme, Some(&existing_conflict_resolution))?;
config.mirrors[index].endpoints = endpoints; config.mirrors[index].endpoints = endpoints;
config.mirrors[index].sync_visibility = sync_visibility; config.mirrors[index].sync_visibility = sync_visibility;
config.mirrors[index].repo_whitelist = repo_filters.whitelist; config.mirrors[index].repo_whitelist = repo_filters.whitelist;
config.mirrors[index].repo_blacklist = repo_filters.blacklist; 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; config.mirrors[index].conflict_resolution = conflict_resolution;
prompt_webhook_setup_styled(config, theme)?; prompt_webhook_setup_styled(config, theme)?;
println!( println!(
@@ -780,7 +791,7 @@ fn prompt_repo_filters_styled(
existing: Option<&RepoFilterInput>, existing: Option<&RepoFilterInput>,
) -> Result<RepoFilterInput> { ) -> Result<RepoFilterInput> {
let existing = existing.cloned().unwrap_or_default(); let existing = existing.cloned().unwrap_or_default();
let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty(); let has_existing = existing.whitelist.is_some() || existing.blacklist.is_some();
if !Confirm::with_theme(theme) if !Confirm::with_theme(theme)
.with_prompt("Configure repository name whitelist/blacklist?") .with_prompt("Configure repository name whitelist/blacklist?")
.default(has_existing) .default(has_existing)
@@ -790,51 +801,69 @@ fn prompt_repo_filters_styled(
} }
Ok(RepoFilterInput { Ok(RepoFilterInput {
whitelist: prompt_repo_pattern_list_styled( whitelist: prompt_repo_pattern_styled(
theme, theme,
"Whitelist regexes (comma-separated, empty means all repo names)", "Whitelist regex (empty means all repo names)",
&existing.whitelist, &existing.whitelist,
)?, )?,
blacklist: prompt_repo_pattern_list_styled( blacklist: prompt_repo_pattern_styled(theme, "Blacklist regex", &existing.blacklist)?,
theme,
"Blacklist regexes (comma-separated)",
&existing.blacklist,
)?,
}) })
} }
fn prompt_repo_pattern_list_styled( fn prompt_repo_pattern_styled(
theme: &ColorfulTheme, theme: &ColorfulTheme,
prompt: &str, prompt: &str,
existing: &[String], existing: &Option<String>,
) -> Result<Vec<String>> { ) -> Result<Option<String>> {
let input = Input::<String>::with_theme(theme) let input = Input::<String>::with_theme(theme)
.with_prompt(prompt) .with_prompt(prompt)
.allow_empty(true) .allow_empty(true)
.validate_with(|value: &String| validate_repo_pattern_list(value)); .validate_with(|value: &String| validate_repo_pattern(value));
let input = if existing.is_empty() { let input = if let Some(existing) = existing {
input input.default(existing.clone())
} else { } else {
input.default(existing.join(", ")) input
}; };
let value = input.interact_text()?; let value = input.interact_text()?;
Ok(parse_repo_pattern_list(&value)) Ok(parse_repo_pattern(&value))
} }
fn validate_repo_pattern_list(value: &str) -> std::result::Result<(), String> { fn print_deletion_backup_notice_styled() {
for pattern in parse_repo_pattern_list(value) { 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(());
};
Regex::new(&pattern).map_err(|error| format!("invalid regex '{pattern}': {error}"))?; Regex::new(&pattern).map_err(|error| format!("invalid regex '{pattern}': {error}"))?;
}
Ok(()) Ok(())
} }
fn parse_repo_pattern_list(value: &str) -> Vec<String> { fn parse_repo_pattern(value: &str) -> Option<String> {
value let value = value.trim();
.split(',') (!value.is_empty()).then(|| value.to_string())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
} }
fn sync_visibility_index(sync_visibility: &SyncVisibility) -> usize { fn sync_visibility_index(sync_visibility: &SyncVisibility) -> usize {
@@ -920,10 +949,11 @@ fn sync_group_summary(config: &Config, mirror: &MirrorConfig) -> String {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(" <-> "); .join(" <-> ");
format!( format!(
"{} ({}, {}, {})", "{} ({}, {}, {}, {})",
endpoints, endpoints,
sync_visibility_label(&mirror.sync_visibility), sync_visibility_label(&mirror.sync_visibility),
repo_filter_label(mirror), repo_filter_label(mirror),
repo_lifecycle_label(mirror),
conflict_resolution_label(&mirror.conflict_resolution) conflict_resolution_label(&mirror.conflict_resolution)
) )
} }
@@ -937,14 +967,28 @@ fn sync_visibility_label(sync_visibility: &SyncVisibility) -> &'static str {
} }
fn repo_filter_label(mirror: &MirrorConfig) -> String { fn repo_filter_label(mirror: &MirrorConfig) -> String {
match (mirror.repo_whitelist.len(), mirror.repo_blacklist.len()) { match (&mirror.repo_whitelist, &mirror.repo_blacklist) {
(0, 0) => "repos: all names".to_string(), (None, None) => "repos: all names".to_string(),
(whitelist, 0) => format!("repos: whitelist {whitelist}"), (Some(_), None) => "repos: whitelist".to_string(),
(0, blacklist) => format!("repos: blacklist {blacklist}"), (None, Some(_)) => "repos: blacklist".to_string(),
(whitelist, blacklist) => { (Some(_), Some(_)) => "repos: whitelist + blacklist".to_string(),
format!("repos: whitelist {whitelist}, blacklist {blacklist}")
} }
}
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 { fn conflict_resolution_label(strategy: &ConflictResolutionStrategy) -> &'static str {
+102 -31
View File
@@ -1,14 +1,14 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::fmt; use std::fmt;
use std::io::{self, IsTerminal, Write}; use std::io::{self, IsTerminal, Write};
use std::sync::{Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use console::style; use console::style;
static OUTPUT: OnceLock<Mutex<OutputState>> = OnceLock::new(); static OUTPUT: OnceLock<Mutex<OutputState>> = OnceLock::new();
thread_local! { thread_local! {
static REPO_LOG: RefCell<Option<RepoLog>> = const { RefCell::new(None) }; static REPO_LOG: RefCell<Option<ActiveRepoLog>> = const { RefCell::new(None) };
} }
#[derive(Default)] #[derive(Default)]
@@ -22,11 +22,21 @@ struct StatusState {
interactive: bool, interactive: bool,
} }
#[derive(Clone)]
pub(crate) struct RepoLogContext {
inner: Arc<RepoLog>,
}
struct ActiveRepoLog {
context: RepoLogContext,
owner: bool,
}
struct RepoLog { struct RepoLog {
repo_name: String, repo_name: String,
slot: usize, slot: usize,
width: usize, width: usize,
lines: Vec<String>, lines: Mutex<Vec<String>>,
} }
pub struct StatusGuard; pub struct StatusGuard;
@@ -63,22 +73,76 @@ pub fn start_status_area(slots: usize) -> StatusGuard {
} }
pub fn start_repo_log(repo_name: String, slot: usize, width: usize) -> RepoLogGuard { pub fn start_repo_log(repo_name: String, slot: usize, width: usize) -> RepoLogGuard {
REPO_LOG.with(|repo_log| { let context = RepoLogContext {
*repo_log.borrow_mut() = Some(RepoLog { inner: Arc::new(RepoLog {
repo_name, repo_name,
slot, slot,
width, width,
lines: Vec::new(), lines: Mutex::new(Vec::new()),
}),
};
REPO_LOG.with(|repo_log| {
*repo_log.borrow_mut() = Some(ActiveRepoLog {
context,
owner: true,
}); });
}); });
RepoLogGuard RepoLogGuard
} }
pub(crate) fn current_repo_log_context() -> Option<RepoLogContext> {
REPO_LOG.with(|repo_log| {
repo_log
.borrow()
.as_ref()
.map(|repo_log| repo_log.context.clone())
})
}
pub(crate) fn inherit_repo_log(context: Option<RepoLogContext>) -> InheritedRepoLogGuard {
let previous = REPO_LOG.with(|repo_log| {
let mut repo_log = repo_log.borrow_mut();
let previous = repo_log.take();
if let Some(context) = context {
*repo_log = Some(ActiveRepoLog {
context,
owner: false,
});
}
previous
});
InheritedRepoLogGuard { previous }
}
pub(crate) struct InheritedRepoLogGuard {
previous: Option<ActiveRepoLog>,
}
impl Drop for InheritedRepoLogGuard {
fn drop(&mut self) {
REPO_LOG.with(|repo_log| {
*repo_log.borrow_mut() = self.previous.take();
});
}
}
pub fn finish_repo_log() { pub fn finish_repo_log() {
let repo_log = REPO_LOG.with(|repo_log| repo_log.borrow_mut().take()); let active = REPO_LOG.with(|repo_log| repo_log.borrow_mut().take());
let Some(repo_log) = repo_log else { let Some(active) = active else {
return; return;
}; };
if !active.owner {
return;
}
let repo_log = active.context.inner;
let lines = {
let mut lines = repo_log
.lines
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::take(&mut *lines)
};
with_output(|output| { with_output(|output| {
if let Some(status) = output.status.as_mut() { if let Some(status) = output.status.as_mut() {
@@ -87,7 +151,7 @@ pub fn finish_repo_log() {
status.slots[repo_log.slot] = None; status.slots[repo_log.slot] = None;
} }
} }
for line in repo_log.lines { for line in lines {
println!("{line}"); println!("{line}");
} }
if let Some(status) = output.status.as_mut() { if let Some(status) = output.status.as_mut() {
@@ -107,27 +171,9 @@ pub fn repo_prefix(repo_name: &str, width: usize) -> String {
pub fn line(args: fmt::Arguments<'_>) { pub fn line(args: fmt::Arguments<'_>) {
let text = args.to_string(); let text = args.to_string();
let captured = REPO_LOG.with(|repo_log| { let context = current_repo_log_context();
let mut repo_log = repo_log.borrow_mut(); if let Some(context) = context {
let Some(repo_log) = repo_log.as_mut() else { capture_repo_line(&context, &text);
return false;
};
if text.is_empty() {
repo_log.lines.push(String::new());
return true;
}
for line in text.lines() {
repo_log.lines.push(line.to_string());
if !line.trim().is_empty() {
update_status(repo_log, line.trim());
}
}
true
});
if captured {
return; return;
} }
@@ -142,7 +188,32 @@ pub fn line(args: fmt::Arguments<'_>) {
}); });
} }
fn update_status(repo_log: &RepoLog, line: &str) { fn capture_repo_line(context: &RepoLogContext, text: &str) {
let mut status_updates = Vec::new();
{
let mut lines = context
.inner
.lines
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if text.is_empty() {
lines.push(String::new());
return;
}
for line in text.lines() {
lines.push(line.to_string());
if !line.trim().is_empty() {
status_updates.push(line.trim().to_string());
}
}
}
for line in status_updates {
update_status(context, &line);
}
}
fn update_status(context: &RepoLogContext, line: &str) {
let repo_log = &context.inner;
let repo = repo_prefix(&repo_log.repo_name, repo_log.width); let repo = repo_prefix(&repo_log.repo_name, repo_log.width);
let line = truncate_status(line, 96); let line = truncate_status(line, 96);
with_output(|output| { with_output(|output| {
+3
View File
@@ -20,13 +20,16 @@ where
let worker_count = jobs.min(items.len()); let worker_count = jobs.min(items.len());
let queue = Arc::new(Mutex::new(VecDeque::from(items))); let queue = Arc::new(Mutex::new(VecDeque::from(items)));
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
let repo_log_context = crate::logging::current_repo_log_context();
thread::scope(|scope| { thread::scope(|scope| {
for _ in 0..worker_count { for _ in 0..worker_count {
let queue = Arc::clone(&queue); let queue = Arc::clone(&queue);
let sender = sender.clone(); let sender = sender.clone();
let f = &f; let f = &f;
let repo_log_context = repo_log_context.clone();
scope.spawn(move || { scope.spawn(move || {
let _repo_log_guard = crate::logging::inherit_repo_log(repo_log_context);
while let Some(item) = pop_item(&queue) { while let Some(item) = pop_item(&queue) {
if sender.send(f(item)).is_err() { if sender.send(f(item)).is_err() {
break; break;
+105 -9
View File
@@ -40,6 +40,12 @@ pub struct PullRequestInfo {
pub url: Option<String>, pub url: Option<String>,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WebhookInstallOutcome {
Created,
Existing,
}
pub fn list_mirror_repos( pub fn list_mirror_repos(
config: &Config, config: &Config,
mirror: &MirrorConfig, mirror: &MirrorConfig,
@@ -166,13 +172,26 @@ impl<'a> ProviderClient<'a> {
) )
} }
pub fn set_default_branch(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
branch: &str,
) -> Result<()> {
dispatch_provider!(self.site.provider,
github => self.github_set_default_branch(endpoint, repo_name, branch),
gitlab => self.gitlab_set_default_branch(endpoint, repo_name, branch),
gitea_like => self.gitea_set_default_branch(endpoint, repo_name, branch),
)
}
pub fn install_webhook( pub fn install_webhook(
&self, &self,
endpoint: &EndpointConfig, endpoint: &EndpointConfig,
repo: &RemoteRepo, repo: &RemoteRepo,
url: &str, url: &str,
secret: &str, secret: &str,
) -> Result<()> { ) -> Result<WebhookInstallOutcome> {
dispatch_provider!(self.site.provider, dispatch_provider!(self.site.provider,
github => self.github_install_webhook(endpoint, repo, url, secret), github => self.github_install_webhook(endpoint, repo, url, secret),
gitlab => self.gitlab_install_webhook(endpoint, repo, url, secret), gitlab => self.gitlab_install_webhook(endpoint, repo, url, secret),
@@ -311,13 +330,24 @@ impl<'a> ProviderClient<'a> {
self.delete(&url).map(|_| ()) self.delete(&url).map(|_| ())
} }
fn github_set_default_branch(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
branch: &str,
) -> Result<()> {
let url = self.repo_url(endpoint, repo_name, "GitHub")?;
self.patch_json::<serde_json::Value>(&url, &json!({ "default_branch": branch }))
.map(|_| ())
}
fn github_install_webhook( fn github_install_webhook(
&self, &self,
endpoint: &EndpointConfig, endpoint: &EndpointConfig,
repo: &RemoteRepo, repo: &RemoteRepo,
url: &str, url: &str,
secret: &str, secret: &str,
) -> Result<()> { ) -> Result<WebhookInstallOutcome> {
let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "GitHub")?; let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "GitHub")?;
let body = json!({ let body = json!({
"name": "web", "name": "web",
@@ -435,7 +465,11 @@ impl<'a> ProviderClient<'a> {
projects.push(project); projects.push(project);
} }
} }
Ok(projects.into_iter().map(Into::into).collect()) Ok(projects
.into_iter()
.filter(|project| !project.is_deletion_scheduled())
.map(Into::into)
.collect())
} }
NamespaceKind::Org | NamespaceKind::Group => { NamespaceKind::Org | NamespaceKind::Group => {
let encoded = urlencoding(&endpoint.namespace); let encoded = urlencoding(&endpoint.namespace);
@@ -444,7 +478,12 @@ impl<'a> ProviderClient<'a> {
self.site.api_base(), self.site.api_base(),
encoded encoded
); );
self.paged_remote_repos::<GitlabProject>(&url) Ok(self
.paged_get::<GitlabProject>(&url)?
.into_iter()
.filter(|project| !project.is_deletion_scheduled())
.map(Into::into)
.collect())
} }
} }
} }
@@ -496,6 +535,17 @@ impl<'a> ProviderClient<'a> {
self.delete(&url).map(|_| ()) self.delete(&url).map(|_| ())
} }
fn gitlab_set_default_branch(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
branch: &str,
) -> Result<()> {
let url = self.gitlab_project_url(endpoint, repo_name);
self.put_json::<serde_json::Value>(&url, &json!({ "default_branch": branch }))
.map(|_| ())
}
fn gitlab_group(&self, namespace: &str) -> Result<GitlabGroup> { fn gitlab_group(&self, namespace: &str) -> Result<GitlabGroup> {
let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace)); let url = format!("{}/groups/{}", self.site.api_base(), urlencoding(namespace));
self.get_json(&url) self.get_json(&url)
@@ -526,7 +576,7 @@ impl<'a> ProviderClient<'a> {
repo: &RemoteRepo, repo: &RemoteRepo,
url: &str, url: &str,
secret: &str, secret: &str,
) -> Result<()> { ) -> Result<WebhookInstallOutcome> {
let hooks_url = self.gitlab_hooks_url(endpoint, &repo.name); let hooks_url = self.gitlab_hooks_url(endpoint, &repo.name);
let body = json!({ let body = json!({
"url": url, "url": url,
@@ -689,13 +739,24 @@ impl<'a> ProviderClient<'a> {
self.delete(&url).map(|_| ()) self.delete(&url).map(|_| ())
} }
fn gitea_set_default_branch(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
branch: &str,
) -> Result<()> {
let url = self.repo_url(endpoint, repo_name, "Gitea/Forgejo")?;
self.patch_json::<serde_json::Value>(&url, &json!({ "default_branch": branch }))
.map(|_| ())
}
fn gitea_install_webhook( fn gitea_install_webhook(
&self, &self,
endpoint: &EndpointConfig, endpoint: &EndpointConfig,
repo: &RemoteRepo, repo: &RemoteRepo,
url: &str, url: &str,
secret: &str, secret: &str,
) -> Result<()> { ) -> Result<WebhookInstallOutcome> {
let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "Gitea/Forgejo")?; let hooks_url = self.repo_hooks_url(endpoint, &repo.name, "Gitea/Forgejo")?;
let body = json!({ let body = json!({
"type": "gitea", "type": "gitea",
@@ -875,10 +936,10 @@ impl<'a> ProviderClient<'a> {
target_url: &str, target_url: &str,
body: &serde_json::Value, body: &serde_json::Value,
put_on_update: bool, put_on_update: bool,
) -> Result<()> { ) -> Result<WebhookInstallOutcome> {
let Some(hook) = self.find_existing_hook(hooks_url, target_url)? else { let Some(hook) = self.find_existing_hook(hooks_url, target_url)? else {
self.post_json::<serde_json::Value>(hooks_url, body)?; self.post_json::<serde_json::Value>(hooks_url, body)?;
return Ok(()); return Ok(WebhookInstallOutcome::Created);
}; };
let update_url = format!("{hooks_url}/{}", hook.id); let update_url = format!("{hooks_url}/{}", hook.id);
@@ -887,7 +948,7 @@ impl<'a> ProviderClient<'a> {
} else { } else {
self.patch_json::<serde_json::Value>(&update_url, body)?; self.patch_json::<serde_json::Value>(&update_url, body)?;
} }
Ok(()) Ok(WebhookInstallOutcome::Existing)
} }
fn delete_matching_hook(&self, hooks_url: &str, target_url: &str) -> Result<bool> { fn delete_matching_hook(&self, hooks_url: &str, target_url: &str) -> Result<bool> {
@@ -1157,6 +1218,10 @@ struct GitlabProject {
http_url_to_repo: String, http_url_to_repo: String,
visibility: String, visibility: String,
description: Option<String>, description: Option<String>,
marked_for_deletion_at: Option<String>,
marked_for_deletion_on: Option<String>,
#[serde(default)]
pending_delete: bool,
} }
impl GitlabProject { impl GitlabProject {
@@ -1184,6 +1249,37 @@ impl GitlabProject {
.eq_ignore_ascii_case(other.project_path()), .eq_ignore_ascii_case(other.project_path()),
} }
} }
fn is_deletion_scheduled(&self) -> bool {
self.pending_delete
|| self
.marked_for_deletion_at
.as_deref()
.is_some_and(|value| !value.is_empty())
|| self
.marked_for_deletion_on
.as_deref()
.is_some_and(|value| !value.is_empty())
|| is_gitlab_deletion_scheduled_path(&self.name)
|| self
.path
.as_deref()
.is_some_and(is_gitlab_deletion_scheduled_path)
|| self
.path_with_namespace
.as_deref()
.and_then(|path| path.rsplit('/').next())
.is_some_and(is_gitlab_deletion_scheduled_path)
}
}
fn is_gitlab_deletion_scheduled_path(path: &str) -> bool {
let Some((name, project_id)) = path.rsplit_once("-deletion_scheduled-") else {
return false;
};
!name.is_empty()
&& !project_id.is_empty()
&& project_id.bytes().all(|byte| byte.is_ascii_digit())
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+1010 -24
View File
File diff suppressed because it is too large Load Diff
+31 -25
View File
@@ -8,7 +8,6 @@ use std::time::Duration;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use console::style; use console::style;
use hmac::{Hmac, KeyInit, Mac}; use hmac::{Hmac, KeyInit, Mac};
use regex::escape;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use sha2::Sha256; use sha2::Sha256;
@@ -18,9 +17,11 @@ use crate::config::{
Config, EndpointConfig, MirrorConfig, ProviderKind, RepoNameFilter, default_work_dir, Config, EndpointConfig, MirrorConfig, ProviderKind, RepoNameFilter, default_work_dir,
validate_config, validate_config,
}; };
use crate::provider::{EndpointRepo, ProviderClient, RemoteRepo, list_mirror_repos}; use crate::provider::{
EndpointRepo, ProviderClient, RemoteRepo, WebhookInstallOutcome, list_mirror_repos,
};
use crate::state::{load_toml_or_default, save_toml}; use crate::state::{load_toml_or_default, save_toml};
use crate::sync::{SyncOptions, sync_all}; use crate::sync::{SyncOptions, sync_all, sync_webhook_repo};
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
const WEBHOOK_STATE_FILE: &str = "webhook-state.toml"; const WEBHOOK_STATE_FILE: &str = "webhook-state.toml";
@@ -151,6 +152,7 @@ fn full_sync_timer_loop(
&config, &config,
SyncOptions { SyncOptions {
work_dir: work_dir.clone(), work_dir: work_dir.clone(),
jobs: config.jobs,
..SyncOptions::default() ..SyncOptions::default()
}, },
) { ) {
@@ -375,15 +377,12 @@ fn worker_loop(
let _sync_guard = sync_lock let _sync_guard = sync_lock
.lock() .lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .unwrap_or_else(|poisoned| poisoned.into_inner());
let result = sync_all( let result = sync_webhook_repo(
&config, &config,
SyncOptions { &job.group,
group: Some(job.group.clone()), &job.repo,
repo_pattern: Some(format!("^{}$", escape(&job.repo))), work_dir.clone(),
work_dir: work_dir.clone(), config.jobs,
jobs: 1,
..SyncOptions::default()
},
); );
match result { match result {
Ok(()) => crate::logln!( Ok(()) => crate::logln!(
@@ -575,24 +574,32 @@ fn run_uninstall_tasks(tasks: Vec<WebhookUninstallTask>, jobs: usize) -> Result<
fn install_webhook_task(task: WebhookInstallTask, state: &Arc<Mutex<WebhookState>>) -> Result<()> { fn install_webhook_task(task: WebhookInstallTask, state: &Arc<Mutex<WebhookState>>) -> Result<()> {
let key = webhook_installation_key(&task.group, &task.endpoint, &task.repo.name); let key = webhook_installation_key(&task.group, &task.endpoint, &task.repo.name);
if task.dry_run {
crate::logln!( crate::logln!(
" {} {} {}", " {} {} {}",
style(if task.dry_run { style("would install").green().bold(),
"would install"
} else {
"install"
})
.green()
.bold(),
style(&task.repo.name).cyan(), style(&task.repo.name).cyan(),
style(format!("webhook on {}", task.endpoint.label())).dim() style(format!("webhook on {}", task.endpoint.label())).dim()
); );
if task.dry_run {
return Ok(()); return Ok(());
} }
let client = ProviderClient::new(&task.site)?; let client = ProviderClient::new(&task.site)?;
if let Err(error) = client.install_webhook(&task.endpoint, &task.repo, &task.url, &task.secret) match client.install_webhook(&task.endpoint, &task.repo, &task.url, &task.secret) {
{ Ok(outcome) => {
let action = match outcome {
WebhookInstallOutcome::Created => "install",
WebhookInstallOutcome::Existing => "exists",
};
crate::logln!(
" {} {} {}",
style(action).green().bold(),
style(&task.repo.name).cyan(),
style(format!("webhook on {}", task.endpoint.label())).dim()
);
record_webhook_installation(state, key, task);
Ok(())
}
Err(error) => {
if is_duplicate_webhook_error(&error) { if is_duplicate_webhook_error(&error) {
crate::logln!( crate::logln!(
" {} {} {}", " {} {} {}",
@@ -625,16 +632,15 @@ fn install_webhook_task(task: WebhookInstallTask, state: &Arc<Mutex<WebhookState
); );
return Ok(()); return Ok(());
} }
return Err(error).with_context(|| { Err(error).with_context(|| {
format!( format!(
"failed to install webhook for {} on {}", "failed to install webhook for {} on {}",
task.repo.name, task.repo.name,
task.endpoint.label() task.endpoint.label()
) )
}); })
}
} }
record_webhook_installation(state, key, task);
Ok(())
} }
fn record_webhook_installation( fn record_webhook_installation(
+526 -7
View File
@@ -25,8 +25,7 @@ const WEBHOOK_SECRET: &str = "refray-e2e-secret";
#[test] #[test]
#[ignore = "destructive live-provider e2e test; run explicitly with --ignored"] #[ignore = "destructive live-provider e2e test; run explicitly with --ignored"]
fn sequential_live_e2e_all_supported_features() -> Result<()> { fn sequential_live_e2e_all_supported_features() -> Result<()> {
let env = EnvFile::load(Path::new(".env"))?; let settings = load_e2e_settings()?;
let settings = E2eSettings::from_env(&env)?;
settings.require_destructive_guard()?; settings.require_destructive_guard()?;
let mut run = E2eRun::new(settings)?; let mut run = E2eRun::new(settings)?;
@@ -59,6 +58,42 @@ fn sequential_live_e2e_all_supported_features() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
#[ignore = "destructive live-provider e2e test; run explicitly with --ignored"]
fn sequential_live_e2e_force_push_detection() -> Result<()> {
let settings = load_e2e_settings()?;
settings.require_destructive_guard()?;
let mut run = E2eRun::new(settings)?;
run.preflight()?;
run.clear_repositories()?;
run.write_config(ConflictMode::AutoRebasePullRequest, None, true)?;
eprintln!("e2e phase: force-push rewind");
run.rewind_force_push_propagates()?;
eprintln!("e2e phase: force-push rewrite");
run.rewrite_force_push_propagates()?;
eprintln!("e2e phase: force-push fast-forward guard");
run.normal_fast_forward_still_syncs()?;
eprintln!("e2e phase: force-push conflict");
run.conflicting_force_pushes_are_not_propagated()?;
eprintln!("e2e phase: force-push plus fast-forward conflict");
run.force_push_plus_fast_forward_is_not_propagated()?;
eprintln!("e2e phase: feature branch force-push");
run.feature_branch_force_push_propagates()?;
run.clear_e2e_repositories()?;
Ok(())
}
fn load_e2e_settings() -> Result<E2eSettings> {
let env_path = std::env::var_os("REFRAY_E2E_ENV_FILE")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".env"));
let env = EnvFile::load(&env_path)?;
E2eSettings::from_env(&env)
}
struct EnvFile { struct EnvFile {
values: HashMap<String, String>, values: HashMap<String, String>,
} }
@@ -339,8 +374,9 @@ secret = {{ value = "{WEBHOOK_SECRET}" }}
[[mirrors]] [[mirrors]]
name = "all" name = "all"
sync_visibility = "all" sync_visibility = "all"
repo_whitelist = ['{}'] repo_whitelist = '{}'
create_missing = {} create_missing = {}
delete_missing = true
visibility = "public" visibility = "public"
conflict_resolution = "{}" conflict_resolution = "{}"
@@ -379,9 +415,10 @@ namespace = "{}"
git(&work, ["tag", "v1.0.0"])?; git(&work, ["tag", "v1.0.0"])?;
let remote_url = source.authenticated_repo_url(&repo)?; let remote_url = source.authenticated_repo_url(&repo)?;
self.git(&work, ["remote", "add", "origin", &remote_url])?; self.git(&work, ["remote", "add", "origin", &remote_url])?;
self.git( self.git_retry(
&work, &work,
["push", "origin", "HEAD:main", "feature/github", "v1.0.0"], ["push", "origin", "HEAD:main", "feature/github", "v1.0.0"],
"initial seed push",
)?; )?;
source.wait_branch( source.wait_branch(
&repo, &repo,
@@ -392,6 +429,7 @@ namespace = "{}"
source.wait_repo_listed(&repo)?; source.wait_repo_listed(&repo)?;
self.sync_repo(&repo, [])?; self.sync_repo(&repo, [])?;
self.assert_branch_all_equal_after_optional_resync(&repo, MAIN_BRANCH)?; self.assert_branch_all_equal_after_optional_resync(&repo, MAIN_BRANCH)?;
self.assert_default_branch_all_except(&repo, MAIN_BRANCH, &source.site_name)?;
self.assert_branch_all_equal(&repo, "feature/github")?; self.assert_branch_all_equal(&repo, "feature/github")?;
self.assert_tag_all_equal(&repo, "v1.0.0")?; self.assert_tag_all_equal(&repo, "v1.0.0")?;
@@ -615,6 +653,7 @@ namespace = "{}"
source.wait_branch_absent(&repo, "delete-me")?; source.wait_branch_absent(&repo, "delete-me")?;
self.sync_repo(&repo, [])?; self.sync_repo(&repo, [])?;
self.assert_branch_absent_everywhere(&repo, "delete-me")?; self.assert_branch_absent_everywhere(&repo, "delete-me")?;
self.assert_backup_bundle_contains(&repo, "refs/refray-backups/branches/")?;
Ok(()) Ok(())
} }
@@ -629,6 +668,219 @@ namespace = "{}"
source.wait_repo_absent(&repo)?; source.wait_repo_absent(&repo)?;
self.sync_repo(&repo, [])?; self.sync_repo(&repo, [])?;
self.assert_repo_absent_everywhere(&repo)?; self.assert_repo_absent_everywhere(&repo)?;
self.assert_backup_bundle_contains(&repo, "refs/refray-backups/repos/")?;
Ok(())
}
fn rewind_force_push_propagates(&self) -> Result<()> {
let repo = self.repo_name("force-rewind");
let source = self.primary_provider();
self.seed_all_main(&repo, "force rewind base", 1_700_001_701)?;
self.sync_repo(&repo, [])?;
let base = self.branch_sha(source, &repo, MAIN_BRANCH)?;
let old = self.commit_to_provider_branch(
source,
&repo,
MAIN_BRANCH,
"old.txt",
"old\n",
"force rewind old",
1_700_001_702,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &old)?;
self.unprotect_main_all(&repo)?;
self.force_push_provider_branch_to_sha(source, &repo, MAIN_BRANCH, &base)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &base)?;
self.assert_backup_bundle_contains(&repo, &old)?;
Ok(())
}
fn rewrite_force_push_propagates(&self) -> Result<()> {
let repo = self.repo_name("force-rewrite");
let source = self.primary_provider();
self.seed_all_main(&repo, "force rewrite base", 1_700_001_711)?;
self.sync_repo(&repo, [])?;
let base = self.branch_sha(source, &repo, MAIN_BRANCH)?;
let old = self.commit_to_provider_branch(
source,
&repo,
MAIN_BRANCH,
"old.txt",
"old\n",
"force rewrite old",
1_700_001_712,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &old)?;
self.unprotect_main_all(&repo)?;
let rewritten = self.force_rewrite_provider_branch_from(
source,
&repo,
MAIN_BRANCH,
&base,
"rewritten.txt",
"rewritten\n",
"force rewrite new",
1_700_001_713,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &rewritten)?;
self.assert_backup_bundle_contains(&repo, &old)?;
Ok(())
}
fn normal_fast_forward_still_syncs(&self) -> Result<()> {
let repo = self.repo_name("force-fast-forward");
let source = self.primary_provider();
self.seed_all_main(&repo, "force fast-forward base", 1_700_001_721)?;
self.sync_repo(&repo, [])?;
let newer = self.commit_to_provider_branch(
source,
&repo,
MAIN_BRANCH,
"newer.txt",
"newer\n",
"normal fast-forward",
1_700_001_722,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &newer)
}
fn conflicting_force_pushes_are_not_propagated(&self) -> Result<()> {
let repo = self.repo_name("force-conflict");
let (source, peer) = self.provider_pair();
self.seed_all_main(&repo, "force conflict base", 1_700_001_731)?;
self.sync_repo(&repo, [])?;
let base = self.branch_sha(source, &repo, MAIN_BRANCH)?;
let old = self.commit_to_provider_branch(
source,
&repo,
MAIN_BRANCH,
"old.txt",
"old\n",
"force conflict old",
1_700_001_732,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &old)?;
self.unprotect_main_all(&repo)?;
self.force_rewrite_provider_branch_from(
source,
&repo,
MAIN_BRANCH,
&base,
"source.txt",
"source\n",
"source force rewrite",
1_700_001_733,
)?;
self.force_rewrite_provider_branch_from(
peer,
&repo,
MAIN_BRANCH,
&base,
"peer.txt",
"peer\n",
"peer force rewrite",
1_700_001_734,
)?;
let expected_refs = self.branch_refs_by_provider(&repo, MAIN_BRANCH)?;
self.write_config(ConflictMode::Fail, Some(&exact_pattern(&repo)), true)?;
self.sync_repo_expect_failure(&repo, [])?;
self.assert_branch_refs_match(&repo, MAIN_BRANCH, &expected_refs)?;
self.write_config(ConflictMode::AutoRebasePullRequest, None, true)?;
Ok(())
}
fn force_push_plus_fast_forward_is_not_propagated(&self) -> Result<()> {
let repo = self.repo_name("force-plus-fast-forward");
let (source, peer) = self.provider_pair();
self.seed_all_main(&repo, "force plus fast-forward base", 1_700_001_741)?;
self.sync_repo(&repo, [])?;
let base = self.branch_sha(source, &repo, MAIN_BRANCH)?;
let old = self.commit_to_provider_branch(
source,
&repo,
MAIN_BRANCH,
"old.txt",
"old\n",
"force plus fast-forward old",
1_700_001_742,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &old)?;
self.unprotect_main_all(&repo)?;
self.force_rewrite_provider_branch_from(
source,
&repo,
MAIN_BRANCH,
&base,
"rewritten.txt",
"rewritten\n",
"force plus fast-forward rewrite",
1_700_001_743,
)?;
self.commit_to_provider_branch(
peer,
&repo,
MAIN_BRANCH,
"peer-fast-forward.txt",
"peer fast-forward\n",
"peer fast-forward",
1_700_001_744,
)?;
let expected_refs = self.branch_refs_by_provider(&repo, MAIN_BRANCH)?;
self.write_config(ConflictMode::Fail, Some(&exact_pattern(&repo)), true)?;
self.sync_repo_expect_failure(&repo, [])?;
self.assert_branch_refs_match(&repo, MAIN_BRANCH, &expected_refs)?;
self.write_config(ConflictMode::AutoRebasePullRequest, None, true)?;
Ok(())
}
fn feature_branch_force_push_propagates(&self) -> Result<()> {
let repo = self.repo_name("force-feature");
let source = self.primary_provider();
let branch = "feature/force-push";
self.seed_all_main(&repo, "force feature base", 1_700_001_751)?;
self.sync_repo(&repo, [])?;
let main = self.branch_sha(source, &repo, MAIN_BRANCH)?;
let old_feature = self.create_provider_branch(
source,
&repo,
MAIN_BRANCH,
branch,
"feature.txt",
"feature\n",
"feature branch old",
1_700_001_752,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, branch, &old_feature)?;
let rewritten_feature = self.force_rewrite_provider_branch_from(
source,
&repo,
branch,
&main,
"feature-rewritten.txt",
"feature rewritten\n",
"feature branch rewrite",
1_700_001_753,
)?;
self.sync_repo(&repo, [])?;
self.assert_branch_all_at(&repo, branch, &rewritten_feature)?;
self.assert_branch_all_at(&repo, MAIN_BRANCH, &main)?;
self.assert_backup_bundle_contains(&repo, &old_feature)?;
Ok(()) Ok(())
} }
@@ -696,7 +948,7 @@ namespace = "{}"
)?; )?;
let remote_url = provider.authenticated_repo_url(repo)?; let remote_url = provider.authenticated_repo_url(repo)?;
self.git(&work, ["remote", "add", "origin", &remote_url])?; self.git(&work, ["remote", "add", "origin", &remote_url])?;
self.git(&work, ["push", "origin", "HEAD:main"])?; self.git_retry(&work, ["push", "origin", "HEAD:main"], "seed push")?;
provider.wait_branch( provider.wait_branch(
repo, repo,
MAIN_BRANCH, MAIN_BRANCH,
@@ -723,7 +975,11 @@ namespace = "{}"
for provider in &self.settings.providers { for provider in &self.settings.providers {
let remote_url = provider.authenticated_repo_url(repo)?; let remote_url = provider.authenticated_repo_url(repo)?;
self.git(&work, ["remote", "add", &provider.site_name, &remote_url])?; self.git(&work, ["remote", "add", &provider.site_name, &remote_url])?;
self.git(&work, ["push", &provider.site_name, "HEAD:main"])?; self.git_retry(
&work,
["push", &provider.site_name, "HEAD:main"],
"seed-all push",
)?;
provider.wait_branch(repo, MAIN_BRANCH, &sha)?; provider.wait_branch(repo, MAIN_BRANCH, &sha)?;
provider.wait_repo_listed(repo)?; provider.wait_repo_listed(repo)?;
provider.unprotect_branch(repo, MAIN_BRANCH)?; provider.unprotect_branch(repo, MAIN_BRANCH)?;
@@ -756,6 +1012,129 @@ namespace = "{}"
Ok(()) Ok(())
} }
#[allow(clippy::too_many_arguments)]
fn commit_to_provider_branch(
&self,
provider: &ProviderAccount,
repo: &str,
branch: &str,
path: &str,
contents: &str,
message: &str,
timestamp: i64,
) -> Result<String> {
let work = self.clone_repo(
provider,
repo,
&format!(
"commit-{}-{}-{repo}",
provider.site_name,
sanitize_path(branch)
),
)?;
self.checkout_remote_branch(&work, branch)?;
write_commit(&work, path, contents, message, timestamp)?;
let sha = git_output(&work, ["rev-parse", "HEAD"])?;
let refspec = format!("HEAD:{branch}");
self.git(&work, ["push", "origin", &refspec])?;
provider.wait_branch(repo, branch, &sha)?;
provider.wait_repo_listed(repo)?;
Ok(sha)
}
#[allow(clippy::too_many_arguments)]
fn create_provider_branch(
&self,
provider: &ProviderAccount,
repo: &str,
base_branch: &str,
branch: &str,
path: &str,
contents: &str,
message: &str,
timestamp: i64,
) -> Result<String> {
let work = self.clone_repo(
provider,
repo,
&format!(
"branch-{}-{}-{repo}",
provider.site_name,
sanitize_path(branch)
),
)?;
let base_ref = format!("origin/{base_branch}");
self.git(&work, ["checkout", "-B", branch, &base_ref])?;
write_commit(&work, path, contents, message, timestamp)?;
let sha = git_output(&work, ["rev-parse", "HEAD"])?;
let refspec = format!("HEAD:{branch}");
self.git(&work, ["push", "origin", &refspec])?;
provider.wait_branch(repo, branch, &sha)?;
provider.wait_repo_listed(repo)?;
Ok(sha)
}
fn force_push_provider_branch_to_sha(
&self,
provider: &ProviderAccount,
repo: &str,
branch: &str,
sha: &str,
) -> Result<()> {
let work = self.clone_repo(
provider,
repo,
&format!(
"force-to-{}-{}-{repo}",
provider.site_name,
sanitize_path(branch)
),
)?;
self.checkout_remote_branch(&work, branch)?;
self.git(&work, ["reset", "--hard", sha])?;
let refspec = format!("HEAD:{branch}");
self.git(&work, ["push", "--force", "origin", &refspec])?;
provider.wait_branch(repo, branch, sha)?;
provider.wait_repo_listed(repo)
}
#[allow(clippy::too_many_arguments)]
fn force_rewrite_provider_branch_from(
&self,
provider: &ProviderAccount,
repo: &str,
branch: &str,
base_sha: &str,
path: &str,
contents: &str,
message: &str,
timestamp: i64,
) -> Result<String> {
let work = self.clone_repo(
provider,
repo,
&format!(
"force-rewrite-{}-{}-{repo}",
provider.site_name,
sanitize_path(branch)
),
)?;
self.checkout_remote_branch(&work, branch)?;
self.git(&work, ["reset", "--hard", base_sha])?;
write_commit(&work, path, contents, message, timestamp)?;
let sha = git_output(&work, ["rev-parse", "HEAD"])?;
let refspec = format!("HEAD:{branch}");
self.git(&work, ["push", "--force", "origin", &refspec])?;
provider.wait_branch(repo, branch, &sha)?;
provider.wait_repo_listed(repo)?;
Ok(sha)
}
fn checkout_remote_branch(&self, work: &Path, branch: &str) -> Result<()> {
let remote_branch = format!("origin/{branch}");
self.git(work, ["checkout", "-B", branch, &remote_branch])
}
fn clone_repo(&self, provider: &ProviderAccount, repo: &str, label: &str) -> Result<PathBuf> { fn clone_repo(&self, provider: &ProviderAccount, repo: &str, label: &str) -> Result<PathBuf> {
let path = self.git_worktree(label)?; let path = self.git_worktree(label)?;
let remote_url = provider.authenticated_repo_url(repo)?; let remote_url = provider.authenticated_repo_url(repo)?;
@@ -784,11 +1163,15 @@ namespace = "{}"
assert_output_success(output, "git", &self.redactor) assert_output_success(output, "git", &self.redactor)
} }
fn git_retry<const N: usize>(&self, path: &Path, args: [&str; N], label: &str) -> Result<()> {
retry(label, || self.git(path, args))
}
fn set_repo_whitelist(&self, pattern: &str) -> Result<()> { fn set_repo_whitelist(&self, pattern: &str) -> Result<()> {
let contents = fs::read_to_string(&self.config_path) let contents = fs::read_to_string(&self.config_path)
.with_context(|| format!("failed to read {}", self.config_path.display()))?; .with_context(|| format!("failed to read {}", self.config_path.display()))?;
let escaped_pattern = pattern.replace('\'', "''"); let escaped_pattern = pattern.replace('\'', "''");
let replacement = format!("repo_whitelist = ['{escaped_pattern}']"); let replacement = format!("repo_whitelist = '{escaped_pattern}'");
let mut replaced = false; let mut replaced = false;
let mut updated = contents let mut updated = contents
.lines() .lines()
@@ -1045,6 +1428,34 @@ namespace = "{}"
}) })
} }
fn assert_branch_all_at(&self, repo: &str, branch: &str, expected: &str) -> Result<()> {
retry("branch convergence to expected tip", || {
for (provider, actual) in self.branch_refs_by_provider(repo, branch)? {
if actual != expected {
bail!("branch {branch} on {provider} is at {actual}, expected {expected}");
}
}
Ok(())
})
}
fn assert_branch_refs_match(
&self,
repo: &str,
branch: &str,
expected: &BTreeMap<String, String>,
) -> Result<()> {
retry("branch refs unchanged", || {
let actual = self.branch_refs_by_provider(repo, branch)?;
if &actual != expected {
bail!(
"branch {branch} refs changed unexpectedly for {repo}: expected {expected:?}, got {actual:?}"
);
}
Ok(())
})
}
fn assert_branch_all_equal_after_optional_resync( fn assert_branch_all_equal_after_optional_resync(
&self, &self,
repo: &str, repo: &str,
@@ -1061,6 +1472,30 @@ namespace = "{}"
} }
} }
fn assert_default_branch_all_except(
&self,
repo: &str,
branch: &str,
excluded_site: &str,
) -> Result<()> {
retry("default branch metadata", || {
for provider in &self.settings.providers {
if provider.site_name == excluded_site {
continue;
}
let actual = provider.default_branch(repo)?;
if actual.as_deref() != Some(branch) {
bail!(
"expected default branch {branch} on {} for {repo}, got {:?}",
provider.site_name,
actual
);
}
}
Ok(())
})
}
fn assert_tag_all_equal(&self, repo: &str, tag: &str) -> Result<()> { fn assert_tag_all_equal(&self, repo: &str, tag: &str) -> Result<()> {
retry("tag convergence", || { retry("tag convergence", || {
let refs = self.refs_by_provider(repo)?; let refs = self.refs_by_provider(repo)?;
@@ -1090,6 +1525,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<Vec<PathBuf>> {
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<()> { fn assert_conflict_branch_exists(&self, repo: &str) -> Result<()> {
retry("conflict branch", || { retry("conflict branch", || {
for refs in self.refs_by_provider(repo)?.values() { for refs in self.refs_by_provider(repo)?.values() {
@@ -1164,6 +1622,36 @@ namespace = "{}"
Ok(output) Ok(output)
} }
fn branch_refs_by_provider(
&self,
repo: &str,
branch: &str,
) -> Result<BTreeMap<String, String>> {
let mut output = BTreeMap::new();
for (provider, refs) in self.refs_by_provider(repo)? {
let sha =
refs.branches.get(branch).cloned().ok_or_else(|| {
anyhow!("branch {branch} missing on {provider} for repo {repo}")
})?;
output.insert(provider, sha);
}
Ok(output)
}
fn branch_sha(&self, provider: &ProviderAccount, repo: &str, branch: &str) -> Result<String> {
provider
.ls_remote(repo)?
.branches
.get(branch)
.cloned()
.ok_or_else(|| {
anyhow!(
"branch {branch} missing on {} for repo {repo}",
provider.site_name
)
})
}
fn unprotect_main_all(&self, repo: &str) -> Result<()> { fn unprotect_main_all(&self, repo: &str) -> Result<()> {
for provider in &self.settings.providers { for provider in &self.settings.providers {
provider.unprotect_branch(repo, MAIN_BRANCH)?; provider.unprotect_branch(repo, MAIN_BRANCH)?;
@@ -1361,6 +1849,17 @@ impl ProviderAccount {
} }
} }
fn default_branch(&self, repo: &str) -> Result<Option<String>> {
let value = self
.get_json::<Value>(&self.repo_api_url(repo))
.with_context(|| format!("failed to inspect {} default branch", self.site_name))?;
Ok(value
.get("default_branch")
.and_then(Value::as_str)
.filter(|branch| !branch.is_empty())
.map(ToOwned::to_owned))
}
fn wait_repo_present(&self, repo: &str) -> Result<()> { fn wait_repo_present(&self, repo: &str) -> Result<()> {
retry("repo present", || { retry("repo present", || {
if self.repo_exists(repo)? { if self.repo_exists(repo)? {
@@ -1951,6 +2450,26 @@ fn assert_output_success(output: Output, label: &str, redactor: &Redactor) -> Re
) )
} }
fn collect_backup_bundles(dir: &Path, repo: &str, output: &mut Vec<PathBuf>) -> 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<()> { fn retry(label: &str, mut action: impl FnMut() -> Result<()>) -> Result<()> {
let mut last_error = None; let mut last_error = None;
for _ in 0..30 { for _ in 0..30 {
+45 -15
View File
@@ -22,9 +22,10 @@ fn parses_value_tokens() {
[[mirrors]] [[mirrors]]
name = "personal" name = "personal"
sync_visibility = "public" sync_visibility = "public"
repo_whitelist = ["^important-", "-mirror$"] repo_whitelist = "^important-|-mirror$"
repo_blacklist = ["-archive$"] repo_blacklist = "-archive$"
create_missing = true create_missing = true
delete_missing = false
visibility = "private" visibility = "private"
conflict_resolution = "auto_rebase_pull_request" conflict_resolution = "auto_rebase_pull_request"
@@ -51,12 +52,13 @@ fn parses_value_tokens() {
assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::Public); assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::Public);
assert_eq!( assert_eq!(
config.mirrors[0].repo_whitelist, config.mirrors[0].repo_whitelist,
vec!["^important-".to_string(), "-mirror$".to_string()] Some("^important-|-mirror$".to_string())
); );
assert_eq!( assert_eq!(
config.mirrors[0].repo_blacklist, config.mirrors[0].repo_blacklist,
vec!["-archive$".to_string()] Some("-archive$".to_string())
); );
assert!(!config.mirrors[0].delete_missing);
let webhook = config.webhook.unwrap(); let webhook = config.webhook.unwrap();
assert!(webhook.install); assert!(webhook.install);
assert_eq!(webhook.url, "https://mirror.example.test/webhook"); assert_eq!(webhook.url, "https://mirror.example.test/webhook");
@@ -92,6 +94,30 @@ fn config_defaults_jobs() {
assert_eq!(config.jobs, DEFAULT_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] #[test]
fn validation_rejects_unknown_sites_and_single_endpoint_groups() { fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
let config = Config { let config = Config {
@@ -105,9 +131,10 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
namespace: "alice".to_string(), namespace: "alice".to_string(),
}], }],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -134,9 +161,10 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -199,8 +227,8 @@ fn sync_visibility_matches_repo_privacy() {
#[test] #[test]
fn repo_name_filter_applies_whitelist_then_blacklist() { fn repo_name_filter_applies_whitelist_then_blacklist() {
let mut mirror = mirror_config(); let mut mirror = mirror_config();
mirror.repo_whitelist = vec!["^important-".to_string(), "-mirror$".to_string()]; mirror.repo_whitelist = Some("^important-|-mirror$".to_string());
mirror.repo_blacklist = vec!["-archive$".to_string()]; mirror.repo_blacklist = Some("-archive$".to_string());
let filter = mirror.repo_filter().unwrap(); let filter = mirror.repo_filter().unwrap();
assert!(filter.matches("important-api")); assert!(filter.matches("important-api"));
@@ -217,7 +245,7 @@ fn validation_rejects_invalid_repo_filter_regex() {
mirrors: vec![mirror_config()], mirrors: vec![mirror_config()],
webhook: None, webhook: None,
}; };
config.mirrors[0].repo_whitelist = vec!["(".to_string()]; config.mirrors[0].repo_whitelist = Some("(".to_string());
let err = validate_config(&config).unwrap_err().to_string(); let err = validate_config(&config).unwrap_err().to_string();
@@ -238,9 +266,10 @@ fn validation_rejects_duplicate_mirror_endpoints() {
name: "broken".to_string(), name: "broken".to_string(),
endpoints: vec![duplicate.clone(), duplicate], endpoints: vec![duplicate.clone(), duplicate],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -284,9 +313,10 @@ fn mirror_config() -> MirrorConfig {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
} }
+107 -1
View File
@@ -41,6 +41,19 @@ fn detects_provider_disabled_repository_errors() {
assert!(!is_disabled_repository_error(&generic_forbidden)); assert!(!is_disabled_repository_error(&generic_forbidden));
} }
#[test]
fn detects_missing_repository_errors() {
let error: anyhow::Error = GitCommandError::new(
"git ls-remote",
"",
"remote: Repository not found.\nfatal: repository 'https://github.com/alice/missing.git/' not found",
)
.into();
assert!(is_missing_repository_error(&error));
assert!(!is_disabled_repository_error(&error));
}
#[test] #[test]
fn ls_remote_snapshot_changes_when_remote_refs_change() { fn ls_remote_snapshot_changes_when_remote_refs_change() {
let fixture = GitFixture::new(); let fixture = GitFixture::new();
@@ -165,7 +178,15 @@ fn auto_rebase_branch_conflict_replays_later_tip_and_marks_force_targets() {
let a_tip = fixture.commit_file("a", "a.txt", "a\n", 1_700_000_100); let a_tip = fixture.commit_file("a", "a.txt", "a\n", 1_700_000_100);
fixture.push_head(&fixture.remote_a, "main"); fixture.push_head(&fixture.remote_a, "main");
fixture.reset_hard(&base); fixture.reset_hard(&base);
let b_tip = fixture.commit_file("b", "b.txt", "b\n", 1_700_000_200); let b_tip = fixture.commit_file_with_committer(
"b",
"b.txt",
"b\n",
1_700_000_200,
"Original Committer",
"original-committer@example.test",
1_700_000_250,
);
fixture.push_head(&fixture.remote_b, "main"); fixture.push_head(&fixture.remote_b, "main");
let mirror = fixture.mirror(); let mirror = fixture.mirror();
@@ -192,6 +213,10 @@ fn auto_rebase_branch_conflict_replays_later_tip_and_marks_force_targets() {
.find(|update| update.target_remote == "b") .find(|update| update.target_remote == "b")
.unwrap(); .unwrap();
assert!(b_update.force); assert!(b_update.force);
assert_eq!(
fixture.mirror_committer(&decision.sha),
fixture.mirror_committer(&b_tip)
);
mirror mirror
.push_branch_updates(&fixture.remotes(), &decision.updates) .push_branch_updates(&fixture.remotes(), &decision.updates)
@@ -275,6 +300,44 @@ fn delete_branches_removes_branch_from_target_remotes() {
assert!(!fixture.remote_ref_exists(&fixture.remote_b, "refs/heads/main")); 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] #[test]
fn tag_decisions_mirror_matching_or_missing_tags_and_skip_divergent_tags() { fn tag_decisions_mirror_matching_or_missing_tags_and_skip_divergent_tags() {
let fixture = GitFixture::new(); let fixture = GitFixture::new();
@@ -444,6 +507,35 @@ impl GitFixture {
self.head() self.head()
} }
fn commit_file_with_committer(
&self,
message: &str,
file_name: &str,
contents: &str,
author_timestamp: i64,
committer_name: &str,
committer_email: &str,
committer_timestamp: i64,
) -> String {
let path = self.work.join(file_name);
fs::write(path, contents).unwrap();
git(Some(&self.work), ["add", file_name]);
let author_date = format!("@{author_timestamp} +0000");
let committer_date = format!("@{committer_timestamp} +0000");
let output = Command::new("git")
.current_dir(&self.work)
.env("GIT_AUTHOR_DATE", &author_date)
.env("GIT_COMMITTER_NAME", committer_name)
.env("GIT_COMMITTER_EMAIL", committer_email)
.env("GIT_COMMITTER_DATE", &committer_date)
.args(["commit", "-m", message])
.output()
.unwrap();
assert_success(&output, "git commit");
self.head()
}
fn head(&self) -> String { fn head(&self) -> String {
git_output(Some(&self.work), ["rev-parse", "HEAD"]) git_output(Some(&self.work), ["rev-parse", "HEAD"])
} }
@@ -508,6 +600,20 @@ impl GitFixture {
.status .status
.success() .success()
} }
fn mirror_committer(&self, reference: &str) -> String {
git_output(
None,
[
"--git-dir",
self.mirror_path.to_str().unwrap(),
"show",
"-s",
"--format=%cn <%ce> %cI",
reference,
],
)
}
} }
fn git<const N: usize>(current_dir: Option<&Path>, args: [&str; N]) { fn git<const N: usize>(current_dir: Option<&Path>, args: [&str; N]) {
+65 -15
View File
@@ -14,6 +14,8 @@ fn wizard_builds_sync_group_from_profile_urls() {
"", "",
"", "",
"", "",
"",
"",
"n", "n",
"4", "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].endpoints[1].namespace, "azalea");
assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::All); assert_eq!(config.mirrors[0].sync_visibility, SyncVisibility::All);
assert!(config.mirrors[0].create_missing); 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].visibility, Visibility::Private);
assert_eq!( assert_eq!(
config.mirrors[0].conflict_resolution, config.mirrors[0].conflict_resolution,
@@ -54,6 +57,9 @@ fn wizard_builds_sync_group_from_profile_urls() {
let output = String::from_utf8(output).unwrap(); let output = String::from_utf8(output).unwrap();
assert!(output.contains("1. github.com/hykilpikonna <-> gitea.example.test/azalea")); 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("Add another sync group"));
assert!(output.contains("Edit an existing group")); assert!(output.contains("Edit an existing group"));
assert!(output.contains("Delete an existing group")); assert!(output.contains("Delete an existing group"));
@@ -77,6 +83,8 @@ fn wizard_can_build_three_way_sync() {
"", "",
"", "",
"", "",
"",
"",
"n", "n",
"4", "4",
] ]
@@ -92,6 +100,35 @@ fn wizard_can_build_three_way_sync() {
assert_eq!(config.sites.len(), 3); 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] #[test]
fn wizard_can_enable_webhooks() { fn wizard_can_enable_webhooks() {
let input = [ let input = [
@@ -105,6 +142,8 @@ fn wizard_can_enable_webhooks() {
"", "",
"", "",
"", "",
"",
"",
"y", "y",
"https://mirror.example.test/webhook", "https://mirror.example.test/webhook",
"y", "y",
@@ -159,6 +198,8 @@ fn wizard_reuses_existing_credentials_for_same_instance() {
"", "",
"", "",
"", "",
"",
"",
"n", "n",
"4", "4",
] ]
@@ -211,9 +252,10 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -240,9 +282,10 @@ fn wizard_can_ask_to_run_full_sync_after_config() {
name: "sync-1".to_string(), name: "sync-1".to_string(),
endpoints: Vec::new(), endpoints: Vec::new(),
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -316,9 +359,10 @@ fn wizard_edits_existing_sync_group_from_menu() {
}, },
], ],
sync_visibility: SyncVisibility::Private, sync_visibility: SyncVisibility::Private,
repo_whitelist: vec!["^important-".to_string()], repo_whitelist: Some("^important-".to_string()),
repo_blacklist: vec!["-archive$".to_string()], repo_blacklist: Some("-archive$".to_string()),
create_missing: false, create_missing: false,
delete_missing: true,
visibility: Visibility::Public, visibility: Visibility::Public,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -337,6 +381,8 @@ fn wizard_edits_existing_sync_group_from_menu() {
"^public-", "^public-",
"-skip$", "-skip$",
"", "",
"",
"",
"n", "n",
"4", "4",
] ]
@@ -356,9 +402,10 @@ fn wizard_edits_existing_sync_group_from_menu() {
assert_eq!(mirror.endpoints[1].site, "gitlab"); assert_eq!(mirror.endpoints[1].site, "gitlab");
assert_eq!(mirror.endpoints[1].namespace, "bob"); assert_eq!(mirror.endpoints[1].namespace, "bob");
assert!(!mirror.create_missing); assert!(!mirror.create_missing);
assert!(mirror.delete_missing);
assert_eq!(mirror.sync_visibility, SyncVisibility::Public); assert_eq!(mirror.sync_visibility, SyncVisibility::Public);
assert_eq!(mirror.repo_whitelist, vec!["^public-".to_string()]); assert_eq!(mirror.repo_whitelist, Some("^public-".to_string()));
assert_eq!(mirror.repo_blacklist, vec!["-skip$".to_string()]); assert_eq!(mirror.repo_blacklist, Some("-skip$".to_string()));
assert_eq!(mirror.visibility, Visibility::Public); assert_eq!(mirror.visibility, Visibility::Public);
let output = String::from_utf8(output).unwrap(); let output = String::from_utf8(output).unwrap();
assert!(output.contains("Edit sync group")); assert!(output.contains("Edit sync group"));
@@ -403,15 +450,16 @@ fn wizard_prefills_existing_sync_group_when_editing() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
webhook: None, 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 reader = Cursor::new(input.as_bytes());
let mut output = Vec::new(); let mut output = Vec::new();
@@ -467,9 +515,10 @@ fn wizard_deletes_existing_sync_group_from_menu() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -526,9 +575,10 @@ fn wizard_can_go_back_from_delete_menu() {
}, },
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
+71 -19
View File
@@ -65,6 +65,9 @@ where
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &[])?; let endpoints = prompt_sync_group_endpoints(reader, writer, config, &[])?;
let sync_visibility = prompt_sync_visibility(reader, writer, None)?; let sync_visibility = prompt_sync_visibility(reader, writer, None)?;
let repo_filters = prompt_repo_filters(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)?; let conflict_resolution = prompt_conflict_resolution(reader, writer, None)?;
config.upsert_mirror(MirrorConfig { config.upsert_mirror(MirrorConfig {
name: next_mirror_name(config), name: next_mirror_name(config),
@@ -72,7 +75,8 @@ where
sync_visibility, sync_visibility,
repo_whitelist: repo_filters.whitelist, repo_whitelist: repo_filters.whitelist,
repo_blacklist: repo_filters.blacklist, repo_blacklist: repo_filters.blacklist,
create_missing: true, create_missing,
delete_missing,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution, conflict_resolution,
}); });
@@ -276,6 +280,8 @@ where
whitelist: config.mirrors[index - 1].repo_whitelist.clone(), whitelist: config.mirrors[index - 1].repo_whitelist.clone(),
blacklist: config.mirrors[index - 1].repo_blacklist.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 = let existing_conflict_resolution =
config.mirrors[index - 1].conflict_resolution.clone(); config.mirrors[index - 1].conflict_resolution.clone();
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &existing)?; let endpoints = prompt_sync_group_endpoints(reader, writer, config, &existing)?;
@@ -283,6 +289,11 @@ where
prompt_sync_visibility(reader, writer, Some(&existing_sync_visibility))?; prompt_sync_visibility(reader, writer, Some(&existing_sync_visibility))?;
let repo_filters = let repo_filters =
prompt_repo_filters(reader, writer, Some(&existing_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( let conflict_resolution = prompt_conflict_resolution(
reader, reader,
writer, writer,
@@ -292,6 +303,8 @@ where
config.mirrors[index - 1].sync_visibility = sync_visibility; config.mirrors[index - 1].sync_visibility = sync_visibility;
config.mirrors[index - 1].repo_whitelist = repo_filters.whitelist; config.mirrors[index - 1].repo_whitelist = repo_filters.whitelist;
config.mirrors[index - 1].repo_blacklist = repo_filters.blacklist; 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; config.mirrors[index - 1].conflict_resolution = conflict_resolution;
prompt_webhook_setup(reader, writer, config)?; prompt_webhook_setup(reader, writer, config)?;
writeln!(writer, "updated sync group {index}")?; writeln!(writer, "updated sync group {index}")?;
@@ -531,7 +544,7 @@ where
W: Write, W: Write,
{ {
let existing = existing.cloned().unwrap_or_default(); let existing = existing.cloned().unwrap_or_default();
let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty(); let has_existing = existing.whitelist.is_some() || existing.blacklist.is_some();
if !prompt_bool( if !prompt_bool(
reader, reader,
writer, writer,
@@ -542,40 +555,79 @@ where
} }
Ok(RepoFilterInput { Ok(RepoFilterInput {
whitelist: prompt_repo_pattern_list( whitelist: prompt_repo_pattern(
reader, reader,
writer, writer,
"Whitelist regexes (comma-separated, empty means all repo names)", "Whitelist regex (empty means all repo names)",
&existing.whitelist, &existing.whitelist,
)?, )?,
blacklist: prompt_repo_pattern_list( blacklist: prompt_repo_pattern(reader, writer, "Blacklist regex", &existing.blacklist)?,
reader,
writer,
"Blacklist regexes (comma-separated)",
&existing.blacklist,
)?,
}) })
} }
fn prompt_repo_pattern_list<R, W>( fn prompt_repo_pattern<R, W>(
reader: &mut R, reader: &mut R,
writer: &mut W, writer: &mut W,
label: &str, label: &str,
existing: &[String], existing: &Option<String>,
) -> Result<Vec<String>> ) -> Result<Option<String>>
where where
R: BufRead, R: BufRead,
W: Write, W: Write,
{ {
let value = if existing.is_empty() { let value = match existing {
prompt_optional(reader, writer, label)? Some(existing) => prompt_with_default(reader, writer, label, existing)?,
} else { None => prompt_optional(reader, writer, label)?,
prompt_with_default(reader, writer, label, &existing.join(", "))?
}; };
if let Err(error) = validate_repo_pattern_list(&value) { if let Err(error) = validate_repo_pattern(&value) {
bail!(error); bail!(error);
} }
Ok(parse_repo_pattern_list(&value)) Ok(parse_repo_pattern(&value))
}
fn write_deletion_backup_notice<W>(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<R, W>(
reader: &mut R,
writer: &mut W,
existing: Option<bool>,
) -> Result<bool>
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<R, W>(
reader: &mut R,
writer: &mut W,
existing: Option<bool>,
) -> Result<bool>
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 { fn sync_visibility_value(sync_visibility: &SyncVisibility) -> &'static str {
+25
View File
@@ -11,3 +11,28 @@ fn status_text_truncates_to_fixed_width() {
assert_eq!(truncate_status("short", 8), "short"); assert_eq!(truncate_status("short", 8), "short");
assert_eq!(truncate_status("very-long-status", 8), "very-lo~"); assert_eq!(truncate_status("very-long-status", 8), "very-lo~");
} }
#[test]
fn repo_log_context_is_inherited_by_parallel_workers() {
let _guard = start_repo_log("repo-a".to_string(), 0, 8);
crate::logln!("outer line");
crate::parallel::map(vec!["worker line"], 1, |line| {
crate::logln!("{line}");
Ok::<_, anyhow::Error>(())
})
.unwrap();
let lines = {
let context = current_repo_log_context().unwrap();
context
.inner
.lines
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
};
finish_repo_log();
assert_eq!(lines, vec!["outer line", "worker line"]);
}
+208 -1
View File
@@ -225,6 +225,42 @@ fn list_gitlab_user_repos_merges_authenticated_owned_projects() {
handle.join().unwrap(); handle.join().unwrap();
} }
#[test]
fn list_gitlab_group_repos_ignores_deletion_scheduled_projects() {
let projects = r#"[
{"name":"active","path":"active","path_with_namespace":"maigolabs/active","http_url_to_repo":"https://gitlab.example.test/maigolabs/active.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"}},
{"name":"Kairos-deletion_scheduled-82068172","path":"Kairos-deletion_scheduled-82068172","path_with_namespace":"maigolabs/Kairos-deletion_scheduled-82068172","http_url_to_repo":"https://gitlab.example.test/maigolabs/Kairos-deletion_scheduled-82068172.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"}},
{"name":"marked-at","path":"marked-at","path_with_namespace":"maigolabs/marked-at","http_url_to_repo":"https://gitlab.example.test/maigolabs/marked-at.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"},"marked_for_deletion_at":"2026-05-17"},
{"name":"marked-on","path":"marked-on","path_with_namespace":"maigolabs/marked-on","http_url_to_repo":"https://gitlab.example.test/maigolabs/marked-on.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"},"marked_for_deletion_on":"2026-05-17"},
{"name":"pending","path":"pending","path_with_namespace":"maigolabs/pending","http_url_to_repo":"https://gitlab.example.test/maigolabs/pending.git","visibility":"private","description":null,"namespace":{"path":"maigolabs","full_path":"maigolabs"},"pending_delete":true}
]"#;
let (api_url, handle) = one_request_server("200 OK", projects, |request| {
assert!(
request.starts_with(
"GET /groups/maigolabs/projects?simple=true&include_subgroups=false&per_page=100 "
),
"request was {request}"
);
});
let site = SiteConfig {
api_url: Some(api_url),
..site(ProviderKind::Gitlab, None)
};
let repos = ProviderClient::new(&site)
.unwrap()
.list_repos(&EndpointConfig {
site: "gitlab".to_string(),
kind: NamespaceKind::Group,
namespace: "maigolabs".to_string(),
})
.unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "active");
handle.join().unwrap();
}
#[test] #[test]
fn create_gitlab_repo_returns_existing_repo_when_path_is_taken() { fn create_gitlab_repo_returns_existing_repo_when_path_is_taken() {
let existing = r#"{"name":"repo","path":"repo","path_with_namespace":"alice/repo","http_url_to_repo":"https://gitlab.example.test/alice/repo.git","visibility":"public","description":"existing","namespace":{"path":"alice","full_path":"alice"}}"#; let existing = r#"{"name":"repo","path":"repo","path_with_namespace":"alice/repo","http_url_to_repo":"https://gitlab.example.test/alice/repo.git","visibility":"public","description":"existing","namespace":{"path":"alice","full_path":"alice"}}"#;
@@ -282,6 +318,82 @@ fn create_gitlab_repo_returns_existing_repo_when_path_is_taken() {
handle.join().unwrap(); handle.join().unwrap();
} }
#[test]
fn set_github_default_branch_patches_repo() {
let (api_url, handle) = one_request_server("200 OK", "{}", |request| {
assert!(
request.starts_with("PATCH /repos/alice/repo "),
"request was {request}"
);
assert!(
request.contains(r#""default_branch":"main""#),
"request was {request}"
);
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer secret"),
"request was {request}"
);
});
let site = SiteConfig {
api_url: Some(api_url),
..site(ProviderKind::Github, None)
};
ProviderClient::new(&site)
.unwrap()
.set_default_branch(
&EndpointConfig {
site: "github".to_string(),
kind: NamespaceKind::User,
namespace: "alice".to_string(),
},
"repo",
"main",
)
.unwrap();
handle.join().unwrap();
}
#[test]
fn set_gitlab_default_branch_updates_project() {
let (api_url, handle) = one_request_server("200 OK", "{}", |request| {
assert!(
request.starts_with("PUT /projects/alice%2Frepo "),
"request was {request}"
);
assert!(
request.contains(r#""default_branch":"main""#),
"request was {request}"
);
assert!(
request
.to_ascii_lowercase()
.contains("private-token: secret"),
"request was {request}"
);
});
let site = SiteConfig {
api_url: Some(api_url),
..site(ProviderKind::Gitlab, None)
};
ProviderClient::new(&site)
.unwrap()
.set_default_branch(
&EndpointConfig {
site: "gitlab".to_string(),
kind: NamespaceKind::User,
namespace: "alice".to_string(),
},
"repo",
"main",
)
.unwrap();
handle.join().unwrap();
}
#[test] #[test]
fn install_webhook_posts_github_hook_when_missing() { fn install_webhook_posts_github_hook_when_missing() {
let (api_url, handle) = request_server( let (api_url, handle) = request_server(
@@ -309,7 +421,7 @@ fn install_webhook_posts_github_hook_when_missing() {
}; };
let client = ProviderClient::new(&site).unwrap(); let client = ProviderClient::new(&site).unwrap();
client let outcome = client
.install_webhook( .install_webhook(
&EndpointConfig { &EndpointConfig {
site: "github".to_string(), site: "github".to_string(),
@@ -326,6 +438,63 @@ fn install_webhook_posts_github_hook_when_missing() {
"secret", "secret",
) )
.unwrap(); .unwrap();
assert_eq!(outcome, WebhookInstallOutcome::Created);
handle.join().unwrap();
}
#[test]
fn install_webhook_reports_existing_forgejo_hook() {
let (api_url, handle) = request_server(
vec![
(
"200 OK",
r#"[{"id":42,"config":{"url":"https://mirror.example.test/webhook/"}}]"#,
),
("200 OK", r#"{"id":42}"#),
],
|index, request| match index {
0 => assert!(
request.starts_with("GET /repos/alice/repo/hooks "),
"request was {request}"
),
1 => {
assert!(
request.starts_with("PATCH /repos/alice/repo/hooks/42 "),
"request was {request}"
);
assert!(request.contains("https://mirror.example.test/webhook"));
assert!(request.contains("secret"));
assert!(request.contains("push"));
}
_ => unreachable!(),
},
);
let site = SiteConfig {
api_url: Some(api_url),
..site(ProviderKind::Forgejo, None)
};
let client = ProviderClient::new(&site).unwrap();
let outcome = client
.install_webhook(
&EndpointConfig {
site: "forgejo".to_string(),
kind: NamespaceKind::User,
namespace: "alice".to_string(),
},
&RemoteRepo {
name: "repo".to_string(),
clone_url: "https://codeberg.org/alice/repo.git".to_string(),
private: true,
description: None,
},
"https://mirror.example.test/webhook",
"secret",
)
.unwrap();
assert_eq!(outcome, WebhookInstallOutcome::Existing);
handle.join().unwrap(); handle.join().unwrap();
} }
@@ -653,6 +822,44 @@ fn create_gitea_repo_returns_existing_repo_on_conflict() {
handle.join().unwrap(); handle.join().unwrap();
} }
#[test]
fn set_gitea_default_branch_patches_repo() {
let (api_url, handle) = one_request_server("200 OK", "{}", |request| {
assert!(
request.starts_with("PATCH /repos/alice/repo "),
"request was {request}"
);
assert!(
request.contains(r#""default_branch":"main""#),
"request was {request}"
);
assert!(
request
.to_ascii_lowercase()
.contains("authorization: token secret"),
"request was {request}"
);
});
let site = SiteConfig {
api_url: Some(api_url),
..site(ProviderKind::Gitea, None)
};
ProviderClient::new(&site)
.unwrap()
.set_default_branch(
&EndpointConfig {
site: "gitea".to_string(),
kind: NamespaceKind::User,
namespace: "alice".to_string(),
},
"repo",
"main",
)
.unwrap();
handle.join().unwrap();
}
#[test] #[test]
fn open_pull_request_posts_github_pull_when_missing() { fn open_pull_request_posts_github_pull_when_missing() {
let (api_url, handle) = request_server( let (api_url, handle) = request_server(
+129 -3
View File
@@ -174,6 +174,29 @@ fn branch_deletion_decisions_ignore_internal_conflict_branches() {
assert!(blocked.is_empty()); 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] #[test]
fn repo_deletion_decision_propagates_previous_synced_repo_deletion() { fn repo_deletion_decision_propagates_previous_synced_repo_deletion() {
let mirror = test_mirror(); 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),
&current,
);
assert_eq!(decision, RepoDeletionDecision::None);
}
#[test] #[test]
fn repo_deletion_decision_conflicts_when_remaining_repo_changed() { fn repo_deletion_decision_conflicts_when_remaining_repo_changed() {
let mirror = test_mirror(); let mirror = test_mirror();
@@ -344,7 +396,7 @@ fn all_visibility_keeps_state_only_repos_for_deletion_detection() {
#[test] #[test]
fn repo_name_filters_do_not_treat_state_only_repos_as_deleted() { fn repo_name_filters_do_not_treat_state_only_repos_as_deleted() {
let mut mirror = test_mirror(); let mut mirror = test_mirror();
mirror.repo_whitelist = vec!["^public-".to_string()]; mirror.repo_whitelist = Some("^public-".to_string());
let repo_filter = mirror.repo_filter().unwrap(); let repo_filter = mirror.repo_filter().unwrap();
let mut ref_state = RefState::default(); let mut ref_state = RefState::default();
ref_state.set_repo( ref_state.set_repo(
@@ -396,6 +448,46 @@ fn endpoint_remote_names_do_not_slug_collide() {
); );
} }
#[test]
fn targeted_endpoint_repos_synthesize_clone_urls_without_listing() {
let mirror = MirrorConfig {
name: "sync-1".to_string(),
endpoints: vec![EndpointConfig {
site: "gitlab".to_string(),
kind: crate::config::NamespaceKind::Group,
namespace: "parent/child".to_string(),
}],
sync_visibility: crate::config::SyncVisibility::All,
repo_whitelist: None,
repo_blacklist: None,
create_missing: true,
delete_missing: true,
visibility: crate::config::Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
};
let config = Config {
jobs: crate::config::DEFAULT_JOBS,
sites: vec![crate::config::SiteConfig {
name: "gitlab".to_string(),
provider: crate::config::ProviderKind::Gitlab,
base_url: "https://gitlab.example.test/root".to_string(),
api_url: None,
token: crate::config::TokenConfig::Value("token".to_string()),
git_username: None,
}],
mirrors: vec![mirror.clone()],
webhook: None,
};
let repos = targeted_endpoint_repos(&config, &mirror, "repo").unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(
repos[0].repo.clone_url,
"https://gitlab.example.test/root/parent/child/repo.git"
);
}
#[test] #[test]
fn created_repo_visibility_follows_existing_public_repo() { fn created_repo_visibility_follows_existing_public_repo() {
let mirror = test_mirror(); let mirror = test_mirror();
@@ -440,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 { fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState {
RemoteRefState { RemoteRefState {
hash: hash.to_string(), hash: hash.to_string(),
@@ -472,9 +597,10 @@ fn test_mirror() -> MirrorConfig {
name: "sync-1".to_string(), name: "sync-1".to_string(),
endpoints: vec![endpoint("github"), endpoint("gitea")], endpoints: vec![endpoint("github"), endpoint("gitea")],
sync_visibility: crate::config::SyncVisibility::All, sync_visibility: crate::config::SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: crate::config::Visibility::Private, visibility: crate::config::Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
} }
+13 -9
View File
@@ -112,9 +112,10 @@ fn matches_jobs_by_provider_and_namespace() {
endpoint("gitea", NamespaceKind::User, "azalea"), endpoint("gitea", NamespaceKind::User, "azalea"),
], ],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -139,9 +140,10 @@ fn matching_jobs_respects_repo_name_filters() {
name: "sync-1".to_string(), name: "sync-1".to_string(),
endpoints: vec![endpoint("github", NamespaceKind::User, "alice")], endpoints: vec![endpoint("github", NamespaceKind::User, "alice")],
sync_visibility: SyncVisibility::All, sync_visibility: SyncVisibility::All,
repo_whitelist: vec!["^important-".to_string()], repo_whitelist: Some("^important-".to_string()),
repo_blacklist: vec!["-archive$".to_string()], repo_blacklist: Some("-archive$".to_string()),
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}; };
@@ -159,7 +161,7 @@ fn matching_jobs_respects_repo_name_filters() {
assert!(matching_jobs(&config, &webhook_event("important-archive")).is_empty()); assert!(matching_jobs(&config, &webhook_event("important-archive")).is_empty());
assert!(matching_jobs(&config, &webhook_event("random")).is_empty()); assert!(matching_jobs(&config, &webhook_event("random")).is_empty());
mirror.repo_whitelist.clear(); mirror.repo_whitelist = None;
let config = Config { let config = Config {
jobs: crate::config::DEFAULT_JOBS, jobs: crate::config::DEFAULT_JOBS,
sites: vec![site("github", ProviderKind::Github)], sites: vec![site("github", ProviderKind::Github)],
@@ -357,9 +359,10 @@ fn uninstall_webhooks_skips_blocked_provider_access() {
endpoint("github-peer", NamespaceKind::User, "bob"), endpoint("github-peer", NamespaceKind::User, "bob"),
], ],
sync_visibility: SyncVisibility::Public, sync_visibility: SyncVisibility::Public,
repo_whitelist: Vec::new(), repo_whitelist: None,
repo_blacklist: Vec::new(), repo_blacklist: None,
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
}], }],
@@ -710,9 +713,10 @@ fn filtered_mirror() -> MirrorConfig {
endpoint("github-peer", NamespaceKind::User, "bob"), endpoint("github-peer", NamespaceKind::User, "bob"),
], ],
sync_visibility: SyncVisibility::Public, sync_visibility: SyncVisibility::Public,
repo_whitelist: vec!["^important-".to_string()], repo_whitelist: Some("^important-".to_string()),
repo_blacklist: vec!["-archive$".to_string()], repo_blacklist: Some("-archive$".to_string()),
create_missing: true, create_missing: true,
delete_missing: true,
visibility: Visibility::Private, visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail, conflict_resolution: ConflictResolutionStrategy::Fail,
} }