62 Commits

Author SHA1 Message Date
Azalea 77251ba53f [+] MIT License
Build executables / Windows x86_64 (push) Has been cancelled
Build executables / macOS universal (push) Has been cancelled
Build executables / Linux arm64 musl static (push) Has been cancelled
Build executables / Linux x86_64 musl static (push) Has been cancelled
2026-05-10 21:32:47 +00:00
azalea b9867aea04 [+] My mailmap 2026-05-10 21:32:17 +00:00
azalea 0d5d40b5f0 [U] Lock 2026-05-10 20:24:05 +00:00
azalea 24ae7cec60 [U] Bump version 2026-05-10 20:23:27 +00:00
azalea 1f36d2a8ee [-] Comment out unnecessary detail from readme
Build executables / Windows x86_64 (push) Has been cancelled
Build executables / macOS universal (push) Has been cancelled
Build executables / Linux arm64 musl static (push) Has been cancelled
Build executables / Linux x86_64 musl static (push) Has been cancelled
2026-05-10 17:04:02 +00:00
azalea 7d87f87938 [U] Revise README for installation and demo info
Build executables / Windows x86_64 (push) Has been cancelled
Build executables / macOS universal (push) Has been cancelled
Build executables / Linux arm64 musl static (push) Has been cancelled
Build executables / Linux x86_64 musl static (push) Has been cancelled
Updated installation instructions and demo generation details in README.
2026-05-10 12:24:39 -04:00
azalea 7f780395f2 [+] Demo 2026-05-10 16:18:35 +00:00
azalea 777f071c7b [F] Fix default branch 2026-05-10 15:23:50 +00:00
azalea 464eb90c10 [F] Name validation 2026-05-10 14:25:28 +00:00
azalea f5b8938e4e [O] Fast path for serve 2026-05-10 13:43:32 +00:00
azalea 9141811f0f [U] Update logo size in README
Increased logo size from 50% to 70% in README.
2026-05-10 21:14:00 +08:00
azalea 539858b151 [O] Increase export res 2026-05-10 13:12:07 +00:00
azalea e39d628852 [+] Logo 2026-05-10 13:08:18 +00:00
azalea 13f2118267 [O] Explicit delete missing question, local backups 2026-05-10 13:07:15 +00:00
azalea d9726c4235 [O] Wording 2026-05-10 11:07:08 +00:00
azalea 7240b05feb [-] Remove unnecessary multiple regex 2026-05-10 10:57:17 +00:00
azalea 1424b9c900 [F] Fix log sync (#6) 2026-05-10 14:14:53 +08:00
azalea c828d1283e [U] info 2026-05-10 05:46:50 +00:00
azalea d0aed91490 [F] Visibility sync 2026-05-10 01:36:06 +00:00
azalea 6f11eeea30 [O] Parallel everything (#5) 2026-05-10 09:28:38 +08:00
azalea 3110653221 [F] Fix gitlab project listing 2026-05-10 01:07:47 +00:00
azalea 2b4695ce9b [F] Fix webhook uninstall 2026-05-09 23:53:43 +00:00
azalea 5827126335 [F] Webhook issues 2026-05-09 23:44:18 +00:00
azalea 888db5d007 [F] Fix webhook uninstall missing url 2026-05-09 22:47:02 +00:00
azalea 1576084f3c [O] Webhook install respect filters 2026-05-09 22:38:50 +00:00
azalea 78e59dad96 [F] Fix gitea pagination 2026-05-09 22:24:13 +00:00
azalea 3ff410734b [O] Test actual PR 2026-05-09 19:37:08 +00:00
azalea 496baa75c9 [-] Remove legacy features 2026-05-09 17:41:20 +00:00
azalea cd5ac0a52e [U] Update deps 2026-05-09 17:07:45 +00:00
azalea 3812704ba2 [O] UX 2026-05-09 08:22:31 +00:00
azalea bf9164f6d3 [O] UX 2026-05-08 16:24:23 +00:00
azalea 74e4a6eff4 [+] End-to-end testing 2026-05-08 15:32:33 +00:00
azalea ac2f2559c1 [+] Track repo deletions 2026-05-08 07:01:32 +00:00
azalea 2caa2e716e [U] Readme 2026-05-08 06:59:17 +00:00
azalea 88883ffc16 [U] Readme 2026-05-08 06:57:55 +00:00
azalea 8c99a3c403 [O] Readme 2026-05-08 06:44:26 +00:00
azalea 0f406d8cb0 [F] Fix build 2026-05-08 06:33:42 +00:00
azalea 4b9de29fdc [+] Docker 2026-05-08 06:29:13 +00:00
azalea 3c287e04d0 [O] Rewrite readme 2026-05-08 05:58:02 +00:00
azalea 73ccb7a559 [M] Rebrand 2026-05-08 05:34:08 +00:00
azalea 8feaea1879 [+] Conflict resolution (#4)
* [+] Conflict resolution

* [F] Fix conflict resolution branches being synched
2026-05-08 13:16:13 +08:00
azalea a7dfd2ccc9 [O] Rework webhook 2026-05-08 04:13:45 +00:00
azalea 09e95f4ad5 [O] Better webhook error handling 2026-05-08 01:03:30 +00:00
azalea 0bc4abf1a7 [O] Better interactive config 2026-05-08 00:52:37 +00:00
azalea f0153ea396 [+] Github Actions builder 2026-05-07 18:48:04 +00:00
azalea 1c413d630a [M] Move tests 2026-05-07 18:27:38 +00:00
azalea 496a57f48d [O] Cleanup codebase 2026-05-07 18:08:33 +00:00
azalea 12b75ae6d3 [O] Clean up codebase 2026-05-07 16:41:18 +00:00
azalea ef6d96e3ac Revert "[O] Use libraries"
This reverts commit ce4a195b4a.
2026-05-07 16:27:28 +00:00
azalea ce4a195b4a [O] Use libraries 2026-05-07 15:03:47 +00:00
azalea c4473d11e3 [+] Webhook mode 2026-05-07 04:55:49 +00:00
azalea 84865134b6 [+] Forgejo (#3)
* [+] Forgejo, tangled

* [-] Tangled
2026-05-07 12:45:01 +08:00
azalea 09657d9adf [F] Fix branch deletion awareness 2026-05-07 02:58:43 +00:00
azalea 44c660e6c2 [F] Fix heuristic 2026-05-07 02:22:34 +00:00
azalea b49660a2eb [O] Better multithreaded logging 2026-05-06 20:19:13 +00:00
azalea 5fd2ce55fb [+] Multithreading 2026-05-06 20:05:59 +00:00
azalea 47b2d73e13 [O] Optimize sync with heuristic 2026-05-06 18:32:16 +00:00
azalea b63ae0b9ca [+] Retry 2026-05-06 17:42:20 +00:00
azalea 723822fbb2 [O] Better CLI wizard (#2) 2026-05-04 06:49:25 +08:00
azalea 236ec5bb8b [O] Better CLI wizard (#1) 2026-05-04 01:54:43 +08:00
azalea 86e8d16759 Initial commit 2026-05-03 17:27:50 +00:00
azalea 898bf237ac [+] Create repo by codex 2026-05-03 17:24:24 +00:00
18 changed files with 22 additions and 1441 deletions
+7 -30
View File
@@ -1,9 +1,13 @@
name: Build executables
"on":
release:
types:
- published
push:
branches:
- main
tags:
- "v*"
pull_request:
workflow_dispatch:
permissions:
contents: read
@@ -112,8 +116,6 @@ jobs:
- name: Install Zig
uses: mlugg/setup-zig@v2
with:
version: 0.14.1
- name: Install cargo-zigbuild
uses: taiki-e/install-action@v2
@@ -146,28 +148,3 @@ jobs:
name: ${{ env.BIN_NAME }}-linux-${{ matrix.arch }}-musl
path: ${{ env.BIN_NAME }}-linux-${{ matrix.arch }}-musl.tar.gz
if-no-files-found: error
release-assets:
name: Attach release assets
runs-on: ubuntu-latest
needs:
- windows-x86_64
- macos-universal
- linux-musl
if: github.event_name == 'release'
permissions:
contents: write
steps:
- name: Download packaged binaries
uses: actions/download-artifact@v4
with:
path: release-assets
merge-multiple: true
- name: Attach binaries to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name }}
files: release-assets/*
fail_on_unmatched_files: true
overwrite_files: true
-31
View File
@@ -1,31 +0,0 @@
name: Test
"on":
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Cargo test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust builds
uses: Swatinem/rust-cache@v2
- name: Test
run: cargo test --locked
Generated
+1 -1
View File
@@ -1118,7 +1118,7 @@ dependencies = [
[[package]]
name = "refray"
version = "0.1.2"
version = "0.1.1"
dependencies = [
"anyhow",
"clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "refray"
version = "0.1.2"
version = "0.1.1"
edition = "2024"
authors = ["Azalea"]
description = "∞-way read-write git mirroring tool"
+1 -10
View File
@@ -12,7 +12,7 @@ Created becasue github is so unusable and [unreliable](https://red-squares.cian.
- **read-write mirrors**: Make changes from any provider, and the changes will sync to the others
- **webhook support**: Sync right after push, reduce potential divergence window
- **conflict handling**: Rebase or open pull requests when two platforms diverge
- **tracks state**: Branches/repo deletions and force pushes also sync across platforms (with backup)
- **tracks deletions**: Branches/repo deletions sync across platforms (with backup)
- **selective sync**: Sync subset of repos by regex white/black list, or by private/public visibility
- **multithreaded**: Process multiple repos simultaneously!
@@ -210,11 +210,6 @@ refray webhook update https://new.example.com/webhook
Issues and pull requests are not mirrored.
## TODO
- [ ] Sync releases and released assets
<!-- ## Sync Semantics
Each mirror group is treated as a set of equivalent namespaces. Repositories are matched by repository name across all endpoints.
@@ -243,12 +238,8 @@ Conflict resolution strategies are configured per mirror group:
- `pull_request`: push temporary `refray/conflicts/...` branches and open provider pull requests/merge requests so a person can resolve the divergence.
- `auto_rebase_pull_request`: try `auto_rebase` first, then fall back to pull requests if rebase hits a conflict.
GitLab protected branches reject force-pushes unless the protected branch rule allows them. By default, `refray` temporarily enables GitLab project-level force-push permission for protected branches it already knows it must force-push, then restores the prior setting immediately afterward. Set `allow_temporary_gitlab_force_push = false` on a mirror group to disable this behavior.
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, 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.
-6
View File
@@ -68,8 +68,6 @@ pub struct MirrorConfig {
pub visibility: Visibility,
#[serde(default)]
pub conflict_resolution: ConflictResolutionStrategy,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub allow_temporary_gitlab_force_push: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
@@ -197,10 +195,6 @@ fn default_true() -> bool {
true
}
fn is_true(value: &bool) -> bool {
*value
}
fn default_jobs() -> usize {
DEFAULT_JOBS
}
+4 -101
View File
@@ -86,12 +86,6 @@ pub struct GitMirror {
dry_run: bool,
}
struct CommitterIdentity {
name: String,
email: String,
date: String,
}
#[derive(Clone, Debug)]
pub struct Redactor {
secrets: Vec<String>,
@@ -588,7 +582,6 @@ impl GitMirror {
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_path = worktree.path().to_path_buf();
self.run([
@@ -596,13 +589,12 @@ impl GitMirror {
"add",
"--detach",
worktree_path.to_str().unwrap(),
onto,
tip,
])?;
let replay_result = self.replay_commits_preserving_committer(&worktree_path, &commits);
if let Err(error) = replay_result {
let _ = self.worktree_git(&worktree_path, ["cherry-pick", "--abort"]);
let _ = self.worktree_git(&worktree_path, ["reset", "--hard"]);
let rebase_result = self.worktree_git(&worktree_path, ["rebase", "--onto", onto, base]);
if let Err(error) = rebase_result {
let _ = self.worktree_git(&worktree_path, ["rebase", "--abort"]);
let _ = self.run([
"worktree",
"remove",
@@ -621,66 +613,6 @@ impl GitMirror {
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> {
let status = self
.command()
@@ -777,35 +709,6 @@ impl GitMirror {
.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 {
-1
View File
@@ -129,7 +129,6 @@ fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<(
delete_missing,
visibility: Visibility::Private,
conflict_resolution,
allow_temporary_gitlab_force_push: true,
});
prompt_webhook_setup_styled(config, theme)?;
-50
View File
@@ -40,11 +40,6 @@ pub struct PullRequestInfo {
pub url: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
struct GitlabProtectedBranch {
allow_force_push: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WebhookInstallOutcome {
Created,
@@ -190,38 +185,6 @@ impl<'a> ProviderClient<'a> {
)
}
pub fn gitlab_protected_branch_allow_force_push(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
branch: &str,
) -> Result<Option<bool>> {
if self.site.provider != ProviderKind::Gitlab {
bail!("protected branch force-push lookup is only supported for GitLab");
}
let url = self.gitlab_protected_branch_url(endpoint, repo_name, branch);
match self.get_json::<GitlabProtectedBranch>(&url) {
Ok(protected_branch) => Ok(Some(protected_branch.allow_force_push)),
Err(error) if is_not_found_error(&error) => Ok(None),
Err(error) => Err(error),
}
}
pub fn set_gitlab_protected_branch_allow_force_push(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
branch: &str,
allow_force_push: bool,
) -> Result<()> {
if self.site.provider != ProviderKind::Gitlab {
bail!("protected branch force-push update is only supported for GitLab");
}
let url = self.gitlab_protected_branch_url(endpoint, repo_name, branch);
self.patch_json::<serde_json::Value>(&url, &json!({ "allow_force_push": allow_force_push }))
.map(|_| ())
}
pub fn install_webhook(
&self,
endpoint: &EndpointConfig,
@@ -950,19 +913,6 @@ impl<'a> ProviderClient<'a> {
)
}
fn gitlab_protected_branch_url(
&self,
endpoint: &EndpointConfig,
repo_name: &str,
branch: &str,
) -> String {
format!(
"{}/protected_branches/{}",
self.gitlab_project_url(endpoint, repo_name),
urlencoding(branch)
)
}
fn gitlab_project_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String {
let project = format!("{}/{repo_name}", endpoint.namespace);
format!(
+5 -472
View File
@@ -5,7 +5,7 @@ use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, anyhow, bail};
use anyhow::{Context, Result, bail};
use console::style;
use regex::Regex;
@@ -658,19 +658,6 @@ struct RepoSyncContext<'a> {
jobs: usize,
}
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct GitlabForcePushTarget {
endpoint: EndpointConfig,
branch: String,
}
#[derive(Clone, Debug)]
struct GitlabForcePushToggle {
endpoint: EndpointConfig,
branch: String,
previous_allow_force_push: bool,
}
#[derive(Default)]
struct RepoSyncOutcome {
state_update: Option<RepoStateUpdate>,
@@ -1412,27 +1399,6 @@ fn push_repo_refs(
fail_on_unresolved_conflict(context, "branch deletion conflict")?;
}
let (force_pushes, force_push_conflicts, force_push_branches) =
branch_force_push_decisions(mirror_repo, remotes, previous_refs, current_refs)?;
let had_force_push_conflicts = !force_push_conflicts.is_empty();
for conflict in &force_push_conflicts {
crate::logln!(
" {} branch {} has conflicting force-push changes on {} ({}, {})",
style("conflict").yellow().bold(),
style(&conflict.branch).cyan(),
conflict.remotes.join("+"),
conflict.reason,
style("skipped").dim()
);
}
if had_force_push_conflicts {
fail_on_unresolved_conflict(context, "branch force-push conflict")?;
}
let blocked_branches = blocked_branches
.union(&force_push_branches)
.cloned()
.collect::<BTreeSet<_>>();
let (branches, conflicts) = mirror_repo.branch_decisions(remotes)?;
let branches_to_push = branches
.into_iter()
@@ -1457,7 +1423,6 @@ fn push_repo_refs(
}
}
let had_branch_conflicts = !unresolved_branch_conflicts.is_empty();
let force_push_updates = force_push_updates(&force_pushes);
let unresolved_branch_names = unresolved_branch_conflicts
.iter()
.map(|conflict| conflict.branch.clone())
@@ -1498,17 +1463,13 @@ fn push_repo_refs(
let pushed_branch_names = branch_names(&branches_to_push);
let rebased_branch_names = branch_names_from_updates(&rebased_branch_updates);
let force_pushed_branch_names = branch_names_from_updates(&force_push_updates);
let mut cleanup_branches = stale_conflict_branches;
cleanup_branches.retain(|branch| {
!pushed_branch_names.contains(branch)
&& !rebased_branch_names.contains(branch)
&& !force_pushed_branch_names.contains(branch)
!pushed_branch_names.contains(branch) && !rebased_branch_names.contains(branch)
});
if branches_to_push.is_empty()
&& rebased_branch_updates.is_empty()
&& force_push_updates.is_empty()
&& tags_to_push.is_empty()
&& unresolved_branch_conflicts.is_empty()
{
@@ -1538,10 +1499,7 @@ fn push_repo_refs(
);
return Ok(RepoRefSyncResult {
pushed: false,
had_conflicts: had_branch_conflicts
|| had_tag_conflicts
|| had_deletion_conflicts
|| had_force_push_conflicts,
had_conflicts: had_branch_conflicts || had_tag_conflicts || had_deletion_conflicts,
});
}
if !branch_deletions.is_empty() {
@@ -1561,35 +1519,9 @@ fn push_repo_refs(
close_resolved_pull_requests(context, mirror_repo, remotes, repos, &pushed_branch_names)?;
}
if !rebased_branch_updates.is_empty() {
push_branch_updates_with_temporary_gitlab_force_push(
context,
mirror_repo,
repo_name,
remotes,
repos,
&rebased_branch_updates,
)?;
mirror_repo.push_branch_updates(remotes, &rebased_branch_updates)?;
close_resolved_pull_requests(context, mirror_repo, remotes, repos, &rebased_branch_names)?;
}
if !force_push_updates.is_empty() {
print_branch_force_pushes(&force_pushes);
backup_force_pushed_branches(context, mirror_repo, repo_name, &force_pushes, current_refs)?;
push_branch_updates_with_temporary_gitlab_force_push(
context,
mirror_repo,
repo_name,
remotes,
repos,
&force_push_updates,
)?;
close_resolved_pull_requests(
context,
mirror_repo,
remotes,
repos,
&force_pushed_branch_names,
)?;
}
if !tags_to_push.is_empty() {
print_tag_decisions(&tags_to_push);
mirror_repo.push_tags(remotes, &tags_to_push)?;
@@ -1609,14 +1541,10 @@ fn push_repo_refs(
Ok(RepoRefSyncResult {
pushed: !branches_to_push.is_empty()
|| !rebased_branch_updates.is_empty()
|| !force_push_updates.is_empty()
|| !tags_to_push.is_empty()
|| !branch_deletions.is_empty()
|| !cleanup_branches.is_empty(),
had_conflicts: had_branch_conflicts
|| had_tag_conflicts
|| had_deletion_conflicts
|| had_force_push_conflicts,
had_conflicts: had_branch_conflicts || had_tag_conflicts || had_deletion_conflicts,
})
}
@@ -1648,34 +1576,6 @@ fn backup_deleted_branches(
Ok(())
}
fn backup_force_pushed_branches(
context: &RepoSyncContext<'_>,
mirror_repo: &GitMirror,
repo_name: &str,
force_pushes: &[BranchForcePush],
current_refs: &BTreeMap<String, RemoteRefState>,
) -> Result<()> {
if context.dry_run {
crate::logln!(
" {} {} force-push backup{}",
style("dry-run").yellow().bold(),
style("would create").dim(),
if force_pushes.len() == 1 { "" } else { "s" }
);
return Ok(());
}
let stamp = backup_stamp()?;
let backups = force_push_ref_backups(force_pushes, current_refs, &stamp);
if backups.is_empty() {
bail!("cannot back up force-push because no target branch refs were available");
}
let refs = mirror_repo.backup_refs(&backups)?;
let bundle_path = backup_dir(context, repo_name).join(format!("force-push-{stamp}.bundle"));
mirror_repo.create_bundle(&bundle_path, &refs)?;
Ok(())
}
fn backup_branches_deleted_everywhere(
context: &RepoSyncContext<'_>,
mirror_repo: &GitMirror,
@@ -1798,201 +1698,6 @@ fn log_rebase_decision(branch: &str, sha: &str, updates: &[BranchUpdate]) {
);
}
fn print_branch_force_pushes(force_pushes: &[BranchForcePush]) {
for force_push in force_pushes {
crate::logln!(
" {} branch {} {} -> {}",
style("force-push detected").green().bold(),
style(&force_push.branch).cyan(),
force_push.source_remotes.join("+"),
force_push.target_remotes.join("+")
);
}
}
fn force_push_updates(force_pushes: &[BranchForcePush]) -> Vec<BranchUpdate> {
force_pushes
.iter()
.flat_map(|force_push| {
force_push
.target_remotes
.iter()
.map(|target_remote| BranchUpdate {
branch: force_push.branch.clone(),
sha: force_push.sha.clone(),
target_remote: target_remote.clone(),
force: true,
})
})
.collect()
}
fn push_branch_updates_with_temporary_gitlab_force_push(
context: &RepoSyncContext<'_>,
mirror_repo: &GitMirror,
repo_name: &str,
remotes: &[RemoteSpec],
repos: &[EndpointRepo],
updates: &[BranchUpdate],
) -> Result<()> {
let toggles = enable_temporary_gitlab_force_push(context, repo_name, repos, updates)?;
let push_result = mirror_repo.push_branch_updates(remotes, updates);
let restore_result = restore_temporary_gitlab_force_push(context, repo_name, &toggles);
match (push_result, restore_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(error), Ok(())) => Err(error),
(Ok(()), Err(error)) => Err(error),
(Err(push_error), Err(restore_error)) => Err(anyhow!(
"git push failed and failed to restore temporary GitLab force-push permissions: {push_error:#}; restore error: {restore_error:#}"
)),
}
}
fn enable_temporary_gitlab_force_push(
context: &RepoSyncContext<'_>,
repo_name: &str,
repos: &[EndpointRepo],
updates: &[BranchUpdate],
) -> Result<Vec<GitlabForcePushToggle>> {
if !context.mirror.allow_temporary_gitlab_force_push || context.dry_run {
return Ok(Vec::new());
}
let mut toggles = Vec::new();
for target in temporary_gitlab_force_push_targets(context, repos, updates)? {
let result = enable_temporary_gitlab_force_push_for_target(context, repo_name, target);
match result {
Ok(Some(toggle)) => toggles.push(toggle),
Ok(None) => {}
Err(error) => {
let restore_result =
restore_temporary_gitlab_force_push(context, repo_name, &toggles);
return match restore_result {
Ok(()) => Err(error),
Err(restore_error) => Err(anyhow!(
"failed to enable temporary GitLab force-push permissions: {error:#}; restore error: {restore_error:#}"
)),
};
}
}
}
Ok(toggles)
}
fn enable_temporary_gitlab_force_push_for_target(
context: &RepoSyncContext<'_>,
repo_name: &str,
target: GitlabForcePushTarget,
) -> Result<Option<GitlabForcePushToggle>> {
let site = context.config.site(&target.endpoint.site).unwrap();
let client = ProviderClient::new(site)?;
let label = target.endpoint.label();
match client
.gitlab_protected_branch_allow_force_push(&target.endpoint, repo_name, &target.branch)
.with_context(|| {
format!(
"failed to inspect GitLab protected branch {} on {}",
target.branch, label
)
})? {
None | Some(true) => Ok(None),
Some(false) => {
crate::logln!(
" {} branch {} on {}",
style("allow force-push").cyan().bold(),
style(&target.branch).cyan(),
style(&label).dim()
);
client
.set_gitlab_protected_branch_allow_force_push(
&target.endpoint,
repo_name,
&target.branch,
true,
)
.with_context(|| {
format!(
"failed to allow GitLab force-push for branch {} on {}",
target.branch, label
)
})?;
Ok(Some(GitlabForcePushToggle {
endpoint: target.endpoint,
branch: target.branch,
previous_allow_force_push: false,
}))
}
}
}
fn restore_temporary_gitlab_force_push(
context: &RepoSyncContext<'_>,
repo_name: &str,
toggles: &[GitlabForcePushToggle],
) -> Result<()> {
let mut failures = Vec::new();
for toggle in toggles.iter().rev() {
let site = context.config.site(&toggle.endpoint.site).unwrap();
let label = toggle.endpoint.label();
crate::logln!(
" {} branch {} on {}",
style("restore force-push").cyan().bold(),
style(&toggle.branch).cyan(),
style(&label).dim()
);
let result = ProviderClient::new(site).and_then(|client| {
client.set_gitlab_protected_branch_allow_force_push(
&toggle.endpoint,
repo_name,
&toggle.branch,
toggle.previous_allow_force_push,
)
});
if let Err(error) = result {
failures.push(format!("branch {} on {}: {error:#}", toggle.branch, label));
}
}
if failures.is_empty() {
Ok(())
} else {
bail!(
"failed to restore temporary GitLab force-push permissions: {}",
failures.join("; ")
)
}
}
fn temporary_gitlab_force_push_targets(
context: &RepoSyncContext<'_>,
repos: &[EndpointRepo],
updates: &[BranchUpdate],
) -> Result<Vec<GitlabForcePushTarget>> {
let repos_by_remote = endpoint_repos_by_remote_name(context, repos)?;
let mut seen = BTreeSet::new();
let mut targets = Vec::new();
for update in updates.iter().filter(|update| update.force) {
let Some(endpoint_repo) = repos_by_remote.get(&update.target_remote) else {
continue;
};
let site = context.config.site(&endpoint_repo.endpoint.site).unwrap();
if site.provider != ProviderKind::Gitlab {
continue;
}
let target = GitlabForcePushTarget {
endpoint: endpoint_repo.endpoint.clone(),
branch: update.branch.clone(),
};
if seen.insert(target.clone()) {
targets.push(target);
}
}
Ok(targets)
}
fn open_conflict_pull_requests(
context: &RepoSyncContext<'_>,
mirror_repo: &GitMirror,
@@ -2263,38 +1968,6 @@ fn branch_ref_backups(
backups
}
fn force_push_ref_backups(
force_pushes: &[BranchForcePush],
current_refs: &BTreeMap<String, RemoteRefState>,
stamp: &str,
) -> Vec<RefBackup> {
let mut backups = Vec::new();
for force_push in force_pushes {
for remote in &force_push.target_remotes {
let Some(sha) = current_refs
.get(remote)
.and_then(|refs| refs.branches.get(&force_push.branch))
else {
continue;
};
backups.push(RefBackup {
refname: format!(
"refs/refray-backups/force-pushes/{}/{}/{}",
hex_component(&force_push.branch),
stamp,
hex_component(remote)
),
sha: sha.clone(),
description: format!(
"branch {} from {} before propagated force-push",
force_push.branch, remote
),
});
}
}
backups
}
fn branches_deleted_everywhere_backups(
previous_refs: &BTreeMap<String, RemoteRefState>,
current_refs: &BTreeMap<String, RemoteRefState>,
@@ -2421,146 +2094,6 @@ fn safe_ref_component(value: &str) -> String {
output.trim_matches('-').to_string()
}
#[derive(Clone, Debug)]
struct BranchForcePush {
branch: String,
sha: String,
source_remotes: Vec<String>,
target_remotes: Vec<String>,
}
struct BranchForcePushConflict {
branch: String,
remotes: Vec<String>,
reason: String,
}
fn branch_force_push_decisions(
mirror_repo: &GitMirror,
remotes: &[RemoteSpec],
previous_refs: Option<&BTreeMap<String, RemoteRefState>>,
current_refs: &BTreeMap<String, RemoteRefState>,
) -> Result<(
Vec<BranchForcePush>,
Vec<BranchForcePushConflict>,
BTreeSet<String>,
)> {
let Some(previous_refs) = previous_refs else {
return Ok((Vec::new(), Vec::new(), BTreeSet::new()));
};
let remote_names = remotes
.iter()
.map(|remote| remote.name.clone())
.collect::<Vec<_>>();
let mut branches = BTreeSet::new();
for refs in previous_refs.values() {
branches.extend(
refs.branches
.keys()
.filter(|branch| !is_internal_conflict_branch(branch))
.cloned(),
);
}
let mut decisions = Vec::new();
let mut conflicts = Vec::new();
let mut blocked = BTreeSet::new();
for branch in branches {
let previous_by_remote = remote_names
.iter()
.filter_map(|remote| {
previous_refs
.get(remote)
.and_then(|refs| refs.branches.get(&branch))
.map(|sha| (remote.clone(), sha.clone()))
})
.collect::<BTreeMap<_, _>>();
if previous_by_remote.len() != remote_names.len() {
continue;
}
let previous_tips = previous_by_remote
.values()
.cloned()
.collect::<BTreeSet<_>>();
if previous_tips.len() != 1 {
continue;
}
let current_by_remote = remote_names
.iter()
.filter_map(|remote| {
current_refs
.get(remote)
.and_then(|refs| refs.branches.get(&branch))
.map(|sha| (remote.clone(), sha.clone()))
})
.collect::<BTreeMap<_, _>>();
if current_by_remote.len() != remote_names.len() {
continue;
}
let mut target_remotes = Vec::new();
let mut fast_forward_remotes = Vec::new();
let mut force_pushed_by_tip = BTreeMap::<String, Vec<String>>::new();
for remote in &remote_names {
let previous = &previous_by_remote[remote];
let current = &current_by_remote[remote];
if previous == current {
target_remotes.push(remote.clone());
} else if mirror_repo.is_ancestor(previous, current)? {
fast_forward_remotes.push(remote.clone());
} else {
force_pushed_by_tip
.entry(current.clone())
.or_default()
.push(remote.clone());
}
}
if force_pushed_by_tip.is_empty() {
continue;
}
let force_pushed_remotes = force_pushed_by_tip
.values()
.flat_map(|remotes| remotes.iter().cloned())
.collect::<Vec<_>>();
if force_pushed_by_tip.len() == 1 && fast_forward_remotes.is_empty() {
let (sha, source_remotes) = force_pushed_by_tip.into_iter().next().unwrap();
if target_remotes.is_empty() {
continue;
}
blocked.insert(branch.clone());
decisions.push(BranchForcePush {
branch,
sha,
source_remotes,
target_remotes,
});
continue;
}
blocked.insert(branch.clone());
let reason = if force_pushed_by_tip.len() > 1 && !fast_forward_remotes.is_empty() {
format!(
"multiple rewritten tips and fast-forward changes on {}",
fast_forward_remotes.join("+")
)
} else if force_pushed_by_tip.len() > 1 {
"multiple rewritten tips".to_string()
} else {
format!("also fast-forwarded on {}", fast_forward_remotes.join("+"))
};
conflicts.push(BranchForcePushConflict {
branch,
remotes: force_pushed_remotes,
reason,
});
}
Ok((decisions, conflicts, blocked))
}
struct BranchDeletionConflict {
branch: String,
deleted_remotes: Vec<String>,
+2 -430
View File
@@ -25,7 +25,8 @@ const WEBHOOK_SECRET: &str = "refray-e2e-secret";
#[test]
#[ignore = "destructive live-provider e2e test; run explicitly with --ignored"]
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()?;
let mut run = E2eRun::new(settings)?;
@@ -58,42 +59,6 @@ fn sequential_live_e2e_all_supported_features() -> Result<()> {
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 {
values: HashMap<String, String>,
}
@@ -672,218 +637,6 @@ namespace = "{}"
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(())
}
fn webhook_commands_and_receiver_work(&self) -> Result<()> {
let repo = self.repo_name("webhook");
let source = self.primary_provider();
@@ -1012,129 +765,6 @@ namespace = "{}"
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> {
let path = self.git_worktree(label)?;
let remote_url = provider.authenticated_repo_url(repo)?;
@@ -1428,34 +1058,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(
&self,
repo: &str,
@@ -1622,36 +1224,6 @@ namespace = "{}"
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<()> {
for provider in &self.settings.providers {
provider.unprotect_branch(repo, MAIN_BRANCH)?;
-48
View File
@@ -59,7 +59,6 @@ fn parses_value_tokens() {
Some("-archive$".to_string())
);
assert!(!config.mirrors[0].delete_missing);
assert!(config.mirrors[0].allow_temporary_gitlab_force_push);
let webhook = config.webhook.unwrap();
assert!(webhook.install);
assert_eq!(webhook.url, "https://mirror.example.test/webhook");
@@ -117,49 +116,6 @@ fn mirror_defaults_to_deleting_missing_repos_for_existing_configs() {
.unwrap();
assert!(config.mirrors[0].delete_missing);
assert!(config.mirrors[0].allow_temporary_gitlab_force_push);
}
#[test]
fn mirror_can_disable_temporary_gitlab_force_push() {
let config: Config = toml::from_str(
r#"
[[mirrors]]
name = "personal"
allow_temporary_gitlab_force_push = false
[[mirrors.endpoints]]
site = "github"
kind = "user"
namespace = "alice"
[[mirrors.endpoints]]
site = "gitlab"
kind = "group"
namespace = "acme"
"#,
)
.unwrap();
assert!(!config.mirrors[0].allow_temporary_gitlab_force_push);
}
#[test]
fn mirror_serializes_temporary_gitlab_force_push_opt_out() {
let mut mirror = mirror_config();
mirror.allow_temporary_gitlab_force_push = false;
let config = Config {
jobs: crate::config::DEFAULT_JOBS,
sites: vec![site("github", ProviderKind::Github)],
mirrors: vec![mirror],
webhook: None,
};
let encoded = toml::to_string(&config).unwrap();
let decoded: Config = toml::from_str(&encoded).unwrap();
assert!(encoded.contains("allow_temporary_gitlab_force_push = false"));
assert!(!decoded.mirrors[0].allow_temporary_gitlab_force_push);
}
#[test]
@@ -181,7 +137,6 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -212,7 +167,6 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -318,7 +272,6 @@ fn validation_rejects_duplicate_mirror_endpoints() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -366,7 +319,6 @@ fn mirror_config() -> MirrorConfig {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}
}
+1 -56
View File
@@ -178,15 +178,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);
fixture.push_head(&fixture.remote_a, "main");
fixture.reset_hard(&base);
let b_tip = fixture.commit_file_with_committer(
"b",
"b.txt",
"b\n",
1_700_000_200,
"Original Committer",
"original-committer@example.test",
1_700_000_250,
);
let b_tip = fixture.commit_file("b", "b.txt", "b\n", 1_700_000_200);
fixture.push_head(&fixture.remote_b, "main");
let mirror = fixture.mirror();
@@ -213,10 +205,6 @@ fn auto_rebase_branch_conflict_replays_later_tip_and_marks_force_targets() {
.find(|update| update.target_remote == "b")
.unwrap();
assert!(b_update.force);
assert_eq!(
fixture.mirror_committer(&decision.sha),
fixture.mirror_committer(&b_tip)
);
mirror
.push_branch_updates(&fixture.remotes(), &decision.updates)
@@ -507,35 +495,6 @@ impl GitFixture {
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 {
git_output(Some(&self.work), ["rev-parse", "HEAD"])
}
@@ -600,20 +559,6 @@ impl GitFixture {
.status
.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]) {
-6
View File
@@ -258,7 +258,6 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -289,7 +288,6 @@ fn wizard_can_ask_to_run_full_sync_after_config() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -367,7 +365,6 @@ fn wizard_edits_existing_sync_group_from_menu() {
delete_missing: true,
visibility: Visibility::Public,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -459,7 +456,6 @@ fn wizard_prefills_existing_sync_group_when_editing() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -525,7 +521,6 @@ fn wizard_deletes_existing_sync_group_from_menu() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -586,7 +581,6 @@ fn wizard_can_go_back_from_delete_menu() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
-1
View File
@@ -79,7 +79,6 @@ where
delete_missing,
visibility: Visibility::Private,
conflict_resolution,
allow_temporary_gitlab_force_push: true,
});
prompt_webhook_setup(reader, writer, config)?;
Ok(())
-113
View File
@@ -686,119 +686,6 @@ fn delete_repo_deletes_url_encoded_gitlab_project() {
handle.join().unwrap();
}
#[test]
fn gitlab_protected_branch_allow_force_push_reads_setting() {
let (api_url, handle) = one_request_server(
"200 OK",
r#"{"name":"main","allow_force_push":false}"#,
|request| {
assert!(
request.starts_with("GET /projects/parent%2Falice%2Frepo/protected_branches/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)
};
let allow = ProviderClient::new(&site)
.unwrap()
.gitlab_protected_branch_allow_force_push(
&EndpointConfig {
site: "gitlab".to_string(),
kind: NamespaceKind::Group,
namespace: "parent/alice".to_string(),
},
"repo",
"main",
)
.unwrap();
assert_eq!(allow, Some(false));
handle.join().unwrap();
}
#[test]
fn gitlab_protected_branch_allow_force_push_treats_404_as_unprotected() {
let (api_url, handle) = one_request_server(
"404 Not Found",
r#"{"message":"404 Not found"}"#,
|request| {
assert!(
request
.starts_with("GET /projects/alice%2Frepo/protected_branches/feature%2Fsync "),
"request was {request}"
);
},
);
let site = SiteConfig {
api_url: Some(api_url),
..site(ProviderKind::Gitlab, None)
};
let allow = ProviderClient::new(&site)
.unwrap()
.gitlab_protected_branch_allow_force_push(
&EndpointConfig {
site: "gitlab".to_string(),
kind: NamespaceKind::User,
namespace: "alice".to_string(),
},
"repo",
"feature/sync",
)
.unwrap();
assert_eq!(allow, None);
handle.join().unwrap();
}
#[test]
fn set_gitlab_protected_branch_allow_force_push_patches_branch() {
let (api_url, handle) = one_request_server(
"200 OK",
r#"{"name":"main","allow_force_push":true}"#,
|request| {
assert!(
request
.starts_with("PATCH /projects/parent%2Falice%2Frepo/protected_branches/main "),
"request was {request}"
);
assert!(
request.contains(r#"{"allow_force_push":true}"#),
"request was {request}"
);
},
);
let site = SiteConfig {
api_url: Some(api_url),
..site(ProviderKind::Gitlab, None)
};
ProviderClient::new(&site)
.unwrap()
.set_gitlab_protected_branch_allow_force_push(
&EndpointConfig {
site: "gitlab".to_string(),
kind: NamespaceKind::Group,
namespace: "parent/alice".to_string(),
},
"repo",
"main",
true,
)
.unwrap();
handle.join().unwrap();
}
#[test]
fn delete_repo_deletes_gitea_repo() {
let (api_url, handle) = one_request_server("204 No Content", "", |request| {
-80
View File
@@ -448,73 +448,6 @@ fn endpoint_remote_names_do_not_slug_collide() {
);
}
#[test]
fn temporary_gitlab_force_push_targets_selects_only_gitlab_force_updates() {
let mirror = MirrorConfig {
endpoints: vec![endpoint("github"), endpoint("gitlab"), endpoint("gitea")],
..test_mirror()
};
let config = Config {
jobs: crate::config::DEFAULT_JOBS,
sites: vec![
site_config("github", crate::config::ProviderKind::Github),
site_config("gitlab", crate::config::ProviderKind::Gitlab),
site_config("gitea", crate::config::ProviderKind::Gitea),
],
mirrors: vec![mirror.clone()],
webhook: None,
};
let context = RepoSyncContext {
config: &config,
mirror: &mirror,
work_dir: Path::new("."),
redactor: Redactor::new(Vec::new()),
dry_run: false,
jobs: crate::config::DEFAULT_JOBS,
};
let updates = vec![
BranchUpdate {
branch: "main".to_string(),
sha: "a".repeat(40),
target_remote: remote_key("gitlab"),
force: true,
},
BranchUpdate {
branch: "main".to_string(),
sha: "a".repeat(40),
target_remote: remote_key("gitlab"),
force: true,
},
BranchUpdate {
branch: "feature".to_string(),
sha: "b".repeat(40),
target_remote: remote_key("gitlab"),
force: false,
},
BranchUpdate {
branch: "main".to_string(),
sha: "c".repeat(40),
target_remote: remote_key("github"),
force: true,
},
];
let repos = vec![
endpoint_repo("github"),
endpoint_repo("gitlab"),
endpoint_repo("gitea"),
];
let targets = temporary_gitlab_force_push_targets(&context, &repos, &updates).unwrap();
assert_eq!(
targets,
vec![GitlabForcePushTarget {
endpoint: endpoint("gitlab"),
branch: "main".to_string(),
}]
);
}
#[test]
fn targeted_endpoint_repos_synthesize_clone_urls_without_listing() {
let mirror = MirrorConfig {
@@ -531,7 +464,6 @@ fn targeted_endpoint_repos_synthesize_clone_urls_without_listing() {
delete_missing: true,
visibility: crate::config::Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
};
let config = Config {
jobs: crate::config::DEFAULT_JOBS,
@@ -671,7 +603,6 @@ fn test_mirror() -> MirrorConfig {
delete_missing: true,
visibility: crate::config::Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}
}
@@ -687,17 +618,6 @@ fn remote_key(site: &str) -> String {
remote_name_for_endpoint(&endpoint(site))
}
fn site_config(name: &str, provider: crate::config::ProviderKind) -> crate::config::SiteConfig {
crate::config::SiteConfig {
name: name.to_string(),
provider,
base_url: format!("https://{name}.invalid"),
api_url: None,
token: crate::config::TokenConfig::Value("token".to_string()),
git_username: None,
}
}
fn endpoint_repo(site: &str) -> EndpointRepo {
EndpointRepo {
endpoint: endpoint(site),
-4
View File
@@ -118,7 +118,6 @@ fn matches_jobs_by_provider_and_namespace() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -147,7 +146,6 @@ fn matching_jobs_respects_repo_name_filters() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
};
let config = Config {
jobs: crate::config::DEFAULT_JOBS,
@@ -367,7 +365,6 @@ fn uninstall_webhooks_skips_blocked_provider_access() {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}],
webhook: None,
};
@@ -722,7 +719,6 @@ fn filtered_mirror() -> MirrorConfig {
delete_missing: true,
visibility: Visibility::Private,
conflict_resolution: ConflictResolutionStrategy::Fail,
allow_temporary_gitlab_force_push: true,
}
}