feat(git): add opt-in for unrelated histories during pull
publish / Build and publish Windows installer (release) Successful in 10m2s
publish / Build and publish Ubuntu AppImage (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 22m1s

Allow pulling repositories with unrelated commit histories when the user
explicitly opts in. The pull argument construction was refactored and
branch resolution made more robust so the backend can include the
--allow-unrelated-histories flag when requested.

- Extract pull argument logic and add support for allowing unrelated histories.
- Prompt users in the UI to confirm merging separate histories and retry pull.
- Restyle and improve the update toast UI for better layout and responsiveness.
This commit is contained in:
2026-08-30 20:25:15 +02:00
parent 4db6f30461
commit c3762ae7a3
5 changed files with 357 additions and 162 deletions
+150 -21
View File
@@ -2593,32 +2593,23 @@ pub async fn pull(
strategy: Option<String>,
remote: Option<String>,
branch: Option<String>,
allow_unrelated_histories: Option<bool>,
) -> Result<GitStatus, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
let strategy = strategy.as_deref().unwrap_or("merge");
let remote = remote
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")])
}
"rebase" => pull_args.push(OsString::from("--rebase")),
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote_name) = remote.as_deref() {
validate_remote_name(&repo, remote_name, true)?;
pull_args.push(remote_name.into());
if let Some(branch) = branch
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
{
pull_args.push(branch.into());
}
}
let branch = branch
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let pull_args = pull_args_for_repo(
&repo,
strategy.as_deref().unwrap_or("merge"),
remote.as_deref(),
branch.as_deref(),
allow_unrelated_histories.unwrap_or(false),
)?;
let output = match (username.as_deref(), password.as_deref()) {
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
run_git_authenticated_output(&repo, pull_args.clone(), u, p)?
@@ -2657,6 +2648,39 @@ pub async fn pull(
.map_err(|err| format!("Could not pull: {err}"))?
}
fn pull_args_for_repo(
repo: &Path,
strategy: &str,
remote: Option<&str>,
branch: Option<&str>,
allow_unrelated_histories: bool,
) -> Result<Vec<OsString>, String> {
let mut pull_args = vec![OsString::from("pull")];
match strategy {
"merge" => {
pull_args.extend([OsString::from("--no-rebase"), OsString::from("--no-edit")]);
if allow_unrelated_histories {
pull_args.push(OsString::from("--allow-unrelated-histories"));
}
}
"rebase" => pull_args.push(OsString::from("--rebase")),
"ff-only" => pull_args.push(OsString::from("--ff-only")),
_ => return Err("Unknown pull strategy.".to_string()),
}
if let Some(remote_name) = remote.map(str::trim).filter(|value| !value.is_empty()) {
validate_remote_name(repo, remote_name, true)?;
pull_args.push(remote_name.into());
let branch = branch
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.map(Ok)
.unwrap_or_else(|| current_branch_name(repo))?;
pull_args.push(branch.into());
}
Ok(pull_args)
}
#[tauri::command]
pub async fn fetch(
path: String,
@@ -2949,7 +2973,9 @@ fn first_remote_name(repo: &Path) -> Option<String> {
}
fn current_branch_name(repo: &Path) -> Result<String, String> {
let branch = run_git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])?;
// `symbolic-ref` also works before the first commit, while
// `rev-parse --abbrev-ref HEAD` fails for an unborn HEAD.
let branch = run_git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"])?;
let branch = String::from_utf8_lossy(&branch).trim().to_string();
if branch.is_empty() || branch == "HEAD" {
return Err("Could not determine current branch.".to_string());
@@ -8515,6 +8541,108 @@ mod tests {
);
}
#[test]
fn pull_with_selected_remote_infers_current_branch() {
let repo = init_temp_repo("pull_selected_remote");
let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, false)
.expect("pull arguments should be created");
assert_eq!(
args,
vec![
OsString::from("pull"),
OsString::from("--no-rebase"),
OsString::from("--no-edit"),
OsString::from("origin"),
OsString::from(branch),
]
);
}
#[test]
fn pull_can_explicitly_allow_unrelated_histories_for_merge() {
let repo = init_temp_repo("pull_unrelated_histories");
let branch = git_output_test(&repo.path, ["symbolic-ref", "--short", "HEAD"]);
run_git_test(&repo.path, ["remote", "add", "origin", "."]);
let args = pull_args_for_repo(&repo.path, "merge", Some("origin"), None, true)
.expect("pull arguments should be created");
assert_eq!(
args,
vec![
OsString::from("pull"),
OsString::from("--no-rebase"),
OsString::from("--no-edit"),
OsString::from("--allow-unrelated-histories"),
OsString::from("origin"),
OsString::from(branch),
]
);
}
#[tokio::test]
#[cfg_attr(
windows,
ignore = "Git for Windows can fail local pull tests with a sh signal pipe error"
)]
async fn pull_retries_unrelated_histories_only_after_explicit_opt_in() {
let remote = init_temp_repo("pull_unrelated_remote");
fs::write(remote.path.join("remote.txt"), "remote history\n")
.expect("remote file should be written");
run_git_test(&remote.path, ["add", "remote.txt"]);
run_git_test(&remote.path, ["commit", "-q", "-m", "remote init"]);
let remote_branch = git_output_test(&remote.path, ["branch", "--show-current"]);
let local = init_temp_repo("pull_unrelated_local");
fs::write(local.path.join("local.txt"), "local history\n")
.expect("local file should be written");
run_git_test(&local.path, ["add", "local.txt"]);
run_git_test(&local.path, ["commit", "-q", "-m", "local init"]);
run_git_test(
&local.path,
["remote", "add", "origin", remote.path.to_str().unwrap()],
);
let error = pull(
local.path.to_string_lossy().to_string(),
None,
None,
Some("merge".to_string()),
Some("origin".to_string()),
Some(remote_branch.clone()),
Some(false),
)
.await
.expect_err("unrelated histories should require explicit opt-in");
assert!(error.contains("refusing to merge unrelated histories"));
let status = pull(
local.path.to_string_lossy().to_string(),
None,
None,
Some("merge".to_string()),
Some("origin".to_string()),
Some(remote_branch),
Some(true),
)
.await
.expect("explicitly allowed histories should merge");
assert!(status.clean, "{:?}", status.files);
assert!(local.path.join("local.txt").exists());
assert!(local.path.join("remote.txt").exists());
let parent_count =
git_output_test(&local.path, ["rev-list", "--parents", "-n", "1", "HEAD"])
.split_whitespace()
.count()
- 1;
assert_eq!(parent_count, 2);
}
#[tokio::test]
#[cfg_attr(
windows,
@@ -8557,6 +8685,7 @@ mod tests {
None,
None,
None,
None,
)
.await
.unwrap();