58 Commits

Author SHA1 Message Date
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
20 changed files with 22 additions and 1463 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
-1
View File
@@ -1 +0,0 @@
Azalea <noreply@aza.moe> <22280294+hykilpikonna@users.noreply.github.com>
Generated
+1 -1
View File
@@ -1118,7 +1118,7 @@ dependencies = [
[[package]]
name = "refray"
version = "0.1.2"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "refray"
version = "0.1.2"
version = "0.1.0"
edition = "2024"
authors = ["Azalea"]
description = "∞-way read-write git mirroring tool"
-21
View File
@@ -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 -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,
}
}