Compare commits
2 Commits
main
..
fix-log-sync
| Author | SHA1 | Date | |
|---|---|---|---|
| 81edd9b8bf | |||
| 09cede6658 |
@@ -1 +0,0 @@
|
|||||||
Azalea <noreply@aza.moe> <22280294+hykilpikonna@users.noreply.github.com>
|
|
||||||
Generated
+1
-1
@@ -1118,7 +1118,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "refray"
|
name = "refray"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "refray"
|
name = "refray"
|
||||||
version = "0.1.1"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["Azalea"]
|
authors = ["Azalea"]
|
||||||
description = "∞-way read-write git mirroring tool"
|
description = "∞-way read-write git mirroring tool"
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
<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.
|
||||||
@@ -12,46 +8,17 @@ 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**: Branches/repo deletions sync across platforms (with backup)
|
- **tracks deletions**: Delete branches/repos across platforms when they are deleted from one platform
|
||||||
- **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]
|
||||||
> My cat made this codebase, meow
|
> Meow
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
<!--
|
|
||||||
|
|
||||||
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 with Cargo
|
### Option 1. Install from source
|
||||||
|
|
||||||
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`
|
||||||
@@ -102,8 +69,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"
|
||||||
@@ -206,17 +173,13 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Issues and Pull Requests
|
## Sync Semantics
|
||||||
|
|
||||||
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. 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.
|
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.
|
||||||
|
|
||||||
For every repository name found in any endpoint, `refray` will:
|
For every repository name found in any endpoint, `refray` will:
|
||||||
|
|
||||||
@@ -240,9 +203,7 @@ 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.
|
||||||
|
|
||||||
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. 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -278,4 +239,8 @@ 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.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 433 KiB |
+23
-31
@@ -56,14 +56,12 @@ 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 = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub repo_whitelist: Option<String>,
|
pub repo_whitelist: Vec<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub repo_blacklist: Option<String>,
|
pub repo_blacklist: Vec<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)]
|
||||||
@@ -137,8 +135,8 @@ pub enum SyncVisibility {
|
|||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RepoNameFilter {
|
pub struct RepoNameFilter {
|
||||||
whitelist: Option<Regex>,
|
whitelist: Vec<Regex>,
|
||||||
blacklist: Option<Regex>,
|
blacklist: Vec<Regex>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncVisibility {
|
impl SyncVisibility {
|
||||||
@@ -154,41 +152,35 @@ 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_pattern(&self.name, "repo_whitelist", &self.repo_whitelist)?,
|
whitelist: compile_repo_patterns(&self.name, "repo_whitelist", &self.repo_whitelist)?,
|
||||||
blacklist: compile_repo_pattern(&self.name, "repo_blacklist", &self.repo_blacklist)?,
|
blacklist: compile_repo_patterns(&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
|
let whitelisted = self.whitelist.is_empty()
|
||||||
.whitelist
|
|| self
|
||||||
.as_ref()
|
.whitelist
|
||||||
.is_none_or(|pattern| pattern.is_match(repo_name));
|
.iter()
|
||||||
|
.any(|pattern| pattern.is_match(repo_name));
|
||||||
let blacklisted = self
|
let blacklisted = self
|
||||||
.blacklist
|
.blacklist
|
||||||
.as_ref()
|
.iter()
|
||||||
.is_some_and(|pattern| pattern.is_match(repo_name));
|
.any(|pattern| pattern.is_match(repo_name));
|
||||||
whitelisted && !blacklisted
|
whitelisted && !blacklisted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compile_repo_pattern(
|
fn compile_repo_patterns(mirror: &str, field: &str, patterns: &[String]) -> Result<Vec<Regex>> {
|
||||||
mirror: &str,
|
patterns
|
||||||
field: &str,
|
.iter()
|
||||||
pattern: &Option<String>,
|
.map(|pattern| {
|
||||||
) -> Result<Option<Regex>> {
|
Regex::new(pattern)
|
||||||
let Some(pattern) = pattern
|
.with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'"))
|
||||||
.as_deref()
|
})
|
||||||
.map(str::trim)
|
.collect()
|
||||||
.filter(|pattern| !pattern.is_empty())
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
Regex::new(pattern)
|
|
||||||
.with_context(|| format!("mirror '{mirror}' has invalid {field} regex '{pattern}'"))
|
|
||||||
.map(Some)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
|
|||||||
+4
-180
@@ -52,13 +52,6 @@ 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,
|
||||||
@@ -86,12 +79,6 @@ 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>,
|
||||||
@@ -433,63 +420,6 @@ 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)
|
||||||
@@ -588,7 +518,6 @@ 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([
|
||||||
@@ -596,13 +525,12 @@ impl GitMirror {
|
|||||||
"add",
|
"add",
|
||||||
"--detach",
|
"--detach",
|
||||||
worktree_path.to_str().unwrap(),
|
worktree_path.to_str().unwrap(),
|
||||||
onto,
|
tip,
|
||||||
])?;
|
])?;
|
||||||
|
|
||||||
let replay_result = self.replay_commits_preserving_committer(&worktree_path, &commits);
|
let rebase_result = self.worktree_git(&worktree_path, ["rebase", "--onto", onto, base]);
|
||||||
if let Err(error) = replay_result {
|
if let Err(error) = rebase_result {
|
||||||
let _ = self.worktree_git(&worktree_path, ["cherry-pick", "--abort"]);
|
let _ = self.worktree_git(&worktree_path, ["rebase", "--abort"]);
|
||||||
let _ = self.worktree_git(&worktree_path, ["reset", "--hard"]);
|
|
||||||
let _ = self.run([
|
let _ = self.run([
|
||||||
"worktree",
|
"worktree",
|
||||||
"remove",
|
"remove",
|
||||||
@@ -621,66 +549,6 @@ 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()
|
||||||
@@ -777,35 +645,6 @@ 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 {
|
||||||
@@ -913,13 +752,6 @@ 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()
|
||||||
@@ -936,14 +768,6 @@ 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
|
||||||
|
|||||||
+38
-82
@@ -38,8 +38,8 @@ struct ParsedProfileUrl {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
struct RepoFilterInput {
|
struct RepoFilterInput {
|
||||||
whitelist: Option<String>,
|
whitelist: Vec<String>,
|
||||||
blacklist: Option<String>,
|
blacklist: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_config_wizard(path: &Path) -> Result<ConfigWizardOutcome> {
|
pub fn run_config_wizard(path: &Path) -> Result<ConfigWizardOutcome> {
|
||||||
@@ -115,9 +115,6 @@ 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),
|
||||||
@@ -125,8 +122,7 @@ 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,
|
create_missing: true,
|
||||||
delete_missing,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution,
|
conflict_resolution,
|
||||||
});
|
});
|
||||||
@@ -451,8 +447,6 @@ 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))?;
|
||||||
@@ -461,17 +455,12 @@ 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!(
|
||||||
@@ -791,7 +780,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_some() || existing.blacklist.is_some();
|
let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty();
|
||||||
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)
|
||||||
@@ -801,69 +790,51 @@ fn prompt_repo_filters_styled(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(RepoFilterInput {
|
Ok(RepoFilterInput {
|
||||||
whitelist: prompt_repo_pattern_styled(
|
whitelist: prompt_repo_pattern_list_styled(
|
||||||
theme,
|
theme,
|
||||||
"Whitelist regex (empty means all repo names)",
|
"Whitelist regexes (comma-separated, empty means all repo names)",
|
||||||
&existing.whitelist,
|
&existing.whitelist,
|
||||||
)?,
|
)?,
|
||||||
blacklist: prompt_repo_pattern_styled(theme, "Blacklist regex", &existing.blacklist)?,
|
blacklist: prompt_repo_pattern_list_styled(
|
||||||
|
theme,
|
||||||
|
"Blacklist regexes (comma-separated)",
|
||||||
|
&existing.blacklist,
|
||||||
|
)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_repo_pattern_styled(
|
fn prompt_repo_pattern_list_styled(
|
||||||
theme: &ColorfulTheme,
|
theme: &ColorfulTheme,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
existing: &Option<String>,
|
existing: &[String],
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Vec<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(value));
|
.validate_with(|value: &String| validate_repo_pattern_list(value));
|
||||||
let input = if let Some(existing) = existing {
|
let input = if existing.is_empty() {
|
||||||
input.default(existing.clone())
|
|
||||||
} else {
|
|
||||||
input
|
input
|
||||||
|
} else {
|
||||||
|
input.default(existing.join(", "))
|
||||||
};
|
};
|
||||||
let value = input.interact_text()?;
|
let value = input.interact_text()?;
|
||||||
Ok(parse_repo_pattern(&value))
|
Ok(parse_repo_pattern_list(&value))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_deletion_backup_notice_styled() {
|
fn validate_repo_pattern_list(value: &str) -> std::result::Result<(), String> {
|
||||||
println!();
|
for pattern in parse_repo_pattern_list(value) {
|
||||||
println!(
|
Regex::new(&pattern).map_err(|error| format!("invalid regex '{pattern}': {error}"))?;
|
||||||
"{} {}",
|
}
|
||||||
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}"))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_repo_pattern(value: &str) -> Option<String> {
|
fn parse_repo_pattern_list(value: &str) -> Vec<String> {
|
||||||
let value = value.trim();
|
value
|
||||||
(!value.is_empty()).then(|| value.to_string())
|
.split(',')
|
||||||
|
.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 {
|
||||||
@@ -949,11 +920,10 @@ 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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -967,28 +937,14 @@ 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, &mirror.repo_blacklist) {
|
match (mirror.repo_whitelist.len(), mirror.repo_blacklist.len()) {
|
||||||
(None, None) => "repos: all names".to_string(),
|
(0, 0) => "repos: all names".to_string(),
|
||||||
(Some(_), None) => "repos: whitelist".to_string(),
|
(whitelist, 0) => format!("repos: whitelist {whitelist}"),
|
||||||
(None, Some(_)) => "repos: blacklist".to_string(),
|
(0, blacklist) => format!("repos: blacklist {blacklist}"),
|
||||||
(Some(_), Some(_)) => "repos: whitelist + blacklist".to_string(),
|
(whitelist, blacklist) => {
|
||||||
}
|
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 {
|
||||||
|
|||||||
+23
-169
@@ -1,27 +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, Write};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use console::style;
|
static OUTPUT: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
|
||||||
static OUTPUT: OnceLock<Mutex<OutputState>> = OnceLock::new();
|
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static REPO_LOG: RefCell<Option<ActiveRepoLog>> = const { RefCell::new(None) };
|
static REPO_LOG: RefCell<Option<ActiveRepoLog>> = const { RefCell::new(None) };
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct OutputState {
|
|
||||||
status: Option<StatusState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StatusState {
|
|
||||||
slots: Vec<Option<String>>,
|
|
||||||
visible: bool,
|
|
||||||
interactive: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct RepoLogContext {
|
pub(crate) struct RepoLogContext {
|
||||||
inner: Arc<RepoLog>,
|
inner: Arc<RepoLog>,
|
||||||
@@ -33,20 +20,9 @@ struct ActiveRepoLog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RepoLog {
|
struct RepoLog {
|
||||||
repo_name: String,
|
|
||||||
slot: usize,
|
|
||||||
width: usize,
|
|
||||||
lines: Mutex<Vec<String>>,
|
lines: Mutex<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StatusGuard;
|
|
||||||
|
|
||||||
impl Drop for StatusGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
finish_status_area();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RepoLogGuard;
|
pub struct RepoLogGuard;
|
||||||
|
|
||||||
impl Drop for RepoLogGuard {
|
impl Drop for RepoLogGuard {
|
||||||
@@ -55,29 +31,9 @@ impl Drop for RepoLogGuard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_status_area(slots: usize) -> StatusGuard {
|
pub fn start_repo_log() -> RepoLogGuard {
|
||||||
with_output(|output| {
|
|
||||||
if let Some(status) = output.status.as_mut() {
|
|
||||||
clear_status(status);
|
|
||||||
}
|
|
||||||
output.status = Some(StatusState {
|
|
||||||
slots: vec![None; slots],
|
|
||||||
visible: false,
|
|
||||||
interactive: io::stdout().is_terminal() && slots > 0,
|
|
||||||
});
|
|
||||||
if let Some(status) = output.status.as_mut() {
|
|
||||||
draw_status(status);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
StatusGuard
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_repo_log(repo_name: String, slot: usize, width: usize) -> RepoLogGuard {
|
|
||||||
let context = RepoLogContext {
|
let context = RepoLogContext {
|
||||||
inner: Arc::new(RepoLog {
|
inner: Arc::new(RepoLog {
|
||||||
repo_name,
|
|
||||||
slot,
|
|
||||||
width,
|
|
||||||
lines: Mutex::new(Vec::new()),
|
lines: Mutex::new(Vec::new()),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -144,31 +100,13 @@ pub fn finish_repo_log() {
|
|||||||
std::mem::take(&mut *lines)
|
std::mem::take(&mut *lines)
|
||||||
};
|
};
|
||||||
|
|
||||||
with_output(|output| {
|
with_output(|| {
|
||||||
if let Some(status) = output.status.as_mut() {
|
|
||||||
clear_status(status);
|
|
||||||
if repo_log.slot < status.slots.len() {
|
|
||||||
status.slots[repo_log.slot] = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for line in lines {
|
for line in lines {
|
||||||
println!("{line}");
|
println!("{line}");
|
||||||
}
|
}
|
||||||
if let Some(status) = output.status.as_mut() {
|
|
||||||
draw_status(status);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn repo_prefix(repo_name: &str, width: usize) -> String {
|
|
||||||
let mut prefix = repo_name.chars().take(width).collect::<String>();
|
|
||||||
if repo_name.chars().count() > width && width > 0 {
|
|
||||||
prefix.pop();
|
|
||||||
prefix.push('~');
|
|
||||||
}
|
|
||||||
format!("{prefix:<width$}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn line(args: fmt::Arguments<'_>) {
|
pub fn line(args: fmt::Arguments<'_>) {
|
||||||
let text = args.to_string();
|
let text = args.to_string();
|
||||||
let context = current_repo_log_context();
|
let context = current_repo_log_context();
|
||||||
@@ -177,119 +115,35 @@ pub fn line(args: fmt::Arguments<'_>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
with_output(|output| {
|
with_output(|| {
|
||||||
if let Some(status) = output.status.as_mut() {
|
|
||||||
clear_status(status);
|
|
||||||
}
|
|
||||||
println!("{text}");
|
println!("{text}");
|
||||||
if let Some(status) = output.status.as_mut() {
|
|
||||||
draw_status(status);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capture_repo_line(context: &RepoLogContext, text: &str) {
|
fn capture_repo_line(context: &RepoLogContext, text: &str) {
|
||||||
let mut status_updates = Vec::new();
|
let mut lines = context
|
||||||
{
|
.inner
|
||||||
let mut lines = context
|
.lines
|
||||||
.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 line = truncate_status(line, 96);
|
|
||||||
with_output(|output| {
|
|
||||||
let Some(status) = output.status.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if repo_log.slot >= status.slots.len() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clear_status(status);
|
|
||||||
status.slots[repo_log.slot] = Some(format!(
|
|
||||||
"{} {} {}",
|
|
||||||
style(format!("worker {}", repo_log.slot + 1)).dim(),
|
|
||||||
style(repo).cyan().bold(),
|
|
||||||
line
|
|
||||||
));
|
|
||||||
draw_status(status);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_status(value: &str, max_chars: usize) -> String {
|
|
||||||
if value.chars().count() <= max_chars {
|
|
||||||
return value.to_string();
|
|
||||||
}
|
|
||||||
let mut output = value.chars().take(max_chars).collect::<String>();
|
|
||||||
output.pop();
|
|
||||||
output.push('~');
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_status_area() {
|
|
||||||
with_output(|output| {
|
|
||||||
if let Some(status) = output.status.as_mut() {
|
|
||||||
clear_status(status);
|
|
||||||
}
|
|
||||||
output.status = None;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_output(action: impl FnOnce(&mut OutputState)) {
|
|
||||||
let output = OUTPUT.get_or_init(|| Mutex::new(OutputState::default()));
|
|
||||||
let mut output = output
|
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
action(&mut output);
|
if text.is_empty() {
|
||||||
|
lines.push(String::new());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for line in text.lines() {
|
||||||
|
lines.push(line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_output(action: impl FnOnce()) {
|
||||||
|
let output = OUTPUT.get_or_init(|| Mutex::new(()));
|
||||||
|
let _output = output
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
action();
|
||||||
let _ = io::stdout().flush();
|
let _ = io::stdout().flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_status(status: &mut StatusState) {
|
|
||||||
if !status.interactive || !status.visible {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lines = status.slots.len();
|
|
||||||
print!("\x1b[{lines}A\r");
|
|
||||||
for _ in 0..lines {
|
|
||||||
println!("\x1b[2K");
|
|
||||||
}
|
|
||||||
print!("\x1b[{lines}A\r");
|
|
||||||
status.visible = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_status(status: &mut StatusState) {
|
|
||||||
if !status.interactive {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for slot in &status.slots {
|
|
||||||
match slot {
|
|
||||||
Some(line) => println!("{line}"),
|
|
||||||
None => println!("{}", style("idle").dim()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
status.visible = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! logln {
|
macro_rules! logln {
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
+9
-105
@@ -40,12 +40,6 @@ 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,
|
||||||
@@ -172,26 +166,13 @@ 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<WebhookInstallOutcome> {
|
) -> Result<()> {
|
||||||
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),
|
||||||
@@ -330,24 +311,13 @@ 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<WebhookInstallOutcome> {
|
) -> Result<()> {
|
||||||
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",
|
||||||
@@ -465,11 +435,7 @@ impl<'a> ProviderClient<'a> {
|
|||||||
projects.push(project);
|
projects.push(project);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(projects
|
Ok(projects.into_iter().map(Into::into).collect())
|
||||||
.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);
|
||||||
@@ -478,12 +444,7 @@ impl<'a> ProviderClient<'a> {
|
|||||||
self.site.api_base(),
|
self.site.api_base(),
|
||||||
encoded
|
encoded
|
||||||
);
|
);
|
||||||
Ok(self
|
self.paged_remote_repos::<GitlabProject>(&url)
|
||||||
.paged_get::<GitlabProject>(&url)?
|
|
||||||
.into_iter()
|
|
||||||
.filter(|project| !project.is_deletion_scheduled())
|
|
||||||
.map(Into::into)
|
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,17 +496,6 @@ 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)
|
||||||
@@ -576,7 +526,7 @@ impl<'a> ProviderClient<'a> {
|
|||||||
repo: &RemoteRepo,
|
repo: &RemoteRepo,
|
||||||
url: &str,
|
url: &str,
|
||||||
secret: &str,
|
secret: &str,
|
||||||
) -> Result<WebhookInstallOutcome> {
|
) -> Result<()> {
|
||||||
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,
|
||||||
@@ -739,24 +689,13 @@ 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<WebhookInstallOutcome> {
|
) -> Result<()> {
|
||||||
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",
|
||||||
@@ -936,10 +875,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<WebhookInstallOutcome> {
|
) -> Result<()> {
|
||||||
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(WebhookInstallOutcome::Created);
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let update_url = format!("{hooks_url}/{}", hook.id);
|
let update_url = format!("{hooks_url}/{}", hook.id);
|
||||||
@@ -948,7 +887,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(WebhookInstallOutcome::Existing)
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
@@ -1218,10 +1157,6 @@ 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 {
|
||||||
@@ -1249,37 +1184,6 @@ 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)]
|
||||||
|
|||||||
+31
-1030
File diff suppressed because it is too large
Load Diff
+58
-64
@@ -8,6 +8,7 @@ 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;
|
||||||
@@ -17,11 +18,9 @@ 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::{
|
use crate::provider::{EndpointRepo, ProviderClient, RemoteRepo, list_mirror_repos};
|
||||||
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, sync_webhook_repo};
|
use crate::sync::{SyncOptions, sync_all};
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
const WEBHOOK_STATE_FILE: &str = "webhook-state.toml";
|
const WEBHOOK_STATE_FILE: &str = "webhook-state.toml";
|
||||||
@@ -152,7 +151,6 @@ 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()
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -377,12 +375,15 @@ 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_webhook_repo(
|
let result = sync_all(
|
||||||
&config,
|
&config,
|
||||||
&job.group,
|
SyncOptions {
|
||||||
&job.repo,
|
group: Some(job.group.clone()),
|
||||||
work_dir.clone(),
|
repo_pattern: Some(format!("^{}$", escape(&job.repo))),
|
||||||
config.jobs,
|
work_dir: work_dir.clone(),
|
||||||
|
jobs: 1,
|
||||||
|
..SyncOptions::default()
|
||||||
|
},
|
||||||
);
|
);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => crate::logln!(
|
Ok(()) => crate::logln!(
|
||||||
@@ -574,73 +575,66 @@ 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);
|
||||||
|
crate::logln!(
|
||||||
|
" {} {} {}",
|
||||||
|
style(if task.dry_run {
|
||||||
|
"would install"
|
||||||
|
} else {
|
||||||
|
"install"
|
||||||
|
})
|
||||||
|
.green()
|
||||||
|
.bold(),
|
||||||
|
style(&task.repo.name).cyan(),
|
||||||
|
style(format!("webhook on {}", task.endpoint.label())).dim()
|
||||||
|
);
|
||||||
if task.dry_run {
|
if task.dry_run {
|
||||||
crate::logln!(
|
|
||||||
" {} {} {}",
|
|
||||||
style("would install").green().bold(),
|
|
||||||
style(&task.repo.name).cyan(),
|
|
||||||
style(format!("webhook on {}", task.endpoint.label())).dim()
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let client = ProviderClient::new(&task.site)?;
|
let client = ProviderClient::new(&task.site)?;
|
||||||
match client.install_webhook(&task.endpoint, &task.repo, &task.url, &task.secret) {
|
if let Err(error) = client.install_webhook(&task.endpoint, &task.repo, &task.url, &task.secret)
|
||||||
Ok(outcome) => {
|
{
|
||||||
let action = match outcome {
|
if is_duplicate_webhook_error(&error) {
|
||||||
WebhookInstallOutcome::Created => "install",
|
|
||||||
WebhookInstallOutcome::Existing => "exists",
|
|
||||||
};
|
|
||||||
crate::logln!(
|
crate::logln!(
|
||||||
" {} {} {}",
|
" {} {} {}",
|
||||||
style(action).green().bold(),
|
style("exists").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()
|
||||||
);
|
);
|
||||||
record_webhook_installation(state, key, task);
|
record_webhook_installation(state, key, task);
|
||||||
Ok(())
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(error) => {
|
if let Some(reason) = non_actionable_webhook_failure_reason(&error) {
|
||||||
if is_duplicate_webhook_error(&error) {
|
crate::logln!(
|
||||||
crate::logln!(
|
" {} {} {}",
|
||||||
" {} {} {}",
|
style("skip").yellow().bold(),
|
||||||
style("exists").green().bold(),
|
style(&task.repo.name).cyan(),
|
||||||
style(&task.repo.name).cyan(),
|
style(format!("webhook on {}: {reason}", task.endpoint.label())).dim()
|
||||||
style(format!("webhook on {}", task.endpoint.label())).dim()
|
);
|
||||||
);
|
let mut state = state
|
||||||
record_webhook_installation(state, key, task);
|
.lock()
|
||||||
return Ok(());
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
}
|
state.skipped.insert(
|
||||||
if let Some(reason) = non_actionable_webhook_failure_reason(&error) {
|
key,
|
||||||
crate::logln!(
|
SkippedWebhookInstallation {
|
||||||
" {} {} {}",
|
group: task.group,
|
||||||
style("skip").yellow().bold(),
|
endpoint: task.endpoint,
|
||||||
style(&task.repo.name).cyan(),
|
repo: task.repo.name,
|
||||||
style(format!("webhook on {}: {reason}", task.endpoint.label())).dim()
|
url: task.url,
|
||||||
);
|
reason,
|
||||||
let mut state = state
|
},
|
||||||
.lock()
|
);
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
return Ok(());
|
||||||
state.skipped.insert(
|
|
||||||
key,
|
|
||||||
SkippedWebhookInstallation {
|
|
||||||
group: task.group,
|
|
||||||
endpoint: task.endpoint,
|
|
||||||
repo: task.repo.name,
|
|
||||||
url: task.url,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Err(error).with_context(|| {
|
|
||||||
format!(
|
|
||||||
"failed to install webhook for {} on {}",
|
|
||||||
task.repo.name,
|
|
||||||
task.endpoint.label()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return Err(error).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to install webhook for {} on {}",
|
||||||
|
task.repo.name,
|
||||||
|
task.endpoint.label()
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
record_webhook_installation(state, key, task);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record_webhook_installation(
|
fn record_webhook_installation(
|
||||||
|
|||||||
+7
-526
@@ -25,7 +25,8 @@ 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 settings = load_e2e_settings()?;
|
let env = EnvFile::load(Path::new(".env"))?;
|
||||||
|
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)?;
|
||||||
@@ -58,42 +59,6 @@ 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>,
|
||||||
}
|
}
|
||||||
@@ -374,9 +339,8 @@ 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 = "{}"
|
||||||
|
|
||||||
@@ -415,10 +379,9 @@ 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_retry(
|
self.git(
|
||||||
&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,
|
||||||
@@ -429,7 +392,6 @@ 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")?;
|
||||||
|
|
||||||
@@ -653,7 +615,6 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,219 +629,6 @@ 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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,7 +696,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_retry(&work, ["push", "origin", "HEAD:main"], "seed push")?;
|
self.git(&work, ["push", "origin", "HEAD:main"])?;
|
||||||
provider.wait_branch(
|
provider.wait_branch(
|
||||||
repo,
|
repo,
|
||||||
MAIN_BRANCH,
|
MAIN_BRANCH,
|
||||||
@@ -975,11 +723,7 @@ 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_retry(
|
self.git(&work, ["push", &provider.site_name, "HEAD:main"])?;
|
||||||
&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)?;
|
||||||
@@ -1012,129 +756,6 @@ 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)?;
|
||||||
@@ -1163,15 +784,11 @@ 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()
|
||||||
@@ -1428,34 +1045,6 @@ 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,
|
||||||
@@ -1472,30 +1061,6 @@ 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)?;
|
||||||
@@ -1525,29 +1090,6 @@ 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() {
|
||||||
@@ -1622,36 +1164,6 @@ 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)?;
|
||||||
@@ -1849,17 +1361,6 @@ 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)? {
|
||||||
@@ -2450,26 +1951,6 @@ 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 {
|
||||||
|
|||||||
+15
-45
@@ -22,10 +22,9 @@ 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"
|
||||||
|
|
||||||
@@ -52,13 +51,12 @@ 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,
|
||||||
Some("^important-|-mirror$".to_string())
|
vec!["^important-".to_string(), "-mirror$".to_string()]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.mirrors[0].repo_blacklist,
|
config.mirrors[0].repo_blacklist,
|
||||||
Some("-archive$".to_string())
|
vec!["-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");
|
||||||
@@ -94,30 +92,6 @@ 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 {
|
||||||
@@ -131,10 +105,9 @@ 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: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -161,10 +134,9 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sync_visibility: SyncVisibility::All,
|
sync_visibility: SyncVisibility::All,
|
||||||
repo_whitelist: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -227,8 +199,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 = Some("^important-|-mirror$".to_string());
|
mirror.repo_whitelist = vec!["^important-".to_string(), "-mirror$".to_string()];
|
||||||
mirror.repo_blacklist = Some("-archive$".to_string());
|
mirror.repo_blacklist = vec!["-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"));
|
||||||
@@ -245,7 +217,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 = Some("(".to_string());
|
config.mirrors[0].repo_whitelist = vec!["(".to_string()];
|
||||||
|
|
||||||
let err = validate_config(&config).unwrap_err().to_string();
|
let err = validate_config(&config).unwrap_err().to_string();
|
||||||
|
|
||||||
@@ -266,10 +238,9 @@ 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: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -313,10 +284,9 @@ fn mirror_config() -> MirrorConfig {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sync_visibility: SyncVisibility::All,
|
sync_visibility: SyncVisibility::All,
|
||||||
repo_whitelist: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-107
@@ -41,19 +41,6 @@ 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();
|
||||||
@@ -178,15 +165,7 @@ 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_with_committer(
|
let b_tip = fixture.commit_file("b", "b.txt", "b\n", 1_700_000_200);
|
||||||
"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();
|
||||||
@@ -213,10 +192,6 @@ 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)
|
||||||
@@ -300,44 +275,6 @@ 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();
|
||||||
@@ -507,35 +444,6 @@ 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"])
|
||||||
}
|
}
|
||||||
@@ -600,20 +508,6 @@ 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]) {
|
||||||
|
|||||||
+15
-65
@@ -14,8 +14,6 @@ fn wizard_builds_sync_group_from_profile_urls() {
|
|||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"n",
|
"n",
|
||||||
"4",
|
"4",
|
||||||
]
|
]
|
||||||
@@ -48,7 +46,6 @@ 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,
|
||||||
@@ -57,9 +54,6 @@ 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"));
|
||||||
@@ -83,8 +77,6 @@ fn wizard_can_build_three_way_sync() {
|
|||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"n",
|
"n",
|
||||||
"4",
|
"4",
|
||||||
]
|
]
|
||||||
@@ -100,35 +92,6 @@ 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 = [
|
||||||
@@ -142,8 +105,6 @@ fn wizard_can_enable_webhooks() {
|
|||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"y",
|
"y",
|
||||||
"https://mirror.example.test/webhook",
|
"https://mirror.example.test/webhook",
|
||||||
"y",
|
"y",
|
||||||
@@ -198,8 +159,6 @@ fn wizard_reuses_existing_credentials_for_same_instance() {
|
|||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"n",
|
"n",
|
||||||
"4",
|
"4",
|
||||||
]
|
]
|
||||||
@@ -252,10 +211,9 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sync_visibility: SyncVisibility::All,
|
sync_visibility: SyncVisibility::All,
|
||||||
repo_whitelist: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -282,10 +240,9 @@ 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: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -359,10 +316,9 @@ fn wizard_edits_existing_sync_group_from_menu() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sync_visibility: SyncVisibility::Private,
|
sync_visibility: SyncVisibility::Private,
|
||||||
repo_whitelist: Some("^important-".to_string()),
|
repo_whitelist: vec!["^important-".to_string()],
|
||||||
repo_blacklist: Some("-archive$".to_string()),
|
repo_blacklist: vec!["-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,
|
||||||
}],
|
}],
|
||||||
@@ -381,8 +337,6 @@ fn wizard_edits_existing_sync_group_from_menu() {
|
|||||||
"^public-",
|
"^public-",
|
||||||
"-skip$",
|
"-skip$",
|
||||||
"",
|
"",
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"n",
|
"n",
|
||||||
"4",
|
"4",
|
||||||
]
|
]
|
||||||
@@ -402,10 +356,9 @@ 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, Some("^public-".to_string()));
|
assert_eq!(mirror.repo_whitelist, vec!["^public-".to_string()]);
|
||||||
assert_eq!(mirror.repo_blacklist, Some("-skip$".to_string()));
|
assert_eq!(mirror.repo_blacklist, vec!["-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"));
|
||||||
@@ -450,16 +403,15 @@ fn wizard_prefills_existing_sync_group_when_editing() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sync_visibility: SyncVisibility::All,
|
sync_visibility: SyncVisibility::All,
|
||||||
repo_whitelist: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
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();
|
||||||
|
|
||||||
@@ -515,10 +467,9 @@ fn wizard_deletes_existing_sync_group_from_menu() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sync_visibility: SyncVisibility::All,
|
sync_visibility: SyncVisibility::All,
|
||||||
repo_whitelist: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -575,10 +526,9 @@ fn wizard_can_go_back_from_delete_menu() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
sync_visibility: SyncVisibility::All,
|
sync_visibility: SyncVisibility::All,
|
||||||
repo_whitelist: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ 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),
|
||||||
@@ -75,8 +72,7 @@ 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,
|
create_missing: true,
|
||||||
delete_missing,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution,
|
conflict_resolution,
|
||||||
});
|
});
|
||||||
@@ -280,8 +276,6 @@ 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)?;
|
||||||
@@ -289,11 +283,6 @@ 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,
|
||||||
@@ -303,8 +292,6 @@ 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}")?;
|
||||||
@@ -544,7 +531,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_some() || existing.blacklist.is_some();
|
let has_existing = !existing.whitelist.is_empty() || !existing.blacklist.is_empty();
|
||||||
if !prompt_bool(
|
if !prompt_bool(
|
||||||
reader,
|
reader,
|
||||||
writer,
|
writer,
|
||||||
@@ -555,79 +542,40 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(RepoFilterInput {
|
Ok(RepoFilterInput {
|
||||||
whitelist: prompt_repo_pattern(
|
whitelist: prompt_repo_pattern_list(
|
||||||
reader,
|
reader,
|
||||||
writer,
|
writer,
|
||||||
"Whitelist regex (empty means all repo names)",
|
"Whitelist regexes (comma-separated, empty means all repo names)",
|
||||||
&existing.whitelist,
|
&existing.whitelist,
|
||||||
)?,
|
)?,
|
||||||
blacklist: prompt_repo_pattern(reader, writer, "Blacklist regex", &existing.blacklist)?,
|
blacklist: prompt_repo_pattern_list(
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
"Blacklist regexes (comma-separated)",
|
||||||
|
&existing.blacklist,
|
||||||
|
)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt_repo_pattern<R, W>(
|
fn prompt_repo_pattern_list<R, W>(
|
||||||
reader: &mut R,
|
reader: &mut R,
|
||||||
writer: &mut W,
|
writer: &mut W,
|
||||||
label: &str,
|
label: &str,
|
||||||
existing: &Option<String>,
|
existing: &[String],
|
||||||
) -> Result<Option<String>>
|
) -> Result<Vec<String>>
|
||||||
where
|
where
|
||||||
R: BufRead,
|
R: BufRead,
|
||||||
W: Write,
|
W: Write,
|
||||||
{
|
{
|
||||||
let value = match existing {
|
let value = if existing.is_empty() {
|
||||||
Some(existing) => prompt_with_default(reader, writer, label, existing)?,
|
prompt_optional(reader, writer, label)?
|
||||||
None => prompt_optional(reader, writer, label)?,
|
} else {
|
||||||
|
prompt_with_default(reader, writer, label, &existing.join(", "))?
|
||||||
};
|
};
|
||||||
if let Err(error) = validate_repo_pattern(&value) {
|
if let Err(error) = validate_repo_pattern_list(&value) {
|
||||||
bail!(error);
|
bail!(error);
|
||||||
}
|
}
|
||||||
Ok(parse_repo_pattern(&value))
|
Ok(parse_repo_pattern_list(&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 {
|
||||||
|
|||||||
+1
-13
@@ -1,20 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repo_prefix_pads_and_truncates_to_fixed_width() {
|
|
||||||
assert_eq!(repo_prefix("api", 6), "api ");
|
|
||||||
assert_eq!(repo_prefix("very-long-repo", 8), "very-lo~");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_text_truncates_to_fixed_width() {
|
|
||||||
assert_eq!(truncate_status("short", 8), "short");
|
|
||||||
assert_eq!(truncate_status("very-long-status", 8), "very-lo~");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repo_log_context_is_inherited_by_parallel_workers() {
|
fn repo_log_context_is_inherited_by_parallel_workers() {
|
||||||
let _guard = start_repo_log("repo-a".to_string(), 0, 8);
|
let _guard = start_repo_log();
|
||||||
|
|
||||||
crate::logln!("outer line");
|
crate::logln!("outer line");
|
||||||
crate::parallel::map(vec!["worker line"], 1, |line| {
|
crate::parallel::map(vec!["worker line"], 1, |line| {
|
||||||
|
|||||||
+1
-208
@@ -225,42 +225,6 @@ 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"}}"#;
|
||||||
@@ -318,82 +282,6 @@ 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(
|
||||||
@@ -421,7 +309,7 @@ fn install_webhook_posts_github_hook_when_missing() {
|
|||||||
};
|
};
|
||||||
let client = ProviderClient::new(&site).unwrap();
|
let client = ProviderClient::new(&site).unwrap();
|
||||||
|
|
||||||
let outcome = client
|
client
|
||||||
.install_webhook(
|
.install_webhook(
|
||||||
&EndpointConfig {
|
&EndpointConfig {
|
||||||
site: "github".to_string(),
|
site: "github".to_string(),
|
||||||
@@ -438,63 +326,6 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,44 +653,6 @@ 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(
|
||||||
|
|||||||
+3
-129
@@ -174,29 +174,6 @@ 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();
|
||||||
@@ -231,35 +208,6 @@ 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),
|
|
||||||
¤t,
|
|
||||||
);
|
|
||||||
|
|
||||||
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();
|
||||||
@@ -396,7 +344,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 = Some("^public-".to_string());
|
mirror.repo_whitelist = vec!["^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(
|
||||||
@@ -448,46 +396,6 @@ 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();
|
||||||
@@ -532,39 +440,6 @@ 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(),
|
||||||
@@ -597,10 +472,9 @@ 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: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-13
@@ -112,10 +112,9 @@ 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: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -140,10 +139,9 @@ 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: Some("^important-".to_string()),
|
repo_whitelist: vec!["^important-".to_string()],
|
||||||
repo_blacklist: Some("-archive$".to_string()),
|
repo_blacklist: vec!["-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,
|
||||||
};
|
};
|
||||||
@@ -161,7 +159,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 = None;
|
mirror.repo_whitelist.clear();
|
||||||
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)],
|
||||||
@@ -359,10 +357,9 @@ 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: None,
|
repo_whitelist: Vec::new(),
|
||||||
repo_blacklist: None,
|
repo_blacklist: Vec::new(),
|
||||||
create_missing: true,
|
create_missing: true,
|
||||||
delete_missing: true,
|
|
||||||
visibility: Visibility::Private,
|
visibility: Visibility::Private,
|
||||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||||
}],
|
}],
|
||||||
@@ -713,10 +710,9 @@ 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: Some("^important-".to_string()),
|
repo_whitelist: vec!["^important-".to_string()],
|
||||||
repo_blacklist: Some("-archive$".to_string()),
|
repo_blacklist: vec!["-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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user