[+] Conflict resolution (#4)
* [+] Conflict resolution * [F] Fix conflict resolution branches being synched
This commit is contained in:
@@ -55,6 +55,18 @@ pub struct MirrorConfig {
|
||||
pub visibility: Visibility,
|
||||
#[serde(default)]
|
||||
pub allow_force: bool,
|
||||
#[serde(default)]
|
||||
pub conflict_resolution: ConflictResolutionStrategy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictResolutionStrategy {
|
||||
#[default]
|
||||
Fail,
|
||||
AutoRebase,
|
||||
PullRequest,
|
||||
AutoRebasePullRequest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
|
||||
+205
@@ -44,6 +44,21 @@ pub struct BranchDeletion {
|
||||
pub target_remotes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BranchUpdate {
|
||||
pub branch: String,
|
||||
pub sha: String,
|
||||
pub target_remote: String,
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BranchRebaseDecision {
|
||||
pub branch: String,
|
||||
pub sha: String,
|
||||
pub updates: Vec<BranchUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TagDecision {
|
||||
pub tag: String,
|
||||
@@ -299,6 +314,91 @@ impl GitMirror {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn push_branch_updates(
|
||||
&self,
|
||||
remotes: &[RemoteSpec],
|
||||
updates: &[BranchUpdate],
|
||||
) -> Result<()> {
|
||||
for update in updates {
|
||||
let remote = remotes
|
||||
.iter()
|
||||
.find(|remote| remote.name == update.target_remote)
|
||||
.with_context(|| format!("unknown remote '{}'", update.target_remote))?;
|
||||
self.push_branch_update(remote, update)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remote_branch_names_with_prefix(
|
||||
&self,
|
||||
remote: &str,
|
||||
prefix: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
Ok(self
|
||||
.remote_branches(remote)?
|
||||
.into_iter()
|
||||
.filter_map(|(branch, _)| branch.starts_with(prefix).then_some(branch))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn auto_rebase_branch_conflict(
|
||||
&self,
|
||||
remotes: &[RemoteSpec],
|
||||
branch: &str,
|
||||
tips: &[(String, String)],
|
||||
) -> Result<BranchRebaseDecision> {
|
||||
let mut ordered_tips = Vec::new();
|
||||
for remote in remotes {
|
||||
let Some((_, sha)) = tips.iter().find(|(name, _)| name == &remote.name) else {
|
||||
continue;
|
||||
};
|
||||
if !ordered_tips
|
||||
.iter()
|
||||
.any(|(_, existing): &(String, String)| existing == sha)
|
||||
{
|
||||
ordered_tips.push((remote.name.clone(), sha.clone()));
|
||||
}
|
||||
}
|
||||
if ordered_tips.len() < 2 {
|
||||
bail!("branch {branch} does not have enough unique tips to auto-rebase");
|
||||
}
|
||||
|
||||
let mut truth = ordered_tips[0].1.clone();
|
||||
for (remote, sha) in ordered_tips.iter().skip(1) {
|
||||
let base = self.merge_base(&truth, sha)?;
|
||||
crate::logln!(
|
||||
" {} branch {} rebasing {}@{} onto {}",
|
||||
style("auto-rebase").cyan().bold(),
|
||||
style(branch).cyan(),
|
||||
remote,
|
||||
short_sha(sha),
|
||||
short_sha(&truth)
|
||||
);
|
||||
truth = self
|
||||
.rebase_tip_onto(&truth, &base, sha)
|
||||
.with_context(|| format!("auto-rebase failed for branch {branch}"))?;
|
||||
}
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for (remote, sha) in tips {
|
||||
if sha == &truth {
|
||||
continue;
|
||||
}
|
||||
updates.push(BranchUpdate {
|
||||
branch: branch.to_string(),
|
||||
sha: truth.clone(),
|
||||
target_remote: remote.clone(),
|
||||
force: !self.is_ancestor(sha, &truth)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(BranchRebaseDecision {
|
||||
branch: branch.to_string(),
|
||||
sha: truth,
|
||||
updates,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push_tags(&self, remotes: &[RemoteSpec], tags: &[TagDecision]) -> Result<()> {
|
||||
for remote in remotes {
|
||||
for tag in tags {
|
||||
@@ -343,6 +443,23 @@ impl GitMirror {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_branch_update(&self, remote: &RemoteSpec, update: &BranchUpdate) -> Result<()> {
|
||||
let refspec = if update.force {
|
||||
format!("+{}:refs/heads/{}", update.sha, update.branch)
|
||||
} else {
|
||||
format!("{}:refs/heads/{}", update.sha, update.branch)
|
||||
};
|
||||
let label = if update.force { "force-push" } else { "push" };
|
||||
crate::logln!(
|
||||
" {} {} {} {}",
|
||||
style(label).green().bold(),
|
||||
style("branch").dim(),
|
||||
style(&update.branch).cyan(),
|
||||
style(format!("-> {}", remote.display)).dim()
|
||||
);
|
||||
self.run(["push", &remote.name, &refspec])
|
||||
}
|
||||
|
||||
fn remote_url(&self, name: &str) -> Result<Option<String>> {
|
||||
let output = self
|
||||
.command()
|
||||
@@ -432,6 +549,46 @@ impl GitMirror {
|
||||
.context("no commits found while choosing force winner")
|
||||
}
|
||||
|
||||
fn merge_base(&self, left: &str, right: &str) -> Result<String> {
|
||||
Ok(self.output(["merge-base", left, right])?.trim().to_string())
|
||||
}
|
||||
|
||||
fn rebase_tip_onto(&self, onto: &str, base: &str, tip: &str) -> Result<String> {
|
||||
if self.dry_run {
|
||||
return Ok(format!("dry-run-rebased-{}", short_sha(tip)));
|
||||
}
|
||||
|
||||
let worktree = tempfile::TempDir::new().context("failed to create temporary worktree")?;
|
||||
let worktree_path = worktree.path().to_path_buf();
|
||||
self.run([
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
worktree_path.to_str().unwrap(),
|
||||
tip,
|
||||
])?;
|
||||
|
||||
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",
|
||||
"--force",
|
||||
worktree_path.to_str().unwrap(),
|
||||
]);
|
||||
return Err(error);
|
||||
}
|
||||
let rebased = self.worktree_git_output(&worktree_path, ["rev-parse", "HEAD"])?;
|
||||
self.run([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
worktree_path.to_str().unwrap(),
|
||||
])?;
|
||||
Ok(rebased.trim().to_string())
|
||||
}
|
||||
|
||||
fn is_ancestor(&self, ancestor: &str, descendant: &str) -> Result<bool> {
|
||||
let status = self
|
||||
.command()
|
||||
@@ -484,6 +641,54 @@ impl GitMirror {
|
||||
command.arg("--git-dir").arg(&self.path);
|
||||
command
|
||||
}
|
||||
|
||||
fn worktree_git<const N: usize>(&self, worktree: &Path, args: [&str; N]) -> Result<()> {
|
||||
run_plain(
|
||||
"git",
|
||||
[
|
||||
"-C",
|
||||
worktree.to_str().unwrap(),
|
||||
"-c",
|
||||
"user.name=git-sync",
|
||||
"-c",
|
||||
"user.email=git-sync@example.invalid",
|
||||
]
|
||||
.into_iter()
|
||||
.chain(args),
|
||||
None,
|
||||
&self.redactor,
|
||||
self.dry_run,
|
||||
)
|
||||
}
|
||||
|
||||
fn worktree_git_output<const N: usize>(
|
||||
&self,
|
||||
worktree: &Path,
|
||||
args: [&str; N],
|
||||
) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(worktree)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| "failed to run git")?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} 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 {
|
||||
sha.get(..12).unwrap_or(sha)
|
||||
}
|
||||
|
||||
pub fn ls_remote_refs(remote: &RemoteSpec, redactor: &Redactor) -> Result<RemoteRefSnapshot> {
|
||||
|
||||
+63
-4
@@ -14,8 +14,8 @@ use tiny_http::{Request, Response, Server, StatusCode};
|
||||
use url::Url;
|
||||
|
||||
use crate::config::{
|
||||
Config, EndpointConfig, MirrorConfig, NamespaceKind, ProviderKind, SiteConfig, TokenConfig,
|
||||
Visibility, WebhookConfig,
|
||||
Config, ConflictResolutionStrategy, EndpointConfig, MirrorConfig, NamespaceKind, ProviderKind,
|
||||
SiteConfig, TokenConfig, Visibility, WebhookConfig,
|
||||
};
|
||||
use crate::provider::ProviderClient;
|
||||
use crate::webhook::check_webhook_url_reachable;
|
||||
@@ -98,12 +98,14 @@ enum WizardAction {
|
||||
|
||||
fn add_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<()> {
|
||||
let endpoints = prompt_sync_group_endpoints_styled(config, theme, &[])?;
|
||||
let conflict_resolution = prompt_conflict_resolution_styled(theme, None)?;
|
||||
config.upsert_mirror(MirrorConfig {
|
||||
name: next_mirror_name(config),
|
||||
endpoints,
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution,
|
||||
});
|
||||
prompt_webhook_setup_styled(config, theme)?;
|
||||
|
||||
@@ -410,8 +412,12 @@ fn edit_sync_group_styled(config: &mut Config, theme: &ColorfulTheme) -> Result<
|
||||
style(format!("sync group {}", index + 1)).cyan()
|
||||
);
|
||||
let existing = config.mirrors[index].endpoints.clone();
|
||||
let existing_conflict_resolution = config.mirrors[index].conflict_resolution.clone();
|
||||
let endpoints = prompt_sync_group_endpoints_styled(config, theme, &existing)?;
|
||||
let conflict_resolution =
|
||||
prompt_conflict_resolution_styled(theme, Some(&existing_conflict_resolution))?;
|
||||
config.mirrors[index].endpoints = endpoints;
|
||||
config.mirrors[index].conflict_resolution = conflict_resolution;
|
||||
prompt_webhook_setup_styled(config, theme)?;
|
||||
println!(
|
||||
"{} {}",
|
||||
@@ -688,6 +694,43 @@ fn prompt_namespace_kind_styled(theme: &ColorfulTheme, namespace: &str) -> Resul
|
||||
})
|
||||
}
|
||||
|
||||
fn prompt_conflict_resolution_styled(
|
||||
theme: &ColorfulTheme,
|
||||
existing: Option<&ConflictResolutionStrategy>,
|
||||
) -> Result<ConflictResolutionStrategy> {
|
||||
let options = [
|
||||
"Fail",
|
||||
"Auto-rebase; fail on file conflict",
|
||||
"Pull request",
|
||||
"Auto-rebase; pull request on file conflict (recommended)",
|
||||
];
|
||||
let default = existing.map(conflict_resolution_index).unwrap_or(3);
|
||||
let index = Select::with_theme(theme)
|
||||
.with_prompt("How should git-sync resolve branch conflicts?")
|
||||
.items(options)
|
||||
.default(default)
|
||||
.interact()?;
|
||||
Ok(conflict_resolution_from_index(index))
|
||||
}
|
||||
|
||||
fn conflict_resolution_index(strategy: &ConflictResolutionStrategy) -> usize {
|
||||
match strategy {
|
||||
ConflictResolutionStrategy::Fail => 0,
|
||||
ConflictResolutionStrategy::AutoRebase => 1,
|
||||
ConflictResolutionStrategy::PullRequest => 2,
|
||||
ConflictResolutionStrategy::AutoRebasePullRequest => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn conflict_resolution_from_index(index: usize) -> ConflictResolutionStrategy {
|
||||
match index {
|
||||
0 => ConflictResolutionStrategy::Fail,
|
||||
1 => ConflictResolutionStrategy::AutoRebase,
|
||||
2 => ConflictResolutionStrategy::PullRequest,
|
||||
_ => ConflictResolutionStrategy::AutoRebasePullRequest,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_sync_groups(config: &Config) {
|
||||
println!();
|
||||
println!("{}", style("Sync groups").cyan().bold());
|
||||
@@ -725,7 +768,7 @@ fn sync_group_summaries(config: &Config) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn sync_group_summary(config: &Config, mirror: &MirrorConfig) -> String {
|
||||
mirror
|
||||
let endpoints = mirror
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|endpoint| {
|
||||
@@ -735,7 +778,23 @@ fn sync_group_summary(config: &Config, mirror: &MirrorConfig) -> String {
|
||||
.unwrap_or_else(|| format!("{}:{}", endpoint.site, endpoint.namespace))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" <-> ")
|
||||
.join(" <-> ");
|
||||
format!(
|
||||
"{} ({})",
|
||||
endpoints,
|
||||
conflict_resolution_label(&mirror.conflict_resolution)
|
||||
)
|
||||
}
|
||||
|
||||
fn conflict_resolution_label(strategy: &ConflictResolutionStrategy) -> &'static str {
|
||||
match strategy {
|
||||
ConflictResolutionStrategy::Fail => "conflicts: fail",
|
||||
ConflictResolutionStrategy::AutoRebase => "conflicts: auto-rebase",
|
||||
ConflictResolutionStrategy::PullRequest => "conflicts: pull request",
|
||||
ConflictResolutionStrategy::AutoRebasePullRequest => {
|
||||
"conflicts: auto-rebase + pull request"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_pat_instructions(
|
||||
|
||||
+308
@@ -23,6 +23,19 @@ pub struct EndpointRepo {
|
||||
pub repo: RemoteRepo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PullRequestRequest {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub head_branch: String,
|
||||
pub base_branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PullRequestInfo {
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ProviderClient<'a> {
|
||||
site: &'a SiteConfig,
|
||||
token: String,
|
||||
@@ -120,6 +133,33 @@ impl<'a> ProviderClient<'a> {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn open_pull_request(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
request: &PullRequestRequest,
|
||||
) -> Result<PullRequestInfo> {
|
||||
dispatch_provider!(self.site.provider,
|
||||
github => self.github_open_pull_request(endpoint, repo, request),
|
||||
gitlab => self.gitlab_open_pull_request(endpoint, repo, request),
|
||||
gitea_like => self.gitea_open_pull_request(endpoint, repo, request),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn close_pull_requests_by_head_prefix(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
base_branch: &str,
|
||||
head_prefix: &str,
|
||||
) -> Result<usize> {
|
||||
dispatch_provider!(self.site.provider,
|
||||
github => self.github_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix),
|
||||
gitlab => self.gitlab_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix),
|
||||
gitea_like => self.gitea_close_pull_requests_by_head_prefix(endpoint, repo, base_branch, head_prefix),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn validate_token(&self) -> Result<()> {
|
||||
let url = format!("{}/user", self.site.api_base());
|
||||
self.get(&url).map(|_| ())
|
||||
@@ -238,6 +278,74 @@ impl<'a> ProviderClient<'a> {
|
||||
self.delete_matching_hook(&hooks_url, url)
|
||||
}
|
||||
|
||||
fn github_open_pull_request(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
request: &PullRequestRequest,
|
||||
) -> Result<PullRequestInfo> {
|
||||
let url = self.repo_pulls_url(endpoint, &repo.name, "GitHub")?;
|
||||
if let Some(existing) =
|
||||
self.github_find_open_pull_request(&url, &request.head_branch, &request.base_branch)?
|
||||
{
|
||||
return Ok(PullRequestInfo {
|
||||
url: existing.url(),
|
||||
});
|
||||
}
|
||||
let body = json!({
|
||||
"title": request.title,
|
||||
"body": request.body,
|
||||
"head": request.head_branch,
|
||||
"base": request.base_branch,
|
||||
});
|
||||
let created: ProviderPullRequest = self.post_json(&url, &body)?;
|
||||
Ok(PullRequestInfo { url: created.url() })
|
||||
}
|
||||
|
||||
fn github_find_open_pull_request(
|
||||
&self,
|
||||
pulls_url: &str,
|
||||
head_branch: &str,
|
||||
base_branch: &str,
|
||||
) -> Result<Option<ProviderPullRequest>> {
|
||||
let url = format!(
|
||||
"{pulls_url}?state=open&base={}&per_page=100",
|
||||
urlencoding(base_branch)
|
||||
);
|
||||
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
|
||||
Ok(pulls
|
||||
.into_iter()
|
||||
.find(|pull| pull.head_ref() == Some(head_branch)))
|
||||
}
|
||||
|
||||
fn github_close_pull_requests_by_head_prefix(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
base_branch: &str,
|
||||
head_prefix: &str,
|
||||
) -> Result<usize> {
|
||||
let pulls_url = self.repo_pulls_url(endpoint, &repo.name, "GitHub")?;
|
||||
let url = format!(
|
||||
"{pulls_url}?state=open&base={}&per_page=100",
|
||||
urlencoding(base_branch)
|
||||
);
|
||||
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
|
||||
let mut closed = 0;
|
||||
for pull in pulls.into_iter().filter(|pull| {
|
||||
pull.head_ref()
|
||||
.is_some_and(|head| head.starts_with(head_prefix))
|
||||
}) {
|
||||
let Some(number) = pull.number else {
|
||||
continue;
|
||||
};
|
||||
let update_url = format!("{pulls_url}/{number}");
|
||||
self.patch_json::<serde_json::Value>(&update_url, &json!({ "state": "closed" }))?;
|
||||
closed += 1;
|
||||
}
|
||||
Ok(closed)
|
||||
}
|
||||
|
||||
fn gitlab_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
|
||||
match endpoint.kind {
|
||||
NamespaceKind::User => {
|
||||
@@ -342,6 +450,75 @@ impl<'a> ProviderClient<'a> {
|
||||
self.delete_matching_hook(&hooks_url, url)
|
||||
}
|
||||
|
||||
fn gitlab_open_pull_request(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
request: &PullRequestRequest,
|
||||
) -> Result<PullRequestInfo> {
|
||||
let url = self.gitlab_merge_requests_url(endpoint, &repo.name);
|
||||
if let Some(existing) =
|
||||
self.gitlab_find_open_merge_request(&url, &request.head_branch, &request.base_branch)?
|
||||
{
|
||||
return Ok(PullRequestInfo {
|
||||
url: existing.url(),
|
||||
});
|
||||
}
|
||||
let body = json!({
|
||||
"title": request.title,
|
||||
"description": request.body,
|
||||
"source_branch": request.head_branch,
|
||||
"target_branch": request.base_branch,
|
||||
});
|
||||
let created: ProviderPullRequest = self.post_json(&url, &body)?;
|
||||
Ok(PullRequestInfo { url: created.url() })
|
||||
}
|
||||
|
||||
fn gitlab_find_open_merge_request(
|
||||
&self,
|
||||
merge_requests_url: &str,
|
||||
source_branch: &str,
|
||||
target_branch: &str,
|
||||
) -> Result<Option<ProviderPullRequest>> {
|
||||
let url = format!(
|
||||
"{merge_requests_url}?state=opened&source_branch={}&target_branch={}&per_page=100",
|
||||
urlencoding(source_branch),
|
||||
urlencoding(target_branch)
|
||||
);
|
||||
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
|
||||
Ok(pulls
|
||||
.into_iter()
|
||||
.find(|pull| pull.head_ref() == Some(source_branch)))
|
||||
}
|
||||
|
||||
fn gitlab_close_pull_requests_by_head_prefix(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
base_branch: &str,
|
||||
head_prefix: &str,
|
||||
) -> Result<usize> {
|
||||
let merge_requests_url = self.gitlab_merge_requests_url(endpoint, &repo.name);
|
||||
let url = format!(
|
||||
"{merge_requests_url}?state=opened&target_branch={}&per_page=100",
|
||||
urlencoding(base_branch)
|
||||
);
|
||||
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
|
||||
let mut closed = 0;
|
||||
for pull in pulls.into_iter().filter(|pull| {
|
||||
pull.head_ref()
|
||||
.is_some_and(|head| head.starts_with(head_prefix))
|
||||
}) {
|
||||
let Some(number) = pull.iid.or(pull.number) else {
|
||||
continue;
|
||||
};
|
||||
let update_url = format!("{merge_requests_url}/{number}");
|
||||
self.put_json::<serde_json::Value>(&update_url, &json!({ "state_event": "close" }))?;
|
||||
closed += 1;
|
||||
}
|
||||
Ok(closed)
|
||||
}
|
||||
|
||||
fn gitea_list_repos(&self, endpoint: &EndpointConfig) -> Result<Vec<RemoteRepo>> {
|
||||
match endpoint.kind {
|
||||
NamespaceKind::User => {
|
||||
@@ -428,6 +605,75 @@ impl<'a> ProviderClient<'a> {
|
||||
self.delete_matching_hook(&hooks_url, url)
|
||||
}
|
||||
|
||||
fn gitea_open_pull_request(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
request: &PullRequestRequest,
|
||||
) -> Result<PullRequestInfo> {
|
||||
let url = self.repo_pulls_url(endpoint, &repo.name, "Gitea/Forgejo")?;
|
||||
if let Some(existing) =
|
||||
self.gitea_find_open_pull_request(&url, &request.head_branch, &request.base_branch)?
|
||||
{
|
||||
return Ok(PullRequestInfo {
|
||||
url: existing.url(),
|
||||
});
|
||||
}
|
||||
let body = json!({
|
||||
"title": request.title,
|
||||
"body": request.body,
|
||||
"head": request.head_branch,
|
||||
"base": request.base_branch,
|
||||
});
|
||||
let created: ProviderPullRequest = self.post_json(&url, &body)?;
|
||||
Ok(PullRequestInfo { url: created.url() })
|
||||
}
|
||||
|
||||
fn gitea_find_open_pull_request(
|
||||
&self,
|
||||
pulls_url: &str,
|
||||
head_branch: &str,
|
||||
base_branch: &str,
|
||||
) -> Result<Option<ProviderPullRequest>> {
|
||||
let url = format!(
|
||||
"{pulls_url}?state=open&base={}&head={}&limit=50",
|
||||
urlencoding(base_branch),
|
||||
urlencoding(head_branch)
|
||||
);
|
||||
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
|
||||
Ok(pulls
|
||||
.into_iter()
|
||||
.find(|pull| pull.head_ref() == Some(head_branch)))
|
||||
}
|
||||
|
||||
fn gitea_close_pull_requests_by_head_prefix(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo: &RemoteRepo,
|
||||
base_branch: &str,
|
||||
head_prefix: &str,
|
||||
) -> Result<usize> {
|
||||
let pulls_url = self.repo_pulls_url(endpoint, &repo.name, "Gitea/Forgejo")?;
|
||||
let url = format!(
|
||||
"{pulls_url}?state=open&base={}&limit=50",
|
||||
urlencoding(base_branch)
|
||||
);
|
||||
let pulls: Vec<ProviderPullRequest> = self.paged_get(&url)?;
|
||||
let mut closed = 0;
|
||||
for pull in pulls.into_iter().filter(|pull| {
|
||||
pull.head_ref()
|
||||
.is_some_and(|head| head.starts_with(head_prefix))
|
||||
}) {
|
||||
let Some(number) = pull.number.or(pull.index) else {
|
||||
continue;
|
||||
};
|
||||
let update_url = format!("{pulls_url}/{number}");
|
||||
self.patch_json::<serde_json::Value>(&update_url, &json!({ "state": "closed" }))?;
|
||||
closed += 1;
|
||||
}
|
||||
Ok(closed)
|
||||
}
|
||||
|
||||
fn repo_hooks_url(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
@@ -444,6 +690,22 @@ impl<'a> ProviderClient<'a> {
|
||||
))
|
||||
}
|
||||
|
||||
fn repo_pulls_url(
|
||||
&self,
|
||||
endpoint: &EndpointConfig,
|
||||
repo_name: &str,
|
||||
provider: &str,
|
||||
) -> Result<String> {
|
||||
if matches!(endpoint.kind, NamespaceKind::Group) {
|
||||
bail!("{provider} endpoints use kind 'user' or 'org'");
|
||||
}
|
||||
Ok(format!(
|
||||
"{}/repos/{}/{repo_name}/pulls",
|
||||
self.site.api_base(),
|
||||
endpoint.namespace
|
||||
))
|
||||
}
|
||||
|
||||
fn gitlab_hooks_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String {
|
||||
let project = format!("{}/{repo_name}", endpoint.namespace);
|
||||
format!(
|
||||
@@ -453,6 +715,15 @@ impl<'a> ProviderClient<'a> {
|
||||
)
|
||||
}
|
||||
|
||||
fn gitlab_merge_requests_url(&self, endpoint: &EndpointConfig, repo_name: &str) -> String {
|
||||
let project = format!("{}/{repo_name}", endpoint.namespace);
|
||||
format!(
|
||||
"{}/projects/{}/merge_requests",
|
||||
self.site.api_base(),
|
||||
urlencoding(&project)
|
||||
)
|
||||
}
|
||||
|
||||
fn find_existing_hook(&self, hooks_url: &str, target_url: &str) -> Result<Option<RepoHook>> {
|
||||
let hooks: Vec<RepoHook> = self.paged_get(hooks_url)?;
|
||||
Ok(hooks
|
||||
@@ -730,6 +1001,43 @@ struct RepoHook {
|
||||
config: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProviderPullRequest {
|
||||
#[serde(default)]
|
||||
number: Option<u64>,
|
||||
#[serde(default)]
|
||||
iid: Option<u64>,
|
||||
#[serde(default)]
|
||||
index: Option<u64>,
|
||||
#[serde(default)]
|
||||
html_url: Option<String>,
|
||||
#[serde(default)]
|
||||
web_url: Option<String>,
|
||||
#[serde(default)]
|
||||
head: Option<PullRequestHead>,
|
||||
#[serde(default)]
|
||||
source_branch: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderPullRequest {
|
||||
fn url(&self) -> Option<String> {
|
||||
self.html_url.clone().or_else(|| self.web_url.clone())
|
||||
}
|
||||
|
||||
fn head_ref(&self) -> Option<&str> {
|
||||
self.head
|
||||
.as_ref()
|
||||
.map(|head| head.reference.as_str())
|
||||
.or(self.source_branch.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PullRequestHead {
|
||||
#[serde(rename = "ref")]
|
||||
reference: String,
|
||||
}
|
||||
|
||||
impl RepoHook {
|
||||
fn url(&self) -> Option<&str> {
|
||||
self.url
|
||||
|
||||
+425
-30
@@ -8,13 +8,16 @@ use anyhow::{Context, Result, bail};
|
||||
use console::style;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::config::{Config, EndpointConfig, MirrorConfig, default_work_dir, validate_config};
|
||||
use crate::config::{
|
||||
Config, ConflictResolutionStrategy, EndpointConfig, MirrorConfig, default_work_dir,
|
||||
validate_config,
|
||||
};
|
||||
use crate::git::{
|
||||
BranchDeletion, GitMirror, Redactor, RemoteRefSnapshot, RemoteSpec,
|
||||
is_disabled_repository_error, ls_remote_refs, safe_remote_name,
|
||||
BranchConflict, BranchDeletion, BranchUpdate, GitMirror, Redactor, RemoteRefSnapshot,
|
||||
RemoteSpec, is_disabled_repository_error, ls_remote_refs, safe_remote_name,
|
||||
};
|
||||
use crate::logging;
|
||||
use crate::provider::{EndpointRepo, ProviderClient, repos_by_name};
|
||||
use crate::provider::{EndpointRepo, ProviderClient, PullRequestRequest, repos_by_name};
|
||||
use crate::webhook;
|
||||
|
||||
mod output;
|
||||
@@ -32,6 +35,7 @@ use self::state::{
|
||||
};
|
||||
|
||||
pub const DEFAULT_JOBS: usize = 4;
|
||||
const CONFLICT_BRANCH_ROOT: &str = "git-sync/conflicts/";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SyncOptions {
|
||||
@@ -601,6 +605,7 @@ fn sync_repo(
|
||||
context,
|
||||
&mirror_repo,
|
||||
&remotes,
|
||||
repos,
|
||||
detailed_repo_ref_state(ref_state.repo(&context.mirror.name, repo_name))
|
||||
.or(cached_ref_state.as_ref()),
|
||||
&initial_ref_state,
|
||||
@@ -737,49 +742,63 @@ fn push_repo_refs(
|
||||
context: &RepoSyncContext<'_>,
|
||||
mirror_repo: &GitMirror,
|
||||
remotes: &[RemoteSpec],
|
||||
repos: &[EndpointRepo],
|
||||
previous_refs: Option<&BTreeMap<String, RemoteRefState>>,
|
||||
current_refs: &BTreeMap<String, RemoteRefState>,
|
||||
) -> Result<RepoRefSyncResult> {
|
||||
let (branch_deletions, deletion_conflicts, blocked_branches) =
|
||||
branch_deletion_decisions(remotes, previous_refs, current_refs);
|
||||
let had_deletion_conflicts = !deletion_conflicts.is_empty();
|
||||
for conflict in deletion_conflicts {
|
||||
for conflict in &deletion_conflicts {
|
||||
crate::logln!(
|
||||
" {} branch {} was deleted on {} but changed on {} ({})",
|
||||
style("conflict").yellow().bold(),
|
||||
style(conflict.branch).cyan(),
|
||||
style(&conflict.branch).cyan(),
|
||||
conflict.deleted_remotes.join("+"),
|
||||
conflict.changed_remotes.join("+"),
|
||||
style("skipped").dim()
|
||||
);
|
||||
}
|
||||
if had_deletion_conflicts {
|
||||
fail_on_unresolved_conflict(context, "branch deletion conflict")?;
|
||||
}
|
||||
|
||||
let (branches, conflicts) = mirror_repo.branch_decisions(remotes, context.allow_force)?;
|
||||
let branches_to_push = branches
|
||||
.into_iter()
|
||||
.filter(|branch| !is_internal_conflict_branch(&branch.branch))
|
||||
.filter(|branch| !blocked_branches.contains(&branch.branch))
|
||||
.filter(|branch| !branch.target_remotes.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let mut had_branch_conflicts = false;
|
||||
let mut unresolved_branch_conflicts = Vec::new();
|
||||
let mut rebased_branch_updates = Vec::new();
|
||||
for conflict in conflicts {
|
||||
if is_internal_conflict_branch(&conflict.branch) {
|
||||
continue;
|
||||
}
|
||||
if blocked_branches.contains(&conflict.branch) {
|
||||
continue;
|
||||
}
|
||||
had_branch_conflicts = true;
|
||||
let details = conflict
|
||||
.tips
|
||||
.iter()
|
||||
.map(|(remote, sha)| format!("{remote}@{}", short_sha(sha)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
crate::logln!(
|
||||
" {} branch {} diverged across {} ({})",
|
||||
style("conflict").yellow().bold(),
|
||||
style(conflict.branch).cyan(),
|
||||
details,
|
||||
style("skipped").dim()
|
||||
);
|
||||
match resolve_branch_conflict(context, mirror_repo, remotes, conflict)? {
|
||||
BranchConflictResolution::Rebased(updates) => rebased_branch_updates.extend(updates),
|
||||
BranchConflictResolution::PullRequest(conflict) => {
|
||||
unresolved_branch_conflicts.push(conflict)
|
||||
}
|
||||
}
|
||||
}
|
||||
let had_branch_conflicts = !unresolved_branch_conflicts.is_empty();
|
||||
let unresolved_branch_names = unresolved_branch_conflicts
|
||||
.iter()
|
||||
.map(|conflict| conflict.branch.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let unresolved_or_blocked_branches = unresolved_branch_names
|
||||
.union(&blocked_branches)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let stale_conflict_branches = conflict_pr_base_branches(current_refs)
|
||||
.difference(&unresolved_or_blocked_branches)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
let (tags, tag_conflicts) = mirror_repo.tag_decisions(remotes)?;
|
||||
let had_tag_conflicts = !tag_conflicts.is_empty();
|
||||
@@ -802,22 +821,35 @@ fn push_repo_refs(
|
||||
style("skipped").dim()
|
||||
);
|
||||
}
|
||||
if had_tag_conflicts {
|
||||
fail_on_unresolved_conflict(context, "tag conflict")?;
|
||||
}
|
||||
|
||||
if branches_to_push.is_empty() && tags_to_push.is_empty() {
|
||||
let pushed_branch_names = branch_names(&branches_to_push);
|
||||
let rebased_branch_names = branch_names_from_updates(&rebased_branch_updates);
|
||||
let mut cleanup_branches = stale_conflict_branches;
|
||||
cleanup_branches.retain(|branch| {
|
||||
!pushed_branch_names.contains(branch) && !rebased_branch_names.contains(branch)
|
||||
});
|
||||
|
||||
if branches_to_push.is_empty()
|
||||
&& rebased_branch_updates.is_empty()
|
||||
&& tags_to_push.is_empty()
|
||||
&& unresolved_branch_conflicts.is_empty()
|
||||
{
|
||||
if !branch_deletions.is_empty() {
|
||||
print_branch_deletions(&branch_deletions);
|
||||
mirror_repo.delete_branches(remotes, &branch_deletions)?;
|
||||
}
|
||||
if !cleanup_branches.is_empty() {
|
||||
close_resolved_pull_requests(context, mirror_repo, remotes, repos, &cleanup_branches)?;
|
||||
}
|
||||
if !branch_deletions.is_empty() || !cleanup_branches.is_empty() {
|
||||
return Ok(RepoRefSyncResult {
|
||||
pushed: true,
|
||||
had_conflicts: had_deletion_conflicts,
|
||||
});
|
||||
}
|
||||
if had_branch_conflicts || had_tag_conflicts || had_deletion_conflicts {
|
||||
return Ok(RepoRefSyncResult {
|
||||
pushed: false,
|
||||
had_conflicts: true,
|
||||
});
|
||||
}
|
||||
crate::logln!(
|
||||
" {} branches and tags already match all endpoints",
|
||||
style("up-to-date").green().bold()
|
||||
@@ -834,17 +866,375 @@ fn push_repo_refs(
|
||||
if !branches_to_push.is_empty() {
|
||||
print_branch_decisions(&branches_to_push);
|
||||
mirror_repo.push_branches(remotes, &branches_to_push, context.allow_force)?;
|
||||
close_resolved_pull_requests(context, mirror_repo, remotes, repos, &pushed_branch_names)?;
|
||||
}
|
||||
if !rebased_branch_updates.is_empty() {
|
||||
mirror_repo.push_branch_updates(remotes, &rebased_branch_updates)?;
|
||||
close_resolved_pull_requests(context, mirror_repo, remotes, repos, &rebased_branch_names)?;
|
||||
}
|
||||
if !tags_to_push.is_empty() {
|
||||
print_tag_decisions(&tags_to_push);
|
||||
mirror_repo.push_tags(remotes, &tags_to_push)?;
|
||||
}
|
||||
if !unresolved_branch_conflicts.is_empty() {
|
||||
open_conflict_pull_requests(
|
||||
context,
|
||||
mirror_repo,
|
||||
remotes,
|
||||
repos,
|
||||
&unresolved_branch_conflicts,
|
||||
)?;
|
||||
}
|
||||
if !cleanup_branches.is_empty() {
|
||||
close_resolved_pull_requests(context, mirror_repo, remotes, repos, &cleanup_branches)?;
|
||||
}
|
||||
Ok(RepoRefSyncResult {
|
||||
pushed: true,
|
||||
pushed: !branches_to_push.is_empty()
|
||||
|| !rebased_branch_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,
|
||||
})
|
||||
}
|
||||
|
||||
enum BranchConflictResolution {
|
||||
Rebased(Vec<BranchUpdate>),
|
||||
PullRequest(BranchConflict),
|
||||
}
|
||||
|
||||
fn resolve_branch_conflict(
|
||||
context: &RepoSyncContext<'_>,
|
||||
mirror_repo: &GitMirror,
|
||||
remotes: &[RemoteSpec],
|
||||
conflict: BranchConflict,
|
||||
) -> Result<BranchConflictResolution> {
|
||||
log_branch_conflict(&conflict, context.mirror.conflict_resolution.clone());
|
||||
match &context.mirror.conflict_resolution {
|
||||
ConflictResolutionStrategy::Fail => {
|
||||
bail!("branch {} diverged across endpoints", conflict.branch)
|
||||
}
|
||||
ConflictResolutionStrategy::PullRequest => {
|
||||
Ok(BranchConflictResolution::PullRequest(conflict))
|
||||
}
|
||||
ConflictResolutionStrategy::AutoRebase => {
|
||||
let decision = mirror_repo.auto_rebase_branch_conflict(
|
||||
remotes,
|
||||
&conflict.branch,
|
||||
&conflict.tips,
|
||||
)?;
|
||||
log_rebase_decision(&decision.branch, &decision.sha, &decision.updates);
|
||||
Ok(BranchConflictResolution::Rebased(decision.updates))
|
||||
}
|
||||
ConflictResolutionStrategy::AutoRebasePullRequest => {
|
||||
match mirror_repo.auto_rebase_branch_conflict(remotes, &conflict.branch, &conflict.tips)
|
||||
{
|
||||
Ok(decision) => {
|
||||
log_rebase_decision(&decision.branch, &decision.sha, &decision.updates);
|
||||
Ok(BranchConflictResolution::Rebased(decision.updates))
|
||||
}
|
||||
Err(error) => {
|
||||
crate::logln!(
|
||||
" {} branch {} auto-rebase failed; opening pull requests ({})",
|
||||
style("fallback").yellow().bold(),
|
||||
style(&conflict.branch).cyan(),
|
||||
style(error.to_string()).dim()
|
||||
);
|
||||
Ok(BranchConflictResolution::PullRequest(conflict))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_on_unresolved_conflict(context: &RepoSyncContext<'_>, label: &str) -> Result<()> {
|
||||
if matches!(
|
||||
&context.mirror.conflict_resolution,
|
||||
ConflictResolutionStrategy::Fail
|
||||
) {
|
||||
bail!("{label} detected");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_branch_conflict(conflict: &BranchConflict, strategy: ConflictResolutionStrategy) {
|
||||
let details = conflict
|
||||
.tips
|
||||
.iter()
|
||||
.map(|(remote, sha)| format!("{remote}@{}", short_sha(sha)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let action = match strategy {
|
||||
ConflictResolutionStrategy::Fail => "failing",
|
||||
ConflictResolutionStrategy::AutoRebase => "auto-rebase",
|
||||
ConflictResolutionStrategy::PullRequest => "pull-request",
|
||||
ConflictResolutionStrategy::AutoRebasePullRequest => "auto-rebase/pull-request",
|
||||
};
|
||||
crate::logln!(
|
||||
" {} branch {} diverged across {} ({})",
|
||||
style("conflict").yellow().bold(),
|
||||
style(&conflict.branch).cyan(),
|
||||
details,
|
||||
style(action).dim()
|
||||
);
|
||||
}
|
||||
|
||||
fn log_rebase_decision(branch: &str, sha: &str, updates: &[BranchUpdate]) {
|
||||
crate::logln!(
|
||||
" {} branch {} resolved at {} ({} push{})",
|
||||
style("auto-rebase").green().bold(),
|
||||
style(branch).cyan(),
|
||||
style(format!("@{}", short_sha(sha))).dim(),
|
||||
updates.len(),
|
||||
if updates.len() == 1 { "" } else { "es" }
|
||||
);
|
||||
}
|
||||
|
||||
fn open_conflict_pull_requests(
|
||||
context: &RepoSyncContext<'_>,
|
||||
mirror_repo: &GitMirror,
|
||||
remotes: &[RemoteSpec],
|
||||
repos: &[EndpointRepo],
|
||||
conflicts: &[BranchConflict],
|
||||
) -> Result<()> {
|
||||
let repos_by_remote = endpoint_repos_by_remote_name(context, repos)?;
|
||||
for conflict in conflicts {
|
||||
for (target_remote, target_sha) in &conflict.tips {
|
||||
if !remotes.iter().any(|remote| &remote.name == target_remote) {
|
||||
continue;
|
||||
}
|
||||
let Some(target_repo) = repos_by_remote.get(target_remote) else {
|
||||
continue;
|
||||
};
|
||||
for (source_remote, source_sha) in
|
||||
conflict.tips.iter().filter(|(_, sha)| sha != target_sha)
|
||||
{
|
||||
let head_branch = conflict_pr_branch(&conflict.branch, source_remote, source_sha);
|
||||
let update = BranchUpdate {
|
||||
branch: head_branch.clone(),
|
||||
sha: source_sha.clone(),
|
||||
target_remote: target_remote.clone(),
|
||||
force: false,
|
||||
};
|
||||
mirror_repo.push_branch_updates(remotes, &[update])?;
|
||||
let title = format!(
|
||||
"Resolve git-sync conflict: {} from {}",
|
||||
conflict.branch, source_remote
|
||||
);
|
||||
let body = format!(
|
||||
"git-sync detected divergent branch tips and opened this pull request to merge {}@{} into {}@{}.",
|
||||
source_remote,
|
||||
short_sha(source_sha),
|
||||
target_remote,
|
||||
short_sha(target_sha)
|
||||
);
|
||||
crate::logln!(
|
||||
" {} branch {} {} -> {}",
|
||||
style("pull request").cyan().bold(),
|
||||
style(&conflict.branch).cyan(),
|
||||
source_remote,
|
||||
target_remote
|
||||
);
|
||||
if context.dry_run {
|
||||
continue;
|
||||
}
|
||||
let site = context.config.site(&target_repo.endpoint.site).unwrap();
|
||||
let client = ProviderClient::new(site)?;
|
||||
let pr = client.open_pull_request(
|
||||
&target_repo.endpoint,
|
||||
&target_repo.repo,
|
||||
&PullRequestRequest {
|
||||
title,
|
||||
body,
|
||||
head_branch,
|
||||
base_branch: conflict.branch.clone(),
|
||||
},
|
||||
)?;
|
||||
if let Some(url) = pr.url {
|
||||
crate::logln!(
|
||||
" {} {}",
|
||||
style("opened").green().bold(),
|
||||
style(url).dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close_resolved_pull_requests(
|
||||
context: &RepoSyncContext<'_>,
|
||||
mirror_repo: &GitMirror,
|
||||
remotes: &[RemoteSpec],
|
||||
repos: &[EndpointRepo],
|
||||
branches: &BTreeSet<String>,
|
||||
) -> Result<()> {
|
||||
if context.dry_run || branches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let repos_by_remote = endpoint_repos_by_remote_name(context, repos)?;
|
||||
for remote in remotes {
|
||||
let Some(endpoint_repo) = repos_by_remote.get(&remote.name) else {
|
||||
continue;
|
||||
};
|
||||
let site = context.config.site(&endpoint_repo.endpoint.site).unwrap();
|
||||
let client = ProviderClient::new(site)?;
|
||||
for branch in branches {
|
||||
let prefix = conflict_pr_branch_prefix(branch);
|
||||
let closed = client.close_pull_requests_by_head_prefix(
|
||||
&endpoint_repo.endpoint,
|
||||
&endpoint_repo.repo,
|
||||
branch,
|
||||
&prefix,
|
||||
)?;
|
||||
if closed > 0 {
|
||||
crate::logln!(
|
||||
" {} {} stale pull request{} for branch {} on {}",
|
||||
style("close").green().bold(),
|
||||
closed,
|
||||
if closed == 1 { "" } else { "s" },
|
||||
style(branch).cyan(),
|
||||
style(&remote.display).dim()
|
||||
);
|
||||
}
|
||||
delete_conflict_branches(mirror_repo, remotes, remote, &prefix)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_conflict_branches(
|
||||
mirror_repo: &GitMirror,
|
||||
remotes: &[RemoteSpec],
|
||||
remote: &RemoteSpec,
|
||||
prefix: &str,
|
||||
) -> Result<()> {
|
||||
let deletions = mirror_repo
|
||||
.remote_branch_names_with_prefix(&remote.name, prefix)?
|
||||
.into_iter()
|
||||
.map(|branch| BranchDeletion {
|
||||
branch,
|
||||
deleted_remotes: Vec::new(),
|
||||
target_remotes: vec![remote.name.clone()],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if deletions.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
mirror_repo.delete_branches(remotes, &deletions)
|
||||
}
|
||||
|
||||
fn endpoint_repos_by_remote_name<'a>(
|
||||
context: &RepoSyncContext<'_>,
|
||||
repos: &'a [EndpointRepo],
|
||||
) -> Result<HashMap<String, &'a EndpointRepo>> {
|
||||
let mut output = HashMap::new();
|
||||
for repo in repos {
|
||||
let remote_name = remote_name_for_endpoint_repo(repo);
|
||||
if context
|
||||
.mirror
|
||||
.endpoints
|
||||
.iter()
|
||||
.any(|endpoint| endpoint == &repo.endpoint)
|
||||
{
|
||||
output.insert(remote_name, repo);
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn remote_name_for_endpoint_repo(endpoint_repo: &EndpointRepo) -> String {
|
||||
safe_remote_name(&format!(
|
||||
"{}_{}",
|
||||
endpoint_repo.endpoint.site, endpoint_repo.endpoint.namespace
|
||||
))
|
||||
}
|
||||
|
||||
fn branch_names(branches: &[crate::git::BranchDecision]) -> BTreeSet<String> {
|
||||
branches
|
||||
.iter()
|
||||
.map(|branch| branch.branch.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn branch_names_from_updates(updates: &[BranchUpdate]) -> BTreeSet<String> {
|
||||
updates.iter().map(|update| update.branch.clone()).collect()
|
||||
}
|
||||
|
||||
fn conflict_pr_base_branches(refs: &BTreeMap<String, RemoteRefState>) -> BTreeSet<String> {
|
||||
refs.values()
|
||||
.flat_map(|remote| remote.branches.keys())
|
||||
.filter_map(|branch| conflict_pr_base_branch(branch))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn conflict_pr_branch(branch: &str, source_remote: &str, source_sha: &str) -> String {
|
||||
format!(
|
||||
"{}from-{}-{}",
|
||||
conflict_pr_branch_prefix(branch),
|
||||
safe_ref_component(source_remote),
|
||||
short_sha(source_sha)
|
||||
)
|
||||
}
|
||||
|
||||
fn conflict_pr_branch_prefix(branch: &str) -> String {
|
||||
format!("{}{}/", CONFLICT_BRANCH_ROOT, hex_component(branch))
|
||||
}
|
||||
|
||||
fn is_internal_conflict_branch(branch: &str) -> bool {
|
||||
branch.starts_with(CONFLICT_BRANCH_ROOT)
|
||||
}
|
||||
|
||||
fn conflict_pr_base_branch(branch: &str) -> Option<String> {
|
||||
let rest = branch.strip_prefix(CONFLICT_BRANCH_ROOT)?;
|
||||
let (encoded, _) = rest.split_once('/')?;
|
||||
decode_hex_component(encoded)
|
||||
}
|
||||
|
||||
fn hex_component(value: &str) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut output = String::with_capacity(value.len() * 2);
|
||||
for byte in value.bytes() {
|
||||
output.push(HEX[(byte >> 4) as usize] as char);
|
||||
output.push(HEX[(byte & 0x0f) as usize] as char);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn decode_hex_component(value: &str) -> Option<String> {
|
||||
if value.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(value.len() / 2);
|
||||
for pair in value.as_bytes().chunks_exact(2) {
|
||||
let high = hex_value(pair[0])?;
|
||||
let low = hex_value(pair[1])?;
|
||||
bytes.push((high << 4) | low);
|
||||
}
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
fn hex_value(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_ref_component(value: &str) -> String {
|
||||
let mut output = String::new();
|
||||
for ch in value.chars() {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
output.push(ch);
|
||||
} else {
|
||||
output.push('-');
|
||||
}
|
||||
}
|
||||
output.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
struct BranchDeletionConflict {
|
||||
branch: String,
|
||||
deleted_remotes: Vec<String>,
|
||||
@@ -869,7 +1259,12 @@ fn branch_deletion_decisions(
|
||||
.collect::<Vec<_>>();
|
||||
let mut branches = BTreeSet::new();
|
||||
for refs in previous_refs.values() {
|
||||
branches.extend(refs.branches.keys().cloned());
|
||||
branches.extend(
|
||||
refs.branches
|
||||
.keys()
|
||||
.filter(|branch| !is_internal_conflict_branch(branch))
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut deletions = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user