[+] Conflict resolution (#4)
* [+] Conflict resolution * [F] Fix conflict resolution branches being synched
This commit is contained in:
@@ -22,6 +22,7 @@ fn parses_token_forms() {
|
||||
create_missing = true
|
||||
visibility = "private"
|
||||
allow_force = false
|
||||
conflict_resolution = "auto_rebase_pull_request"
|
||||
|
||||
[[mirrors.endpoints]]
|
||||
site = "github"
|
||||
@@ -38,6 +39,10 @@ fn parses_token_forms() {
|
||||
|
||||
assert_eq!(config.sites.len(), 1);
|
||||
assert_eq!(config.mirrors[0].endpoints.len(), 2);
|
||||
assert_eq!(
|
||||
config.mirrors[0].conflict_resolution,
|
||||
ConflictResolutionStrategy::AutoRebasePullRequest
|
||||
);
|
||||
let webhook = config.webhook.unwrap();
|
||||
assert!(webhook.install);
|
||||
assert_eq!(webhook.url, "https://mirror.example.test/webhook");
|
||||
@@ -62,6 +67,7 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
@@ -87,6 +93,7 @@ fn validation_rejects_unknown_sites_and_single_endpoint_groups() {
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
|
||||
@@ -180,6 +180,83 @@ fn branch_decisions_force_selects_newest_divergent_tip() {
|
||||
assert_eq!(main.target_remotes, vec!["a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rebase_branch_conflict_replays_later_tip_and_marks_force_targets() {
|
||||
let fixture = GitFixture::new();
|
||||
let base = fixture.commit("base", "base", 1_700_000_000);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
|
||||
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("b", "b.txt", "b\n", 1_700_000_200);
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
|
||||
let mirror = fixture.mirror();
|
||||
fixture.fetch_all(&mirror);
|
||||
let (_, conflicts) = mirror.branch_decisions(&fixture.remotes(), false).unwrap();
|
||||
|
||||
let decision = mirror
|
||||
.auto_rebase_branch_conflict(&fixture.remotes(), "main", &conflicts[0].tips)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(decision.branch, "main");
|
||||
assert_ne!(decision.sha, a_tip);
|
||||
assert_ne!(decision.sha, b_tip);
|
||||
assert_eq!(decision.updates.len(), 2);
|
||||
let a_update = decision
|
||||
.updates
|
||||
.iter()
|
||||
.find(|update| update.target_remote == "a")
|
||||
.unwrap();
|
||||
assert!(!a_update.force);
|
||||
let b_update = decision
|
||||
.updates
|
||||
.iter()
|
||||
.find(|update| update.target_remote == "b")
|
||||
.unwrap();
|
||||
assert!(b_update.force);
|
||||
|
||||
mirror
|
||||
.push_branch_updates(&fixture.remotes(), &decision.updates)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fixture.remote_ref(&fixture.remote_a, "refs/heads/main"),
|
||||
decision.sha
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.remote_ref(&fixture.remote_b, "refs/heads/main"),
|
||||
decision.sha
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_rebase_branch_conflict_fails_on_file_conflict() {
|
||||
let fixture = GitFixture::new();
|
||||
fixture.commit_file("base", "file.txt", "base\n", 1_700_000_000);
|
||||
let base = fixture.head();
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
|
||||
fixture.commit_file("a", "file.txt", "a\n", 1_700_000_100);
|
||||
fixture.push_head(&fixture.remote_a, "main");
|
||||
fixture.reset_hard(&base);
|
||||
fixture.commit_file("b", "file.txt", "b\n", 1_700_000_200);
|
||||
fixture.push_head(&fixture.remote_b, "main");
|
||||
|
||||
let mirror = fixture.mirror();
|
||||
fixture.fetch_all(&mirror);
|
||||
let (_, conflicts) = mirror.branch_decisions(&fixture.remotes(), false).unwrap();
|
||||
|
||||
let error = mirror
|
||||
.auto_rebase_branch_conflict(&fixture.remotes(), "main", &conflicts[0].tips)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("auto-rebase failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_branches_creates_missing_branch_on_other_remotes() {
|
||||
let fixture = GitFixture::new();
|
||||
@@ -369,6 +446,29 @@ impl GitFixture {
|
||||
self.head()
|
||||
}
|
||||
|
||||
fn commit_file(
|
||||
&self,
|
||||
message: &str,
|
||||
file_name: &str,
|
||||
contents: &str,
|
||||
timestamp: i64,
|
||||
) -> String {
|
||||
let path = self.work.join(file_name);
|
||||
fs::write(path, contents).unwrap();
|
||||
git(Some(&self.work), ["add", file_name]);
|
||||
|
||||
let date = format!("@{timestamp} +0000");
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.work)
|
||||
.env("GIT_AUTHOR_DATE", &date)
|
||||
.env("GIT_COMMITTER_DATE", &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"])
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ fn wizard_builds_sync_group_from_profile_urls() {
|
||||
"gt-token",
|
||||
"",
|
||||
"n",
|
||||
"",
|
||||
"n",
|
||||
"4",
|
||||
]
|
||||
@@ -44,6 +45,10 @@ fn wizard_builds_sync_group_from_profile_urls() {
|
||||
assert!(config.mirrors[0].create_missing);
|
||||
assert_eq!(config.mirrors[0].visibility, Visibility::Private);
|
||||
assert!(!config.mirrors[0].allow_force);
|
||||
assert_eq!(
|
||||
config.mirrors[0].conflict_resolution,
|
||||
ConflictResolutionStrategy::AutoRebasePullRequest
|
||||
);
|
||||
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("1. github.com/hykilpikonna <-> gitea.example.test/azalea"));
|
||||
@@ -67,6 +72,7 @@ fn wizard_can_build_three_way_sync() {
|
||||
"gt-token",
|
||||
"",
|
||||
"n",
|
||||
"",
|
||||
"n",
|
||||
"4",
|
||||
]
|
||||
@@ -92,6 +98,7 @@ fn wizard_can_enable_webhooks() {
|
||||
"gt-token",
|
||||
"",
|
||||
"n",
|
||||
"",
|
||||
"y",
|
||||
"https://mirror.example.test/webhook",
|
||||
"y",
|
||||
@@ -142,6 +149,7 @@ fn wizard_reuses_existing_credentials_for_same_instance() {
|
||||
"https://github.com/bob",
|
||||
"",
|
||||
"n",
|
||||
"",
|
||||
"n",
|
||||
"4",
|
||||
]
|
||||
@@ -195,6 +203,7 @@ fn wizard_starts_existing_config_at_sync_group_menu() {
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
@@ -256,6 +265,7 @@ fn wizard_edits_existing_sync_group_from_menu() {
|
||||
create_missing: false,
|
||||
visibility: Visibility::Public,
|
||||
allow_force: true,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
@@ -267,6 +277,7 @@ fn wizard_edits_existing_sync_group_from_menu() {
|
||||
"https://gitlab.com/bob",
|
||||
"",
|
||||
"n",
|
||||
"",
|
||||
"n",
|
||||
"4",
|
||||
]
|
||||
@@ -333,10 +344,11 @@ fn wizard_prefills_existing_sync_group_when_editing() {
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
let input = ["2", "1", "", "", "", "", "n", "n", "4"].join("\n") + "\n";
|
||||
let input = ["2", "1", "", "", "", "", "n", "", "n", "4"].join("\n") + "\n";
|
||||
let mut reader = Cursor::new(input.as_bytes());
|
||||
let mut output = Vec::new();
|
||||
|
||||
@@ -393,6 +405,7 @@ fn wizard_deletes_existing_sync_group_from_menu() {
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
@@ -448,6 +461,7 @@ fn wizard_can_go_back_from_delete_menu() {
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
|
||||
@@ -47,12 +47,14 @@ where
|
||||
W: Write,
|
||||
{
|
||||
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &[])?;
|
||||
let conflict_resolution = prompt_conflict_resolution(reader, writer, 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(reader, writer, config)?;
|
||||
Ok(())
|
||||
@@ -249,8 +251,16 @@ where
|
||||
match value.parse::<usize>() {
|
||||
Ok(index) if (1..=config.mirrors.len()).contains(&index) => {
|
||||
let existing = config.mirrors[index - 1].endpoints.clone();
|
||||
let existing_conflict_resolution =
|
||||
config.mirrors[index - 1].conflict_resolution.clone();
|
||||
let endpoints = prompt_sync_group_endpoints(reader, writer, config, &existing)?;
|
||||
let conflict_resolution = prompt_conflict_resolution(
|
||||
reader,
|
||||
writer,
|
||||
Some(&existing_conflict_resolution),
|
||||
)?;
|
||||
config.mirrors[index - 1].endpoints = endpoints;
|
||||
config.mirrors[index - 1].conflict_resolution = conflict_resolution;
|
||||
prompt_webhook_setup(reader, writer, config)?;
|
||||
writeln!(writer, "updated sync group {index}")?;
|
||||
return Ok(true);
|
||||
@@ -408,6 +418,57 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_conflict_resolution<R, W>(
|
||||
reader: &mut R,
|
||||
writer: &mut W,
|
||||
existing: Option<&ConflictResolutionStrategy>,
|
||||
) -> Result<ConflictResolutionStrategy>
|
||||
where
|
||||
R: BufRead,
|
||||
W: Write,
|
||||
{
|
||||
let default = existing
|
||||
.map(conflict_resolution_value)
|
||||
.unwrap_or("auto-rebase + pull-request");
|
||||
loop {
|
||||
writeln!(writer, "How should git-sync resolve branch conflicts?")?;
|
||||
writeln!(writer, " 1. fail")?;
|
||||
writeln!(writer, " 2. auto-rebase and fail on file conflict")?;
|
||||
writeln!(writer, " 3. pull-request")?;
|
||||
writeln!(writer, " 4. auto-rebase + pull-request (recommended)")?;
|
||||
let value = prompt_with_default(reader, writer, "Conflict resolution", default)?;
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "fail" => return Ok(ConflictResolutionStrategy::Fail),
|
||||
"2" | "auto-rebase" | "auto_rebase" | "rebase" => {
|
||||
return Ok(ConflictResolutionStrategy::AutoRebase);
|
||||
}
|
||||
"3" | "pull-request" | "pull_request" | "pr" => {
|
||||
return Ok(ConflictResolutionStrategy::PullRequest);
|
||||
}
|
||||
"4"
|
||||
| "auto-rebase + pull-request"
|
||||
| "auto-rebase+pull-request"
|
||||
| "auto_rebase_pull_request"
|
||||
| "auto-rebase-pull-request" => {
|
||||
return Ok(ConflictResolutionStrategy::AutoRebasePullRequest);
|
||||
}
|
||||
_ => writeln!(
|
||||
writer,
|
||||
"Enter 1, 2, 3, 4, fail, auto-rebase, pull-request, or auto-rebase + pull-request."
|
||||
)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn conflict_resolution_value(strategy: &ConflictResolutionStrategy) -> &'static str {
|
||||
match strategy {
|
||||
ConflictResolutionStrategy::Fail => "fail",
|
||||
ConflictResolutionStrategy::AutoRebase => "auto-rebase",
|
||||
ConflictResolutionStrategy::PullRequest => "pull-request",
|
||||
ConflictResolutionStrategy::AutoRebasePullRequest => "auto-rebase + pull-request",
|
||||
}
|
||||
}
|
||||
|
||||
fn write_sync_groups<W>(config: &Config, writer: &mut W) -> Result<()>
|
||||
where
|
||||
W: Write,
|
||||
|
||||
@@ -255,6 +255,120 @@ fn uninstall_webhook_deletes_matching_github_hook() {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_pull_request_posts_github_pull_when_missing() {
|
||||
let (api_url, handle) = request_server(
|
||||
vec![
|
||||
("200 OK", "[]"),
|
||||
(
|
||||
"201 Created",
|
||||
r#"{"number":7,"html_url":"https://github.example.test/pull/7"}"#,
|
||||
),
|
||||
],
|
||||
|index, request| match index {
|
||||
0 => assert!(
|
||||
request
|
||||
.starts_with("GET /repos/alice/repo/pulls?state=open&base=main&per_page=100 "),
|
||||
"request was {request}"
|
||||
),
|
||||
1 => {
|
||||
assert!(
|
||||
request.starts_with("POST /repos/alice/repo/pulls "),
|
||||
"request was {request}"
|
||||
);
|
||||
assert!(request.contains("Resolve conflict"));
|
||||
assert!(request.contains("git-sync/conflicts/main/from-b-abc123"));
|
||||
assert!(request.contains("main"));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let site = SiteConfig {
|
||||
api_url: Some(api_url),
|
||||
..site(ProviderKind::Github, None)
|
||||
};
|
||||
let client = ProviderClient::new(&site).unwrap();
|
||||
|
||||
let pr = client
|
||||
.open_pull_request(
|
||||
&EndpointConfig {
|
||||
site: "github".to_string(),
|
||||
kind: NamespaceKind::User,
|
||||
namespace: "alice".to_string(),
|
||||
},
|
||||
&RemoteRepo {
|
||||
name: "repo".to_string(),
|
||||
clone_url: "https://github.com/alice/repo.git".to_string(),
|
||||
private: true,
|
||||
description: None,
|
||||
},
|
||||
&PullRequestRequest {
|
||||
title: "Resolve conflict".to_string(),
|
||||
body: "Body".to_string(),
|
||||
head_branch: "git-sync/conflicts/main/from-b-abc123".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pr.url.unwrap(), "https://github.example.test/pull/7");
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_pull_requests_by_head_prefix_closes_matching_github_pulls() {
|
||||
let (api_url, handle) = request_server(
|
||||
vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"[{"number":7,"head":{"ref":"git-sync/conflicts/main/from-b-abc123"}},{"number":8,"head":{"ref":"feature"}}]"#,
|
||||
),
|
||||
("200 OK", r#"{"number":7}"#),
|
||||
],
|
||||
|index, request| match index {
|
||||
0 => assert!(
|
||||
request
|
||||
.starts_with("GET /repos/alice/repo/pulls?state=open&base=main&per_page=100 "),
|
||||
"request was {request}"
|
||||
),
|
||||
1 => {
|
||||
assert!(
|
||||
request.starts_with("PATCH /repos/alice/repo/pulls/7 "),
|
||||
"request was {request}"
|
||||
);
|
||||
assert!(request.contains("closed"));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let site = SiteConfig {
|
||||
api_url: Some(api_url),
|
||||
..site(ProviderKind::Github, None)
|
||||
};
|
||||
let client = ProviderClient::new(&site).unwrap();
|
||||
|
||||
let closed = client
|
||||
.close_pull_requests_by_head_prefix(
|
||||
&EndpointConfig {
|
||||
site: "github".to_string(),
|
||||
kind: NamespaceKind::User,
|
||||
namespace: "alice".to_string(),
|
||||
},
|
||||
&RemoteRepo {
|
||||
name: "repo".to_string(),
|
||||
clone_url: "https://github.com/alice/repo.git".to_string(),
|
||||
private: true,
|
||||
description: None,
|
||||
},
|
||||
"main",
|
||||
"git-sync/conflicts/main/",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(closed, 1);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
fn site(provider: ProviderKind, git_username: Option<String>) -> SiteConfig {
|
||||
SiteConfig {
|
||||
name: "site".to_string(),
|
||||
|
||||
@@ -146,6 +146,52 @@ fn branch_deletion_decisions_conflict_when_branch_changed_elsewhere() {
|
||||
assert_eq!(conflicts[0].changed_remotes, vec!["gitea".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_deletion_decisions_ignore_internal_conflict_branches() {
|
||||
let remotes = test_remotes();
|
||||
let conflict_branch = conflict_pr_branch("main", "gitea", "abc123");
|
||||
let mut previous = BTreeMap::new();
|
||||
previous.insert(
|
||||
"github".to_string(),
|
||||
remote_ref_state("a", &[(conflict_branch.as_str(), "111")]),
|
||||
);
|
||||
previous.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("b", &[(conflict_branch.as_str(), "111")]),
|
||||
);
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert("github".to_string(), remote_ref_state("c", &[]));
|
||||
current.insert(
|
||||
"gitea".to_string(),
|
||||
remote_ref_state("d", &[(conflict_branch.as_str(), "111")]),
|
||||
);
|
||||
|
||||
let (deletions, conflicts, blocked) =
|
||||
branch_deletion_decisions(&remotes, Some(&previous), ¤t);
|
||||
|
||||
assert!(deletions.is_empty());
|
||||
assert!(conflicts.is_empty());
|
||||
assert!(blocked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_branch_prefixes_are_reversible_not_slug_collisions() {
|
||||
let slash_branch = conflict_pr_branch_prefix("release/foo");
|
||||
let dash_branch = conflict_pr_branch_prefix("release-foo");
|
||||
|
||||
assert_ne!(slash_branch, dash_branch);
|
||||
assert!(slash_branch.starts_with(CONFLICT_BRANCH_ROOT));
|
||||
assert!(dash_branch.starts_with(CONFLICT_BRANCH_ROOT));
|
||||
assert_eq!(
|
||||
conflict_pr_base_branch(&format!("{slash_branch}from-gitea-abc123")),
|
||||
Some("release/foo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
conflict_pr_base_branch(&format!("{dash_branch}from-gitea-abc123")),
|
||||
Some("release-foo".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
fn remote_ref_state(hash: &str, branches: &[(&str, &str)]) -> RemoteRefState {
|
||||
RemoteRefState {
|
||||
hash: hash.to_string(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use crate::config::{
|
||||
EndpointConfig, MirrorConfig, NamespaceKind, SiteConfig, TokenConfig, Visibility,
|
||||
ConflictResolutionStrategy, EndpointConfig, MirrorConfig, NamespaceKind, SiteConfig,
|
||||
TokenConfig, Visibility,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -111,6 +112,7 @@ fn matches_jobs_by_provider_and_namespace() {
|
||||
create_missing: true,
|
||||
visibility: Visibility::Private,
|
||||
allow_force: false,
|
||||
conflict_resolution: ConflictResolutionStrategy::Fail,
|
||||
}],
|
||||
webhook: None,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user