4048 lines
139 KiB
Rust
4048 lines
139 KiB
Rust
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Output, Stdio};
|
|
#[cfg(windows)]
|
|
use std::sync::OnceLock;
|
|
|
|
#[cfg(windows)]
|
|
const NULL_DEVICE: &str = "NUL";
|
|
#[cfg(not(windows))]
|
|
const NULL_DEVICE: &str = "/dev/null";
|
|
|
|
fn apply_isolated_git_config_env(cmd: &mut Command) {
|
|
// Keep integration tests deterministic by ignoring host git config.
|
|
cmd.env("GIT_CONFIG_NOSYSTEM", "1");
|
|
cmd.env("GIT_CONFIG_GLOBAL", NULL_DEVICE);
|
|
// Force deterministic git output for string assertions in tests.
|
|
cmd.env("LC_ALL", "C");
|
|
cmd.env("LANG", "C");
|
|
// Submodule scenarios in this suite clone from local file:// URLs.
|
|
cmd.env("GIT_ALLOW_PROTOCOL", "file");
|
|
}
|
|
#[cfg(windows)]
|
|
fn is_git_shell_startup_failure(text: &str) -> bool {
|
|
text.contains("sh.exe: *** fatal error -")
|
|
&& (text.contains("couldn't create signal pipe") || text.contains("CreateFileMapping"))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn git_shell_available_for_tooling() -> bool {
|
|
static AVAILABLE: OnceLock<bool> = OnceLock::new();
|
|
*AVAILABLE.get_or_init(|| {
|
|
let mut cmd = Command::new("git");
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
let output = match cmd.args(["mergetool", "--tool-help"]).output() {
|
|
Ok(output) => output,
|
|
Err(_) => return true,
|
|
};
|
|
if output.status.success() {
|
|
return true;
|
|
}
|
|
let text = format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
!is_git_shell_startup_failure(&text)
|
|
})
|
|
}
|
|
|
|
fn require_git_shell_for_tool_tests() -> bool {
|
|
#[cfg(windows)]
|
|
{
|
|
if !git_shell_available_for_tooling() {
|
|
eprintln!(
|
|
"skipping Git mergetool integration tests: Git-for-Windows shell startup failed in this environment"
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn gitcomet_bin() -> PathBuf {
|
|
for env_key in ["CARGO_BIN_EXE_gitcomet"] {
|
|
if let Some(path) = std::env::var_os(env_key).map(PathBuf::from) {
|
|
if path.is_file() {
|
|
return path;
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(path) = gitcomet_bin_from_current_exe() {
|
|
return path;
|
|
}
|
|
|
|
panic!(
|
|
"gitcomet binary path was not found. Tried CARGO_BIN_EXE_gitcomet and a fallback relative to current test executable"
|
|
);
|
|
}
|
|
|
|
fn gitcomet_bin_from_current_exe() -> Option<PathBuf> {
|
|
let test_exe = std::env::current_exe().ok()?;
|
|
let deps_dir = test_exe.parent()?;
|
|
let profile_dir = deps_dir.parent()?;
|
|
let exe_suffix = std::env::consts::EXE_SUFFIX;
|
|
|
|
for bin_name in ["gitcomet"] {
|
|
let candidate = profile_dir.join(format!("{bin_name}{exe_suffix}"));
|
|
if candidate.is_file() {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn shell_quote(value: &str) -> String {
|
|
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
|
}
|
|
|
|
fn is_effectively_absolute_path(value: &str) -> bool {
|
|
if Path::new(value).is_absolute() {
|
|
return true;
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
// Git-for-Windows may surface temp stage paths in POSIX form such as
|
|
// `/tmp/...` or `/c/...`; treat these as absolute for parity checks.
|
|
value.starts_with('/')
|
|
}
|
|
#[cfg(not(windows))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
fn run_git(repo: &Path, args: &[&str]) {
|
|
let mut cmd = Command::new("git");
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
let output = cmd
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.output()
|
|
.expect("git command to run");
|
|
assert!(
|
|
output.status.success(),
|
|
"git {:?} failed\nstdout:\n{}\nstderr:\n{}",
|
|
args,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn run_git_capture(repo: &Path, args: &[&str]) -> Output {
|
|
let mut cmd = Command::new("git");
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
cmd.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.output()
|
|
.expect("git command to run")
|
|
}
|
|
|
|
fn run_git_capture_with_env(repo: &Path, args: &[&str], env_vars: &[(&str, &str)]) -> Output {
|
|
let mut cmd = Command::new("git");
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
cmd.arg("-C").arg(repo).args(args);
|
|
for (key, value) in env_vars {
|
|
cmd.env(key, value);
|
|
}
|
|
cmd.output().expect("git command to run")
|
|
}
|
|
|
|
fn run_git_capture_in(cwd: &Path, args: &[&str]) -> Output {
|
|
let mut cmd = Command::new("git");
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
cmd.current_dir(cwd)
|
|
.args(args)
|
|
.output()
|
|
.expect("git command to run")
|
|
}
|
|
|
|
fn run_git_expect_failure(repo: &Path, args: &[&str]) -> Output {
|
|
let output = run_git_capture(repo, args);
|
|
assert!(
|
|
!output.status.success(),
|
|
"git {:?} unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}",
|
|
args,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
output
|
|
}
|
|
|
|
fn run_git_capture_with_display(repo: &Path, args: &[&str], display: Option<&str>) -> Output {
|
|
let mut cmd = Command::new("git");
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
cmd.arg("-C").arg(repo).args(args);
|
|
if let Some(display) = display {
|
|
cmd.env("DISPLAY", display);
|
|
} else {
|
|
cmd.env_remove("DISPLAY");
|
|
}
|
|
cmd.output().expect("git command to run")
|
|
}
|
|
|
|
fn run_git_with_stdin(repo: &Path, args: &[&str], stdin_text: &str) -> Output {
|
|
let mut cmd = Command::new("git");
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
cmd.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
let mut child = cmd.spawn().expect("git command to spawn");
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
let _ = stdin.write_all(stdin_text.as_bytes());
|
|
}
|
|
child.wait_with_output().expect("git command to complete")
|
|
}
|
|
|
|
fn write_file(repo: &Path, rel: &str, contents: &str) {
|
|
let path = repo.join(rel);
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).expect("create parent directories");
|
|
}
|
|
fs::write(path, contents).expect("write file");
|
|
}
|
|
|
|
fn init_repo(repo: &Path) {
|
|
run_git(repo, &["init", "-b", "main"]);
|
|
run_git(repo, &["config", "user.email", "you@example.com"]);
|
|
run_git(repo, &["config", "user.name", "You"]);
|
|
run_git(repo, &["config", "commit.gpgsign", "false"]);
|
|
}
|
|
|
|
fn commit_all(repo: &Path, message: &str) {
|
|
run_git(repo, &["add", "-A"]);
|
|
run_git(
|
|
repo,
|
|
&["-c", "commit.gpgsign=false", "commit", "-m", message],
|
|
);
|
|
}
|
|
|
|
fn configure_gitcomet_mergetool(repo: &Path) {
|
|
let bin = gitcomet_bin();
|
|
let bin_q = shell_quote(&bin.to_string_lossy());
|
|
let cmd = format!(
|
|
"{bin_q} mergetool --base \"$BASE\" --local \"$LOCAL\" --remote \"$REMOTE\" --merged \"$MERGED\""
|
|
);
|
|
|
|
run_git(repo, &["config", "merge.tool", "gitcomet"]);
|
|
run_git(repo, &["config", "mergetool.gitcomet.cmd", &cmd]);
|
|
run_git(
|
|
repo,
|
|
&["config", "mergetool.gitcomet.trustExitCode", "true"],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
// Disable backup file creation for cleaner assertions.
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn configure_gitcomet_mergetool_with_alias_flags(repo: &Path) {
|
|
let bin = gitcomet_bin();
|
|
let bin_q = shell_quote(&bin.to_string_lossy());
|
|
let cmd = format!(
|
|
"{bin_q} mergetool -o \"$MERGED\" --base \"$BASE\" --local \"$LOCAL\" --remote \"$REMOTE\" --L1 \"BASE_ALIAS\" --L2 \"LOCAL_ALIAS\" --L3 \"REMOTE_ALIAS\""
|
|
);
|
|
|
|
run_git(repo, &["config", "merge.tool", "gitcomet"]);
|
|
run_git(repo, &["config", "mergetool.gitcomet.cmd", &cmd]);
|
|
run_git(
|
|
repo,
|
|
&["config", "mergetool.gitcomet.trustExitCode", "true"],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn configure_kdiff3_path_override_to_gitcomet(repo: &Path, trust_exit_code: bool) {
|
|
let bin = gitcomet_bin();
|
|
let bin_path = bin.to_string_lossy().to_string();
|
|
|
|
run_git(repo, &["config", "merge.tool", "kdiff3"]);
|
|
run_git(repo, &["config", "mergetool.kdiff3.path", &bin_path]);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"config",
|
|
"mergetool.kdiff3.trustExitCode",
|
|
if trust_exit_code { "true" } else { "false" },
|
|
],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn configure_meld_path_override_to_gitcomet(repo: &Path, trust_exit_code: bool) {
|
|
let bin = gitcomet_bin();
|
|
let bin_path = bin.to_string_lossy().to_string();
|
|
|
|
run_git(repo, &["config", "merge.tool", "meld"]);
|
|
run_git(repo, &["config", "mergetool.meld.path", &bin_path]);
|
|
run_git(repo, &["config", "mergetool.meld.hasOutput", "true"]);
|
|
run_git(repo, &["config", "mergetool.meld.useAutoMerge", "true"]);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"config",
|
|
"mergetool.meld.trustExitCode",
|
|
if trust_exit_code { "true" } else { "false" },
|
|
],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
/// Create a mergetool command that echoes a marker to stderr and resolves
|
|
/// the conflict by copying $REMOTE to $MERGED. This simulates a successful
|
|
/// merge tool and allows tests to detect which tool was selected by checking
|
|
/// for the marker in the combined output.
|
|
fn mergetool_marker_cmd(marker: &str) -> String {
|
|
format!("echo TOOL={marker} >&2; cat \"$REMOTE\" > \"$MERGED\"")
|
|
}
|
|
|
|
fn configure_mergetool_command(repo: &Path, tool: &str, cmd: &str) {
|
|
let cmd_key = format!("mergetool.{tool}.cmd");
|
|
run_git(repo, &["config", &cmd_key, cmd]);
|
|
}
|
|
|
|
fn configure_mergetool_trust_exit_code(repo: &Path, tool: &str, trust: bool) {
|
|
let key = format!("mergetool.{tool}.trustExitCode");
|
|
run_git(
|
|
repo,
|
|
&["config", &key, if trust { "true" } else { "false" }],
|
|
);
|
|
}
|
|
|
|
fn configure_mergetool_selection(
|
|
repo: &Path,
|
|
merge_tool: &str,
|
|
merge_guitool: Option<&str>,
|
|
gui_default: Option<&str>,
|
|
) {
|
|
run_git(repo, &["config", "merge.tool", merge_tool]);
|
|
if let Some(gui_tool) = merge_guitool {
|
|
run_git(repo, &["config", "merge.guitool", gui_tool]);
|
|
}
|
|
if let Some(gui_default) = gui_default {
|
|
run_git(repo, &["config", "mergetool.guiDefault", gui_default]);
|
|
}
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn configure_recording_mergetool(repo: &Path, tool: &str, log_path: &Path) {
|
|
let log_q = shell_quote(&log_path.to_string_lossy());
|
|
let cmd = format!("printf '%s\\n' \"$MERGED\" >> {log_q}; cat \"$REMOTE\" > \"$MERGED\"");
|
|
run_git(repo, &["config", "merge.tool", tool]);
|
|
run_git(repo, &["config", &format!("mergetool.{tool}.cmd"), &cmd]);
|
|
run_git(
|
|
repo,
|
|
&["config", &format!("mergetool.{tool}.trustExitCode"), "true"],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn configure_stage_path_recording_mergetool(repo: &Path, tool: &str) {
|
|
let cmd = "printf '%s\\n%s\\n%s\\n' \"$BASE\" \"$LOCAL\" \"$REMOTE\" > \"$MERGED.env\"; cat \"$REMOTE\" > \"$MERGED\"";
|
|
run_git(repo, &["config", "merge.tool", tool]);
|
|
run_git(repo, &["config", &format!("mergetool.{tool}.cmd"), cmd]);
|
|
run_git(
|
|
repo,
|
|
&["config", &format!("mergetool.{tool}.trustExitCode"), "true"],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn configure_stage_metadata_recording_mergetool(repo: &Path, tool: &str) {
|
|
let cmd = "base_size=MISSING; if [ -n \"$BASE\" ] && [ -f \"$BASE\" ]; then base_size=$(wc -c < \"$BASE\"); fi; printf '%s\\n%s\\n%s\\nBASE_SIZE=%s\\n' \"$BASE\" \"$LOCAL\" \"$REMOTE\" \"$base_size\" > \"$MERGED.env\"; cat \"$REMOTE\" > \"$MERGED\"";
|
|
run_git(repo, &["config", "merge.tool", tool]);
|
|
run_git(repo, &["config", &format!("mergetool.{tool}.cmd"), cmd]);
|
|
run_git(
|
|
repo,
|
|
&["config", &format!("mergetool.{tool}.trustExitCode"), "true"],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn setup_order_file_conflict(repo: &Path) {
|
|
init_repo(repo);
|
|
write_file(repo, "a", "start\n");
|
|
write_file(repo, "b", "start\n");
|
|
commit_all(repo, "start");
|
|
|
|
run_git(repo, &["checkout", "-b", "side1"]);
|
|
write_file(repo, "a", "side1\n");
|
|
write_file(repo, "b", "side1\n");
|
|
commit_all(repo, "side1 changes");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
run_git(repo, &["checkout", "-b", "side2"]);
|
|
write_file(repo, "a", "side2\n");
|
|
write_file(repo, "b", "side2\n");
|
|
commit_all(repo, "side2 changes");
|
|
|
|
run_git_expect_failure(repo, &["merge", "side1"]);
|
|
}
|
|
|
|
/// Create the t7610-style rename/rename setup that presents as a delete/delete
|
|
/// conflict at `a/a/file.txt` when merging `move-to-b` into `move-to-c`.
|
|
///
|
|
/// Returns `true` when a merge conflict is present and mergetool scenarios
|
|
/// should run. Some git versions may auto-resolve; callers can skip on `false`.
|
|
fn setup_delete_delete_rename_conflict(repo: &Path) -> bool {
|
|
init_repo(repo);
|
|
|
|
// Base file at a/a/file.txt
|
|
fs::create_dir_all(repo.join("a/a")).unwrap();
|
|
write_file(repo, "a/a/file.txt", "one\ntwo\n3\n4\n");
|
|
commit_all(repo, "base file");
|
|
|
|
// move-to-b branch: rename + edit.
|
|
run_git(repo, &["checkout", "-b", "move-to-b"]);
|
|
fs::create_dir_all(repo.join("b/b")).unwrap();
|
|
run_git(repo, &["mv", "a/a/file.txt", "b/b/file.txt"]);
|
|
write_file(repo, "b/b/file.txt", "one\ntwo\n4\n");
|
|
commit_all(repo, "move to b");
|
|
|
|
// move-to-c branch: rename + edit.
|
|
run_git(repo, &["checkout", "main"]);
|
|
run_git(repo, &["checkout", "-b", "move-to-c"]);
|
|
fs::create_dir_all(repo.join("c/c")).unwrap();
|
|
run_git(repo, &["mv", "a/a/file.txt", "c/c/file.txt"]);
|
|
write_file(repo, "c/c/file.txt", "one\ntwo\n3\n");
|
|
commit_all(repo, "move to c");
|
|
|
|
let merge_output = run_git_capture(repo, &["merge", "move-to-b"]);
|
|
!merge_output.status.success()
|
|
}
|
|
|
|
fn read_recorded_merge_order(log_path: &Path) -> Vec<String> {
|
|
let raw = fs::read_to_string(log_path).expect("read merge-order log");
|
|
raw.lines()
|
|
.map(|line| {
|
|
let normalized = line.strip_prefix("./").unwrap_or(line);
|
|
Path::new(normalized)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(normalized)
|
|
.to_string()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn output_text(output: &Output) -> String {
|
|
format!(
|
|
"{}{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
)
|
|
}
|
|
|
|
fn read_recorded_argv(log_path: &Path) -> Vec<String> {
|
|
let raw = fs::read_to_string(log_path)
|
|
.unwrap_or_else(|e| panic!("failed to read argv dump {}: {e}", log_path.display()));
|
|
raw.lines().map(ToOwned::to_owned).collect()
|
|
}
|
|
|
|
fn contains_kdiff3_label_form(args: &[String], long_flag: &str, short_flag: &str) -> bool {
|
|
let long_with_equals = format!("{long_flag}=");
|
|
let short_with_equals = format!("{short_flag}=");
|
|
args.iter().any(|arg| {
|
|
arg == long_flag
|
|
|| arg == short_flag
|
|
|| arg.starts_with(&long_with_equals)
|
|
|| arg.starts_with(&short_with_equals)
|
|
|| (arg.starts_with(short_flag) && arg.len() > short_flag.len())
|
|
})
|
|
}
|
|
|
|
fn read_recorded_stage_paths(repo: &Path, merged_path: &str) -> Vec<String> {
|
|
let dump_path = repo.join(format!("{merged_path}.env"));
|
|
let raw = fs::read_to_string(&dump_path).unwrap_or_else(|e| {
|
|
panic!(
|
|
"failed to read stage-path dump {}: {e}",
|
|
dump_path.display()
|
|
)
|
|
});
|
|
raw.lines().map(ToOwned::to_owned).collect()
|
|
}
|
|
|
|
fn has_unmerged_entries_for_path(repo: &Path, path: &str) -> bool {
|
|
let output = run_git_capture(repo, &["ls-files", "-u", "--", path]);
|
|
!output.stdout.is_empty()
|
|
}
|
|
|
|
fn stage_zero_gitlink_oid(repo: &Path, path: &str) -> Option<String> {
|
|
let output = run_git_capture(repo, &["ls-files", "--stage", "--", path]);
|
|
assert!(
|
|
output.status.success(),
|
|
"git ls-files --stage failed for {path}\nstdout:\n{}\nstderr:\n{}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
for line in text.lines() {
|
|
let mut fields = line.split_whitespace();
|
|
let Some(mode) = fields.next() else { continue };
|
|
let Some(oid) = fields.next() else { continue };
|
|
let Some(stage) = fields.next() else { continue };
|
|
let Some(entry_path) = fields.next() else {
|
|
continue;
|
|
};
|
|
if mode == "160000" && stage == "0" && entry_path == path {
|
|
return Some(oid.to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Create a repo with a genuine merge conflict (overlapping changes).
|
|
fn setup_overlapping_conflict(repo: &Path) {
|
|
setup_overlapping_conflict_at_path(repo, "file.txt");
|
|
}
|
|
|
|
/// Create a repo with a genuine merge conflict (overlapping changes) at a
|
|
/// caller-provided path.
|
|
fn setup_overlapping_conflict_at_path(repo: &Path, path: &str) {
|
|
init_repo(repo);
|
|
write_file(repo, path, "aaa\nbbb\nccc\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, path, "aaa\nREMOTE\nccc\n");
|
|
commit_all(repo, "feature: change line 2");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, path, "aaa\nLOCAL\nccc\n");
|
|
commit_all(repo, "main: change line 2");
|
|
|
|
// Merge will fail with a conflict.
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected merge to fail with conflict"
|
|
);
|
|
}
|
|
|
|
/// Create a repo with a whitespace-only overlapping conflict that requires
|
|
/// running mergetool, but can be auto-resolved by gitcomet with `--auto`.
|
|
fn setup_whitespace_only_conflict(repo: &Path) {
|
|
setup_whitespace_only_conflict_at_path(repo, "file.txt");
|
|
}
|
|
|
|
/// Create a whitespace-only overlapping conflict at a caller-provided path.
|
|
fn setup_whitespace_only_conflict_at_path(repo: &Path, path: &str) {
|
|
init_repo(repo);
|
|
write_file(repo, path, "aaa\nbbb\nccc\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
// Remote adds trailing tab to line 2.
|
|
write_file(repo, path, "aaa\nbbb\t\nccc\n");
|
|
commit_all(repo, "feature: add tab to line 2");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
// Local adds trailing spaces to line 2.
|
|
write_file(repo, path, "aaa\nbbb \nccc\n");
|
|
commit_all(repo, "main: add spaces to line 2");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected merge to fail with whitespace-only conflict"
|
|
);
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_resolves_overlapping_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
// Run git mergetool. Our tool will detect the conflict and write
|
|
// markers to MERGED, exiting 1.
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// The tool should have run (even if exit code is non-zero due to conflicts).
|
|
// Check that the MERGED file was written by our tool.
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
|
|
// Our mergetool reads the actual BASE/LOCAL/REMOTE stage files and
|
|
// performs its own 3-way merge. For this overlapping conflict,
|
|
// it should write conflict markers.
|
|
assert!(
|
|
merged.contains("<<<<<<<") || merged.contains("LOCAL") || merged.contains("REMOTE"),
|
|
"expected mergetool to have processed file.txt\nmerged:\n{merged}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_custom_cmd_copies_remote_to_merged() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
|
|
let tool = "customcopy";
|
|
configure_mergetool_selection(repo, tool, None, None);
|
|
configure_mergetool_command(repo, tool, "cat \"$REMOTE\" > \"$MERGED\"");
|
|
configure_mergetool_trust_exit_code(repo, tool, true);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected custom mergetool command to resolve conflict\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
assert_eq!(
|
|
merged, "aaa\nREMOTE\nccc\n",
|
|
"expected custom command to copy REMOTE into MERGED\n{text}"
|
|
);
|
|
|
|
let unmerged = run_git_capture(repo, &["ls-files", "-u"]);
|
|
assert!(
|
|
unmerged.stdout.is_empty(),
|
|
"expected no unmerged index entries after custom command\n{}",
|
|
output_text(&unmerged)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_accepts_kdiff3_alias_flags_in_cmd() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_gitcomet_mergetool_with_alias_flags(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
|
|
assert!(
|
|
!text.contains("unexpected argument '-o'")
|
|
&& !text.contains("unexpected argument '--L1'")
|
|
&& !text.contains("unexpected argument '--L2'")
|
|
&& !text.contains("unexpected argument '--L3'"),
|
|
"expected alias flags to be accepted by gitcomet mergetool\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("Auto-merging file.txt"),
|
|
"expected gitcomet mergetool to run\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
merged.contains("LOCAL") || merged.contains("REMOTE") || merged.contains("<<<<<<<"),
|
|
"expected merged file to be processed\nmerged:\n{merged}\noutput:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_kdiff3_path_override_invokes_compat_mode() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
// This test targets compatibility argument parsing, not Git's handling of
|
|
// non-zero exits when trustExitCode=false (which varies by Git version).
|
|
setup_whitespace_only_conflict(repo);
|
|
configure_kdiff3_path_override_to_gitcomet(repo, true);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected kdiff3 path-override invocation to succeed with gitcomet compatibility parsing\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("unexpected argument '--auto'")
|
|
&& !text.contains("unexpected argument '--L1'")
|
|
&& !text.contains("unexpected argument '--L2'")
|
|
&& !text.contains("unexpected argument '--L3'")
|
|
&& !text.contains("unexpected argument '-o'"),
|
|
"expected kdiff3 compatibility flags to be accepted\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
assert!(
|
|
!merged.contains("<<<<<<<"),
|
|
"expected whitespace-only conflict to be auto-resolved\nmerged:\n{merged}\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
!has_unmerged_entries_for_path(repo, "file.txt"),
|
|
"expected no unmerged entries after compat auto-merge\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_kdiff3_path_override_records_real_argv_shape() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_whitespace_only_conflict(repo);
|
|
configure_kdiff3_path_override_to_gitcomet(repo, true);
|
|
|
|
let repo_tag = repo
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| "repo".to_string());
|
|
let argv_log_name = format!("kdiff3-argv-{}-{repo_tag}.log", std::process::id());
|
|
let argv_log = std::env::temp_dir()
|
|
.join("gitcomet-compat-argv")
|
|
.join(&argv_log_name);
|
|
let _ = fs::remove_file(&argv_log);
|
|
let output = run_git_capture_with_env(
|
|
repo,
|
|
&["mergetool", "--no-prompt"],
|
|
&[("GITCOMET_COMPAT_ARGV_LOG", argv_log_name.as_str())],
|
|
);
|
|
let text = output_text(&output);
|
|
// Git's post-tool resolution behavior can vary by version. This check is
|
|
// about argv shape capture, not final mergetool resolution status.
|
|
|
|
let args = read_recorded_argv(&argv_log);
|
|
assert!(
|
|
!args.is_empty(),
|
|
"expected argv recorder to capture kdiff3 invocation args\n{text}"
|
|
);
|
|
let has_output_flag = args.iter().any(|arg| {
|
|
arg == "-o"
|
|
|| arg == "--output"
|
|
|| arg == "--out"
|
|
|| arg.starts_with("--output=")
|
|
|| arg.starts_with("--out=")
|
|
|| (arg.starts_with("-o") && arg.len() > 2)
|
|
});
|
|
assert!(
|
|
has_output_flag,
|
|
"expected kdiff3 invocation to include an output flag; got args: {args:?}"
|
|
);
|
|
assert!(
|
|
contains_kdiff3_label_form(&args, "--L1", "-L1"),
|
|
"expected kdiff3 invocation to include parser-supported L1 label form; got args: {args:?}"
|
|
);
|
|
assert!(
|
|
contains_kdiff3_label_form(&args, "--L2", "-L2"),
|
|
"expected kdiff3 invocation to include parser-supported L2 label form; got args: {args:?}"
|
|
);
|
|
assert!(
|
|
contains_kdiff3_label_form(&args, "--L3", "-L3"),
|
|
"expected kdiff3 invocation to include parser-supported L3 label form; got args: {args:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_meld_path_override_invokes_compat_mode() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
// This test validates compatibility argv parsing in a version-agnostic
|
|
// flow where the tool reports success.
|
|
setup_whitespace_only_conflict(repo);
|
|
configure_meld_path_override_to_gitcomet(repo, true);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected meld path-override invocation to succeed with gitcomet compatibility parsing\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("unexpected argument '--auto-merge'")
|
|
&& !text.contains("unexpected argument '--output'"),
|
|
"expected meld compatibility flags to be accepted\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
assert!(
|
|
!merged.contains("<<<<<<<"),
|
|
"expected whitespace-only conflict to be auto-resolved\nmerged:\n{merged}\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
!has_unmerged_entries_for_path(repo, "file.txt"),
|
|
"expected no unmerged entries after compat auto-merge\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_kdiff3_path_override_handles_spaced_unicode_path() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
let compat_path = "docs/spaced \u{65e5}\u{672c}\u{8a9e} file.txt";
|
|
setup_whitespace_only_conflict_at_path(repo, compat_path);
|
|
configure_kdiff3_path_override_to_gitcomet(repo, true);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--", compat_path]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected kdiff3 path-override mergetool to handle spaced/unicode path\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("Invalid external"),
|
|
"compat parser rejected spaced/unicode path\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("unexpected argument '--auto'")
|
|
&& !text.contains("unexpected argument '--L1'")
|
|
&& !text.contains("unexpected argument '--L2'")
|
|
&& !text.contains("unexpected argument '--L3'")
|
|
&& !text.contains("unexpected argument '-o'"),
|
|
"expected kdiff3 compatibility flags to be accepted for spaced/unicode path\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join(compat_path)).unwrap();
|
|
assert!(
|
|
!merged.contains("<<<<<<<"),
|
|
"expected spaced/unicode path conflict to be auto-resolved\nmerged:\n{merged}\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
!has_unmerged_entries_for_path(repo, compat_path),
|
|
"expected no unmerged entries for spaced/unicode path\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_meld_path_override_handles_spaced_unicode_path() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
let compat_path = "docs/spaced \u{65e5}\u{672c}\u{8a9e} file.txt";
|
|
setup_whitespace_only_conflict_at_path(repo, compat_path);
|
|
configure_meld_path_override_to_gitcomet(repo, true);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--", compat_path]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected meld path-override mergetool to handle spaced/unicode path\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("unexpected argument '--auto-merge'")
|
|
&& !text.contains("unexpected argument '--output'"),
|
|
"expected meld compatibility flags to be accepted for spaced/unicode path\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("Invalid external"),
|
|
"compat parser rejected spaced/unicode path\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join(compat_path)).unwrap();
|
|
assert!(
|
|
!merged.contains("<<<<<<<"),
|
|
"expected spaced/unicode path conflict to be auto-resolved\nmerged:\n{merged}\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
!has_unmerged_entries_for_path(repo, compat_path),
|
|
"expected no unmerged entries for spaced/unicode path\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_with_trust_exit_code_marks_clean_merge_resolved() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Our mergetool with --auto resolves whitespace-only conflicts cleanly
|
|
// (exit 0). With trustExitCode=true, git accepts the result and removes
|
|
// the file from the unmerged index.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_whitespace_only_conflict_at_path(repo, "ws.txt");
|
|
|
|
// Configure mergetool with --auto so whitespace-only conflicts are
|
|
// resolved automatically by our tool's heuristics.
|
|
let bin = gitcomet_bin();
|
|
let bin_q = shell_quote(&bin.to_string_lossy());
|
|
let cmd = format!(
|
|
"{bin_q} mergetool --auto --base \"$BASE\" --local \"$LOCAL\" --remote \"$REMOTE\" --merged \"$MERGED\""
|
|
);
|
|
run_git(repo, &["config", "merge.tool", "gitcomet"]);
|
|
run_git(repo, &["config", "mergetool.gitcomet.cmd", &cmd]);
|
|
run_git(
|
|
repo,
|
|
&["config", "mergetool.gitcomet.trustExitCode", "true"],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// git mergetool should succeed because our tool auto-resolved the
|
|
// whitespace conflict and exited 0, and trustExitCode=true accepts that.
|
|
assert!(
|
|
output.status.success(),
|
|
"expected git mergetool to exit 0 when tool reports clean merge\n{text}"
|
|
);
|
|
|
|
// The merged file should contain clean content with no conflict markers.
|
|
let merged = fs::read_to_string(repo.join("ws.txt")).unwrap();
|
|
assert!(
|
|
!merged.contains("<<<<<<<"),
|
|
"expected no conflict markers in auto-resolved output\n{merged}"
|
|
);
|
|
assert!(
|
|
merged.contains("aaa") && merged.contains("ccc"),
|
|
"expected surrounding context preserved\n{merged}"
|
|
);
|
|
|
|
// The file should no longer be in the unmerged state in the index.
|
|
let unmerged = run_git_capture(repo, &["ls-files", "-u", "--", "ws.txt"]);
|
|
let unmerged_text = String::from_utf8_lossy(&unmerged.stdout);
|
|
assert!(
|
|
unmerged_text.is_empty(),
|
|
"expected file to be resolved (no unmerged index entries)\n{unmerged_text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_handles_path_with_spaces() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "docs/spaced name.txt", "original\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "docs/spaced name.txt", "remote change\n");
|
|
commit_all(repo, "feature: change spaced file");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "docs/spaced name.txt", "local change\n");
|
|
commit_all(repo, "main: change spaced file");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected merge conflict for spaced file"
|
|
);
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
let merged = fs::read_to_string(repo.join("docs/spaced name.txt")).unwrap();
|
|
// Tool should have processed the file despite spaces in path.
|
|
assert!(
|
|
merged.contains("local change")
|
|
|| merged.contains("remote change")
|
|
|| merged.contains("<<<<<<<"),
|
|
"expected mergetool to process spaced-path file\nmerged:\n{merged}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_handles_unicode_path() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
let unicode_path = "docs/\u{65e5}\u{672c}\u{8a9e}-\u{0444}\u{0430}\u{0439}\u{043b}.txt";
|
|
write_file(repo, unicode_path, "original\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, unicode_path, "remote change\n");
|
|
commit_all(repo, "feature: change unicode file");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, unicode_path, "local change\n");
|
|
commit_all(repo, "main: change unicode file");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected merge conflict for unicode path"
|
|
);
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
let merged = fs::read_to_string(repo.join(unicode_path)).unwrap();
|
|
assert!(
|
|
merged.contains("local change")
|
|
|| merged.contains("remote change")
|
|
|| merged.contains("<<<<<<<"),
|
|
"expected mergetool to process unicode-path file\nmerged:\n{merged}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_works_from_subdirectory() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "sub/dir/nested.txt", "base content\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "sub/dir/nested.txt", "remote content\n");
|
|
commit_all(repo, "feature: change nested");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "sub/dir/nested.txt", "local content\n");
|
|
commit_all(repo, "main: change nested");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected merge conflict for nested file"
|
|
);
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
// Run from subdirectory.
|
|
let subdir = repo.join("sub/dir");
|
|
let output = run_git_capture_in(&subdir, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
let merged = fs::read_to_string(repo.join("sub/dir/nested.txt")).unwrap();
|
|
assert!(
|
|
merged.contains("local content")
|
|
|| merged.contains("remote content")
|
|
|| merged.contains("<<<<<<<"),
|
|
"expected mergetool to process file from subdirectory\nmerged:\n{merged}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_handles_add_add_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
// Create an empty initial commit so both branches can add new files.
|
|
write_file(repo, "README", "init\n");
|
|
commit_all(repo, "initial");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "new_file.txt", "added by remote\n");
|
|
commit_all(repo, "feature: add new_file");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "new_file.txt", "added by local\n");
|
|
commit_all(repo, "main: add new_file");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected add/add merge conflict");
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
let merged = fs::read_to_string(repo.join("new_file.txt")).unwrap();
|
|
// For add/add, BASE is empty. Our tool treats this as empty base,
|
|
// resulting in a conflict (both sides added different content).
|
|
assert!(
|
|
merged.contains("added by local")
|
|
|| merged.contains("added by remote")
|
|
|| merged.contains("<<<<<<<"),
|
|
"expected mergetool to handle add/add conflict\nmerged:\n{merged}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_add_add_provides_empty_base_stage_file() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Portability parity with git t7610 "no-base file":
|
|
// for add/add conflicts, the tool should still receive a BASE stage path
|
|
// and report it as an empty stage file (size 0).
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "README", "init\n");
|
|
commit_all(repo, "initial");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "new_file.txt", "added by remote\n");
|
|
commit_all(repo, "feature: add new_file");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "new_file.txt", "added by local\n");
|
|
commit_all(repo, "main: add new_file");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected add/add merge conflict");
|
|
|
|
configure_stage_metadata_recording_mergetool(repo, "recorder");
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected git mergetool to resolve add/add conflict\n{text}"
|
|
);
|
|
|
|
let dump = fs::read_to_string(repo.join("new_file.txt.env"))
|
|
.expect("read add/add stage metadata dump");
|
|
let lines: Vec<&str> = dump.lines().collect();
|
|
assert!(lines.len() >= 4, "expected stage metadata dump\n{text}");
|
|
|
|
let base_var = lines[0].to_string();
|
|
assert!(
|
|
!base_var.is_empty(),
|
|
"expected BASE to be a stage file path, got empty value\n{text}"
|
|
);
|
|
assert!(
|
|
base_var.contains("_BASE_"),
|
|
"expected BASE stage filename marker in path, got: {base_var}\n{text}"
|
|
);
|
|
|
|
let local_var = lines[1];
|
|
let remote_var = lines[2];
|
|
assert!(
|
|
!local_var.is_empty() && !remote_var.is_empty(),
|
|
"expected LOCAL/REMOTE stage paths to be non-empty\n{text}"
|
|
);
|
|
|
|
let base_size_line = lines
|
|
.iter()
|
|
.find(|line| line.starts_with("BASE_SIZE="))
|
|
.copied()
|
|
.unwrap_or("BASE_SIZE=MISSING");
|
|
let base_size = base_size_line
|
|
.strip_prefix("BASE_SIZE=")
|
|
.map(str::trim)
|
|
.and_then(|value| value.parse::<u64>().ok());
|
|
assert!(
|
|
base_size == Some(0),
|
|
"expected add/add BASE stage file size to be 0, got {base_size_line}\n{text}\n{dump}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_trust_exit_code_conflict_preserves_unmerged_state() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// When our tool exits 1 (unresolved conflict) with trustExitCode=true,
|
|
// git should leave the file as unmerged. This verifies the exit code
|
|
// contract between gitcomet and git mergetool.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "conflict.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "conflict.txt", "feature side\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "conflict.txt", "main side\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// Our tool exits 1 on unresolved conflict. With trustExitCode=true,
|
|
// git interprets this as failure and restores the original MERGED
|
|
// content. The file should still have conflict markers.
|
|
let merged = fs::read_to_string(repo.join("conflict.txt")).unwrap();
|
|
assert!(
|
|
merged.contains("<<<<<<<"),
|
|
"expected conflict markers to remain after tool reports failure\nmerged:\n{merged}\ngit output:\n{text}"
|
|
);
|
|
|
|
// The file should still be in unmerged state (shown as UU in porcelain).
|
|
let status = run_git_capture(repo, &["status", "--porcelain"]);
|
|
let status_text = String::from_utf8_lossy(&status.stdout);
|
|
assert!(
|
|
status_text.contains("UU") || status_text.contains("AA"),
|
|
"expected unmerged file in git status\nstatus:\n{status_text}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_no_trust_exit_code_unchanged_output_stays_unresolved() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_mergetool_selection(repo, "fake", None, None);
|
|
configure_mergetool_command(repo, "fake", "exit 0");
|
|
configure_mergetool_trust_exit_code(repo, "fake", false);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "fake"]);
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected git mergetool to fail when trustExitCode=false and output is unchanged\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("seems unchanged"),
|
|
"expected unchanged-output warning in git output\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("Was the merge successful"),
|
|
"expected no-trust follow-up prompt in git output\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
assert!(
|
|
merged.contains("<<<<<<<"),
|
|
"expected conflict markers to remain when fake tool leaves output unchanged\nmerged:\n{merged}\n{text}"
|
|
);
|
|
|
|
let status = run_git_capture(repo, &["status", "--porcelain"]);
|
|
let status_text = String::from_utf8_lossy(&status.stdout);
|
|
assert!(
|
|
status_text.contains("UU") || status_text.contains("AA"),
|
|
"expected unresolved conflict after unchanged fake tool output\nstatus:\n{status_text}\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_no_trust_exit_code_changed_output_resolves_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_mergetool_selection(repo, "fake", None, None);
|
|
configure_mergetool_command(
|
|
repo,
|
|
"fake",
|
|
// Use an explicit success exit so behavior does not depend on Git's
|
|
// trustExitCode=false handling for non-zero tool exits.
|
|
//
|
|
// With trustExitCode=false, upstream git-mergetool does not compare
|
|
// file contents. It checks whether MERGED is newer than BACKUP using
|
|
// `test -nt`. On some platforms/runners this can be same-tick and
|
|
// falsely report "seems unchanged" even after writing new content.
|
|
"echo TOOL=fake >&2; cat \"$REMOTE\" > \"$MERGED\"; sleep 1; touch \"$MERGED\"; exit 0",
|
|
);
|
|
configure_mergetool_trust_exit_code(repo, "fake", false);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "fake"]);
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"expected git mergetool to accept changed output when trustExitCode=false\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("TOOL=fake"),
|
|
"expected fake tool marker in output\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("Was the merge successful"),
|
|
"did not expect no-trust prompt when fake tool changed MERGED\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
assert_eq!(merged, "aaa\nREMOTE\nccc\n");
|
|
|
|
let status = run_git_capture(repo, &["status", "--porcelain"]);
|
|
let status_text = String::from_utf8_lossy(&status.stdout);
|
|
assert!(
|
|
!status_text.contains("UU") && !status_text.contains("AA"),
|
|
"expected conflict to be cleared after fake tool changed output\nstatus:\n{status_text}\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_trust_exit_code_deleted_output_resolves_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// External tools can resolve by deleting MERGED (e.g. remove file outcome).
|
|
// With trustExitCode=true, git should accept exit-code success, clear the
|
|
// conflict, and stage file deletion.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_mergetool_selection(repo, "fake", None, None);
|
|
configure_mergetool_command(repo, "fake", "rm -f \"$MERGED\"; exit 0");
|
|
configure_mergetool_trust_exit_code(repo, "fake", true);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "fake"]);
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"expected git mergetool to accept deleted output when trustExitCode=true\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("Was the merge successful"),
|
|
"did not expect no-trust prompt when trustExitCode=true\n{text}"
|
|
);
|
|
assert!(
|
|
!repo.join("file.txt").exists(),
|
|
"expected MERGED path to be deleted by fake tool\n{text}"
|
|
);
|
|
assert!(
|
|
!has_unmerged_entries_for_path(repo, "file.txt"),
|
|
"expected no unmerged entries after delete-output resolution\n{text}"
|
|
);
|
|
|
|
let status = run_git_capture(repo, &["status", "--porcelain"]);
|
|
let status_text = String::from_utf8_lossy(&status.stdout);
|
|
assert!(
|
|
status_text
|
|
.lines()
|
|
.any(|line| line.starts_with("D ") && line.ends_with("file.txt")),
|
|
"expected staged deletion after delete-output resolution\nstatus:\n{status_text}\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_no_trust_exit_code_deleted_output_prompts_and_stays_unresolved() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// With trustExitCode=false, upstream git does not treat deleted MERGED as
|
|
// a changed-resolution signal in this flow: it restores backup content,
|
|
// prompts, and leaves the conflict unresolved.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_mergetool_selection(repo, "fake", None, None);
|
|
configure_mergetool_command(repo, "fake", "rm -f \"$MERGED\"; exit 1");
|
|
configure_mergetool_trust_exit_code(repo, "fake", false);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "fake"]);
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected git mergetool to fail for delete-output with trustExitCode=false\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("seems unchanged"),
|
|
"expected unchanged-output warning in git output\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("Was the merge successful"),
|
|
"expected no-trust follow-up prompt in git output\n{text}"
|
|
);
|
|
assert!(
|
|
repo.join("file.txt").exists(),
|
|
"expected MERGED path to be restored by git after failed run\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
assert!(
|
|
merged.contains("<<<<<<<"),
|
|
"expected conflict markers to remain after failed delete-output run\nmerged:\n{merged}\n{text}"
|
|
);
|
|
|
|
assert!(
|
|
has_unmerged_entries_for_path(repo, "file.txt"),
|
|
"expected unmerged entries to remain after failed delete-output run\n{text}"
|
|
);
|
|
|
|
let status = run_git_capture(repo, &["status", "--porcelain"]);
|
|
let status_text = String::from_utf8_lossy(&status.stdout);
|
|
assert!(
|
|
status_text.contains("UU") || status_text.contains("AA"),
|
|
"expected unresolved conflict after failed delete-output run\nstatus:\n{status_text}\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_multiple_conflicted_files() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "alpha.txt", "base alpha\n");
|
|
write_file(repo, "beta.txt", "base beta\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "alpha.txt", "remote alpha\n");
|
|
write_file(repo, "beta.txt", "remote beta\n");
|
|
commit_all(repo, "feature: change both");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "alpha.txt", "local alpha\n");
|
|
write_file(repo, "beta.txt", "local beta\n");
|
|
commit_all(repo, "main: change both");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// Both files should have been processed by the mergetool.
|
|
let alpha = fs::read_to_string(repo.join("alpha.txt")).unwrap();
|
|
let beta = fs::read_to_string(repo.join("beta.txt")).unwrap();
|
|
|
|
assert!(
|
|
alpha.contains("<<<<<<<")
|
|
|| alpha.contains("local alpha")
|
|
|| alpha.contains("remote alpha"),
|
|
"expected alpha.txt to be processed\nalpha:\n{alpha}\ngit output:\n{text}"
|
|
);
|
|
assert!(
|
|
beta.contains("<<<<<<<") || beta.contains("local beta") || beta.contains("remote beta"),
|
|
"expected beta.txt to be processed\nbeta:\n{beta}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_pathspec_resolves_only_selected_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "alpha.txt", "base alpha\n");
|
|
write_file(repo, "beta.txt", "base beta\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "alpha.txt", "remote alpha\n");
|
|
write_file(repo, "beta.txt", "remote beta\n");
|
|
commit_all(repo, "feature: change both");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "alpha.txt", "local alpha\n");
|
|
write_file(repo, "beta.txt", "local beta\n");
|
|
commit_all(repo, "main: change both");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
configure_mergetool_selection(repo, "fake", None, None);
|
|
configure_mergetool_command(
|
|
repo,
|
|
"fake",
|
|
"echo TOOL=fake >&2; cat \"$REMOTE\" > \"$MERGED\"",
|
|
);
|
|
configure_mergetool_trust_exit_code(repo, "fake", true);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--", "alpha.txt"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected pathspec-targeted mergetool run to succeed\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("TOOL=fake"),
|
|
"expected fake tool invocation marker\n{text}"
|
|
);
|
|
|
|
let alpha = fs::read_to_string(repo.join("alpha.txt")).unwrap();
|
|
let beta = fs::read_to_string(repo.join("beta.txt")).unwrap();
|
|
assert_eq!(
|
|
alpha, "remote alpha\n",
|
|
"expected selected path to be resolved using fake tool"
|
|
);
|
|
assert!(
|
|
beta.contains("<<<<<<<") && beta.contains("local beta") && beta.contains("remote beta"),
|
|
"expected non-selected conflicted path to remain unresolved\nbeta:\n{beta}\n{text}"
|
|
);
|
|
|
|
let unmerged = run_git_capture(repo, &["ls-files", "-u"]);
|
|
let unmerged_text = String::from_utf8_lossy(&unmerged.stdout);
|
|
assert!(
|
|
!unmerged_text.contains("alpha.txt"),
|
|
"expected selected path to be resolved in index\n{unmerged_text}"
|
|
);
|
|
assert!(
|
|
unmerged_text.contains("beta.txt"),
|
|
"expected non-selected path to remain unmerged\n{unmerged_text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_crlf_content_preserved() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
// Disable autocrlf to preserve exact line endings.
|
|
run_git(repo, &["config", "core.autocrlf", "false"]);
|
|
|
|
write_file(repo, "crlf.txt", "line1\r\nline2\r\nline3\r\n");
|
|
commit_all(repo, "base with CRLF");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "crlf.txt", "remote1\r\nline2\r\nline3\r\n");
|
|
commit_all(repo, "feature: change line 1");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "crlf.txt", "local1\r\nline2\r\nline3\r\n");
|
|
commit_all(repo, "main: change line 1");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected CRLF merge conflict");
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
let merged_bytes = fs::read(repo.join("crlf.txt")).unwrap();
|
|
let merged = String::from_utf8_lossy(&merged_bytes);
|
|
|
|
// The tool should have processed the file. Content should still
|
|
// contain CRLF sequences from the original input.
|
|
assert!(
|
|
merged.contains("\r\n"),
|
|
"expected CRLF to be preserved in merged output\nmerged:\n{merged}\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── writeToTemp path semantics parity ───────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_write_to_temp_true_uses_absolute_stage_paths() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_stage_path_recording_mergetool(repo, "stagepaths");
|
|
run_git(repo, &["config", "mergetool.writeToTemp", "true"]);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "stagepaths"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"git mergetool failed for writeToTemp=true\n{text}"
|
|
);
|
|
|
|
let vars = read_recorded_stage_paths(repo, "file.txt");
|
|
assert_eq!(
|
|
vars.len(),
|
|
3,
|
|
"expected BASE/LOCAL/REMOTE stage paths\n{text}"
|
|
);
|
|
for var in vars {
|
|
assert!(
|
|
is_effectively_absolute_path(&var),
|
|
"writeToTemp=true should provide absolute stage paths, got: {var}\n{text}"
|
|
);
|
|
assert!(
|
|
!var.starts_with("./"),
|
|
"writeToTemp=true should not use ./-prefixed stage paths, got: {var}\n{text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_write_to_temp_false_uses_workdir_prefixed_stage_paths() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
configure_stage_path_recording_mergetool(repo, "stagepaths");
|
|
run_git(repo, &["config", "mergetool.writeToTemp", "false"]);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "stagepaths"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"git mergetool failed for writeToTemp=false\n{text}"
|
|
);
|
|
|
|
let vars = read_recorded_stage_paths(repo, "file.txt");
|
|
assert_eq!(
|
|
vars.len(),
|
|
3,
|
|
"expected BASE/LOCAL/REMOTE stage paths\n{text}"
|
|
);
|
|
for var in vars {
|
|
assert!(
|
|
var.starts_with("./"),
|
|
"writeToTemp=false should provide ./-prefixed stage paths, got: {var}\n{text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── diff.orderFile ordering parity ───────────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_honors_diff_order_file_configuration() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_order_file_conflict(repo);
|
|
write_file(repo, "order-file", "b\na\n");
|
|
run_git(repo, &["config", "diff.orderFile", "order-file"]);
|
|
|
|
let order_log = repo.join(".mergetool-order.log");
|
|
configure_recording_mergetool(repo, "ordercheck", &order_log);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "ordercheck"]);
|
|
let text = output_text(&output);
|
|
assert!(output.status.success(), "git mergetool failed\n{text}");
|
|
|
|
let order = read_recorded_merge_order(&order_log);
|
|
assert_eq!(order, vec!["b", "a"], "unexpected merge order\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_o_flag_overrides_diff_order_file() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_order_file_conflict(repo);
|
|
write_file(repo, "order-file", "b\na\n");
|
|
write_file(repo, "cli-order-file", "a\nb\n");
|
|
run_git(repo, &["config", "diff.orderFile", "order-file"]);
|
|
|
|
let order_log = repo.join(".mergetool-order.log");
|
|
configure_recording_mergetool(repo, "ordercheck", &order_log);
|
|
|
|
let output = run_git_capture(
|
|
repo,
|
|
&[
|
|
"mergetool",
|
|
"-Ocli-order-file",
|
|
"--no-prompt",
|
|
"--tool",
|
|
"ordercheck",
|
|
],
|
|
);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"git mergetool with -O override failed\n{text}"
|
|
);
|
|
|
|
let order = read_recorded_merge_order(&order_log);
|
|
assert_eq!(order, vec!["a", "b"], "unexpected merge order\n{text}");
|
|
}
|
|
|
|
// ── Tool-help discoverability ────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_tool_help_lists_gitcomet_tool() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--tool-help"]);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"git mergetool --tool-help failed\n{text}"
|
|
);
|
|
assert!(
|
|
text.contains("gitcomet"),
|
|
"expected gitcomet tool name in --tool-help output\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── GUI tool selection parity ────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_gui_default_auto_prefers_gui_tool_when_display_set() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
// Configure two tools with distinct markers.
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
configure_mergetool_command(repo, "gui", &mergetool_marker_cmd("gui"));
|
|
configure_mergetool_trust_exit_code(repo, "gui", true);
|
|
configure_mergetool_selection(repo, "cli", Some("gui"), Some("auto"));
|
|
|
|
// With DISPLAY set, guiDefault=auto should select the GUI tool.
|
|
let output = run_git_capture_with_display(repo, &["mergetool", "--no-prompt"], Some(":99"));
|
|
let text = output_text(&output);
|
|
assert!(
|
|
text.contains("TOOL=gui"),
|
|
"expected gui tool selection with DISPLAY set\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_gui_default_auto_prefers_cli_tool_without_display() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
configure_mergetool_command(repo, "gui", &mergetool_marker_cmd("gui"));
|
|
configure_mergetool_trust_exit_code(repo, "gui", true);
|
|
configure_mergetool_selection(repo, "cli", Some("gui"), Some("auto"));
|
|
|
|
// Without DISPLAY, guiDefault=auto should select the CLI tool.
|
|
let output = run_git_capture_with_display(repo, &["mergetool", "--no-prompt"], None);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
text.contains("TOOL=cli"),
|
|
"expected cli tool selection without DISPLAY\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_gui_default_true_prefers_gui_tool_without_display() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
configure_mergetool_command(repo, "gui", &mergetool_marker_cmd("gui"));
|
|
configure_mergetool_trust_exit_code(repo, "gui", true);
|
|
configure_mergetool_selection(repo, "cli", Some("gui"), Some("true"));
|
|
|
|
let output = run_git_capture_with_display(repo, &["mergetool", "--no-prompt"], None);
|
|
let text = output_text(&output);
|
|
assert!(output.status.success(), "git mergetool failed\n{text}");
|
|
assert!(
|
|
text.contains("TOOL=gui"),
|
|
"expected gui tool selection when guiDefault=true\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_gui_default_false_prefers_cli_tool_with_display() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
configure_mergetool_command(repo, "gui", &mergetool_marker_cmd("gui"));
|
|
configure_mergetool_trust_exit_code(repo, "gui", true);
|
|
configure_mergetool_selection(repo, "cli", Some("gui"), Some("false"));
|
|
|
|
let output = run_git_capture_with_display(repo, &["mergetool", "--no-prompt"], Some(":99"));
|
|
let text = output_text(&output);
|
|
assert!(output.status.success(), "git mergetool failed\n{text}");
|
|
assert!(
|
|
text.contains("TOOL=cli"),
|
|
"expected regular tool selection when guiDefault=false\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_gui_flag_overrides_selection() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
configure_mergetool_command(repo, "gui", &mergetool_marker_cmd("gui"));
|
|
configure_mergetool_trust_exit_code(repo, "gui", true);
|
|
// guiDefault=false, but --gui flag should override.
|
|
configure_mergetool_selection(repo, "cli", Some("gui"), Some("false"));
|
|
|
|
let output = run_git_capture_with_display(repo, &["mergetool", "--gui", "--no-prompt"], None);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
text.contains("TOOL=gui"),
|
|
"expected --gui to force gui tool selection\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_no_gui_flag_overrides_gui_default_true() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
configure_mergetool_command(repo, "gui", &mergetool_marker_cmd("gui"));
|
|
configure_mergetool_trust_exit_code(repo, "gui", true);
|
|
// guiDefault=true, but --no-gui flag should override.
|
|
configure_mergetool_selection(repo, "cli", Some("gui"), Some("true"));
|
|
|
|
let output =
|
|
run_git_capture_with_display(repo, &["mergetool", "--no-gui", "--no-prompt"], Some(":99"));
|
|
let text = output_text(&output);
|
|
assert!(
|
|
text.contains("TOOL=cli"),
|
|
"expected --no-gui to force regular tool selection\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_gui_fallback_when_no_guitool_configured() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// When --gui is specified but no merge.guitool is configured,
|
|
// git falls back to merge.tool.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
// Only merge.tool set, no merge.guitool.
|
|
configure_mergetool_selection(repo, "cli", None, None);
|
|
|
|
let output =
|
|
run_git_capture_with_display(repo, &["mergetool", "--gui", "--no-prompt"], Some(":99"));
|
|
let text = output_text(&output);
|
|
// Git falls back to merge.tool when no guitool is configured.
|
|
assert!(
|
|
text.contains("TOOL=cli"),
|
|
"expected fallback to merge.tool when no guitool configured\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_gui_default_true_fallback_when_no_guitool_configured() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Even with guiDefault=true, git mergetool should fall back to merge.tool
|
|
// if no merge.guitool is configured.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
setup_overlapping_conflict(repo);
|
|
|
|
configure_mergetool_command(repo, "cli", &mergetool_marker_cmd("cli"));
|
|
configure_mergetool_trust_exit_code(repo, "cli", true);
|
|
// Only merge.tool set, no merge.guitool — but guiDefault=true.
|
|
configure_mergetool_selection(repo, "cli", None, Some("true"));
|
|
|
|
let output = run_git_capture_with_display(repo, &["mergetool", "--no-prompt"], Some(":99"));
|
|
let text = output_text(&output);
|
|
assert!(
|
|
text.contains("TOOL=cli"),
|
|
"expected fallback to merge.tool with guiDefault=true and no merge.guitool\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── Nonexistent tool error handling ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_nonexistent_tool_reports_error() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(!output.status.success(), "expected merge conflict");
|
|
|
|
// Configure a tool that points to a nonexistent command.
|
|
run_git(repo, &["config", "merge.tool", "nonexistent_tool_xyz"]);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"config",
|
|
"mergetool.nonexistent_tool_xyz.cmd",
|
|
"/absolutely/nonexistent/binary --merge",
|
|
],
|
|
);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"config",
|
|
"mergetool.nonexistent_tool_xyz.trustExitCode",
|
|
"true",
|
|
],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// Git should report failure when the tool command fails to execute.
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected git mergetool to fail with nonexistent tool\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_absent_tool_reports_cmd_not_set_error() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Portability parity with git t7610: explicit --tool=<name> without a
|
|
// configured mergetool.<name>.cmd should fail with actionable text.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote\n");
|
|
commit_all(repo, "feature change");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local\n");
|
|
commit_all(repo, "main change");
|
|
|
|
let merge_output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!merge_output.status.success(),
|
|
"expected merge conflict before mergetool"
|
|
);
|
|
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt", "--tool", "absent"]);
|
|
let text = output_text(&output);
|
|
let text_lower = text.to_ascii_lowercase();
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected git mergetool --tool absent to fail\n{text}"
|
|
);
|
|
let absent_diag = text_lower.contains("absent")
|
|
|| text_lower.contains("cmd not set")
|
|
|| text_lower.contains("unknown merge tool")
|
|
|| text_lower.contains("unknown mergetool")
|
|
|| text_lower.contains("no known")
|
|
|| text_lower.contains("not available");
|
|
assert!(
|
|
absent_diag || has_unmerged_entries_for_path(repo, "file.txt"),
|
|
"expected missing-tool diagnostics or unresolved conflict to remain\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── Delete/delete conflict behavior ──────────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_delete_delete_conflict_handling() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// When both branches delete the same file, git mergetool handles
|
|
// this without invoking the external tool. The file just needs to
|
|
// be staged as deleted.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "to_delete.txt", "content\n");
|
|
write_file(repo, "keep.txt", "kept\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
run_git(repo, &["rm", "to_delete.txt"]);
|
|
// Also modify keep.txt to create a real merge (not fast-forward).
|
|
write_file(repo, "keep.txt", "feature version\n");
|
|
commit_all(repo, "feature: delete file and modify keep");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
run_git(repo, &["rm", "to_delete.txt"]);
|
|
write_file(repo, "keep.txt", "main version\n");
|
|
commit_all(repo, "main: delete file and modify keep");
|
|
|
|
let merge_output = run_git_capture(repo, &["merge", "feature"]);
|
|
// Depending on git version, both-deleted might auto-resolve or conflict.
|
|
// If the merge succeeds (both-deleted auto-resolved), skip the mergetool test.
|
|
if merge_output.status.success() {
|
|
// Both-deleted auto-resolved by git — verify file is gone.
|
|
assert!(
|
|
!repo.join("to_delete.txt").exists(),
|
|
"expected deleted file to stay deleted after merge"
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Configure mergetool and attempt to resolve.
|
|
configure_mergetool_command(repo, "gitcomet", &mergetool_marker_cmd("gitcomet"));
|
|
configure_mergetool_trust_exit_code(repo, "gitcomet", true);
|
|
configure_mergetool_selection(repo, "gitcomet", None, None);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let _text = output_text(&output);
|
|
|
|
// After mergetool, the deleted file should not exist in the working tree.
|
|
// Git handles delete/delete internally (may prompt for d/m/a choices,
|
|
// or auto-resolve when both sides agree on deletion).
|
|
assert!(
|
|
!repo.join("to_delete.txt").exists(),
|
|
"expected both-deleted file to be removed after mergetool"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_delete_delete_choice_d_deletes_original_path() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Port of t7610 delete/delete "d" choice.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
if !setup_delete_delete_rename_conflict(repo) {
|
|
// Auto-resolved by git version under test.
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_with_stdin(repo, &["mergetool", "a/a/file.txt"], "d\n");
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected delete/delete resolution with 'd' to succeed\n{text}"
|
|
);
|
|
assert!(
|
|
!repo.join("a/a/file.txt").exists(),
|
|
"expected original path to be deleted after 'd' choice\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_delete_delete_choice_m_keeps_modified_destination() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Port of t7610 delete/delete "m" choice.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
if !setup_delete_delete_rename_conflict(repo) {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_with_stdin(repo, &["mergetool", "a/a/file.txt"], "m\n");
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected delete/delete resolution with 'm' to succeed\n{text}"
|
|
);
|
|
assert!(
|
|
repo.join("b/b/file.txt").exists(),
|
|
"expected modified destination file b/b/file.txt after 'm' choice\n{text}"
|
|
);
|
|
assert!(
|
|
!repo.join("a/a/file.txt").exists(),
|
|
"expected original path to remain deleted after 'm' choice\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_delete_delete_choice_a_aborts_with_nonzero() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Port of t7610 delete/delete "a" (abort) behavior.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
if !setup_delete_delete_rename_conflict(repo) {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_with_stdin(repo, &["mergetool", "a/a/file.txt"], "a\n");
|
|
let text = output_text(&output);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected delete/delete 'a' abort to return non-zero\n{text}"
|
|
);
|
|
assert!(
|
|
!repo.join("a/a/file.txt").exists(),
|
|
"expected original path to remain absent after abort\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_keep_backup_delete_delete_no_errors() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Parity with git t7610: "mergetool produces no errors when keepBackup is used"
|
|
//
|
|
// When both branches rename a file from the same path to different
|
|
// destinations, git sees a delete/delete conflict at the original path.
|
|
// With keepBackup=true, resolving via "d" (delete) should produce NO
|
|
// errors on stderr and the original directory should be cleaned up.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
|
|
// Create a file inside a nested directory: a/a/file.txt
|
|
fs::create_dir_all(repo.join("a/a")).unwrap();
|
|
write_file(repo, "a/a/file.txt", "one\ntwo\n3\n4\n");
|
|
commit_all(repo, "base file");
|
|
|
|
// Branch move-to-b: rename a/a/file.txt -> b/b/file.txt (with edit)
|
|
run_git(repo, &["checkout", "-b", "move-to-b"]);
|
|
fs::create_dir_all(repo.join("b/b")).unwrap();
|
|
run_git(repo, &["mv", "a/a/file.txt", "b/b/file.txt"]);
|
|
write_file(repo, "b/b/file.txt", "one\ntwo\n4\n");
|
|
commit_all(repo, "move to b");
|
|
|
|
// Branch move-to-c: rename a/a/file.txt -> c/c/file.txt (with edit)
|
|
run_git(repo, &["checkout", "main"]);
|
|
run_git(repo, &["checkout", "-b", "move-to-c"]);
|
|
fs::create_dir_all(repo.join("c/c")).unwrap();
|
|
run_git(repo, &["mv", "a/a/file.txt", "c/c/file.txt"]);
|
|
write_file(repo, "c/c/file.txt", "one\ntwo\n3\n");
|
|
commit_all(repo, "move to c");
|
|
|
|
// Merge move-to-b into move-to-c → creates delete/delete at a/a/file.txt
|
|
let merge_output = run_git_capture(repo, &["merge", "move-to-b"]);
|
|
if merge_output.status.success() {
|
|
// Git auto-resolved the rename/rename — skip this test.
|
|
return;
|
|
}
|
|
|
|
// Configure mergetool with keepBackup=true (the setting under test).
|
|
configure_mergetool_command(repo, "gitcomet", &mergetool_marker_cmd("gitcomet"));
|
|
configure_mergetool_trust_exit_code(repo, "gitcomet", true);
|
|
run_git(repo, &["config", "merge.tool", "gitcomet"]);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "true"]);
|
|
|
|
// Resolve with "d" (delete) for the delete/delete conflict at the
|
|
// original path, and "d" again for any rename/rename prompts git may
|
|
// present. Pipe enough answers for all prompts git may ask.
|
|
let output = run_git_with_stdin(repo, &["mergetool", "--no-prompt"], "d\nd\nd\nd\n");
|
|
|
|
// Key assertion: stderr must be empty (no errors from keepBackup
|
|
// interacting with delete/delete cleanup).
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
// Filter out git's own informational messages (e.g. "Merging:") —
|
|
// only assert that no error lines are present.
|
|
let error_lines: Vec<&str> = stderr
|
|
.lines()
|
|
.filter(|line| {
|
|
let l = line.trim();
|
|
// Skip empty lines and known git informational output.
|
|
!l.is_empty()
|
|
&& !l.starts_with("Merging")
|
|
&& !l.starts_with("Normal merge")
|
|
&& !l.starts_with("Deleted merge")
|
|
&& !l.starts_with("TOOL=")
|
|
})
|
|
.collect();
|
|
assert!(
|
|
error_lines.is_empty(),
|
|
"expected no errors on stderr with keepBackup=true for delete/delete conflict\nstderr lines: {error_lines:?}\nfull stderr:\n{stderr}"
|
|
);
|
|
|
|
// The original directory "a" should have been cleaned up.
|
|
// (Git removes it when the file inside is deleted.)
|
|
assert!(
|
|
!repo.join("a/a/file.txt").exists(),
|
|
"expected original file a/a/file.txt to be gone after delete resolution"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_keep_temporaries_delete_delete_abort_keeps_stage_files() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Parity with git t7610: "mergetool keeps tempfiles when aborting delete/delete"
|
|
// for a path-targeted delete/delete conflict flow.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
if !setup_delete_delete_rename_conflict(repo) {
|
|
// Some git versions may auto-resolve this rename/rename setup.
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
run_git(repo, &["config", "mergetool.keepTemporaries", "true"]);
|
|
run_git(repo, &["config", "mergetool.writeToTemp", "false"]);
|
|
|
|
let output = run_git_with_stdin(repo, &["mergetool", "a/a/file.txt"], "a\n");
|
|
let text = output_text(&output);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected abort to return non-zero for delete/delete conflict\n{text}"
|
|
);
|
|
|
|
let temp_dir = repo.join("a/a");
|
|
assert!(
|
|
temp_dir.is_dir(),
|
|
"expected delete/delete temp directory to exist after abort with keepTemporaries=true"
|
|
);
|
|
|
|
let mut entries: Vec<String> = fs::read_dir(&temp_dir)
|
|
.expect("read preserved temp directory")
|
|
.map(|entry| {
|
|
entry
|
|
.expect("read temp directory entry")
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.to_string()
|
|
})
|
|
.collect();
|
|
entries.sort();
|
|
|
|
let has_base = entries
|
|
.iter()
|
|
.any(|name| name.starts_with("file_BASE_") && name.ends_with(".txt"));
|
|
let has_local = entries
|
|
.iter()
|
|
.any(|name| name.starts_with("file_LOCAL_") && name.ends_with(".txt"));
|
|
let has_remote = entries
|
|
.iter()
|
|
.any(|name| name.starts_with("file_REMOTE_") && name.ends_with(".txt"));
|
|
|
|
assert!(
|
|
has_base && has_local && has_remote,
|
|
"expected preserved BASE/LOCAL/REMOTE stage files, got {entries:?}\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── Modify/delete conflict ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn git_mergetool_modify_delete_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// One branch modifies a file, the other deletes it.
|
|
// Git mergetool presents this as a special conflict type.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
write_file(repo, "file.txt", "original\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
run_git(repo, &["rm", "file.txt"]);
|
|
commit_all(repo, "feature: delete file");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "modified content\n");
|
|
commit_all(repo, "main: modify file");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected modify/delete merge conflict"
|
|
);
|
|
|
|
// Configure our tool. For modify/delete, git will still invoke
|
|
// the mergetool (with a special prompt in some cases).
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_capture(repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// Git should report the modify/delete conflict.
|
|
// The mergetool pipeline should complete without crashing.
|
|
// For modify/delete, git handles the conflict internally (deleted-by prompt)
|
|
// and the external tool may or may not be invoked depending on git version.
|
|
// The fact that run_git_capture above returned proves the pipeline didn't hang.
|
|
// Verify git status still works after the mergetool run.
|
|
let status = run_git_capture(repo, &["status", "--porcelain"]);
|
|
assert!(
|
|
status.status.success(),
|
|
"git status should succeed after mergetool\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── Symlink conflict behavior ────────────────────────────────────────
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn git_mergetool_symlink_conflict_resolved_via_local() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// When both branches change a symlink's target, git mergetool handles
|
|
// the symlink conflict internally with a l/r/a prompt (does NOT invoke
|
|
// the external tool). Verify that answering "l" keeps the local target.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
// Create a symlink in the base commit.
|
|
std::os::unix::fs::symlink("original_target", repo.join("link")).expect("create symlink");
|
|
commit_all(repo, "base: add symlink");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
fs::remove_file(repo.join("link")).unwrap();
|
|
std::os::unix::fs::symlink("remote_target", repo.join("link")).expect("create symlink");
|
|
commit_all(repo, "feature: change link target");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
fs::remove_file(repo.join("link")).unwrap();
|
|
std::os::unix::fs::symlink("local_target", repo.join("link")).expect("create symlink");
|
|
commit_all(repo, "main: change link target");
|
|
|
|
let merge_out = run_git_capture(repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
// Some git versions auto-resolve symlink conflicts — skip this test.
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
// Pipe "l\n" to stdin to answer the symlink resolution prompt.
|
|
let output = run_git_with_stdin(repo, &["mergetool", "--no-prompt"], "l\n");
|
|
let text = output_text(&output);
|
|
|
|
// After answering "l" (local), the symlink should point to local_target.
|
|
let target = fs::read_link(repo.join("link"));
|
|
assert!(
|
|
target.is_ok(),
|
|
"expected symlink to exist after resolution\ngit output:\n{text}"
|
|
);
|
|
let target = target.unwrap();
|
|
assert_eq!(
|
|
target.to_string_lossy(),
|
|
"local_target",
|
|
"expected local symlink target after answering 'l'\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn git_mergetool_symlink_conflict_resolved_via_remote() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Verify that answering "r" to a symlink conflict keeps the remote target.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
std::os::unix::fs::symlink("original_target", repo.join("link")).expect("create symlink");
|
|
commit_all(repo, "base: add symlink");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
fs::remove_file(repo.join("link")).unwrap();
|
|
std::os::unix::fs::symlink("remote_target", repo.join("link")).expect("create symlink");
|
|
commit_all(repo, "feature: change link target");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
fs::remove_file(repo.join("link")).unwrap();
|
|
std::os::unix::fs::symlink("local_target", repo.join("link")).expect("create symlink");
|
|
commit_all(repo, "main: change link target");
|
|
|
|
let merge_out = run_git_capture(repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
let output = run_git_with_stdin(repo, &["mergetool", "--no-prompt"], "r\n");
|
|
let text = output_text(&output);
|
|
|
|
let target = fs::read_link(repo.join("link"));
|
|
assert!(
|
|
target.is_ok(),
|
|
"expected symlink to exist after resolution\ngit output:\n{text}"
|
|
);
|
|
let target = target.unwrap();
|
|
assert_eq!(
|
|
target.to_string_lossy(),
|
|
"remote_target",
|
|
"expected remote symlink target after answering 'r'\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn git_mergetool_symlink_alongside_normal_file_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// When both a symlink conflict and a normal file conflict exist,
|
|
// git handles the symlink internally (l/r/a prompt) and invokes
|
|
// our external tool for the normal file.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path();
|
|
|
|
init_repo(repo);
|
|
std::os::unix::fs::symlink("original_target", repo.join("link")).expect("create symlink");
|
|
write_file(repo, "normal.txt", "base\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
fs::remove_file(repo.join("link")).unwrap();
|
|
std::os::unix::fs::symlink("remote_target", repo.join("link")).expect("create symlink");
|
|
write_file(repo, "normal.txt", "remote\n");
|
|
commit_all(repo, "feature: change both");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
fs::remove_file(repo.join("link")).unwrap();
|
|
std::os::unix::fs::symlink("local_target", repo.join("link")).expect("create symlink");
|
|
write_file(repo, "normal.txt", "local\n");
|
|
commit_all(repo, "main: change both");
|
|
|
|
let merge_out = run_git_capture(repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(repo);
|
|
|
|
// Pipe "l\n" for the symlink prompt. The normal file conflict
|
|
// will be processed by our external tool automatically.
|
|
let output = run_git_with_stdin(repo, &["mergetool", "--no-prompt"], "l\n");
|
|
let text = output_text(&output);
|
|
|
|
// Verify the normal file was processed by our mergetool.
|
|
let normal_content = fs::read_to_string(repo.join("normal.txt")).unwrap();
|
|
assert!(
|
|
normal_content.contains("local")
|
|
|| normal_content.contains("remote")
|
|
|| normal_content.contains("<<<<<<<"),
|
|
"expected external tool to process normal file conflict\nnormal.txt:\n{normal_content}\ngit output:\n{text}"
|
|
);
|
|
|
|
// Verify the symlink was resolved by git's internal handler.
|
|
let target = fs::read_link(repo.join("link"));
|
|
assert!(
|
|
target.is_ok(),
|
|
"expected symlink to be resolved\ngit output:\n{text}"
|
|
);
|
|
assert_eq!(
|
|
target.unwrap().to_string_lossy(),
|
|
"local_target",
|
|
"expected local symlink target"
|
|
);
|
|
}
|
|
|
|
// ── Submodule conflict behavior ──────────────────────────────────────
|
|
|
|
fn create_submodule_repo(path: &Path) {
|
|
run_git(path, &["init", "-b", "main"]);
|
|
run_git(path, &["config", "user.email", "sub@example.com"]);
|
|
run_git(path, &["config", "user.name", "Sub"]);
|
|
run_git(path, &["config", "commit.gpgsign", "false"]);
|
|
write_file(path, "sub_file.txt", "submodule content\n");
|
|
run_git(path, &["add", "-A"]);
|
|
run_git(
|
|
path,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"initial submodule",
|
|
],
|
|
);
|
|
}
|
|
|
|
fn advance_submodule(path: &Path, content: &str, message: &str) {
|
|
write_file(path, "sub_file.txt", content);
|
|
run_git(path, &["add", "-A"]);
|
|
run_git(
|
|
path,
|
|
&["-c", "commit.gpgsign=false", "commit", "-m", message],
|
|
);
|
|
}
|
|
|
|
/// Set up a deleted-vs-modified submodule conflict:
|
|
/// - `feature` updates `submod` to a newer gitlink
|
|
/// - `main` deletes `submod`
|
|
///
|
|
/// Returns the updated submodule commit SHA when a conflict is present.
|
|
/// Some git versions may auto-resolve; callers should skip when `None`.
|
|
fn setup_deleted_vs_modified_submodule_conflict(repo: &Path, sub_repo: &Path) -> Option<String> {
|
|
create_submodule_repo(sub_repo);
|
|
init_repo(repo);
|
|
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"-c",
|
|
"protocol.file.allow=always",
|
|
"submodule",
|
|
"add",
|
|
&sub_url,
|
|
"submod",
|
|
],
|
|
);
|
|
commit_all(repo, "add submodule");
|
|
|
|
advance_submodule(sub_repo, "advanced\n", "advance");
|
|
let advanced_out = run_git_capture(sub_repo, &["rev-parse", "HEAD"]);
|
|
let advanced_commit = String::from_utf8_lossy(&advanced_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
// Feature updates the submodule to the advanced commit.
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &advanced_commit]);
|
|
run_git(repo, &["add", "submod"]);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"feature: update submod",
|
|
],
|
|
);
|
|
|
|
// Main deletes the submodule.
|
|
run_git(repo, &["checkout", "main"]);
|
|
run_git(repo, &["submodule", "deinit", "-f", "submod"]);
|
|
run_git(repo, &["rm", "-f", "submod"]);
|
|
let gitmodules = repo.join(".gitmodules");
|
|
if gitmodules.exists() {
|
|
let content = fs::read_to_string(&gitmodules).unwrap_or_default();
|
|
if content.trim().is_empty() || !content.contains("[submodule") {
|
|
let _ = run_git_capture(repo, &["rm", "-f", ".gitmodules"]);
|
|
}
|
|
}
|
|
run_git(repo, &["add", "-A"]);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"main: remove submod",
|
|
],
|
|
);
|
|
|
|
let merge_out = run_git_capture(repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
None
|
|
} else {
|
|
Some(advanced_commit)
|
|
}
|
|
}
|
|
|
|
/// Set up a modified-vs-deleted submodule conflict (reverse orientation):
|
|
/// - `feature` updates `submod` to a newer gitlink
|
|
/// - `main` deletes `submod`
|
|
/// - merge `main` into `feature` so local=modified and remote=deleted.
|
|
///
|
|
/// Returns the updated submodule commit SHA when a conflict is present.
|
|
/// Some git versions may auto-resolve; callers should skip when `None`.
|
|
fn setup_modified_vs_deleted_submodule_conflict(repo: &Path, sub_repo: &Path) -> Option<String> {
|
|
create_submodule_repo(sub_repo);
|
|
init_repo(repo);
|
|
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"-c",
|
|
"protocol.file.allow=always",
|
|
"submodule",
|
|
"add",
|
|
&sub_url,
|
|
"submod",
|
|
],
|
|
);
|
|
commit_all(repo, "add submodule");
|
|
|
|
advance_submodule(sub_repo, "advanced\n", "advance");
|
|
let advanced_out = run_git_capture(sub_repo, &["rev-parse", "HEAD"]);
|
|
let advanced_commit = String::from_utf8_lossy(&advanced_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
// Feature updates the submodule to the advanced commit.
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &advanced_commit]);
|
|
run_git(repo, &["add", "submod"]);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"feature: update submod",
|
|
],
|
|
);
|
|
|
|
// Main deletes the submodule.
|
|
run_git(repo, &["checkout", "main"]);
|
|
run_git(repo, &["submodule", "deinit", "-f", "submod"]);
|
|
run_git(repo, &["rm", "-f", "submod"]);
|
|
let gitmodules = repo.join(".gitmodules");
|
|
if gitmodules.exists() {
|
|
let content = fs::read_to_string(&gitmodules).unwrap_or_default();
|
|
if content.trim().is_empty() || !content.contains("[submodule") {
|
|
let _ = run_git_capture(repo, &["rm", "-f", ".gitmodules"]);
|
|
}
|
|
}
|
|
run_git(repo, &["add", "-A"]);
|
|
run_git(
|
|
repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"main: remove submod",
|
|
],
|
|
);
|
|
|
|
// Merge deletion into modified branch: local=feature(modified), remote=main(deleted).
|
|
run_git(repo, &["checkout", "feature"]);
|
|
let merge_out = run_git_capture(repo, &["merge", "main"]);
|
|
if merge_out.status.success() {
|
|
None
|
|
} else {
|
|
Some(advanced_commit)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_submodule_conflict_resolved_via_local() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// When both branches update a submodule to different commits,
|
|
// git mergetool handles it internally with l/r/a prompt.
|
|
// Answering "l" keeps the local submodule commit.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
// Create the submodule source repo with initial commit.
|
|
create_submodule_repo(&sub_repo);
|
|
|
|
// Create the main repo and add the submodule.
|
|
init_repo(&repo);
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(&repo, &["submodule", "add", &sub_url, "submod"]);
|
|
commit_all(&repo, "add submodule");
|
|
|
|
// Create two diverging submodule commits.
|
|
advance_submodule(&sub_repo, "commit A\n", "advance A");
|
|
let commit_a_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_a = String::from_utf8_lossy(&commit_a_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
advance_submodule(&sub_repo, "commit B\n", "advance B");
|
|
let commit_b_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_b = String::from_utf8_lossy(&commit_b_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
// Branch feature: update submodule to commit_a.
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &commit_a]);
|
|
run_git(&repo, &["add", "submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"feature: update submod",
|
|
],
|
|
);
|
|
|
|
// Branch main: update submodule to commit_b.
|
|
run_git(&repo, &["checkout", "main"]);
|
|
run_git(&repo, &["submodule", "update", "--init"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &commit_b]);
|
|
run_git(&repo, &["add", "submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"main: update submod",
|
|
],
|
|
);
|
|
|
|
// Merge.
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
// Git auto-resolved the submodule conflict — skip.
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
// Answer "l" for the submodule prompt.
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt"], "l\n");
|
|
let text = output_text(&output);
|
|
|
|
// The submodule should be resolved to the local (main) commit.
|
|
let submod_head = run_git_capture(&repo.join("submod"), &["rev-parse", "HEAD"]);
|
|
let resolved_commit = String::from_utf8_lossy(&submod_head.stdout)
|
|
.trim()
|
|
.to_string();
|
|
assert_eq!(
|
|
resolved_commit, commit_b,
|
|
"expected local submodule commit after answering 'l'\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_submodule_conflict_resolved_via_remote() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Verify answering "r" keeps the remote submodule commit.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
create_submodule_repo(&sub_repo);
|
|
init_repo(&repo);
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(&repo, &["submodule", "add", &sub_url, "submod"]);
|
|
commit_all(&repo, "add submodule");
|
|
|
|
advance_submodule(&sub_repo, "commit A\n", "advance A");
|
|
let commit_a_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_a = String::from_utf8_lossy(&commit_a_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
advance_submodule(&sub_repo, "commit B\n", "advance B");
|
|
let commit_b_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let _commit_b = String::from_utf8_lossy(&commit_b_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &commit_a]);
|
|
run_git(&repo, &["add", "submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"feature: update submod",
|
|
],
|
|
);
|
|
|
|
run_git(&repo, &["checkout", "main"]);
|
|
run_git(&repo, &["submodule", "update", "--init"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &_commit_b]);
|
|
run_git(&repo, &["add", "submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"main: update submod",
|
|
],
|
|
);
|
|
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt"], "r\n");
|
|
let text = output_text(&output);
|
|
|
|
// The submodule should be resolved to the remote (feature) commit.
|
|
let submod_head = run_git_capture(&repo.join("submod"), &["rev-parse", "HEAD"]);
|
|
let resolved_commit = String::from_utf8_lossy(&submod_head.stdout)
|
|
.trim()
|
|
.to_string();
|
|
assert_eq!(
|
|
resolved_commit, commit_a,
|
|
"expected remote submodule commit after answering 'r'\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_submodule_conflict_choice_a_aborts_with_nonzero() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Parity with git submodule conflict prompt behavior: answering "a"
|
|
// should abort the mergetool run and leave the submodule conflict unresolved.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
create_submodule_repo(&sub_repo);
|
|
init_repo(&repo);
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(&repo, &["submodule", "add", &sub_url, "submod"]);
|
|
commit_all(&repo, "add submodule");
|
|
|
|
advance_submodule(&sub_repo, "commit A\n", "advance A");
|
|
let commit_a_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_a = String::from_utf8_lossy(&commit_a_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
advance_submodule(&sub_repo, "commit B\n", "advance B");
|
|
let commit_b_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_b = String::from_utf8_lossy(&commit_b_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &commit_a]);
|
|
run_git(&repo, &["add", "submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"feature: update submod",
|
|
],
|
|
);
|
|
|
|
run_git(&repo, &["checkout", "main"]);
|
|
run_git(&repo, &["submodule", "update", "--init"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &commit_b]);
|
|
run_git(&repo, &["add", "submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"main: update submod",
|
|
],
|
|
);
|
|
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
// Git auto-resolved the submodule conflict — skip.
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt"], "a\n");
|
|
let text = output_text(&output);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected mergetool to abort on submodule choice 'a'\n{text}"
|
|
);
|
|
assert!(
|
|
has_unmerged_entries_for_path(&repo, "submod"),
|
|
"expected submodule conflict to remain unresolved after abort\n{text}"
|
|
);
|
|
assert!(
|
|
stage_zero_gitlink_oid(&repo, "submod").is_none(),
|
|
"expected no stage-0 gitlink after abort\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_submodule_alongside_normal_file_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// When a repo has both a submodule conflict and a normal file conflict,
|
|
// git handles the submodule internally and invokes our external tool
|
|
// for the normal file conflict.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
create_submodule_repo(&sub_repo);
|
|
init_repo(&repo);
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(&repo, &["submodule", "add", &sub_url, "submod"]);
|
|
write_file(&repo, "normal.txt", "base\n");
|
|
commit_all(&repo, "add submodule and file");
|
|
|
|
advance_submodule(&sub_repo, "commit A\n", "advance A");
|
|
let commit_a_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_a = String::from_utf8_lossy(&commit_a_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
advance_submodule(&sub_repo, "commit B\n", "advance B");
|
|
let commit_b_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_b = String::from_utf8_lossy(&commit_b_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
// Feature branch: update submod to commit A and change normal.txt.
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &commit_a]);
|
|
write_file(&repo, "normal.txt", "remote change\n");
|
|
run_git(&repo, &["add", "submod", "normal.txt"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"feature changes",
|
|
],
|
|
);
|
|
|
|
// Main branch: update submod to commit B and change normal.txt differently.
|
|
run_git(&repo, &["checkout", "main"]);
|
|
run_git(&repo, &["submodule", "update", "--init"]);
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &commit_b]);
|
|
write_file(&repo, "normal.txt", "local change\n");
|
|
run_git(&repo, &["add", "submod", "normal.txt"]);
|
|
run_git(
|
|
&repo,
|
|
&["-c", "commit.gpgsign=false", "commit", "-m", "main changes"],
|
|
);
|
|
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
// Answer "l" for the submodule prompt. The normal file conflict
|
|
// will be handled by our external tool.
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt"], "l\n");
|
|
let text = output_text(&output);
|
|
|
|
// Verify the normal file was processed by our mergetool.
|
|
let normal_content = fs::read_to_string(repo.join("normal.txt")).unwrap();
|
|
assert!(
|
|
normal_content.contains("local")
|
|
|| normal_content.contains("remote")
|
|
|| normal_content.contains("<<<<<<<"),
|
|
"expected external tool to process normal file conflict\nnormal.txt:\n{normal_content}\ngit output:\n{text}"
|
|
);
|
|
|
|
// Verify the submodule was resolved.
|
|
let submod_head = run_git_capture(&repo.join("submod"), &["rev-parse", "HEAD"]);
|
|
let resolved_commit = String::from_utf8_lossy(&submod_head.stdout)
|
|
.trim()
|
|
.to_string();
|
|
assert_eq!(
|
|
resolved_commit, commit_b,
|
|
"expected local submodule commit\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_file_replaced_by_submodule_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// One branch keeps a regular file, the other replaces it with a submodule.
|
|
// Git mergetool handles this as a file-vs-submodule conflict.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
create_submodule_repo(&sub_repo);
|
|
init_repo(&repo);
|
|
|
|
// Base: create a regular file at the path that will become a submodule.
|
|
write_file(&repo, "submod", "not a submodule\n");
|
|
commit_all(&repo, "base: file at submod path");
|
|
|
|
// Feature: replace the file with a submodule.
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo, &["rm", "submod"]);
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"protocol.file.allow=always",
|
|
"submodule",
|
|
"add",
|
|
&sub_url,
|
|
"submod",
|
|
],
|
|
);
|
|
commit_all(&repo, "feature: replace file with submodule");
|
|
|
|
// Main: modify the regular file.
|
|
run_git(&repo, &["checkout", "main"]);
|
|
write_file(&repo, "submod", "modified file content\n");
|
|
commit_all(&repo, "main: modify file");
|
|
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
// Git handles file-vs-submodule conflicts with its own prompt.
|
|
// Pipe "l" to keep the local (file) side.
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt"], "l\n");
|
|
let _text = output_text(&output);
|
|
|
|
// The pipeline should complete without hanging or crashing.
|
|
// The exact resolution depends on git version, but the key is
|
|
// that the mergetool handled the mixed conflict type.
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_submodule_in_subdirectory_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Submodule conflict where the submodule is inside a subdirectory.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
create_submodule_repo(&sub_repo);
|
|
init_repo(&repo);
|
|
fs::create_dir_all(repo.join("subdir")).unwrap();
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"protocol.file.allow=always",
|
|
"submodule",
|
|
"add",
|
|
&sub_url,
|
|
"subdir/submod",
|
|
],
|
|
);
|
|
commit_all(&repo, "add submodule in subdirectory");
|
|
|
|
advance_submodule(&sub_repo, "commit A\n", "advance A");
|
|
let commit_a_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_a = String::from_utf8_lossy(&commit_a_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
advance_submodule(&sub_repo, "commit B\n", "advance B");
|
|
let commit_b_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let commit_b = String::from_utf8_lossy(&commit_b_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo.join("subdir/submod"), &["fetch"]);
|
|
run_git(&repo.join("subdir/submod"), &["checkout", &commit_a]);
|
|
run_git(&repo, &["add", "subdir/submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"feature: update submod",
|
|
],
|
|
);
|
|
|
|
run_git(&repo, &["checkout", "main"]);
|
|
run_git(&repo, &["submodule", "update", "--init"]);
|
|
run_git(&repo.join("subdir/submod"), &["fetch"]);
|
|
run_git(&repo.join("subdir/submod"), &["checkout", &commit_b]);
|
|
run_git(&repo, &["add", "subdir/submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"main: update submod",
|
|
],
|
|
);
|
|
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt"], "l\n");
|
|
let text = output_text(&output);
|
|
|
|
// Verify the submodule in the subdirectory was resolved.
|
|
let submod_head = run_git_capture(&repo.join("subdir/submod"), &["rev-parse", "HEAD"]);
|
|
let resolved_commit = String::from_utf8_lossy(&submod_head.stdout)
|
|
.trim()
|
|
.to_string();
|
|
assert_eq!(
|
|
resolved_commit, commit_b,
|
|
"expected local submodule commit in subdirectory\ngit output:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_deleted_submodule_choice_r_keeps_modified_module() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Parity with git t7610 deleted-vs-modified submodule matrix:
|
|
// when local side deleted and remote side modified, choosing "r"
|
|
// should keep the modified submodule gitlink.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
let Some(advanced_commit) = setup_deleted_vs_modified_submodule_conflict(&repo, &sub_repo)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt", "submod"], "r\n");
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
!has_unmerged_entries_for_path(&repo, "submod"),
|
|
"expected submodule conflict to be resolved after choosing 'r'\n{text}"
|
|
);
|
|
|
|
let resolved_oid = stage_zero_gitlink_oid(&repo, "submod");
|
|
assert_eq!(
|
|
resolved_oid.as_deref(),
|
|
Some(advanced_commit.as_str()),
|
|
"expected submodule gitlink to resolve to modified commit after choosing 'r'\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_deleted_submodule_choice_l_keeps_deletion() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Parity with git t7610 deleted-vs-modified submodule matrix:
|
|
// when local side deleted and remote side modified, choosing "l"
|
|
// should keep deletion.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
let Some(_advanced_commit) = setup_deleted_vs_modified_submodule_conflict(&repo, &sub_repo)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt", "submod"], "l\n");
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
!has_unmerged_entries_for_path(&repo, "submod"),
|
|
"expected submodule conflict to be resolved after choosing 'l'\n{text}"
|
|
);
|
|
assert!(
|
|
stage_zero_gitlink_oid(&repo, "submod").is_none(),
|
|
"expected submodule gitlink to be absent after choosing 'l'\n{text}"
|
|
);
|
|
assert!(
|
|
!repo.join("submod").exists(),
|
|
"expected deleted submodule path to remain absent after choosing 'l'\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_deleted_submodule_remote_deleted_choice_r_keeps_deletion() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Reverse deleted-vs-modified orientation parity:
|
|
// local side has modified submodule, remote side deleted it.
|
|
// Choosing "r" should keep deletion (remote side).
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
let Some(_advanced_commit) = setup_modified_vs_deleted_submodule_conflict(&repo, &sub_repo)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt", "submod"], "r\n");
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
!has_unmerged_entries_for_path(&repo, "submod"),
|
|
"expected submodule conflict to be resolved after choosing 'r'\n{text}"
|
|
);
|
|
assert!(
|
|
stage_zero_gitlink_oid(&repo, "submod").is_none(),
|
|
"expected submodule gitlink to be absent after choosing 'r'\n{text}"
|
|
);
|
|
assert!(
|
|
!repo.join("submod").exists(),
|
|
"expected deleted submodule path to remain absent after choosing 'r'\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_deleted_submodule_remote_deleted_choice_l_keeps_modified_module() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Reverse deleted-vs-modified orientation parity:
|
|
// local side has modified submodule, remote side deleted it.
|
|
// Choosing "l" should keep the modified submodule gitlink.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
let Some(advanced_commit) = setup_modified_vs_deleted_submodule_conflict(&repo, &sub_repo)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt", "submod"], "l\n");
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
!has_unmerged_entries_for_path(&repo, "submod"),
|
|
"expected submodule conflict to be resolved after choosing 'l'\n{text}"
|
|
);
|
|
|
|
let resolved_oid = stage_zero_gitlink_oid(&repo, "submod");
|
|
assert_eq!(
|
|
resolved_oid.as_deref(),
|
|
Some(advanced_commit.as_str()),
|
|
"expected submodule gitlink to resolve to modified commit after choosing 'l'\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_directory_vs_submodule_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
// Parity with git t7610: "directory vs modified submodule".
|
|
// One branch replaces a submodule with a regular directory (containing files).
|
|
// The other branch modifies the submodule. Git handles this conflict with
|
|
// its own l/r prompts; we verify the mergetool pipeline completes.
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().join("main_repo");
|
|
let sub_repo = tmp.path().join("sub_repo");
|
|
fs::create_dir_all(&repo).unwrap();
|
|
fs::create_dir_all(&sub_repo).unwrap();
|
|
|
|
create_submodule_repo(&sub_repo);
|
|
init_repo(&repo);
|
|
|
|
// Base: add a submodule.
|
|
let sub_url = format!("file://{}", sub_repo.display());
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"protocol.file.allow=always",
|
|
"submodule",
|
|
"add",
|
|
&sub_url,
|
|
"submod",
|
|
],
|
|
);
|
|
commit_all(&repo, "add submodule");
|
|
|
|
// Feature: replace the submodule with a regular directory.
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
run_git(&repo, &["submodule", "deinit", "-f", "submod"]);
|
|
run_git(&repo, &["rm", "-f", "submod"]);
|
|
// Clean up .gitmodules if empty.
|
|
let gitmodules = repo.join(".gitmodules");
|
|
if gitmodules.exists() {
|
|
let content = fs::read_to_string(&gitmodules).unwrap_or_default();
|
|
if content.trim().is_empty() || !content.contains("[submodule") {
|
|
let _ = run_git_capture(&repo, &["rm", "-f", ".gitmodules"]);
|
|
}
|
|
}
|
|
// Create a regular directory at the submod path.
|
|
fs::create_dir_all(repo.join("submod")).unwrap();
|
|
write_file(&repo, "submod/file16.txt", "not a submodule\n");
|
|
commit_all(&repo, "feature: replace submodule with directory");
|
|
|
|
// Main: update the submodule to a new commit.
|
|
run_git(&repo, &["checkout", "main"]);
|
|
run_git(&repo, &["submodule", "update", "--init"]);
|
|
advance_submodule(&sub_repo, "advanced content\n", "advance submod");
|
|
let advanced_out = run_git_capture(&sub_repo, &["rev-parse", "HEAD"]);
|
|
let advanced_commit = String::from_utf8_lossy(&advanced_out.stdout)
|
|
.trim()
|
|
.to_string();
|
|
run_git(&repo.join("submod"), &["fetch"]);
|
|
run_git(&repo.join("submod"), &["checkout", &advanced_commit]);
|
|
run_git(&repo, &["add", "submod"]);
|
|
run_git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"commit.gpgsign=false",
|
|
"commit",
|
|
"-m",
|
|
"main: update submod",
|
|
],
|
|
);
|
|
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature"]);
|
|
if merge_out.status.success() {
|
|
// Some git versions may auto-resolve this; that's fine.
|
|
return;
|
|
}
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
// Git handles directory-vs-submodule conflicts with its own prompt.
|
|
// Answer "l" to keep the local side (submodule).
|
|
let output = run_git_with_stdin(&repo, &["mergetool", "--no-prompt"], "l\n");
|
|
let _text = output_text(&output);
|
|
|
|
// The pipeline should complete without hanging or crashing.
|
|
// The exact resolution depends on git version.
|
|
}
|
|
|
|
// ── Git config fallback tests ──────────────────────────────────────
|
|
//
|
|
// Conflictstyle assertions only need the MERGED content written by gitcomet.
|
|
// Run gitcomet and then force a zero exit to avoid Git-version-dependent
|
|
// behavior around trustExitCode=false with non-zero tool exits.
|
|
fn configure_gitcomet_mergetool_preserve_output(repo: &Path) {
|
|
let bin = gitcomet_bin();
|
|
let bin_q = shell_quote(&bin.to_string_lossy());
|
|
let cmd = format!(
|
|
"{bin_q} mergetool --base \"$BASE\" --local \"$LOCAL\" --remote \"$REMOTE\" --merged \"$MERGED\"; exit 0"
|
|
);
|
|
|
|
run_git(repo, &["config", "merge.tool", "gitcomet"]);
|
|
run_git(repo, &["config", "mergetool.gitcomet.cmd", &cmd]);
|
|
run_git(
|
|
repo,
|
|
&["config", "mergetool.gitcomet.trustExitCode", "true"],
|
|
);
|
|
run_git(repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(repo, &["config", "mergetool.keepBackup", "false"]);
|
|
}
|
|
|
|
fn setup_simple_overlapping_conflict(repo: &Path) {
|
|
write_file(repo, "file.txt", "original\n");
|
|
commit_all(repo, "base");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
write_file(repo, "file.txt", "remote change\n");
|
|
commit_all(repo, "feature");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
write_file(repo, "file.txt", "local change\n");
|
|
commit_all(repo, "main");
|
|
|
|
let merge_out = run_git_capture(repo, &["merge", "feature", "--no-commit"]);
|
|
assert!(
|
|
!merge_out.status.success(),
|
|
"expected merge conflict, got success"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_respects_merge_conflictstyle_zdiff3_from_git_config() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
init_repo(&repo);
|
|
setup_simple_overlapping_conflict(&repo);
|
|
|
|
// Set zdiff3 via git config (no CLI flag).
|
|
run_git(&repo, &["config", "merge.conflictstyle", "zdiff3"]);
|
|
|
|
configure_gitcomet_mergetool_preserve_output(&repo);
|
|
|
|
let output = run_git_capture(&repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
|
|
// zdiff3/diff3 both include the ||||||| base section.
|
|
assert!(
|
|
merged.contains("|||||||"),
|
|
"zdiff3 should include base section (|||||||), got:\n{merged}\nstderr:\n{text}"
|
|
);
|
|
// zdiff3 base section should contain the original content.
|
|
assert!(
|
|
merged.contains("original"),
|
|
"zdiff3 base section should contain 'original', got:\n{merged}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_respects_merge_conflictstyle_diff3_from_git_config() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
init_repo(&repo);
|
|
setup_simple_overlapping_conflict(&repo);
|
|
|
|
// Set diff3 via git config.
|
|
run_git(&repo, &["config", "merge.conflictstyle", "diff3"]);
|
|
|
|
configure_gitcomet_mergetool_preserve_output(&repo);
|
|
|
|
let _output = run_git_capture(&repo, &["mergetool", "--no-prompt"]);
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
|
|
// diff3 format should include base section with "original".
|
|
assert!(
|
|
merged.contains("|||||||"),
|
|
"diff3 should include base section (|||||||), got:\n{merged}"
|
|
);
|
|
assert!(
|
|
merged.contains("original"),
|
|
"diff3 base section should contain original content, got:\n{merged}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gitcomet_mergetool_reads_conflictstyle_from_repo_when_cwd_is_outside_repo() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
init_repo(&repo);
|
|
run_git(&repo, &["config", "merge.conflictstyle", "diff3"]);
|
|
|
|
let base = repo.join("base.txt");
|
|
let local = repo.join("local.txt");
|
|
let remote = repo.join("remote.txt");
|
|
let merged = repo.join("merged.txt");
|
|
|
|
write_file(&repo, "base.txt", "original\n");
|
|
write_file(&repo, "local.txt", "local change\n");
|
|
write_file(&repo, "remote.txt", "remote change\n");
|
|
|
|
let outside = tempfile::tempdir().unwrap();
|
|
let mut cmd = Command::new(gitcomet_bin());
|
|
apply_isolated_git_config_env(&mut cmd);
|
|
let output = cmd
|
|
.current_dir(outside.path())
|
|
.arg("mergetool")
|
|
.arg("--base")
|
|
.arg(&base)
|
|
.arg("--local")
|
|
.arg(&local)
|
|
.arg("--remote")
|
|
.arg(&remote)
|
|
.arg("--merged")
|
|
.arg(&merged)
|
|
.output()
|
|
.expect("gitcomet mergetool command to run");
|
|
|
|
let text = output_text(&output);
|
|
let merged_text = fs::read_to_string(&merged).unwrap_or_else(|e| {
|
|
panic!(
|
|
"expected merged output at {}, got error: {e}\n{text}",
|
|
merged.display()
|
|
)
|
|
});
|
|
|
|
assert!(
|
|
merged_text.contains("|||||||"),
|
|
"diff3 fallback should include base section when cwd is outside repo\nmerged:\n{merged_text}\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
merged_text.contains("original"),
|
|
"diff3 base section should contain original content\nmerged:\n{merged_text}\noutput:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_kdiff3_path_override_respects_merge_conflictstyle_diff3_from_git_config() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
init_repo(&repo);
|
|
setup_simple_overlapping_conflict(&repo);
|
|
|
|
// Use a kdiff3-compatible argv shape and force a zero shell exit so this
|
|
// test does not depend on Git-version-specific no-trust behavior.
|
|
let bin = gitcomet_bin();
|
|
let bin_q = shell_quote(&bin.to_string_lossy());
|
|
let compat_cmd = format!(
|
|
"{bin_q} --auto --L1 \"BASE\" --L2 \"LOCAL\" --L3 \"REMOTE\" \"$BASE\" \"$LOCAL\" \"$REMOTE\" -o \"$MERGED\"; exit 0"
|
|
);
|
|
configure_mergetool_selection(&repo, "kdiff3compat", None, None);
|
|
configure_mergetool_command(&repo, "kdiff3compat", &compat_cmd);
|
|
configure_mergetool_trust_exit_code(&repo, "kdiff3compat", true);
|
|
run_git(&repo, &["config", "merge.conflictstyle", "diff3"]);
|
|
|
|
let output = run_git_capture(
|
|
&repo,
|
|
&["mergetool", "--no-prompt", "--tool", "kdiff3compat"],
|
|
);
|
|
let text = output_text(&output);
|
|
assert!(
|
|
output.status.success(),
|
|
"expected kdiff3 compatibility-style invocation to succeed\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("unexpected argument '--auto'")
|
|
&& !text.contains("unexpected argument '--L1'")
|
|
&& !text.contains("unexpected argument '--L2'")
|
|
&& !text.contains("unexpected argument '--L3'")
|
|
&& !text.contains("unexpected argument '-o'"),
|
|
"expected kdiff3 compatibility flags to be accepted\n{text}"
|
|
);
|
|
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
assert!(
|
|
merged.contains("|||||||"),
|
|
"compat mergetool should honor diff3 conflictstyle from git config, got:\n{merged}\noutput:\n{text}"
|
|
);
|
|
assert!(
|
|
merged.contains("original"),
|
|
"compat diff3 base section should contain original content, got:\n{merged}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_respects_diff_algorithm_histogram_from_git_config() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
init_repo(&repo);
|
|
|
|
// C code case where histogram produces clean merge but Myers may not.
|
|
let base = "void f() {\n x = 1;\n}\nvoid g() {\n y = 1;\n}\n";
|
|
let ours = "void h() {\n z = 1;\n}\nvoid g() {\n y = 1;\n}\n";
|
|
let theirs = "void f() {\n x = 1;\n}\nvoid g() {\n y = 2;\n}\n";
|
|
|
|
write_file(&repo, "code.c", base);
|
|
commit_all(&repo, "base");
|
|
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
write_file(&repo, "code.c", theirs);
|
|
commit_all(&repo, "feature");
|
|
|
|
run_git(&repo, &["checkout", "main"]);
|
|
write_file(&repo, "code.c", ours);
|
|
commit_all(&repo, "main");
|
|
|
|
let merge_out = run_git_capture(&repo, &["merge", "feature", "--no-commit"]);
|
|
if merge_out.status.success() {
|
|
// Git itself may auto-resolve; skip if no conflict.
|
|
return;
|
|
}
|
|
|
|
// Set histogram via git config.
|
|
run_git(&repo, &["config", "diff.algorithm", "histogram"]);
|
|
|
|
// Histogram should produce a clean merge (exit 0), so trustExitCode=true
|
|
// works fine here — git will accept the result.
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_capture(&repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
let merged = fs::read_to_string(repo.join("code.c")).unwrap();
|
|
|
|
// Histogram should produce a cleaner merge that includes both changes.
|
|
assert!(
|
|
merged.contains("void h()") || merged.contains("void g()"),
|
|
"histogram merge should produce meaningful output, got:\n{merged}\nstderr:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn git_mergetool_cli_flag_overrides_git_config() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
init_repo(&repo);
|
|
setup_simple_overlapping_conflict(&repo);
|
|
|
|
// Git config says zdiff3, but CLI flag overrides to merge.
|
|
run_git(&repo, &["config", "merge.conflictstyle", "zdiff3"]);
|
|
|
|
// Configure mergetool with explicit --conflict-style merge flag.
|
|
let bin = gitcomet_bin();
|
|
let bin_q = shell_quote(&bin.to_string_lossy());
|
|
let cmd = format!(
|
|
"{bin_q} mergetool --conflict-style merge --base \"$BASE\" --local \"$LOCAL\" --remote \"$REMOTE\" --merged \"$MERGED\"; exit 0"
|
|
);
|
|
run_git(&repo, &["config", "merge.tool", "gitcomet"]);
|
|
run_git(&repo, &["config", "mergetool.gitcomet.cmd", &cmd]);
|
|
// Force success so this test does not depend on non-zero no-trust behavior.
|
|
run_git(
|
|
&repo,
|
|
&["config", "mergetool.gitcomet.trustExitCode", "true"],
|
|
);
|
|
run_git(&repo, &["config", "mergetool.prompt", "false"]);
|
|
run_git(&repo, &["config", "mergetool.keepBackup", "false"]);
|
|
|
|
let _output = run_git_capture(&repo, &["mergetool", "--no-prompt"]);
|
|
let merged = fs::read_to_string(repo.join("file.txt")).unwrap();
|
|
|
|
// CLI flag "merge" should override config "zdiff3" — no base section.
|
|
assert!(
|
|
!merged.contains("|||||||"),
|
|
"explicit --conflict-style merge should suppress base section, got:\n{merged}"
|
|
);
|
|
assert!(merged.contains("<<<<<<<"), "should have conflict markers");
|
|
}
|
|
|
|
// ── Binary file conflict handling ────────────────────────────────────
|
|
|
|
fn write_bytes_to_file(repo: &Path, rel: &str, bytes: &[u8]) {
|
|
let path = repo.join(rel);
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).expect("create parent directories");
|
|
}
|
|
fs::write(path, bytes).expect("write bytes file");
|
|
}
|
|
|
|
/// Create a conflict on a binary file: base has one binary content,
|
|
/// main and feature branches modify it differently.
|
|
fn setup_binary_conflict(repo: &Path) {
|
|
init_repo(repo);
|
|
|
|
// Base: a small "binary" file with null bytes.
|
|
let base_bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0x01, 0x02, 0x03];
|
|
write_bytes_to_file(repo, "image.bin", &base_bytes);
|
|
commit_all(repo, "base: add binary file");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
let feature_bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0xAA, 0xBB, 0xCC];
|
|
write_bytes_to_file(repo, "image.bin", &feature_bytes);
|
|
commit_all(repo, "feature: modify binary file");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
let main_bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0xDD, 0xEE, 0xFF];
|
|
write_bytes_to_file(repo, "image.bin", &main_bytes);
|
|
commit_all(repo, "main: modify binary file differently");
|
|
|
|
// Merge will fail with a conflict.
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected merge to fail with binary conflict"
|
|
);
|
|
}
|
|
|
|
/// Create a conflict on a non-UTF-8 file (without NUL bytes): base is text,
|
|
/// then both branches write different invalid UTF-8 payloads.
|
|
fn setup_non_utf8_conflict(repo: &Path) {
|
|
init_repo(repo);
|
|
|
|
write_bytes_to_file(repo, "payload.dat", b"prefix\nbase\n");
|
|
commit_all(repo, "base: add payload file");
|
|
|
|
run_git(repo, &["checkout", "-b", "feature"]);
|
|
let feature_bytes: Vec<u8> = b"prefix\n\xFF\n".to_vec();
|
|
write_bytes_to_file(repo, "payload.dat", &feature_bytes);
|
|
commit_all(repo, "feature: write non-utf8 bytes");
|
|
|
|
run_git(repo, &["checkout", "main"]);
|
|
let main_bytes: Vec<u8> = b"prefix\n\xFE\n".to_vec();
|
|
write_bytes_to_file(repo, "payload.dat", &main_bytes);
|
|
commit_all(repo, "main: write different non-utf8 bytes");
|
|
|
|
let output = run_git_capture(repo, &["merge", "feature"]);
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected merge to fail with non-UTF-8 conflict"
|
|
);
|
|
}
|
|
|
|
/// E2E: `git mergetool` with a binary file conflict reports the binary
|
|
/// conflict and keeps the local version in the merged output.
|
|
///
|
|
/// This covers behavior matrix item #4 (binary and non-UTF8 content)
|
|
/// end-to-end through the actual `git mergetool` invocation.
|
|
#[test]
|
|
fn git_mergetool_binary_conflict_keeps_local_version() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
setup_binary_conflict(&repo);
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_capture(&repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// The tool should report binary conflict via stderr.
|
|
assert!(
|
|
text.contains("binary") || text.contains("Binary"),
|
|
"expected binary conflict message in tool output, got:\n{text}"
|
|
);
|
|
|
|
// With trustExitCode=true and our tool exiting 1 (CANCELED for binary),
|
|
// git considers the file unresolved. The merged file should contain
|
|
// the local version's bytes (our tool copies LOCAL to MERGED for binary).
|
|
let merged_bytes = fs::read(repo.join("image.bin")).unwrap();
|
|
let main_bytes: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0xDD, 0xEE, 0xFF];
|
|
assert_eq!(
|
|
merged_bytes, main_bytes,
|
|
"binary conflict should keep local (main) version in MERGED"
|
|
);
|
|
}
|
|
|
|
/// E2E: `git mergetool` with non-UTF-8 bytes (no NULs) still routes through
|
|
/// conflict handling without crashing and preserves raw invalid bytes.
|
|
#[test]
|
|
fn git_mergetool_non_utf8_conflict_keeps_local_version() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
setup_non_utf8_conflict(&repo);
|
|
configure_gitcomet_mergetool(&repo);
|
|
|
|
let output = run_git_capture(&repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
assert!(
|
|
text.contains("binary")
|
|
|| text.contains("Binary")
|
|
|| text.contains("CONFLICT")
|
|
|| text.contains("conflict"),
|
|
"expected conflict-related message in tool output, got:\n{text}"
|
|
);
|
|
|
|
let merged_bytes = fs::read(repo.join("payload.dat")).unwrap();
|
|
let main_bytes: Vec<u8> = b"prefix\n\xFE\n".to_vec();
|
|
let has_conflict_markers = merged_bytes
|
|
.windows("<<<<<<<".len())
|
|
.any(|window| window == b"<<<<<<<");
|
|
|
|
if has_conflict_markers {
|
|
assert!(
|
|
merged_bytes.contains(&0xFE),
|
|
"expected local invalid byte to be preserved"
|
|
);
|
|
assert!(
|
|
merged_bytes.contains(&0xFF),
|
|
"expected remote invalid byte to be preserved when markers remain"
|
|
);
|
|
} else {
|
|
assert_eq!(
|
|
merged_bytes, main_bytes,
|
|
"non-UTF-8 conflict without markers should keep local (main) version in MERGED"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// E2E: `git mergetool` with a binary file conflict alongside a text conflict
|
|
/// processes both files correctly — the text conflict gets auto-merged while
|
|
/// the binary conflict is detected and handled separately.
|
|
#[test]
|
|
fn git_mergetool_binary_conflict_alongside_text_conflict() {
|
|
if !require_git_shell_for_tool_tests() {
|
|
return;
|
|
}
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo = tmp.path().to_path_buf();
|
|
init_repo(&repo);
|
|
|
|
// Base commit: one text file and one binary file.
|
|
write_file(&repo, "text.txt", "aaa\nbbb\nccc\n");
|
|
let base_bytes: Vec<u8> = vec![0x00, 0x01, 0x02, 0x03];
|
|
write_bytes_to_file(&repo, "image.bin", &base_bytes);
|
|
commit_all(&repo, "base");
|
|
|
|
// Feature branch: modify both files.
|
|
run_git(&repo, &["checkout", "-b", "feature"]);
|
|
write_file(&repo, "text.txt", "aaa\nREMOTE\nccc\n");
|
|
let feature_bytes: Vec<u8> = vec![0x00, 0xAA, 0xBB, 0xCC];
|
|
write_bytes_to_file(&repo, "image.bin", &feature_bytes);
|
|
commit_all(&repo, "feature: change both files");
|
|
|
|
// Main branch: modify both files differently.
|
|
run_git(&repo, &["checkout", "main"]);
|
|
write_file(&repo, "text.txt", "aaa\nLOCAL\nccc\n");
|
|
let main_bytes: Vec<u8> = vec![0x00, 0xDD, 0xEE, 0xFF];
|
|
write_bytes_to_file(&repo, "image.bin", &main_bytes);
|
|
commit_all(&repo, "main: change both files differently");
|
|
|
|
let merge_output = run_git_capture(&repo, &["merge", "feature"]);
|
|
assert!(
|
|
!merge_output.status.success(),
|
|
"expected merge to fail with conflicts"
|
|
);
|
|
|
|
configure_gitcomet_mergetool(&repo);
|
|
let output = run_git_capture(&repo, &["mergetool", "--no-prompt"]);
|
|
let text = output_text(&output);
|
|
|
|
// The text file conflict should have been processed by our mergetool.
|
|
// Since both sides changed the same line differently, it will have markers.
|
|
let text_merged = fs::read_to_string(repo.join("text.txt")).unwrap();
|
|
assert!(
|
|
text_merged.contains("<<<<<<<")
|
|
|| text_merged.contains("LOCAL")
|
|
|| text_merged.contains("REMOTE"),
|
|
"text conflict should be processed, got:\n{text_merged}"
|
|
);
|
|
|
|
// The binary file should have been handled: local version kept.
|
|
let bin_merged = fs::read(repo.join("image.bin")).unwrap();
|
|
assert_eq!(
|
|
bin_merged, main_bytes,
|
|
"binary conflict should keep local (main) version"
|
|
);
|
|
|
|
// Tool output should mention binary.
|
|
assert!(
|
|
text.contains("binary") || text.contains("Binary"),
|
|
"expected binary conflict mention in output, got:\n{text}"
|
|
);
|
|
}
|