GitComet/crates/gitcomet-git-gix/tests/status_integration.rs
2026-03-12 12:35:47 +02:00

7333 lines
226 KiB
Rust

use gitcomet_core::conflict_session::{ConflictPayload, ConflictResolverStrategy};
use gitcomet_core::domain::{DiffArea, DiffTarget, FileConflictKind, FileStatusKind};
use gitcomet_core::error::ErrorKind;
use gitcomet_core::services::ConflictSide;
use gitcomet_core::services::GitBackend;
use gitcomet_git_gix::GixBackend;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::Mutex;
#[cfg(windows)]
use std::sync::OnceLock;
#[cfg(windows)]
use std::thread;
#[cfg(windows)]
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
#[cfg(windows)]
const NULL_DEVICE: &str = "NUL";
#[cfg(not(windows))]
const NULL_DEVICE: &str = "/dev/null";
fn git_path_arg(path: &Path) -> String {
path.to_str()
.expect("test path should be unicode")
.to_string()
}
fn git_remote_url(path: &Path) -> String {
git_path_arg(path)
}
fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
fn repo_local_mergetool_consent_key(repo: &Path, tool_name: &str) -> String {
let repo_path = fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
let mut bytes = stable_path_bytes(&repo_path);
bytes.push(0);
bytes.extend_from_slice(tool_name.as_bytes());
format!(
"gitcomet.mergetool.allowrepolocalcmd-{:016x}",
fnv1a_64(&bytes)
)
}
fn stable_path_bytes(path: &Path) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt as _;
return path.as_os_str().as_bytes().to_vec();
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt as _;
let mut bytes = Vec::new();
for unit in path.as_os_str().encode_wide() {
bytes.extend_from_slice(&unit.to_le_bytes());
}
return bytes;
}
#[cfg(not(any(unix, windows)))]
{
path.to_str()
.map(|text| text.as_bytes().to_vec())
.unwrap_or_else(|| format!("{path:?}").into_bytes())
}
}
fn allow_repo_local_mergetool_cmd(repo: &Path, tool_name: &str) {
static GLOBAL_CONFIG_WRITE_LOCK: Mutex<()> = Mutex::new(());
let _guard = GLOBAL_CONFIG_WRITE_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let consent_key = repo_local_mergetool_consent_key(repo, tool_name);
let status = Command::new("git")
.arg("-C")
.arg(repo)
.args(["config", "--global", &consent_key, "true"])
.status()
.expect("git config --global to run");
assert!(
status.success(),
"git config --global {} true failed",
consent_key
);
}
fn set_repo_local_mergetool_cmd_with_consent(repo: &Path, tool_name: &str, command: &str) {
let cmd_key = format!("mergetool.{tool_name}.cmd");
run_git(repo, &["config", &cmd_key, command]);
allow_repo_local_mergetool_cmd(repo, tool_name);
}
#[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)]
const GIT_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
#[cfg(windows)]
const GIT_PROBE_WAIT_POLL: Duration = Duration::from_millis(50);
#[cfg(windows)]
fn run_command_with_timeout(mut cmd: Command) -> Option<std::process::Output> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().ok()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return child.wait_with_output().ok(),
Ok(None) => {
if start.elapsed() >= GIT_PROBE_TIMEOUT {
let _ = child.kill();
let _ = child.wait();
return None;
}
thread::sleep(GIT_PROBE_WAIT_POLL);
}
Err(_) => return None,
}
}
}
#[cfg(windows)]
fn git_shell_available_for_status_integration_tests() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
let output = match run_command_with_timeout({
let mut cmd = Command::new("git");
cmd.args(["difftool", "--tool-help"]);
cmd
}) {
Some(output) => output,
None => return false,
};
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)
})
}
#[cfg(windows)]
fn git_local_push_available_for_status_integration_tests() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(|| {
let dir = match tempfile::tempdir() {
Ok(dir) => dir,
Err(_) => return true,
};
let remote_repo = dir.path().join("probe-remote.git");
let work_repo = dir.path().join("probe-work");
if fs::create_dir_all(&remote_repo).is_err() || fs::create_dir_all(&work_repo).is_err() {
return true;
}
let init_remote = match run_command_with_timeout({
let mut cmd = git_command();
cmd.arg("-C").arg(&remote_repo).args(["init", "--bare"]);
cmd
}) {
Some(output) => output.status.success(),
None => false,
};
if !init_remote {
return true;
}
let init_work = match run_command_with_timeout({
let mut cmd = git_command();
cmd.arg("-C").arg(&work_repo).args(["init"]);
cmd
}) {
Some(output) => output.status.success(),
None => false,
};
if !init_work {
return true;
}
for args in [
["config", "user.email", "you@example.com"].as_slice(),
["config", "user.name", "You"].as_slice(),
["config", "commit.gpgsign", "false"].as_slice(),
["config", "core.autocrlf", "false"].as_slice(),
["config", "core.eol", "lf"].as_slice(),
] {
let output = match run_command_with_timeout({
let mut cmd = git_command();
cmd.arg("-C").arg(&work_repo).args(args);
cmd
}) {
Some(output) => output,
None => return false,
};
if !output.status.success() {
return true;
}
}
if fs::write(work_repo.join("probe.txt"), "probe\n").is_err() {
return true;
}
for args in [
["add", "probe.txt"].as_slice(),
["-c", "commit.gpgsign=false", "commit", "-m", "probe"].as_slice(),
] {
let output = match run_command_with_timeout({
let mut cmd = git_command();
cmd.arg("-C").arg(&work_repo).args(args);
cmd
}) {
Some(output) => output,
None => return false,
};
if !output.status.success() {
return true;
}
}
let remote_url = git_remote_url(&remote_repo);
let add_remote = match run_command_with_timeout({
let mut cmd = git_command();
cmd.arg("-C")
.arg(&work_repo)
.args(["remote", "add", "origin", remote_url.as_str()]);
cmd
}) {
Some(output) => output.status.success(),
None => false,
};
if !add_remote {
return true;
}
let push_output = match run_command_with_timeout({
let mut cmd = git_command();
cmd.arg("-C")
.arg(&work_repo)
.args(["push", "-u", "origin", "HEAD"]);
cmd
}) {
Some(output) => output,
None => return false,
};
if push_output.status.success() {
return true;
}
let text = format!(
"{}{}",
String::from_utf8_lossy(&push_output.stdout),
String::from_utf8_lossy(&push_output.stderr)
);
!is_git_shell_startup_failure(&text)
})
}
fn require_git_shell_for_status_integration_tests() -> bool {
#[cfg(windows)]
{
if !git_shell_available_for_status_integration_tests() {
eprintln!(
"skipping status integration test: Git-for-Windows shell startup failed in this environment"
);
return false;
}
if !git_local_push_available_for_status_integration_tests() {
eprintln!(
"skipping status integration test: Git-for-Windows local push shell startup failed in this environment"
);
return false;
}
}
true
}
fn git_command() -> Command {
let mut cmd = Command::new("git");
// Keep integration tests deterministic by isolating from host git config.
cmd.env("GIT_CONFIG_NOSYSTEM", "1");
cmd.env("GIT_CONFIG_GLOBAL", NULL_DEVICE);
cmd.env("GIT_TERMINAL_PROMPT", "0");
cmd.env("GCM_INTERACTIVE", "Never");
// Some scenarios clone local file:// remotes (submodules, temp-origin repos).
cmd.env("GIT_ALLOW_PROTOCOL", "file");
cmd
}
fn run_git(repo: &Path, args: &[&str]) {
let status = git_command()
.arg("-C")
.arg(repo)
.args(args)
.status()
.expect("git command to run");
assert!(status.success(), "git {:?} failed", args);
if args.first() == Some(&"init") {
// Keep text-file assertions deterministic across platforms, regardless
// of host/user git defaults.
run_git(repo, &["config", "core.autocrlf", "false"]);
run_git(repo, &["config", "core.eol", "lf"]);
// Avoid host credential manager prompts/retries in backend commands.
run_git(repo, &["config", "credential.helper", ""]);
run_git(repo, &["config", "credential.interactive", "never"]);
// Ensure local file:// remotes are always usable in this test repo.
run_git(repo, &["config", "protocol.file.allow", "always"]);
}
}
fn run_git_expect_failure(repo: &Path, args: &[&str]) {
let status = git_command()
.arg("-C")
.arg(repo)
.args(args)
.status()
.expect("git command to run");
assert!(!status.success(), "expected git {:?} to fail", args);
}
fn run_git_output(repo: &Path, args: &[&str]) -> String {
let output = git_command()
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git command to run");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn write(repo: &Path, rel: &str, contents: &str) -> PathBuf {
let path = repo.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, contents).unwrap();
path
}
fn write_bytes(repo: &Path, rel: &str, contents: &[u8]) -> PathBuf {
let path = repo.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, contents).unwrap();
path
}
fn hash_blob(repo: &Path, contents: &[u8]) -> String {
let mut child = git_command()
.arg("-C")
.arg(repo)
.args(["hash-object", "-w", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("git hash-object to run");
child
.stdin
.as_mut()
.expect("stdin pipe")
.write_all(contents)
.expect("write blob contents");
let output = child.wait_with_output().expect("wait for hash-object");
assert!(
output.status.success(),
"git hash-object failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("hash-object stdout utf8")
.trim()
.to_owned()
}
fn set_unmerged_stages(
repo: &Path,
path: &str,
base_blob: Option<&str>,
ours_blob: Option<&str>,
theirs_blob: Option<&str>,
) {
run_git(repo, &["update-index", "--force-remove", "--", path]);
let _ = fs::remove_file(repo.join(path));
let mut index_info = String::new();
if let Some(blob) = base_blob {
index_info.push_str(&format!("100644 {blob} 1\t{path}\n"));
}
if let Some(blob) = ours_blob {
index_info.push_str(&format!("100644 {blob} 2\t{path}\n"));
}
if let Some(blob) = theirs_blob {
index_info.push_str(&format!("100644 {blob} 3\t{path}\n"));
}
if index_info.is_empty() {
return;
}
let mut child = git_command()
.arg("-C")
.arg(repo)
.args(["update-index", "--index-info"])
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("git update-index --index-info to run");
child
.stdin
.as_mut()
.expect("stdin pipe")
.write_all(index_info.as_bytes())
.expect("write index-info");
let output = child.wait_with_output().expect("wait for update-index");
assert!(
output.status.success(),
"git update-index --index-info failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn setup_both_modified_text_conflict(repo: &Path, path: &str, ours: &str, theirs: &str) {
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
run_git(repo, &["config", "mergetool.guiDefault", "false"]);
run_git(repo, &["config", "merge.guitool", ""]);
write(repo, path, "base\n");
run_git(repo, &["add", path]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, path, theirs);
run_git(repo, &["add", path]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
write(repo, path, ours);
run_git(repo, &["add", path]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
}
fn setup_both_added_text_conflict(repo: &Path, path: &str, ours: &str, theirs: &str) {
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
run_git(repo, &["config", "mergetool.guiDefault", "false"]);
run_git(repo, &["config", "merge.guitool", ""]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, path, theirs);
run_git(repo, &["add", path]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs_add"],
);
run_git(repo, &["checkout", "-"]);
write(repo, path, ours);
run_git(repo, &["add", path]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours_add"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
}
#[cfg(unix)]
fn make_executable(path: &Path) {
fs::set_permissions(path, Permissions::from_mode(0o755)).unwrap();
}
#[cfg(windows)]
fn set_fixed_mtime(path: &Path) {
let status = Command::new("powershell")
.args([
"-NoProfile",
"-Command",
"(Get-Item -LiteralPath $env:GITCOMET_TARGET).LastWriteTimeUtc=[DateTimeOffset]::FromUnixTimeSeconds(1700000000).UtcDateTime",
])
.env("GITCOMET_TARGET", path)
.status()
.expect("powershell to run");
assert!(status.success());
}
#[cfg(not(windows))]
fn set_fixed_mtime(path: &Path) {
// `touch -d` is GNU-specific; `-t [[CC]YY]MMDDhhmm[.ss]` is supported on
// both GNU/Linux and BSD/macOS.
let status = Command::new("touch")
.arg("-t")
.arg("202311142213.20")
.arg(path)
.status()
.expect("touch to run");
assert!(status.success());
}
#[cfg(windows)]
fn cmd_same_size_content_change_and_exit_failure() -> &'static str {
r#"powershell -NoProfile -Command "$path=$env:MERGED; $len=(Get-Item -LiteralPath $path).Length; $bytes=New-Object byte[] $len; for ($i=0; $i -lt $len; $i++) { $bytes[$i]=[byte][char]'R' }; [System.IO.File]::WriteAllBytes($path, $bytes); (Get-Item -LiteralPath $path).LastWriteTimeUtc=[DateTimeOffset]::FromUnixTimeSeconds(1700000000).UtcDateTime" & exit /b 1"#
}
#[cfg(not(windows))]
fn cmd_same_size_content_change_and_exit_failure() -> &'static str {
"len=$(wc -c < \"$MERGED\"); head -c \"$len\" /dev/zero | tr '\\0' 'R' > \"$MERGED\"; touch -t 202311142213.20 \"$MERGED\"; exit 1"
}
#[cfg(windows)]
fn cmd_exit_success() -> &'static str {
"exit /b 0"
}
#[cfg(not(windows))]
fn cmd_exit_success() -> &'static str {
"exit 0"
}
#[cfg(windows)]
fn cmd_delete_merged_and_exit_failure() -> &'static str {
r#"powershell -NoProfile -Command "Remove-Item -LiteralPath $env:MERGED -Force -ErrorAction SilentlyContinue" & exit /b 1"#
}
#[cfg(not(windows))]
fn cmd_delete_merged_and_exit_failure() -> &'static str {
"rm -f \"$MERGED\"; exit 1"
}
#[cfg(windows)]
fn cmd_write_unresolved_markers_and_exit_success() -> &'static str {
r#"powershell -NoProfile -Command "[System.IO.File]::WriteAllText($env:MERGED, ('<<<<<<< ours' + [Environment]::NewLine + 'left' + [Environment]::NewLine + '=======' + [Environment]::NewLine + 'right' + [Environment]::NewLine + '>>>>>>> theirs' + [Environment]::NewLine))" & exit /b 0"#
}
#[cfg(not(windows))]
fn cmd_write_unresolved_markers_and_exit_success() -> &'static str {
"printf '<<<<<<< ours\nleft\n=======\nright\n>>>>>>> theirs\n' > \"$MERGED\"; exit 0"
}
#[cfg(windows)]
fn cmd_copy_remote_to_merged_and_exit_success() -> &'static str {
r#"powershell -NoProfile -Command "[System.IO.File]::WriteAllBytes($env:MERGED, [System.IO.File]::ReadAllBytes($env:REMOTE))""#
}
#[cfg(not(windows))]
fn cmd_copy_remote_to_merged_and_exit_success() -> &'static str {
"cat \"$REMOTE\" > \"$MERGED\"; exit 0"
}
#[cfg(windows)]
fn cmd_write_cli_to_merged() -> &'static str {
r#"powershell -NoProfile -Command "[System.IO.File]::WriteAllText($env:MERGED, 'cli' + [char]10)""#
}
#[cfg(not(windows))]
fn cmd_write_cli_to_merged() -> &'static str {
"printf 'cli\\n' > \"$MERGED\""
}
#[cfg(windows)]
fn cmd_write_gui_to_merged() -> &'static str {
r#"powershell -NoProfile -Command "[System.IO.File]::WriteAllText($env:MERGED, 'gui' + [char]10)""#
}
#[cfg(not(windows))]
fn cmd_write_gui_to_merged() -> &'static str {
"printf 'gui\\n' > \"$MERGED\""
}
#[cfg(windows)]
fn cmd_write_cmd_to_merged() -> &'static str {
r#"powershell -NoProfile -Command "[System.IO.File]::WriteAllText($env:MERGED, 'cmd' + [char]10)""#
}
#[cfg(not(windows))]
fn cmd_write_cmd_to_merged() -> &'static str {
"printf 'cmd\\n' > \"$MERGED\"; exit 0"
}
#[cfg(windows)]
fn cmd_dump_stage_paths_and_copy_remote() -> &'static str {
r#"powershell -NoProfile -Command "[System.IO.File]::WriteAllLines($env:MERGED + '.env', @($env:BASE, $env:LOCAL, $env:REMOTE)); [System.IO.File]::WriteAllBytes($env:MERGED, [System.IO.File]::ReadAllBytes($env:REMOTE))""#
}
#[cfg(not(windows))]
fn cmd_dump_stage_paths_and_copy_remote() -> &'static str {
"printf '%s\\n%s\\n%s\\n' \"$BASE\" \"$LOCAL\" \"$REMOTE\" > \"$MERGED.env\"; cat \"$REMOTE\" > \"$MERGED\""
}
#[cfg(windows)]
fn cmd_dump_stage_paths_and_exit_failure() -> &'static str {
r#"powershell -NoProfile -Command "[System.IO.File]::WriteAllLines($env:MERGED + '.env', @($env:BASE, $env:LOCAL, $env:REMOTE))" & exit /b 1"#
}
#[cfg(not(windows))]
fn cmd_dump_stage_paths_and_exit_failure() -> &'static str {
"printf '%s\\n%s\\n%s\\n' \"$BASE\" \"$LOCAL\" \"$REMOTE\" > \"$MERGED.env\"; exit 1"
}
#[cfg(windows)]
fn cmd_dump_base_size_and_copy_remote() -> &'static str {
r#"powershell -NoProfile -Command "$size=(Get-Item -LiteralPath $env:BASE).Length; [System.IO.File]::WriteAllText($env:MERGED + '.base-size', [string]$size); [System.IO.File]::WriteAllBytes($env:MERGED, [System.IO.File]::ReadAllBytes($env:REMOTE))""#
}
#[cfg(not(windows))]
fn cmd_dump_base_size_and_copy_remote() -> &'static str {
"printf '%s' \"$(wc -c < \"$BASE\" | tr -d '[:space:]')\" > \"$MERGED.base-size\"; cat \"$REMOTE\" > \"$MERGED\""
}
fn read_stage_env_vars(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| line.trim().to_string())
.collect()
}
fn normalize_stage_var(stage_var: &str) -> String {
stage_var.trim().replace('\\', "/")
}
fn stage_var_to_fs_path(repo: &Path, stage_var: &str) -> PathBuf {
let stage_path = Path::new(stage_var.trim());
if stage_path.is_absolute() {
stage_path.to_path_buf()
} else if let Ok(relative) = stage_path.strip_prefix(".") {
repo.join(relative)
} else {
repo.join(stage_path)
}
}
fn png_1x1_rgba(r: u8, g: u8, b: u8, a: u8) -> Vec<u8> {
fn push_be_u32(out: &mut Vec<u8>, v: u32) {
out.extend_from_slice(&v.to_be_bytes());
}
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = 0xFFFF_FFFFu32;
for &byte in bytes {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320u32 & mask);
}
}
!crc
}
fn adler32(bytes: &[u8]) -> u32 {
const MOD: u32 = 65521;
let mut a = 1u32;
let mut b = 0u32;
for &byte in bytes {
a = (a + byte as u32) % MOD;
b = (b + a) % MOD;
}
(b << 16) | a
}
let raw = [0u8, r, g, b, a];
let len = raw.len() as u16;
let nlen = !len;
let mut zlib = Vec::new();
zlib.push(0x78);
zlib.push(0x01);
zlib.push(0x01);
zlib.extend_from_slice(&len.to_le_bytes());
zlib.extend_from_slice(&nlen.to_le_bytes());
zlib.extend_from_slice(&raw);
push_be_u32(&mut zlib, adler32(&raw));
let mut out = Vec::new();
out.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]);
let mut ihdr = Vec::new();
push_be_u32(&mut ihdr, 1);
push_be_u32(&mut ihdr, 1);
ihdr.push(8);
ihdr.push(6);
ihdr.push(0);
ihdr.push(0);
ihdr.push(0);
push_be_u32(&mut out, ihdr.len() as u32);
out.extend_from_slice(b"IHDR");
out.extend_from_slice(&ihdr);
push_be_u32(&mut out, crc32(&[b"IHDR".as_slice(), &ihdr].concat()));
push_be_u32(&mut out, zlib.len() as u32);
out.extend_from_slice(b"IDAT");
out.extend_from_slice(&zlib);
push_be_u32(&mut out, crc32(&[b"IDAT".as_slice(), &zlib].concat()));
push_be_u32(&mut out, 0);
out.extend_from_slice(b"IEND");
push_be_u32(&mut out, crc32(b"IEND"));
out
}
#[derive(Clone, Copy)]
struct ConflictStageFixture {
path: &'static str,
kind: FileConflictKind,
has_base: bool,
has_ours: bool,
has_theirs: bool,
}
#[test]
fn status_separates_staged_and_unstaged() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
run_git(repo, &["add", "a.txt"]);
write(repo, "b.txt", "untracked\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert_eq!(status.staged.len(), 1);
assert_eq!(status.staged[0].path, PathBuf::from("a.txt"));
assert_eq!(status.staged[0].kind, FileStatusKind::Modified);
assert_eq!(status.unstaged.len(), 1);
assert_eq!(status.unstaged[0].path, PathBuf::from("b.txt"));
assert_eq!(status.unstaged[0].kind, FileStatusKind::Untracked);
}
#[test]
fn status_lists_untracked_files_in_directories() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
write(repo, "dir/a.txt", "one\n");
write(repo, "dir/b.txt", "two\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert_eq!(status.unstaged.len(), 2);
assert!(
status
.unstaged
.iter()
.any(|e| e.path == Path::new("dir/a.txt") && e.kind == FileStatusKind::Untracked)
);
assert!(
status
.unstaged
.iter()
.any(|e| e.path == Path::new("dir/b.txt") && e.kind == FileStatusKind::Untracked)
);
}
#[test]
fn status_ignores_nested_target_directories_with_target_slash_pattern() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, ".gitignore", "target/\n");
run_git(repo, &["add", ".gitignore"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init ignore"],
);
write(
repo,
"crates/gitcomet-ui-gpui/target/criterion/report/index.html",
"ignored\n",
);
write(repo, "visible.txt", "untracked\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert!(
status.unstaged.iter().all(|entry| !entry
.path
.starts_with(Path::new("crates/gitcomet-ui-gpui/target"))),
"expected nested target/ contents to be ignored, got {status:?}"
);
assert!(
status
.unstaged
.iter()
.any(|entry| entry.path == Path::new("visible.txt")
&& entry.kind == FileStatusKind::Untracked),
"expected visible.txt as untracked, got {status:?}"
);
}
#[test]
fn diff_unified_works_for_staged_and_unstaged() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let unstaged = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap();
assert!(unstaged.contains("@@"));
run_git(repo, &["add", "a.txt"]);
let staged = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap();
assert!(staged.contains("@@"));
}
#[test]
fn diff_file_text_reports_old_and_new_for_working_tree_and_commits() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let unstaged = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap()
.expect("file diff for unstaged changes");
assert_eq!(unstaged.path, PathBuf::from("a.txt"));
assert_eq!(unstaged.old.as_deref(), Some("one\n"));
assert_eq!(unstaged.new.as_deref(), Some("one\ntwo\n"));
run_git(repo, &["add", "a.txt"]);
let staged = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap()
.expect("file diff for staged changes");
assert_eq!(staged.old.as_deref(), Some("one\n"));
assert_eq!(staged.new.as_deref(), Some("one\ntwo\n"));
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "second"],
);
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("git rev-parse to run");
assert!(head.status.success());
let head = String::from_utf8(head.stdout).unwrap().trim().to_string();
let commit = opened
.diff_file_text(&DiffTarget::Commit {
commit_id: gitcomet_core::domain::CommitId(head),
path: Some(PathBuf::from("a.txt")),
})
.unwrap()
.expect("file diff for commit");
assert_eq!(commit.old.as_deref(), Some("one\n"));
assert_eq!(commit.new.as_deref(), Some("one\ntwo\n"));
}
#[test]
fn diff_file_text_root_commit_has_no_parent_side() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "root"],
);
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("git rev-parse to run");
assert!(head.status.success());
let head = String::from_utf8(head.stdout).unwrap().trim().to_string();
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let commit = opened
.diff_file_text(&DiffTarget::Commit {
commit_id: gitcomet_core::domain::CommitId(head),
path: Some(PathBuf::from("a.txt")),
})
.unwrap()
.expect("file diff for root commit");
assert_eq!(commit.old.as_deref(), None);
assert_eq!(commit.new.as_deref(), Some("one\n"));
}
#[test]
fn diff_file_text_staged_add_and_delete_report_missing_sides() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
// Stage a new file (missing on HEAD) and delete the initial file (missing on disk + index).
write(repo, "b.txt", "new\n");
run_git(repo, &["add", "b.txt"]);
run_git(repo, &["rm", "a.txt"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let added = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("b.txt"),
area: DiffArea::Staged,
})
.unwrap()
.expect("file diff for staged added file");
assert_eq!(added.path, PathBuf::from("b.txt"));
assert_eq!(added.old.as_deref(), None);
assert_eq!(added.new.as_deref(), Some("new\n"));
let deleted = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap()
.expect("file diff for staged deleted file");
assert_eq!(deleted.path, PathBuf::from("a.txt"));
assert_eq!(deleted.old.as_deref(), Some("one\n"));
assert_eq!(deleted.new.as_deref(), None);
}
#[test]
fn diff_file_text_returns_none_for_directories() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
write(repo, "dir/a.txt", "one\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("dir"),
area: DiffArea::Unstaged,
})
.unwrap();
assert!(result.is_none());
run_git(repo, &["add", "dir/a.txt"]);
let staged_result = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("dir"),
area: DiffArea::Staged,
})
.unwrap();
assert!(staged_result.is_none());
}
#[test]
fn diff_file_image_reports_old_and_new_for_working_tree_and_commits() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
let old_png = png_1x1_rgba(0, 0, 0, 255);
let new_png = png_1x1_rgba(255, 0, 0, 255);
write_bytes(repo, "img.png", &old_png);
run_git(repo, &["add", "img.png"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write_bytes(repo, "img.png", &new_png);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let unstaged = opened
.diff_file_image(&DiffTarget::WorkingTree {
path: PathBuf::from("img.png"),
area: DiffArea::Unstaged,
})
.unwrap()
.expect("image diff for unstaged changes");
assert_eq!(unstaged.path, PathBuf::from("img.png"));
assert_eq!(unstaged.old.as_deref(), Some(old_png.as_slice()));
assert_eq!(unstaged.new.as_deref(), Some(new_png.as_slice()));
run_git(repo, &["add", "img.png"]);
let staged = opened
.diff_file_image(&DiffTarget::WorkingTree {
path: PathBuf::from("img.png"),
area: DiffArea::Staged,
})
.unwrap()
.expect("image diff for staged changes");
assert_eq!(staged.old.as_deref(), Some(old_png.as_slice()));
assert_eq!(staged.new.as_deref(), Some(new_png.as_slice()));
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "second"],
);
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("git rev-parse to run");
assert!(head.status.success());
let head = String::from_utf8(head.stdout).unwrap().trim().to_string();
let commit = opened
.diff_file_image(&DiffTarget::Commit {
commit_id: gitcomet_core::domain::CommitId(head),
path: Some(PathBuf::from("img.png")),
})
.unwrap()
.expect("image diff for commit");
assert_eq!(commit.old.as_deref(), Some(old_png.as_slice()));
assert_eq!(commit.new.as_deref(), Some(new_png.as_slice()));
}
#[test]
fn diff_file_image_returns_none_for_directories() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
write(repo, "dir/a.png", "not really a png\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened
.diff_file_image(&DiffTarget::WorkingTree {
path: PathBuf::from("dir"),
area: DiffArea::Unstaged,
})
.unwrap();
assert!(result.is_none());
run_git(repo, &["add", "dir/a.png"]);
let staged_result = opened
.diff_file_image(&DiffTarget::WorkingTree {
path: PathBuf::from("dir"),
area: DiffArea::Staged,
})
.unwrap();
assert!(staged_result.is_none());
}
#[test]
fn gitlink_added_and_unstaged_modified_reports_expected_status_and_diff() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let nested = repo.join("chess3");
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
std::fs::create_dir_all(&nested).expect("create nested repo path");
run_git(&nested, &["init"]);
run_git(&nested, &["config", "user.email", "you@example.com"]);
run_git(&nested, &["config", "user.name", "You"]);
run_git(&nested, &["config", "commit.gpgsign", "false"]);
write(&nested, "file.txt", "one\n");
run_git(&nested, &["add", "file.txt"]);
run_git(
&nested,
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c1"],
);
run_git(repo, &["add", "chess3"]);
write(&nested, "file.txt", "one\ntwo\n");
run_git(&nested, &["add", "file.txt"]);
run_git(
&nested,
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c2"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert!(
status
.staged
.iter()
.any(|e| e.path == Path::new("chess3") && e.kind == FileStatusKind::Added),
"expected staged Added gitlink entry; status={status:?}"
);
assert!(
status
.unstaged
.iter()
.any(|e| e.path == Path::new("chess3") && e.kind == FileStatusKind::Modified),
"expected unstaged Modified gitlink entry; status={status:?}"
);
let diff = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("chess3"),
area: DiffArea::Unstaged,
})
.unwrap();
assert!(
diff.contains("Subproject commit"),
"expected unstaged gitlink unified diff to include subproject commit line; diff={diff}"
);
let file_text = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("chess3"),
area: DiffArea::Unstaged,
})
.unwrap();
assert!(
file_text.is_none(),
"expected no direct file text payload for directory-backed gitlink target"
);
}
#[test]
fn committed_gitlink_unstaged_modified_reports_modified_status_and_diff() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let nested = repo.join("chess3");
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
std::fs::create_dir_all(&nested).expect("create nested repo path");
run_git(&nested, &["init"]);
run_git(&nested, &["config", "user.email", "you@example.com"]);
run_git(&nested, &["config", "user.name", "You"]);
run_git(&nested, &["config", "commit.gpgsign", "false"]);
write(&nested, "file.txt", "one\n");
run_git(&nested, &["add", "file.txt"]);
run_git(
&nested,
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c1"],
);
run_git(repo, &["add", "chess3"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "add gitlink"],
);
write(&nested, "file.txt", "one\ntwo\n");
run_git(&nested, &["add", "file.txt"]);
run_git(
&nested,
&["-c", "commit.gpgsign=false", "commit", "-m", "nested c2"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert!(
status
.unstaged
.iter()
.any(|e| e.path == Path::new("chess3") && e.kind == FileStatusKind::Modified),
"expected unstaged Modified gitlink entry after nested repo advances; status={status:?}"
);
let diff = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("chess3"),
area: DiffArea::Unstaged,
})
.unwrap();
assert!(
diff.contains("Subproject commit"),
"expected unstaged gitlink unified diff to include subproject commit line; diff={diff}"
);
}
#[test]
fn diff_file_commit_target_without_path_returns_none() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let head = run_git_output(repo, &["rev-parse", "HEAD"]);
let target = DiffTarget::Commit {
commit_id: gitcomet_core::domain::CommitId(head),
path: None,
};
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
assert!(opened.diff_file_text(&target).unwrap().is_none());
assert!(opened.diff_file_image(&target).unwrap().is_none());
}
#[test]
fn diff_unified_outside_repository_path_returns_backend_error() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let outside = dir.path().join("outside.txt");
fs::create_dir_all(&repo).unwrap();
fs::write(&outside, "outside\n").unwrap();
run_git(&repo, &["init"]);
run_git(&repo, &["config", "user.email", "you@example.com"]);
run_git(&repo, &["config", "user.name", "You"]);
run_git(&repo, &["config", "commit.gpgsign", "false"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
let err = opened
.diff_unified(&DiffTarget::WorkingTree {
path: outside,
area: DiffArea::Unstaged,
})
.expect_err("expected diff_unified to fail for outside path");
assert!(
matches!(err.kind(), ErrorKind::Backend(_)),
"expected backend error, got {err:?}"
);
}
#[test]
fn diff_working_tree_with_absolute_file_path_reads_current_file() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
let absolute = repo.join("a.txt");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let text = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: absolute.clone(),
area: DiffArea::Unstaged,
})
.unwrap()
.expect("text diff for absolute path");
assert_eq!(text.old, None);
assert_eq!(text.new.as_deref(), Some("one\ntwo\n"));
let image = opened
.diff_file_image(&DiffTarget::WorkingTree {
path: absolute,
area: DiffArea::Unstaged,
})
.unwrap()
.expect("image diff for absolute path");
assert_eq!(image.old, None);
assert_eq!(image.new.as_deref(), Some("one\ntwo\n".as_bytes()));
}
#[test]
fn staged_diff_for_unmerged_conflict_prefers_ours_for_text_and_image() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let text = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap()
.expect("staged text diff for conflict");
assert_eq!(text.old.as_deref(), Some("ours\n"));
assert_eq!(text.new.as_deref(), Some("ours\n"));
let image = opened
.diff_file_image(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap()
.expect("staged image diff for conflict");
assert_eq!(image.old.as_deref(), Some("ours\n".as_bytes()));
assert_eq!(image.new.as_deref(), Some("ours\n".as_bytes()));
}
#[test]
fn diff_commit_with_unknown_revision_and_outside_conflict_path_are_handled() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let outside = dir.path().join("outside.txt");
fs::create_dir_all(&repo).unwrap();
fs::write(&outside, "outside\n").unwrap();
run_git(&repo, &["init"]);
run_git(&repo, &["config", "user.email", "you@example.com"]);
run_git(&repo, &["config", "user.name", "You"]);
run_git(&repo, &["config", "commit.gpgsign", "false"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
let unknown_target = DiffTarget::Commit {
commit_id: gitcomet_core::domain::CommitId("not-a-real-revision".to_string()),
path: Some(PathBuf::from("a.txt")),
};
let text = opened
.diff_file_text(&unknown_target)
.unwrap()
.expect("text diff object for unknown revision");
assert_eq!(text.old, None);
assert_eq!(text.new, None);
let image = opened
.diff_file_image(&unknown_target)
.unwrap()
.expect("image diff object for unknown revision");
assert_eq!(image.old, None);
assert_eq!(image.new, None);
let err = opened
.conflict_session(&outside)
.expect_err("outside absolute path should be rejected");
assert!(
matches!(err.kind(), ErrorKind::Backend(_)),
"expected backend error for outside path, got {err:?}"
);
}
#[test]
fn diff_file_text_uses_ours_and_theirs_for_conflicted_paths() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "theirs\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "a.txt", "ours\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert_eq!(status.unstaged.len(), 1);
assert_eq!(status.unstaged[0].path, PathBuf::from("a.txt"));
assert_eq!(status.unstaged[0].kind, FileStatusKind::Conflicted);
assert_eq!(
status.unstaged[0].conflict,
Some(FileConflictKind::BothModified)
);
let diff = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap()
.expect("file diff for conflicted changes");
assert_eq!(diff.old.as_deref(), Some("ours\n"));
assert_eq!(diff.new.as_deref(), Some("theirs\n"));
let session = opened
.conflict_session(Path::new("a.txt"))
.unwrap()
.expect("conflict session");
assert_eq!(session.conflict_kind, FileConflictKind::BothModified);
assert_eq!(session.strategy, ConflictResolverStrategy::FullTextResolver);
assert_eq!(session.total_regions(), 1);
assert_eq!(session.unsolved_count(), 1);
assert_eq!(session.regions[0].ours, "ours\n");
assert_eq!(session.regions[0].theirs, "theirs\n");
}
#[test]
fn status_and_conflict_stages_cover_all_conflict_kinds() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
let base_blob = hash_blob(repo, b"base\n");
let ours_blob = hash_blob(repo, b"ours\n");
let theirs_blob = hash_blob(repo, b"theirs\n");
let fixtures = [
ConflictStageFixture {
path: "dd.txt",
kind: FileConflictKind::BothDeleted,
has_base: true,
has_ours: false,
has_theirs: false,
},
ConflictStageFixture {
path: "au.txt",
kind: FileConflictKind::AddedByUs,
has_base: false,
has_ours: true,
has_theirs: false,
},
ConflictStageFixture {
path: "ud.txt",
kind: FileConflictKind::DeletedByThem,
has_base: true,
has_ours: true,
has_theirs: false,
},
ConflictStageFixture {
path: "ua.txt",
kind: FileConflictKind::AddedByThem,
has_base: false,
has_ours: false,
has_theirs: true,
},
ConflictStageFixture {
path: "du.txt",
kind: FileConflictKind::DeletedByUs,
has_base: true,
has_ours: false,
has_theirs: true,
},
ConflictStageFixture {
path: "aa.txt",
kind: FileConflictKind::BothAdded,
has_base: false,
has_ours: true,
has_theirs: true,
},
ConflictStageFixture {
path: "uu.txt",
kind: FileConflictKind::BothModified,
has_base: true,
has_ours: true,
has_theirs: true,
},
];
for fixture in &fixtures {
set_unmerged_stages(
repo,
fixture.path,
fixture.has_base.then_some(base_blob.as_str()),
fixture.has_ours.then_some(ours_blob.as_str()),
fixture.has_theirs.then_some(theirs_blob.as_str()),
);
}
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
for fixture in &fixtures {
let path = Path::new(fixture.path);
let status_entry = status
.unstaged
.iter()
.find(|e| e.path == path)
.unwrap_or_else(|| panic!("missing status entry for {}", fixture.path));
assert_eq!(
status_entry.kind,
FileStatusKind::Conflicted,
"expected conflicted kind for {}",
fixture.path
);
assert_eq!(
status_entry.conflict,
Some(fixture.kind),
"wrong conflict kind for {}",
fixture.path
);
assert!(
!status.staged.iter().any(|e| e.path == path),
"conflicted path {} should not appear in staged status",
fixture.path
);
let stages = opened
.conflict_file_stages(path)
.unwrap()
.expect("conflict stages");
assert_eq!(
stages.base.is_some(),
fixture.has_base,
"base stage mismatch for {}",
fixture.path
);
assert_eq!(
stages.ours.is_some(),
fixture.has_ours,
"ours stage mismatch for {}",
fixture.path
);
assert_eq!(
stages.theirs.is_some(),
fixture.has_theirs,
"theirs stage mismatch for {}",
fixture.path
);
let session = opened
.conflict_session(path)
.unwrap()
.expect("conflict session");
assert_eq!(session.path, PathBuf::from(fixture.path));
assert_eq!(session.conflict_kind, fixture.kind);
assert_eq!(
session.strategy,
ConflictResolverStrategy::for_conflict(fixture.kind, false)
);
assert_eq!(session.base.is_absent(), !fixture.has_base);
assert_eq!(session.ours.is_absent(), !fixture.has_ours);
assert_eq!(session.theirs.is_absent(), !fixture.has_theirs);
}
}
#[test]
fn checkout_conflict_side_resolves_all_conflict_stage_shapes() {
if !require_git_shell_for_status_integration_tests() {
return;
}
#[derive(Clone, Copy)]
struct ConflictCheckoutFixture {
kind: FileConflictKind,
has_base: bool,
has_ours: bool,
has_theirs: bool,
}
let fixtures = [
ConflictCheckoutFixture {
kind: FileConflictKind::BothDeleted,
has_base: true,
has_ours: false,
has_theirs: false,
},
ConflictCheckoutFixture {
kind: FileConflictKind::AddedByUs,
has_base: false,
has_ours: true,
has_theirs: false,
},
ConflictCheckoutFixture {
kind: FileConflictKind::DeletedByThem,
has_base: true,
has_ours: true,
has_theirs: false,
},
ConflictCheckoutFixture {
kind: FileConflictKind::AddedByThem,
has_base: false,
has_ours: false,
has_theirs: true,
},
ConflictCheckoutFixture {
kind: FileConflictKind::DeletedByUs,
has_base: true,
has_ours: false,
has_theirs: true,
},
ConflictCheckoutFixture {
kind: FileConflictKind::BothAdded,
has_base: false,
has_ours: true,
has_theirs: true,
},
ConflictCheckoutFixture {
kind: FileConflictKind::BothModified,
has_base: true,
has_ours: true,
has_theirs: true,
},
];
for fixture in fixtures {
for side in [ConflictSide::Ours, ConflictSide::Theirs] {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
let base_blob = hash_blob(repo, b"base\n");
let ours_blob = hash_blob(repo, b"ours\n");
let theirs_blob = hash_blob(repo, b"theirs\n");
set_unmerged_stages(
repo,
"a.txt",
fixture.has_base.then_some(base_blob.as_str()),
fixture.has_ours.then_some(ours_blob.as_str()),
fixture.has_theirs.then_some(theirs_blob.as_str()),
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let before = opened.status().unwrap();
let conflict_entry = before
.unstaged
.iter()
.find(|e| e.path == Path::new("a.txt"))
.expect("expected staged-shape fixture to appear as conflict");
assert_eq!(conflict_entry.kind, FileStatusKind::Conflicted);
assert_eq!(conflict_entry.conflict, Some(fixture.kind));
opened
.checkout_conflict_side(Path::new("a.txt"), side)
.unwrap();
let after = opened.status().unwrap();
let selected_stage_exists = match side {
ConflictSide::Ours => fixture.has_ours,
ConflictSide::Theirs => fixture.has_theirs,
};
if selected_stage_exists {
let expected_bytes: &[u8] = match side {
ConflictSide::Ours => b"ours\n",
ConflictSide::Theirs => b"theirs\n",
};
assert_eq!(fs::read(repo.join("a.txt")).unwrap(), expected_bytes);
assert!(
after
.staged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Added),
"expected selected side to stage added file for {:?} with {:?}; status={after:?}",
fixture.kind,
side
);
assert!(
after.unstaged.iter().all(|e| e.path != Path::new("a.txt")),
"expected conflict path to disappear from unstaged after resolving {:?} with {:?}; status={after:?}",
fixture.kind,
side
);
} else {
assert!(
!repo.join("a.txt").exists(),
"expected path to be removed when chosen stage is missing for {:?} with {:?}",
fixture.kind,
side
);
assert!(
after
.staged
.iter()
.chain(after.unstaged.iter())
.all(|e| e.path != Path::new("a.txt")),
"expected no status entry for removed path after resolving {:?} with {:?}; status={after:?}",
fixture.kind,
side
);
}
}
}
}
#[test]
fn accept_conflict_deletion_resolves_delete_outcome_conflicts() {
if !require_git_shell_for_status_integration_tests() {
return;
}
#[derive(Clone, Copy)]
struct ConflictDeleteFixture {
kind: FileConflictKind,
has_base: bool,
has_ours: bool,
has_theirs: bool,
}
let fixtures = [
ConflictDeleteFixture {
kind: FileConflictKind::BothDeleted,
has_base: true,
has_ours: false,
has_theirs: false,
},
ConflictDeleteFixture {
kind: FileConflictKind::AddedByUs,
has_base: false,
has_ours: true,
has_theirs: false,
},
ConflictDeleteFixture {
kind: FileConflictKind::AddedByThem,
has_base: false,
has_ours: false,
has_theirs: true,
},
ConflictDeleteFixture {
kind: FileConflictKind::DeletedByUs,
has_base: true,
has_ours: false,
has_theirs: true,
},
ConflictDeleteFixture {
kind: FileConflictKind::DeletedByThem,
has_base: true,
has_ours: true,
has_theirs: false,
},
];
for fixture in fixtures {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
let base_blob = hash_blob(repo, b"base\n");
let ours_blob = hash_blob(repo, b"ours\n");
let theirs_blob = hash_blob(repo, b"theirs\n");
set_unmerged_stages(
repo,
"a.txt",
fixture.has_base.then_some(base_blob.as_str()),
fixture.has_ours.then_some(ours_blob.as_str()),
fixture.has_theirs.then_some(theirs_blob.as_str()),
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let before = opened.status().unwrap();
let conflict_entry = before
.unstaged
.iter()
.find(|e| e.path == Path::new("a.txt"))
.expect("expected fixture path to appear as conflict");
assert_eq!(conflict_entry.kind, FileStatusKind::Conflicted);
assert_eq!(conflict_entry.conflict, Some(fixture.kind));
opened.accept_conflict_deletion(Path::new("a.txt")).unwrap();
let after = opened.status().unwrap();
assert!(
!repo.join("a.txt").exists(),
"expected path to be removed after accepting deletion for {:?}",
fixture.kind
);
assert!(
after
.staged
.iter()
.chain(after.unstaged.iter())
.all(|e| e.path != Path::new("a.txt")),
"expected no status entry for deleted path after resolving {:?}; status={after:?}",
fixture.kind
);
}
}
#[test]
fn status_reports_single_conflict_for_modify_delete() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "theirs\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
run_git(repo, &["rm", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours_delete"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
let entries = status
.unstaged
.iter()
.filter(|e| e.path == Path::new("a.txt"))
.collect::<Vec<_>>();
assert_eq!(
entries.len(),
1,
"expected exactly one status entry for a.txt, got {:#?}",
status.unstaged
);
assert_eq!(entries[0].kind, FileStatusKind::Conflicted);
assert_eq!(entries[0].conflict, Some(FileConflictKind::DeletedByUs));
}
#[test]
fn status_reports_conflict_kind_for_add_add() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "base.txt", "base\n");
run_git(repo, &["add", "base.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "theirs\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs_add"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "a.txt", "ours\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours_add"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
assert_eq!(status.unstaged.len(), 1);
assert_eq!(status.unstaged[0].path, PathBuf::from("a.txt"));
assert_eq!(status.unstaged[0].kind, FileStatusKind::Conflicted);
assert_eq!(
status.unstaged[0].conflict,
Some(FileConflictKind::BothAdded)
);
}
#[test]
fn conflict_file_stages_preserve_non_utf8_bytes() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
let base_bytes = b"\x00base\xff\n".to_vec();
let ours_bytes = b"\x00ours\xff\n".to_vec();
let theirs_bytes = b"\x00theirs\xff\n".to_vec();
write_bytes(repo, "bin.dat", &base_bytes);
run_git(repo, &["add", "bin.dat"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write_bytes(repo, "bin.dat", &theirs_bytes);
run_git(repo, &["add", "bin.dat"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
write_bytes(repo, "bin.dat", &ours_bytes);
run_git(repo, &["add", "bin.dat"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let stages = opened
.conflict_file_stages(Path::new("bin.dat"))
.unwrap()
.expect("conflict stage data");
assert_eq!(stages.path, PathBuf::from("bin.dat"));
assert_eq!(stages.base_bytes.as_deref(), Some(base_bytes.as_slice()));
assert_eq!(stages.ours_bytes.as_deref(), Some(ours_bytes.as_slice()));
assert_eq!(
stages.theirs_bytes.as_deref(),
Some(theirs_bytes.as_slice())
);
assert_eq!(stages.base, None);
assert_eq!(stages.ours, None);
assert_eq!(stages.theirs, None);
let session = opened
.conflict_session(Path::new("bin.dat"))
.unwrap()
.expect("conflict session");
assert_eq!(session.path, PathBuf::from("bin.dat"));
assert_eq!(session.strategy, ConflictResolverStrategy::BinarySidePick);
assert_eq!(session.total_regions(), 1);
assert_eq!(session.unsolved_count(), 1);
assert!(!session.is_fully_resolved());
assert!(matches!(session.base, ConflictPayload::Binary(_)));
assert!(matches!(session.ours, ConflictPayload::Binary(_)));
assert!(matches!(session.theirs, ConflictPayload::Binary(_)));
}
#[test]
fn checkout_conflict_side_resolves_non_utf8_binary_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
let base_bytes = b"\x00base\xff\n".to_vec();
let ours_bytes = b"\x00ours\xff\n".to_vec();
let theirs_bytes = b"\x00theirs\xff\n".to_vec();
write_bytes(repo, "bin.dat", &base_bytes);
run_git(repo, &["add", "bin.dat"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write_bytes(repo, "bin.dat", &theirs_bytes);
run_git(repo, &["add", "bin.dat"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
write_bytes(repo, "bin.dat", &ours_bytes);
run_git(repo, &["add", "bin.dat"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let session = opened
.conflict_session(Path::new("bin.dat"))
.unwrap()
.expect("binary conflict session");
assert_eq!(session.strategy, ConflictResolverStrategy::BinarySidePick);
opened
.checkout_conflict_side(Path::new("bin.dat"), ConflictSide::Theirs)
.unwrap();
assert_eq!(fs::read(repo.join("bin.dat")).unwrap(), theirs_bytes);
let status_after = opened.status().unwrap();
assert!(
!status_after
.unstaged
.iter()
.any(|e| e.path == Path::new("bin.dat") && e.kind == FileStatusKind::Conflicted),
"binary conflict should be cleared after choosing theirs"
);
assert!(
status_after
.staged
.iter()
.any(|e| e.path == Path::new("bin.dat")),
"chosen binary side should be staged"
);
}
#[test]
fn conflict_session_both_deleted_binary_prefers_decision_strategy() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
let base_blob = hash_blob(repo, b"\x00base\xff\n");
set_unmerged_stages(repo, "gone.bin", Some(base_blob.as_str()), None, None);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let status = opened.status().unwrap();
let entry = status
.unstaged
.iter()
.find(|e| e.path == Path::new("gone.bin"))
.expect("expected conflict status entry");
assert_eq!(entry.kind, FileStatusKind::Conflicted);
assert_eq!(entry.conflict, Some(FileConflictKind::BothDeleted));
let session = opened
.conflict_session(Path::new("gone.bin"))
.unwrap()
.expect("conflict session");
assert_eq!(session.conflict_kind, FileConflictKind::BothDeleted);
assert_eq!(session.strategy, ConflictResolverStrategy::DecisionOnly);
assert!(matches!(session.base, ConflictPayload::Binary(_)));
assert!(session.ours.is_absent());
assert!(session.theirs.is_absent());
}
#[test]
fn diff_file_text_handles_modify_delete_conflicts() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "theirs\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
run_git(repo, &["rm", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours_delete"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let diff = opened
.diff_file_text(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap()
.expect("file diff for conflicted changes");
assert_eq!(diff.old, None);
assert_eq!(diff.new.as_deref(), Some("theirs\n"));
}
#[test]
fn checkout_conflict_side_resolves_modify_delete_using_ours() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "theirs\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
run_git(repo, &["rm", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours_delete"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.checkout_conflict_side(Path::new("a.txt"), ConflictSide::Ours)
.unwrap();
assert!(
!repo.join("a.txt").exists(),
"expected ours resolution to remove file from worktree"
);
let status = opened.status().unwrap();
assert!(
!status
.staged
.iter()
.chain(status.unstaged.iter())
.any(|e| e.path == Path::new("a.txt")),
"expected ours resolution to clear status entries for a.txt, got {status:?}"
);
}
#[test]
fn checkout_conflict_side_resolves_modify_delete_using_theirs() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "theirs\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
run_git(repo, &["rm", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours_delete"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.checkout_conflict_side(Path::new("a.txt"), ConflictSide::Theirs)
.unwrap();
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"theirs\n",
"expected theirs resolution to restore file contents"
);
let status = opened.status().unwrap();
assert_eq!(
status.unstaged,
Vec::new(),
"expected theirs resolution to clear unstaged entries"
);
assert!(
status
.staged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Added),
"expected theirs resolution to stage file as added, got {status:?}"
);
}
#[test]
fn checkout_conflict_side_stages_resolution() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "theirs\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "a.txt", "ours\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.checkout_conflict_side(Path::new("a.txt"), ConflictSide::Theirs)
.unwrap();
let status = opened.status().unwrap();
assert!(status.unstaged.iter().all(|s| s.path != Path::new("a.txt")));
assert!(
status
.staged
.iter()
.any(|s| s.path == Path::new("a.txt") && s.kind == FileStatusKind::Modified)
);
let on_disk = fs::read_to_string(repo.join("a.txt")).unwrap();
assert_eq!(on_disk, "theirs\n");
}
#[test]
fn launch_mergetool_trust_exit_false_detects_same_size_content_change() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
// Normalize pre-tool mtime to a fixed timestamp so metadata-only checks
// cannot detect the edit when the command restores mtime.
set_fixed_mtime(&repo.join("a.txt"));
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(
repo,
"fake",
cmd_same_size_content_change_and_exit_failure(),
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "false"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(result.success);
assert_eq!(result.tool_name, "fake");
assert_eq!(result.output.exit_code, Some(1));
let on_disk = fs::read(repo.join("a.txt")).unwrap();
assert!(!on_disk.is_empty());
assert_eq!(on_disk[0], b'R');
assert_eq!(result.merged_contents.as_deref(), Some(on_disk.as_slice()));
let status = opened.status().unwrap();
assert!(status.unstaged.iter().all(|e| e.path != Path::new("a.txt")));
assert!(
status
.staged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Modified),
"expected staged resolution after content-changing mergetool run, got {status:?}"
);
}
#[test]
fn launch_mergetool_trust_exit_false_requires_content_change() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_exit_success());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "false"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(!result.success);
assert_eq!(result.tool_name, "fake");
assert_eq!(result.output.exit_code, Some(0));
assert!(result.merged_contents.is_none());
let status = opened.status().unwrap();
assert!(
status
.staged
.iter()
.all(|entry| entry.path != Path::new("a.txt")),
"unexpected staged resolution when mergetool did not change output: {status:?}"
);
let conflict_entry = status
.unstaged
.iter()
.find(|entry| entry.path == Path::new("a.txt"))
.expect("conflict should remain unresolved");
assert_eq!(conflict_entry.kind, FileStatusKind::Conflicted);
assert_eq!(
conflict_entry.conflict,
Some(FileConflictKind::BothModified)
);
}
#[test]
fn launch_mergetool_trust_exit_false_detects_deleted_output_change() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_delete_merged_and_exit_failure());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "false"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(result.success);
assert_eq!(result.tool_name, "fake");
assert_eq!(result.output.exit_code, Some(1));
assert!(
result.merged_contents.is_none(),
"deleted-output resolution should not return merged file bytes"
);
assert!(
!repo.join("a.txt").exists(),
"mergetool delete output should remove the worktree file"
);
let status = opened.status().unwrap();
assert!(
status.unstaged.iter().all(|e| e.path != Path::new("a.txt")),
"expected conflict to clear from unstaged after delete-output mergetool run, got {status:?}"
);
assert!(
status
.staged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Deleted),
"expected delete-output mergetool run to stage file deletion, got {status:?}"
);
}
#[test]
fn launch_mergetool_rejects_unresolved_marker_output() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(
repo,
"fake",
cmd_write_unresolved_markers_and_exit_success(),
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let err = opened
.launch_mergetool(Path::new("a.txt"))
.expect_err("mergetool should fail when merged output still has markers");
match err.kind() {
ErrorKind::Backend(msg) => {
assert!(
msg.contains("left unresolved conflict markers"),
"unexpected backend error: {msg}"
);
assert!(
msg.contains("a.txt"),
"backend error should include conflicted path: {msg}"
);
}
other => panic!("expected backend error, got {other:?}"),
}
let status = opened.status().unwrap();
assert!(
status
.staged
.iter()
.all(|entry| entry.path != Path::new("a.txt")),
"unexpected staged resolution when mergetool left markers: {status:?}"
);
let conflict_entry = status
.unstaged
.iter()
.find(|entry| entry.path == Path::new("a.txt"))
.expect("conflict should remain unresolved");
assert_eq!(conflict_entry.kind, FileStatusKind::Conflicted);
assert_eq!(
conflict_entry.conflict,
Some(FileConflictKind::BothModified)
);
}
#[cfg(not(windows))]
#[test]
fn launch_mergetool_custom_cmd_supports_braced_env_variables() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let conflicted_path = "docs/a space.txt";
setup_both_modified_text_conflict(repo, conflicted_path, "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(
repo,
"fake",
"cat \"${REMOTE}\" > \"${MERGED}\"; exit 0",
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let path = Path::new(conflicted_path);
let result = opened.launch_mergetool(path).unwrap();
assert!(
result.success,
"expected braced variable expansion to succeed, got {result:?}"
);
assert_eq!(result.tool_name, "fake");
assert_eq!(result.output.exit_code, Some(0));
let on_disk = fs::read_to_string(repo.join(conflicted_path)).unwrap();
assert_eq!(on_disk, "theirs\n");
assert_eq!(
result.merged_contents.as_deref(),
Some("theirs\n".as_bytes())
);
let status = opened.status().unwrap();
assert!(
status.unstaged.iter().all(|e| e.path != path),
"expected conflict to clear after mergetool resolution: {status:?}"
);
assert!(
status
.staged
.iter()
.any(|e| e.path == path && e.kind == FileStatusKind::Modified),
"expected resolved file to be staged after mergetool run: {status:?}"
);
}
#[test]
#[cfg(windows)]
fn launch_mergetool_custom_cmd_supports_cmd_percent_env_variables() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let conflicted_path = "docs/a space.txt";
setup_both_modified_text_conflict(repo, conflicted_path, "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(
repo,
"fake",
"copy /Y \"%REMOTE%\" \"%MERGED%\" > NUL && exit /b 0",
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let path = Path::new(conflicted_path);
let result = opened.launch_mergetool(path).unwrap();
assert!(result.success, "{result:?}");
assert_eq!(result.tool_name, "fake");
assert_eq!(result.output.exit_code, Some(0));
assert_eq!(
fs::read_to_string(repo.join(conflicted_path)).unwrap(),
"theirs\n"
);
}
#[test]
fn launch_mergetool_custom_cmd_supports_unicode_conflicted_path() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let conflicted_path = "docs/spaced 日本語 file.txt";
setup_both_modified_text_conflict(repo, conflicted_path, "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(
repo,
"fake",
cmd_copy_remote_to_merged_and_exit_success(),
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let path = Path::new(conflicted_path);
let result = opened.launch_mergetool(path).unwrap();
assert!(
result.success,
"expected unicode conflicted path to resolve, got {result:?}"
);
assert_eq!(result.tool_name, "fake");
assert_eq!(result.output.exit_code, Some(0));
let on_disk = fs::read_to_string(repo.join(conflicted_path)).unwrap();
assert_eq!(on_disk, "theirs\n");
assert_eq!(
result.merged_contents.as_deref(),
Some("theirs\n".as_bytes())
);
let status = opened.status().unwrap();
assert!(
status.unstaged.iter().all(|entry| entry.path != path),
"expected unicode conflict to clear after mergetool resolution: {status:?}"
);
assert!(
status
.staged
.iter()
.any(|entry| entry.path == path && entry.kind == FileStatusKind::Modified),
"expected resolved unicode path to be staged after mergetool run: {status:?}"
);
}
#[test]
fn launch_mergetool_prefers_merge_guitool_when_gui_default_true() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "cli"]);
run_git(repo, &["config", "merge.guitool", "gui"]);
run_git(repo, &["config", "mergetool.guiDefault", "true"]);
set_repo_local_mergetool_cmd_with_consent(repo, "cli", cmd_write_cli_to_merged());
set_repo_local_mergetool_cmd_with_consent(repo, "gui", cmd_write_gui_to_merged());
run_git(repo, &["config", "mergetool.cli.trustExitCode", "true"]);
run_git(repo, &["config", "mergetool.gui.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(result.success);
assert_eq!(result.tool_name, "gui");
assert_eq!(result.merged_contents.as_deref(), Some("gui\n".as_bytes()));
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "gui\n");
}
#[cfg(unix)]
#[test]
fn launch_mergetool_uses_tool_path_override_without_custom_cmd() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
let script_path = repo.join("fake-merge-tool.sh");
fs::write(
&script_path,
"#!/bin/sh\n# args: local base remote merged\ncat \"$3\" > \"$4\"\n",
)
.unwrap();
make_executable(&script_path);
run_git(repo, &["config", "merge.tool", "fake"]);
run_git(
repo,
&[
"config",
"mergetool.fake.path",
git_path_arg(&script_path).as_str(),
],
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(result.success);
assert_eq!(result.tool_name, "fake");
assert_eq!(
result.merged_contents.as_deref(),
Some("theirs\n".as_bytes())
);
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "theirs\n");
}
#[cfg(unix)]
#[test]
fn launch_mergetool_prefers_custom_cmd_over_tool_path_override() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
let script_path = repo.join("fake-merge-tool.sh");
fs::write(
&script_path,
"#!/bin/sh\nprintf 'path\\n' > \"$4\"\ntouch \"$PWD/path_invoked\"\n",
)
.unwrap();
make_executable(&script_path);
run_git(repo, &["config", "merge.tool", "fake"]);
run_git(
repo,
&[
"config",
"mergetool.fake.path",
git_path_arg(&script_path).as_str(),
],
);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_write_cmd_to_merged());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(result.success);
assert_eq!(result.tool_name, "fake");
assert_eq!(result.output.exit_code, Some(0));
assert_eq!(result.merged_contents.as_deref(), Some("cmd\n".as_bytes()));
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "cmd\n");
assert!(
!repo.join("path_invoked").exists(),
"tool path executable should not run when mergetool.<tool>.cmd is configured"
);
}
#[test]
fn launch_mergetool_write_to_temp_true_uses_temp_stage_paths() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_dump_stage_paths_and_copy_remote());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
run_git(repo, &["config", "mergetool.writeToTemp", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(result.success);
let vars = read_stage_env_vars(&repo.join("a.txt.env"));
assert_eq!(vars.len(), 3, "expected BASE/LOCAL/REMOTE dump");
for var in vars {
let var_path = Path::new(&var);
let normalized_var = normalize_stage_var(&var);
assert!(
var_path.is_absolute(),
"writeToTemp=true should pass absolute temp paths, got {var}"
);
assert!(
normalized_var.contains("gitcomet-mergetool-"),
"expected temporary mergetool prefix in path, got {var}"
);
assert!(
!normalized_var.starts_with("./"),
"writeToTemp=true should not use workdir-prefixed paths: {var}"
);
assert!(
!var_path.exists(),
"writeToTemp=true with default keepTemporaries=false should cleanup stage files: {var}"
);
}
}
#[test]
fn launch_mergetool_write_to_temp_false_uses_workdir_prefixed_stage_paths() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "docs/note.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_dump_stage_paths_and_copy_remote());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
run_git(repo, &["config", "mergetool.writeToTemp", "false"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("docs/note.txt")).unwrap();
assert!(result.success, "{result:?}");
let vars = read_stage_env_vars(&repo.join("docs/note.txt.env"));
assert_eq!(vars.len(), 3, "expected BASE/LOCAL/REMOTE dump");
for var in vars {
let normalized_var = normalize_stage_var(&var);
assert!(
normalized_var.starts_with("./docs/note_"),
"writeToTemp=false should use './' prefixed workdir paths, got {var}"
);
assert!(
normalized_var.contains("_BASE_")
|| normalized_var.contains("_LOCAL_")
|| normalized_var.contains("_REMOTE_"),
"unexpected stage-file naming: {var}"
);
let fs_path = stage_var_to_fs_path(repo, &var);
assert!(
!fs_path.exists(),
"writeToTemp=false with default keepTemporaries=false should cleanup stage files: {var}"
);
}
}
#[test]
fn launch_mergetool_write_to_temp_false_keep_temporaries_preserves_stage_files() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "docs/note.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_dump_stage_paths_and_copy_remote());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
run_git(repo, &["config", "mergetool.writeToTemp", "false"]);
run_git(repo, &["config", "mergetool.keepTemporaries", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("docs/note.txt")).unwrap();
assert!(result.success, "{result:?}");
let vars = read_stage_env_vars(&repo.join("docs/note.txt.env"));
assert_eq!(vars.len(), 3, "expected BASE/LOCAL/REMOTE dump");
for var in vars {
let normalized_var = normalize_stage_var(&var);
assert!(
normalized_var.starts_with("./docs/note_"),
"writeToTemp=false should use './' prefixed workdir paths, got {var}"
);
let fs_path = stage_var_to_fs_path(repo, &var);
assert!(
fs_path.exists(),
"keepTemporaries=true should keep stage file in workdir mode: {var}"
);
}
}
#[test]
fn launch_mergetool_write_to_temp_false_keep_temporaries_preserves_stage_files_on_abort() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "docs/note.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(
repo,
"fake",
cmd_dump_stage_paths_and_exit_failure(),
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
run_git(repo, &["config", "mergetool.writeToTemp", "false"]);
run_git(repo, &["config", "mergetool.keepTemporaries", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("docs/note.txt")).unwrap();
assert!(
!result.success,
"tool exit failure should be reported as unresolved"
);
let vars = read_stage_env_vars(&repo.join("docs/note.txt.env"));
assert_eq!(vars.len(), 3, "expected BASE/LOCAL/REMOTE dump");
for var in vars {
let normalized_var = normalize_stage_var(&var);
assert!(
normalized_var.starts_with("./docs/note_"),
"writeToTemp=false should use './' prefixed workdir paths, got {var}"
);
let fs_path = stage_var_to_fs_path(repo, &var);
assert!(
fs_path.exists(),
"keepTemporaries=true should keep stage file on abort in workdir mode: {var}"
);
}
}
#[test]
fn launch_mergetool_write_to_temp_true_keep_temporaries_preserves_stage_files() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_dump_stage_paths_and_copy_remote());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
run_git(repo, &["config", "mergetool.writeToTemp", "true"]);
run_git(repo, &["config", "mergetool.keepTemporaries", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(result.success, "{result:?}");
let vars = read_stage_env_vars(&repo.join("a.txt.env"));
assert_eq!(vars.len(), 3, "expected BASE/LOCAL/REMOTE dump");
let mut temp_dirs: Vec<PathBuf> = Vec::new();
for var in vars {
let var_path = Path::new(&var);
let normalized_var = normalize_stage_var(&var);
assert!(
var_path.is_absolute(),
"writeToTemp=true should pass absolute temp paths, got {var}"
);
assert!(
normalized_var.contains("gitcomet-mergetool-"),
"expected temporary mergetool prefix in path, got {var}"
);
assert!(
var_path.exists(),
"keepTemporaries=true should keep stage file in temp mode: {var}"
);
if let Some(parent) = var_path.parent()
&& !temp_dirs.iter().any(|dir| dir == parent)
{
temp_dirs.push(parent.to_path_buf());
}
}
// Keep test environment clean even though behavior keeps temp files.
for dir in temp_dirs {
let _ = fs::remove_dir_all(dir);
}
}
#[test]
fn launch_mergetool_write_to_temp_true_keep_temporaries_preserves_stage_files_on_abort() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_modified_text_conflict(repo, "a.txt", "ours\n", "theirs\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(
repo,
"fake",
cmd_dump_stage_paths_and_exit_failure(),
);
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
run_git(repo, &["config", "mergetool.writeToTemp", "true"]);
run_git(repo, &["config", "mergetool.keepTemporaries", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("a.txt")).unwrap();
assert!(
!result.success,
"tool exit failure should be reported as unresolved"
);
let vars = read_stage_env_vars(&repo.join("a.txt.env"));
assert_eq!(vars.len(), 3, "expected BASE/LOCAL/REMOTE dump");
let mut temp_dirs: Vec<PathBuf> = Vec::new();
for var in vars {
let var_path = Path::new(&var);
let normalized_var = normalize_stage_var(&var);
assert!(
var_path.is_absolute(),
"writeToTemp=true should pass absolute temp paths, got {var}"
);
assert!(
normalized_var.contains("gitcomet-mergetool-"),
"expected temporary mergetool prefix in path, got {var}"
);
assert!(
var_path.exists(),
"keepTemporaries=true should keep stage file on abort in temp mode: {var}"
);
if let Some(parent) = var_path.parent()
&& !temp_dirs.iter().any(|dir| dir == parent)
{
temp_dirs.push(parent.to_path_buf());
}
}
// Keep test environment clean even though behavior keeps temp files.
for dir in temp_dirs {
let _ = fs::remove_dir_all(dir);
}
}
#[test]
fn launch_mergetool_no_base_conflict_passes_empty_base_file() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_added_text_conflict(repo, "new.txt", "ours added\n", "theirs added\n");
run_git(repo, &["config", "merge.tool", "fake"]);
set_repo_local_mergetool_cmd_with_consent(repo, "fake", cmd_dump_base_size_and_copy_remote());
run_git(repo, &["config", "mergetool.fake.trustExitCode", "true"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let result = opened.launch_mergetool(Path::new("new.txt")).unwrap();
assert!(result.success, "{result:?}");
assert_eq!(
fs::read_to_string(repo.join("new.txt.base-size")).unwrap(),
"0",
"BASE should be an empty file for both-added/no-base conflicts"
);
assert_eq!(
fs::read_to_string(repo.join("new.txt")).unwrap(),
"theirs added\n"
);
}
#[test]
fn stage_and_unstage_paths_update_status() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
write(repo, "b.txt", "untracked\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.stage(&[Path::new("a.txt")]).unwrap();
let status = opened.status().unwrap();
assert_eq!(status.staged.len(), 1);
assert_eq!(status.staged[0].path, PathBuf::from("a.txt"));
assert_eq!(status.staged[0].kind, FileStatusKind::Modified);
assert_eq!(status.unstaged.len(), 1);
assert_eq!(status.unstaged[0].path, PathBuf::from("b.txt"));
assert_eq!(status.unstaged[0].kind, FileStatusKind::Untracked);
opened.unstage(&[Path::new("a.txt")]).unwrap();
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert_eq!(status.unstaged.len(), 2);
assert!(
status
.unstaged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Modified)
);
assert!(
status
.unstaged
.iter()
.any(|e| e.path == Path::new("b.txt") && e.kind == FileStatusKind::Untracked)
);
}
#[test]
fn unstage_empty_paths_with_head_unstages_all_index_changes() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
write(repo, "b.txt", "base\n");
run_git(repo, &["add", "a.txt", "b.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
write(repo, "b.txt", "base\nnext\n");
run_git(repo, &["add", "a.txt", "b.txt"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.unstage(&[]).unwrap();
let staged = run_git_output(repo, &["diff", "--cached", "--name-only"]);
assert!(
staged.is_empty(),
"expected empty staged diff, got {staged:?}"
);
let unstaged = run_git_output(repo, &["diff", "--name-only"]);
assert!(
unstaged.lines().any(|line| line == "a.txt"),
"expected a.txt to be unstaged-modified, got {unstaged:?}"
);
assert!(
unstaged.lines().any(|line| line == "b.txt"),
"expected b.txt to be unstaged-modified, got {unstaged:?}"
);
}
#[test]
fn unstage_empty_paths_without_head_unstages_all_added_paths() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
write(repo, "a.txt", "one\n");
write(repo, "b.txt", "two\n");
run_git(repo, &["add", "a.txt", "b.txt"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.unstage(&[]).unwrap();
let staged = run_git_output(repo, &["diff", "--cached", "--name-only"]);
assert!(
staged.is_empty(),
"expected empty staged diff, got {staged:?}"
);
let short = run_git_output(repo, &["status", "--short"]);
assert!(
short.lines().any(|line| line == "?? a.txt"),
"expected a.txt to be untracked after unstage-all, got {short:?}"
);
assert!(
short.lines().any(|line| line == "?? b.txt"),
"expected b.txt to be untracked after unstage-all, got {short:?}"
);
}
#[test]
fn unstage_paths_without_head_only_unstages_selected_entries() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
write(repo, "a.txt", "one\n");
write(repo, "b.txt", "two\n");
run_git(repo, &["add", "a.txt", "b.txt"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.unstage(&[Path::new("a.txt")]).unwrap();
let short = run_git_output(repo, &["status", "--short"]);
assert!(
short.lines().any(|line| line == "?? a.txt"),
"expected a.txt to be untracked after targeted unstage, got {short:?}"
);
assert!(
short.lines().any(|line| line == "A b.txt"),
"expected b.txt to remain staged after targeted unstage, got {short:?}"
);
}
#[test]
fn commit_creates_new_commit_and_cleans_status() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
run_git(repo, &["add", "a.txt"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.commit("second").unwrap();
let msg = git_command()
.arg("-C")
.arg(repo)
.args(["log", "-1", "--pretty=%B"])
.output()
.expect("git log to run");
assert!(msg.status.success());
assert_eq!(String::from_utf8(msg.stdout).unwrap().trim(), "second");
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn reset_soft_moves_head_and_leaves_changes_staged() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c1"]);
let c1 = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse c1");
assert!(c1.status.success());
let c1 = String::from_utf8(c1.stdout).unwrap().trim().to_string();
write(repo, "a.txt", "two\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c2"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.reset_with_output("HEAD~1", gitcomet_core::services::ResetMode::Soft)
.unwrap();
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse head");
assert!(head.status.success());
assert_eq!(String::from_utf8(head.stdout).unwrap().trim(), c1);
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "two\n");
let status = opened.status().unwrap();
assert_eq!(status.staged.len(), 1);
assert_eq!(status.staged[0].path, PathBuf::from("a.txt"));
assert_eq!(status.staged[0].kind, FileStatusKind::Modified);
assert!(status.unstaged.is_empty());
}
#[test]
fn reset_mixed_moves_head_and_leaves_changes_unstaged() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c1"]);
let c1 = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse c1");
assert!(c1.status.success());
let c1 = String::from_utf8(c1.stdout).unwrap().trim().to_string();
write(repo, "a.txt", "two\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c2"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.reset_with_output("HEAD~1", gitcomet_core::services::ResetMode::Mixed)
.unwrap();
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse head");
assert!(head.status.success());
assert_eq!(String::from_utf8(head.stdout).unwrap().trim(), c1);
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "two\n");
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert_eq!(status.unstaged.len(), 1);
assert_eq!(status.unstaged[0].path, PathBuf::from("a.txt"));
assert_eq!(status.unstaged[0].kind, FileStatusKind::Modified);
}
#[test]
fn reset_hard_moves_head_and_discards_changes() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c1"]);
let c1 = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse c1");
assert!(c1.status.success());
let c1 = String::from_utf8(c1.stdout).unwrap().trim().to_string();
write(repo, "a.txt", "two\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c2"]);
write(repo, "a.txt", "two-modified\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.reset_with_output("HEAD~1", gitcomet_core::services::ResetMode::Hard)
.unwrap();
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse head");
assert!(head.status.success());
assert_eq!(String::from_utf8(head.stdout).unwrap().trim(), c1);
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "one\n");
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn revert_commit_creates_new_commit_and_reverts_content() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c1"]);
write(repo, "a.txt", "two\n");
run_git(repo, &["add", "a.txt"]);
run_git(repo, &["-c", "commit.gpgsign=false", "commit", "-m", "c2"]);
let c2 = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse c2");
assert!(c2.status.success());
let c2 = String::from_utf8(c2.stdout).unwrap().trim().to_string();
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.revert(&gitcomet_core::domain::CommitId(c2.clone()))
.unwrap();
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "one\n");
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse head");
assert!(head.status.success());
let head = String::from_utf8(head.stdout).unwrap().trim().to_string();
assert_ne!(head, c2, "expected revert to create a new commit");
}
#[test]
fn amend_rewrites_head_commit_message_and_content() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let head_before = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse head");
assert!(head_before.status.success());
let head_before = String::from_utf8(head_before.stdout)
.unwrap()
.trim()
.to_string();
write(repo, "a.txt", "one\ntwo\n");
run_git(repo, &["add", "a.txt"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.commit_amend("amended").unwrap();
let head_after = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse head");
assert!(head_after.status.success());
let head_after = String::from_utf8(head_after.stdout)
.unwrap()
.trim()
.to_string();
assert_ne!(head_after, head_before);
let count = git_command()
.arg("-C")
.arg(repo)
.args(["rev-list", "--count", "HEAD"])
.output()
.expect("rev-list --count");
assert!(count.status.success());
assert_eq!(String::from_utf8(count.stdout).unwrap().trim(), "1");
let msg = git_command()
.arg("-C")
.arg(repo)
.args(["log", "-1", "--pretty=%B"])
.output()
.expect("git log to run");
assert!(msg.status.success());
assert_eq!(String::from_utf8(msg.stdout).unwrap().trim(), "amended");
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"one\ntwo\n"
);
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn merge_creates_merge_commit_when_branches_diverged() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "b.txt", "feature\n");
run_git(repo, &["add", "b.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "c.txt", "main\n");
run_git(repo, &["add", "c.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.merge_ref_with_output("feature").unwrap();
let parents = git_command()
.arg("-C")
.arg(repo)
.args(["rev-list", "--parents", "-n", "1", "HEAD"])
.output()
.expect("rev-list --parents");
assert!(parents.status.success());
let parent_count = String::from_utf8(parents.stdout)
.unwrap()
.split_whitespace()
.count()
.saturating_sub(1);
assert_eq!(parent_count, 2, "expected merge commit");
assert!(repo.join("b.txt").exists());
assert!(repo.join("c.txt").exists());
assert_eq!(fs::read_to_string(repo.join("b.txt")).unwrap(), "feature\n");
assert_eq!(fs::read_to_string(repo.join("c.txt")).unwrap(), "main\n");
}
#[test]
fn merge_fast_forwards_when_possible_even_if_merge_ff_is_disabled() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "b.txt", "feature\n");
run_git(repo, &["add", "b.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "-"]);
run_git(repo, &["config", "merge.ff", "false"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.merge_ref_with_output("feature").unwrap();
let parents = git_command()
.arg("-C")
.arg(repo)
.args(["rev-list", "--parents", "-n", "1", "HEAD"])
.output()
.expect("rev-list --parents");
assert!(parents.status.success());
let parent_count = String::from_utf8(parents.stdout)
.unwrap()
.split_whitespace()
.count()
.saturating_sub(1);
assert_eq!(parent_count, 1, "expected fast-forward");
let msg = git_command()
.arg("-C")
.arg(repo)
.args(["log", "-1", "--pretty=%B"])
.output()
.expect("git log to run");
assert!(msg.status.success());
assert_eq!(String::from_utf8(msg.stdout).unwrap().trim(), "feature");
}
#[test]
fn squash_ref_stages_changes_without_creating_merge_commit() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "b.txt", "feature\n");
run_git(repo, &["add", "b.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "c.txt", "main\n");
run_git(repo, &["add", "c.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let output = opened
.squash_ref_with_output("feature")
.expect("squash should succeed");
assert_eq!(output.exit_code, Some(0));
let parents = git_command()
.arg("-C")
.arg(repo)
.args(["rev-list", "--parents", "-n", "1", "HEAD"])
.output()
.expect("rev-list --parents");
assert!(parents.status.success());
let parent_count = String::from_utf8(parents.stdout)
.unwrap()
.split_whitespace()
.count()
.saturating_sub(1);
assert_eq!(parent_count, 1, "squash should not create a merge commit");
assert_eq!(fs::read_to_string(repo.join("b.txt")).unwrap(), "feature\n");
let status = opened.status().unwrap();
assert!(
status
.staged
.iter()
.any(|f| f.path == PathBuf::from("b.txt")),
"expected squashed changes to be staged"
);
}
#[test]
fn merge_commit_message_is_available_during_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "feature\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "a.txt", "main\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
assert!(opened.merge_ref_with_output("feature").is_err());
let msg = opened
.merge_commit_message()
.unwrap()
.expect("merge commit message");
assert_eq!(
msg.lines().next().unwrap_or_default(),
"Merge branch 'feature'"
);
assert!(
!msg.contains('#'),
"expected message to be cleaned, got: {msg}"
);
run_git(repo, &["merge", "--abort"]);
assert!(opened.merge_commit_message().unwrap().is_none());
}
#[test]
fn commit_finishes_merge_when_resolved_tree_matches_head() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "feature\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "a.txt", "main\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
assert!(opened.merge_ref_with_output("feature").is_err());
run_git(repo, &["checkout", "--ours", "a.txt"]);
run_git(repo, &["add", "a.txt"]);
let status = opened.status().unwrap();
assert!(status.staged.is_empty(), "expected no staged changes");
assert!(status.unstaged.is_empty(), "expected no unstaged changes");
opened
.commit("Merge branch 'feature'")
.expect("merge commit should succeed even without tree changes");
assert!(opened.merge_commit_message().unwrap().is_none());
let parents = git_command()
.arg("-C")
.arg(repo)
.args(["rev-list", "--parents", "-n", "1", "HEAD"])
.output()
.expect("rev-list --parents");
assert!(parents.status.success());
let parent_count = String::from_utf8(parents.stdout)
.unwrap()
.split_whitespace()
.count()
.saturating_sub(1);
assert_eq!(parent_count, 2, "expected merge commit");
}
#[test]
fn rebase_replays_commits_onto_target_branch() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.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"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "b.txt", "feature\n");
run_git(repo, &["add", "b.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "c.txt", "main\n");
run_git(repo, &["add", "c.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
let master_head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse master");
assert!(master_head.status.success());
let master_head = String::from_utf8(master_head.stdout)
.unwrap()
.trim()
.to_string();
run_git(repo, &["checkout", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.rebase_with_output("main").unwrap();
let parent = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD^"])
.output()
.expect("rev-parse parent");
assert!(parent.status.success());
assert_eq!(
String::from_utf8(parent.stdout).unwrap().trim(),
master_head
);
assert!(repo.join("b.txt").exists());
assert_eq!(fs::read_to_string(repo.join("b.txt")).unwrap(), "feature\n");
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn rebase_in_progress_and_abort_round_trip() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.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"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "feature\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "main"]);
write(repo, "a.txt", "main\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
run_git(repo, &["checkout", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
assert!(!opened.rebase_in_progress().unwrap());
assert!(opened.rebase_with_output("main").is_err());
assert!(opened.rebase_in_progress().unwrap());
opened.rebase_abort_with_output().unwrap();
assert!(!opened.rebase_in_progress().unwrap());
}
#[test]
fn rebase_continue_without_in_progress_rebase_returns_error() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
assert!(opened.rebase_continue_with_output().is_err());
}
#[test]
fn rebase_abort_falls_back_to_git_am_abort() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.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"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "feature\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
let patch_output = git_command()
.arg("-C")
.arg(repo)
.args(["format-patch", "-1", "HEAD", "--stdout"])
.output()
.expect("git format-patch to run");
assert!(
patch_output.status.success(),
"git format-patch failed: {}",
String::from_utf8_lossy(&patch_output.stderr)
);
let patch_file = tempfile::NamedTempFile::new().expect("create patch temp file");
fs::write(patch_file.path(), &patch_output.stdout).expect("write patch file");
run_git(repo, &["checkout", "main"]);
write(repo, "a.txt", "main\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
assert!(opened.apply_patch_with_output(patch_file.path()).is_err());
assert!(
opened.rebase_in_progress().unwrap(),
"expected apply-patch sequencer state to be in progress"
);
let abort_output = opened.rebase_abort_with_output().unwrap();
assert_eq!(
abort_output.command, "git am --abort",
"expected rebase abort fallback to use git am --abort"
);
assert!(!opened.rebase_in_progress().unwrap());
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "main\n");
}
#[test]
fn merge_abort_with_output_clears_conflict_state() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "feature\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "a.txt", "main\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "main"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
assert!(opened.merge_ref_with_output("feature").is_err());
assert!(opened.merge_commit_message().unwrap().is_some());
opened.merge_abort_with_output().unwrap();
assert!(opened.merge_commit_message().unwrap().is_none());
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn create_and_delete_local_branch() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let head = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse HEAD");
assert!(head.status.success());
let head = String::from_utf8(head.stdout)
.expect("HEAD is utf-8")
.trim()
.to_owned();
opened
.create_branch("feature", &gitcomet_core::domain::CommitId(head))
.unwrap();
run_git(
repo,
&["show-ref", "--verify", "--quiet", "refs/heads/feature"],
);
opened.delete_branch("feature").unwrap();
let deleted = git_command()
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", "refs/heads/feature"])
.status()
.expect("show-ref");
assert!(!deleted.success(), "expected branch to be deleted");
}
#[test]
fn create_branch_from_detached_head_using_head_revision() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.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"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "first"],
);
write(repo, "a.txt", "two\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "second"],
);
let first_commit = run_git_output(repo, &["rev-parse", "HEAD~1"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.checkout_commit(&gitcomet_core::domain::CommitId(first_commit.clone()))
.unwrap();
opened
.create_branch(
"rescue",
&gitcomet_core::domain::CommitId("HEAD".to_string()),
)
.unwrap();
let rescue_target = run_git_output(repo, &["rev-parse", "rescue"]);
assert_eq!(rescue_target, first_commit);
}
#[test]
fn checkout_branch_switches_head_to_target_branch() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.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"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(repo, &["branch", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.checkout_branch("feature").unwrap();
let head = run_git_output(repo, &["rev-parse", "--abbrev-ref", "HEAD"]);
assert_eq!(head, "feature");
}
#[test]
fn delete_branch_force_removes_unmerged_branch() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.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"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "feature.txt", "feature\n");
run_git(repo, &["add", "feature.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(repo, &["checkout", "main"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let err = opened
.delete_branch("feature")
.expect_err("safe delete should fail for unmerged branch");
match err.kind() {
ErrorKind::Backend(msg) => {
assert!(
msg.contains("not fully merged") || msg.contains("cannot delete branch"),
"unexpected delete-branch error: {msg}"
);
}
other => panic!("expected backend error, got {other:?}"),
}
opened.delete_branch_force("feature").unwrap();
let deleted = git_command()
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", "refs/heads/feature"])
.status()
.expect("show-ref");
assert!(!deleted.success(), "expected force-delete to remove branch");
}
#[test]
fn cherry_pick_applies_commit_onto_current_branch() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.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"]);
write(repo, "a.txt", "base\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "b.txt", "feature\n");
run_git(repo, &["add", "b.txt"]);
run_git(
repo,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"feature commit",
],
);
let feature_sha = run_git_output(repo, &["rev-parse", "HEAD"]);
run_git(repo, &["checkout", "main"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.cherry_pick(&gitcomet_core::domain::CommitId(feature_sha))
.unwrap();
assert_eq!(fs::read_to_string(repo.join("b.txt")).unwrap(), "feature\n");
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn create_and_delete_local_tag() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
run_git(repo, &["config", "tag.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.create_tag_with_output("v1.0.0", "HEAD").unwrap();
run_git(
repo,
&["show-ref", "--verify", "--quiet", "refs/tags/v1.0.0"],
);
let tag_type = git_command()
.arg("-C")
.arg(repo)
.args(["cat-file", "-t", "refs/tags/v1.0.0"])
.output()
.expect("cat-file");
assert!(
tag_type.status.success(),
"expected refs/tags/v1.0.0 to exist"
);
assert_eq!(String::from_utf8_lossy(&tag_type.stdout).trim(), "tag");
opened.delete_tag_with_output("v1.0.0").unwrap();
let deleted = git_command()
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", "refs/tags/v1.0.0"])
.status()
.expect("show-ref");
assert!(!deleted.success(), "expected tag to be deleted");
}
#[test]
fn create_tag_respects_tag_gpgsign_config() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
run_git(repo, &["config", "tag.gpgsign", "true"]);
run_git(
repo,
&["config", "gpg.program", "gitcomet-missing-gpg-program"],
);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let err = opened
.create_tag_with_output("v1.0.0", "HEAD")
.expect_err("tag creation should fail when signing is required and gpg is missing");
match err.kind() {
ErrorKind::Backend(msg) => {
assert!(
msg.contains("git tag -m v1.0.0 -- v1.0.0 HEAD failed"),
"unexpected backend error: {msg}"
);
let lower = msg.to_ascii_lowercase();
assert!(
msg.contains("gitcomet-missing-gpg-program") || lower.contains("sign"),
"expected signing failure details in backend error: {msg}"
);
}
other => panic!("expected backend error, got {other:?}"),
}
let tag_present = git_command()
.arg("-C")
.arg(repo)
.args(["show-ref", "--verify", "--quiet", "refs/tags/v1.0.0"])
.status()
.expect("show-ref");
assert!(
!tag_present.success(),
"tag should not exist when signing failed"
);
}
#[test]
fn list_tags_returns_sorted_names_with_commit_targets() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(repo, &["tag", "-a", "a-first", "-m", "a-first"]);
run_git(repo, &["tag", "z-last"]);
let head = run_git_output(repo, &["rev-parse", "HEAD"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let tags = opened.list_tags().unwrap();
let names = tags.iter().map(|tag| tag.name.as_str()).collect::<Vec<_>>();
assert_eq!(names, vec!["a-first", "z-last"]);
assert!(tags.iter().all(|tag| tag.target.0 == head));
}
#[test]
fn list_remote_tags_collects_sorted_results_and_skips_unavailable_remote() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
let backup = dir.path().join("backup.git");
let missing = dir.path().join("missing.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&origin).unwrap();
fs::create_dir_all(&backup).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(&backup, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(
&repo,
&["remote", "add", "backup", git_remote_url(&backup).as_str()],
);
run_git(
&repo,
&["remote", "add", "broken", git_remote_url(&missing).as_str()],
);
run_git(&repo, &["tag", "origin-tag"]);
run_git(&repo, &["tag", "backup-tag"]);
run_git(&repo, &["push", "origin", "refs/tags/origin-tag"]);
run_git(&repo, &["push", "backup", "refs/tags/backup-tag"]);
let head = run_git_output(&repo, &["rev-parse", "HEAD"]);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
let remote_tags = opened.list_remote_tags().unwrap();
let tuples = remote_tags
.iter()
.map(|tag| {
(
tag.remote.as_str(),
tag.name.as_str(),
tag.target.as_ref().to_string(),
)
})
.collect::<Vec<_>>();
assert_eq!(
tuples,
vec![
("backup", "backup-tag", head.clone()),
("origin", "origin-tag", head)
]
);
}
#[test]
fn push_and_delete_remote_tag() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&origin).unwrap();
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"]);
run_git(&repo, &["config", "tag.gpgsign", "false"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
opened.create_tag_with_output("v1.0.0", "HEAD").unwrap();
opened.push_tag_with_output("origin", "v1.0.0").unwrap();
run_git(
&origin,
&["show-ref", "--verify", "--quiet", "refs/tags/v1.0.0"],
);
opened
.delete_remote_tag_with_output("origin", "v1.0.0")
.unwrap();
let deleted = git_command()
.arg("-C")
.arg(&origin)
.args(["show-ref", "--verify", "--quiet", "refs/tags/v1.0.0"])
.status()
.expect("show-ref");
assert!(!deleted.success(), "expected remote tag to be deleted");
}
#[test]
fn prune_merged_branches_deletes_local_branches_missing_on_remote() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&origin).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo, &["push", "-u", "origin", "main"]);
run_git(&repo, &["checkout", "-b", "feature"]);
write(&repo, "feature.txt", "feature\n");
run_git(&repo, &["add", "feature.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(&repo, &["push", "-u", "origin", "feature"]);
run_git(&repo, &["checkout", "main"]);
run_git(
&repo,
&[
"-c",
"commit.gpgsign=false",
"merge",
"--no-ff",
"feature",
"-m",
"merge feature",
],
);
run_git(&repo, &["push", "origin", "main"]);
run_git(&repo, &["push", "origin", "--delete", "feature"]);
run_git(
&repo,
&["show-ref", "--verify", "--quiet", "refs/heads/feature"],
);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
opened.prune_merged_branches_with_output().unwrap();
let deleted = git_command()
.arg("-C")
.arg(&repo)
.args(["show-ref", "--verify", "--quiet", "refs/heads/feature"])
.status()
.expect("show-ref");
assert!(
!deleted.success(),
"expected merged local branch to be deleted"
);
}
#[test]
fn prune_local_tags_deletes_tags_missing_from_remotes() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&origin).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo, &["push", "-u", "origin", "main"]);
run_git(&repo, &["tag", "v1.0.0"]);
run_git(&repo, &["tag", "stale-local"]);
run_git(&repo, &["push", "origin", "refs/tags/v1.0.0"]);
run_git(
&repo,
&["show-ref", "--verify", "--quiet", "refs/tags/stale-local"],
);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
opened.prune_local_tags_with_output().unwrap();
run_git(
&repo,
&["show-ref", "--verify", "--quiet", "refs/tags/v1.0.0"],
);
let stale_deleted = git_command()
.arg("-C")
.arg(&repo)
.args(["show-ref", "--verify", "--quiet", "refs/tags/stale-local"])
.status()
.expect("show-ref");
assert!(
!stale_deleted.success(),
"expected stale local tag to be deleted"
);
}
#[test]
fn prune_local_tags_with_output_no_remotes_is_noop() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
fs::create_dir_all(&repo).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&repo, &["tag", "local-only"]);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
let output = opened.prune_local_tags_with_output().unwrap();
assert_eq!(output.exit_code, Some(0));
assert!(
output
.stdout
.contains("No remotes configured; skipping tag prune."),
"unexpected stdout: {}",
output.stdout
);
run_git(
&repo,
&["show-ref", "--verify", "--quiet", "refs/tags/local-only"],
);
}
#[test]
fn prune_local_tags_with_output_reports_noop_when_all_tags_exist_remotely() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&origin).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo, &["push", "-u", "origin", "main"]);
run_git(&repo, &["tag", "v1.0.0"]);
run_git(&repo, &["push", "origin", "refs/tags/v1.0.0"]);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
let output = opened.prune_local_tags_with_output().unwrap();
assert_eq!(output.exit_code, Some(0));
assert!(
output.stdout.contains("No local tags to prune."),
"unexpected stdout: {}",
output.stdout
);
run_git(
&repo,
&["show-ref", "--verify", "--quiet", "refs/tags/v1.0.0"],
);
}
#[test]
fn list_remote_branches_includes_fetched_remote_tracking_refs() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
fs::create_dir_all(&repo).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
fs::create_dir_all(&origin).unwrap();
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo, &["push", "-u", "origin", "main"]);
run_git(&repo, &["checkout", "-b", "feature"]);
write(&repo, "b.txt", "feature\n");
run_git(&repo, &["add", "b.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(&repo, &["push", "-u", "origin", "feature"]);
run_git(&repo, &["fetch", "origin"]);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
let branches = opened.list_remote_branches().unwrap();
assert!(
branches
.iter()
.any(|b| b.remote == "origin" && b.name == "main")
);
assert!(
branches
.iter()
.any(|b| b.remote == "origin" && b.name == "feature")
);
assert!(!branches.iter().any(|b| b.name == "HEAD"));
}
#[test]
fn checkout_remote_branch_creates_tracking_branch_when_missing_locally() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let origin = dir.path().join("origin.git");
let seed = dir.path().join("seed");
let clone = dir.path().join("clone");
fs::create_dir_all(&origin).unwrap();
fs::create_dir_all(&seed).unwrap();
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(&seed, &["init", "-b", "main"]);
run_git(&seed, &["config", "user.email", "you@example.com"]);
run_git(&seed, &["config", "user.name", "You"]);
run_git(&seed, &["config", "commit.gpgsign", "false"]);
write(&seed, "a.txt", "one\n");
run_git(&seed, &["add", "a.txt"]);
run_git(
&seed,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(
&seed,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&seed, &["push", "-u", "origin", "main"]);
run_git(&seed, &["checkout", "-b", "feature"]);
write(&seed, "feature.txt", "feature\n");
run_git(&seed, &["add", "feature.txt"]);
run_git(
&seed,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(&seed, &["push", "-u", "origin", "feature"]);
run_git(
dir.path(),
&[
"clone",
git_remote_url(&origin).as_str(),
git_path_arg(&clone).as_str(),
],
);
let backend = GixBackend;
let opened = backend.open(&clone).unwrap();
opened
.checkout_remote_branch("origin", "feature", "feature")
.unwrap();
let head = run_git_output(&clone, &["rev-parse", "--abbrev-ref", "HEAD"]);
assert_eq!(head, "feature");
let upstream = run_git_output(
&clone,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
);
assert_eq!(upstream, "origin/feature");
}
#[test]
fn checkout_remote_branch_existing_local_branch_updates_upstream_and_checks_out() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let origin = dir.path().join("origin.git");
let seed = dir.path().join("seed");
let clone = dir.path().join("clone");
fs::create_dir_all(&origin).unwrap();
fs::create_dir_all(&seed).unwrap();
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(&seed, &["init", "-b", "main"]);
run_git(&seed, &["config", "user.email", "you@example.com"]);
run_git(&seed, &["config", "user.name", "You"]);
run_git(&seed, &["config", "commit.gpgsign", "false"]);
write(&seed, "a.txt", "one\n");
run_git(&seed, &["add", "a.txt"]);
run_git(
&seed,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(
&seed,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&seed, &["push", "-u", "origin", "main"]);
run_git(&seed, &["checkout", "-b", "feature"]);
write(&seed, "feature.txt", "feature\n");
run_git(&seed, &["add", "feature.txt"]);
run_git(
&seed,
&["-c", "commit.gpgsign=false", "commit", "-m", "feature"],
);
run_git(&seed, &["push", "-u", "origin", "feature"]);
run_git(
dir.path(),
&[
"clone",
git_remote_url(&origin).as_str(),
git_path_arg(&clone).as_str(),
],
);
run_git(&clone, &["checkout", "-b", "topic"]);
run_git(&clone, &["checkout", "main"]);
let upstream_before = git_command()
.arg("-C")
.arg(&clone)
.args([
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"topic@{upstream}",
])
.status()
.expect("topic upstream probe");
assert!(
!upstream_before.success(),
"topic should start without upstream tracking"
);
let backend = GixBackend;
let opened = backend.open(&clone).unwrap();
opened
.checkout_remote_branch("origin", "feature", "topic")
.unwrap();
let head = run_git_output(&clone, &["rev-parse", "--abbrev-ref", "HEAD"]);
assert_eq!(head, "topic");
let upstream = run_git_output(
&clone,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
);
assert_eq!(upstream, "origin/feature");
}
#[test]
fn checkout_remote_branch_returns_backend_error_for_missing_remote_branch() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let origin = dir.path().join("origin.git");
let repo = dir.path().join("repo");
fs::create_dir_all(&origin).unwrap();
fs::create_dir_all(&repo).unwrap();
run_git(&origin, &["init", "--bare", "-b", "main"]);
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo, &["push", "-u", "origin", "main"]);
run_git(&repo, &["fetch", "origin"]);
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
let err = opened
.checkout_remote_branch("origin", "missing-branch", "topic")
.expect_err("missing remote branch should return backend error");
match err.kind() {
ErrorKind::Backend(msg) => {
assert!(
msg.contains("git checkout --track failed"),
"unexpected backend error: {msg}"
);
}
other => panic!("expected backend error, got {other:?}"),
}
}
#[test]
fn push_with_output_updates_remote_head() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&origin).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo, &["push", "-u", "origin", "main"]);
write(&repo, "a.txt", "one\ntwo\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "second"],
);
let head_local = git_command()
.arg("-C")
.arg(&repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse HEAD");
assert!(head_local.status.success());
let head_local = String::from_utf8(head_local.stdout)
.unwrap()
.trim()
.to_string();
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
opened.push_with_output().unwrap();
let head_remote = git_command()
.arg("-C")
.arg(&origin)
.args(["rev-parse", "refs/heads/main"])
.output()
.expect("rev-parse origin/main");
assert!(head_remote.status.success());
let head_remote = String::from_utf8(head_remote.stdout)
.unwrap()
.trim()
.to_string();
assert_eq!(head_remote, head_local);
}
#[test]
fn force_push_with_output_updates_remote_head_after_rewrite() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().join("repo");
let origin = dir.path().join("origin.git");
fs::create_dir_all(&repo).unwrap();
fs::create_dir_all(&origin).unwrap();
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"]);
write(&repo, "a.txt", "one\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(
&repo,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo, &["push", "-u", "origin", "main"]);
write(&repo, "a.txt", "one\ntwo\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "second"],
);
run_git(&repo, &["push"]);
run_git(&repo, &["fetch", "origin"]);
// Rewrite local history so it diverges from the remote.
run_git(&repo, &["reset", "--hard", "HEAD~1"]);
write(&repo, "a.txt", "one\ntwo (rewritten)\n");
run_git(&repo, &["add", "a.txt"]);
run_git(
&repo,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"second rewritten",
],
);
let head_local = git_command()
.arg("-C")
.arg(&repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse HEAD");
assert!(head_local.status.success());
let head_local = String::from_utf8(head_local.stdout)
.unwrap()
.trim()
.to_string();
let backend = GixBackend;
let opened = backend.open(&repo).unwrap();
opened.push_force_with_output().unwrap();
let head_remote = git_command()
.arg("-C")
.arg(&origin)
.args(["rev-parse", "refs/heads/main"])
.output()
.expect("rev-parse refs/heads/main");
assert!(head_remote.status.success());
let head_remote = String::from_utf8(head_remote.stdout)
.unwrap()
.trim()
.to_string();
assert_eq!(head_remote, head_local);
}
#[test]
fn pull_with_output_fast_forwards_from_remote() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let origin = dir.path().join("origin.git");
let repo_a = dir.path().join("repo-a");
let repo_b = dir.path().join("repo-b");
fs::create_dir_all(&origin).unwrap();
fs::create_dir_all(&repo_a).unwrap();
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(&repo_a, &["init", "-b", "main"]);
run_git(&repo_a, &["config", "user.email", "you@example.com"]);
run_git(&repo_a, &["config", "user.name", "You"]);
run_git(&repo_a, &["config", "commit.gpgsign", "false"]);
write(&repo_a, "a.txt", "one\n");
run_git(&repo_a, &["add", "a.txt"]);
run_git(
&repo_a,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(
&repo_a,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo_a, &["push", "-u", "origin", "main"]);
run_git(
dir.path(),
&[
"clone",
git_remote_url(&origin).as_str(),
git_path_arg(&repo_b).as_str(),
],
);
write(&repo_a, "a.txt", "one\ntwo\n");
run_git(&repo_a, &["add", "a.txt"]);
run_git(
&repo_a,
&["-c", "commit.gpgsign=false", "commit", "-m", "second"],
);
run_git(&repo_a, &["push"]);
let head_origin = git_command()
.arg("-C")
.arg(&origin)
.args(["rev-parse", "refs/heads/main"])
.output()
.expect("rev-parse origin");
assert!(head_origin.status.success());
let head_origin = String::from_utf8(head_origin.stdout)
.unwrap()
.trim()
.to_string();
let backend = GixBackend;
let opened_b = backend.open(&repo_b).unwrap();
opened_b
.pull_with_output(gitcomet_core::services::PullMode::FastForwardOnly)
.unwrap();
let head_b = git_command()
.arg("-C")
.arg(&repo_b)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse b");
assert!(head_b.status.success());
let head_b = String::from_utf8(head_b.stdout).unwrap().trim().to_string();
assert_eq!(head_b, head_origin);
}
#[test]
fn pull_with_output_fast_forwards_when_possible_even_if_pull_ff_is_disabled() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let origin = dir.path().join("origin.git");
let repo_a = dir.path().join("repo-a");
let repo_b = dir.path().join("repo-b");
fs::create_dir_all(&origin).unwrap();
fs::create_dir_all(&repo_a).unwrap();
run_git(&origin, &["init", "--bare", "-b", "main"]);
run_git(&repo_a, &["init", "-b", "main"]);
run_git(&repo_a, &["config", "user.email", "you@example.com"]);
run_git(&repo_a, &["config", "user.name", "You"]);
run_git(&repo_a, &["config", "commit.gpgsign", "false"]);
write(&repo_a, "a.txt", "one\n");
run_git(&repo_a, &["add", "a.txt"]);
run_git(
&repo_a,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
run_git(
&repo_a,
&["remote", "add", "origin", git_remote_url(&origin).as_str()],
);
run_git(&repo_a, &["push", "-u", "origin", "main"]);
run_git(
dir.path(),
&[
"clone",
git_remote_url(&origin).as_str(),
git_path_arg(&repo_b).as_str(),
],
);
run_git(&repo_b, &["config", "user.email", "you@example.com"]);
run_git(&repo_b, &["config", "user.name", "You"]);
run_git(&repo_b, &["config", "commit.gpgsign", "false"]);
run_git(&repo_b, &["config", "pull.ff", "false"]);
write(&repo_a, "a.txt", "one\ntwo\n");
run_git(&repo_a, &["add", "a.txt"]);
run_git(
&repo_a,
&["-c", "commit.gpgsign=false", "commit", "-m", "second"],
);
run_git(&repo_a, &["push"]);
let head_origin = git_command()
.arg("-C")
.arg(&origin)
.args(["rev-parse", "refs/heads/main"])
.output()
.expect("rev-parse origin");
assert!(head_origin.status.success());
let head_origin = String::from_utf8(head_origin.stdout)
.unwrap()
.trim()
.to_string();
let backend = GixBackend;
let opened_b = backend.open(&repo_b).unwrap();
opened_b
.pull_with_output(gitcomet_core::services::PullMode::Merge)
.unwrap();
let head_b = git_command()
.arg("-C")
.arg(&repo_b)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse b");
assert!(head_b.status.success());
let head_b = String::from_utf8(head_b.stdout).unwrap().trim().to_string();
assert_eq!(head_b, head_origin);
let parents = git_command()
.arg("-C")
.arg(&repo_b)
.args(["rev-list", "--parents", "-n", "1", "HEAD"])
.output()
.expect("rev-list --parents");
assert!(parents.status.success());
let parent_count = String::from_utf8(parents.stdout)
.unwrap()
.split_whitespace()
.count()
.saturating_sub(1);
assert_eq!(parent_count, 1, "expected fast-forward");
}
#[test]
fn stash_create_list_apply_and_drop_work() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.stash_create("wip", false).unwrap();
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "one\n");
let stashes = opened.stash_list().unwrap();
assert!(!stashes.is_empty());
assert_eq!(stashes[0].index, 0);
assert!(stashes[0].message.contains("wip"));
opened.stash_apply(0).unwrap();
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"one\ntwo\n"
);
opened.stash_drop(0).unwrap();
let stashes = opened.stash_list().unwrap();
assert!(stashes.is_empty());
}
#[test]
fn stash_apply_conflict_is_mergeable() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\nline\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
write(repo, "a.txt", "base\nstash-change\n");
opened.stash_create("wip", false).unwrap();
write(repo, "a.txt", "base\nbranch-change\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"branch-change",
],
);
let err = opened
.stash_apply(0)
.expect_err("stash apply conflict should report failure");
assert!(
err.to_string().contains("git stash apply failed"),
"unexpected error: {err}"
);
let status = opened.status().unwrap();
let conflict_entry = status
.unstaged
.iter()
.find(|entry| entry.path == Path::new("a.txt"))
.expect("expected conflicted path after stash apply merge");
assert_eq!(conflict_entry.kind, FileStatusKind::Conflicted);
assert_eq!(
conflict_entry.conflict,
Some(FileConflictKind::BothModified)
);
let contents = fs::read_to_string(repo.join("a.txt")).unwrap();
assert!(contents.contains("<<<<<<<"));
assert!(contents.contains("======="));
assert!(contents.contains(">>>>>>>"));
}
#[test]
fn stash_apply_still_errors_when_merge_does_not_start() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\nline\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
write(repo, "a.txt", "base\nstash-change\n");
opened.stash_create("wip", false).unwrap();
write(repo, "a.txt", "base\nlocal-uncommitted-change\n");
let err = opened
.stash_apply(0)
.expect_err("stash apply should fail when local edits would be overwritten");
assert!(
err.to_string().contains("overwritten by merge"),
"unexpected error: {err}"
);
let status = opened.status().unwrap();
let entry = status
.unstaged
.iter()
.find(|candidate| candidate.path == Path::new("a.txt"))
.expect("expected modified file in unstaged status");
assert_eq!(entry.kind, FileStatusKind::Modified);
assert_eq!(entry.conflict, None);
}
#[test]
fn stash_apply_allows_merge_when_only_untracked_restore_fails() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\nline\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
write(repo, "a.txt", "base\nstash-change\n");
write(repo, "Cargo.toml.orig", "from stash\n");
opened.stash_create("wip", true).unwrap();
// Existing untracked file blocks restoration of untracked payload from stash.
write(repo, "Cargo.toml.orig", "local copy\n");
let err = opened
.stash_apply(0)
.expect_err("stash apply should report untracked restore failure");
assert!(
err.to_string()
.contains("could not restore untracked files from stash")
|| err.to_string().contains("already exists, no checkout"),
"unexpected error: {err}"
);
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"base\nstash-change\n"
);
let untracked_merged = fs::read_to_string(repo.join("Cargo.toml.orig")).unwrap();
assert!(untracked_merged.contains("<<<<<<< Current file"));
assert!(untracked_merged.contains("local copy"));
assert!(untracked_merged.contains("======="));
assert!(untracked_merged.contains("from stash"));
assert!(untracked_merged.contains(">>>>>>> Stashed file"));
let status = opened.status().unwrap();
let tracked = status
.unstaged
.iter()
.find(|candidate| candidate.path == Path::new("a.txt"))
.expect("expected tracked stash change to be present");
assert_eq!(tracked.kind, FileStatusKind::Modified);
assert_eq!(tracked.conflict, None);
assert!(status.unstaged.iter().any(|candidate| {
candidate.path == Path::new("Cargo.toml.orig")
&& candidate.kind == FileStatusKind::Untracked
}));
}
#[test]
fn stash_apply_allows_untracked_restore_failure_when_stash_has_tracked_payload() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\nline\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// Stash contains tracked and untracked payload.
write(repo, "a.txt", "base\nstash-change\n");
write(repo, "Cargo.toml.orig", "from stash\n");
opened.stash_create("wip", true).unwrap();
// Apply the same tracked change on the branch first, so stash apply has no
// tracked-status delta even though stash had tracked payload.
write(repo, "a.txt", "base\nstash-change\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"same-tracked-change",
],
);
// Existing untracked file blocks restoration of stash untracked payload.
write(repo, "Cargo.toml.orig", "local copy\n");
let err = opened
.stash_apply(0)
.expect_err("stash apply should report untracked restore failure");
assert!(
err.to_string()
.contains("could not restore untracked files from stash")
|| err.to_string().contains("already exists, no checkout"),
"unexpected error: {err}"
);
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"base\nstash-change\n"
);
let untracked_merged = fs::read_to_string(repo.join("Cargo.toml.orig")).unwrap();
assert!(untracked_merged.contains("<<<<<<< Current file"));
assert!(untracked_merged.contains("local copy"));
assert!(untracked_merged.contains("======="));
assert!(untracked_merged.contains("from stash"));
assert!(untracked_merged.contains(">>>>>>> Stashed file"));
let status = opened.status().unwrap();
assert!(
status
.unstaged
.iter()
.all(|entry| entry.path != Path::new("a.txt"))
);
assert!(status.unstaged.iter().any(|candidate| {
candidate.path == Path::new("Cargo.toml.orig")
&& candidate.kind == FileStatusKind::Untracked
}));
}
#[test]
fn stash_apply_merges_when_only_untracked_restore_fails_without_tracked_changes() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base\nline\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
write(repo, "Cargo.toml.orig", "from stash\n");
opened.stash_create("wip", true).unwrap();
write(repo, "Cargo.toml.orig", "local copy\n");
let err = opened
.stash_apply(0)
.expect_err("stash apply should report untracked restore failure");
assert!(
err.to_string()
.contains("could not restore untracked files from stash")
|| err.to_string().contains("already exists, no checkout"),
"unexpected error: {err}"
);
let contents = fs::read_to_string(repo.join("Cargo.toml.orig")).unwrap();
assert!(contents.contains("<<<<<<< Current file"));
assert!(contents.contains("local copy"));
assert!(contents.contains("======="));
assert!(contents.contains("from stash"));
assert!(contents.contains(">>>>>>> Stashed file"));
let status = opened.status().unwrap();
assert!(status.unstaged.iter().any(|entry| {
entry.path == Path::new("Cargo.toml.orig") && entry.kind == FileStatusKind::Untracked
}));
}
#[test]
fn stash_list_reports_reflog_indices_for_drop() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened.stash_create("wip-1", false).unwrap();
write(repo, "a.txt", "one\nthree\n");
opened.stash_create("wip-2", false).unwrap();
let stashes = opened.stash_list().unwrap();
assert_eq!(stashes.len(), 2);
assert_eq!(stashes[0].index, 0);
assert_eq!(stashes[1].index, 1);
// Drop the older stash by the index returned from `stash_list`.
opened.stash_drop(stashes[1].index).unwrap();
let stashes = opened.stash_list().unwrap();
assert_eq!(stashes.len(), 1);
assert_eq!(stashes[0].index, 0);
assert!(stashes[0].message.contains("wip-2"));
}
#[test]
fn checkout_commit_detaches_head_at_target() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let sha = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse HEAD");
assert!(sha.status.success());
let sha = String::from_utf8(sha.stdout).unwrap().trim().to_string();
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.checkout_commit(&gitcomet_core::domain::CommitId(sha.clone()))
.unwrap();
let head_name = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.expect("rev-parse --abbrev-ref");
assert!(head_name.status.success());
assert_eq!(String::from_utf8(head_name.stdout).unwrap().trim(), "HEAD");
let head_sha = git_command()
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.expect("rev-parse head sha");
assert!(head_sha.status.success());
assert_eq!(String::from_utf8(head_sha.stdout).unwrap().trim(), sha);
}
#[test]
fn discard_worktree_changes_reverts_to_index_version() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
run_git(repo, &["add", "a.txt"]);
write(repo, "a.txt", "one\ntwo\nthree\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.discard_worktree_changes(&[Path::new("a.txt")])
.unwrap();
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"one\ntwo\n"
);
let status = opened.status().unwrap();
assert!(
status
.staged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Modified)
);
assert!(!status.unstaged.iter().any(|e| e.path == Path::new("a.txt")));
}
#[test]
fn discard_worktree_changes_reverts_modified_file_to_head() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one\ntwo\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.discard_worktree_changes(&[Path::new("a.txt")])
.unwrap();
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "one\n");
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn discard_worktree_changes_removes_staged_new_file() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "new.txt", "new\n");
run_git(repo, &["add", "new.txt"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.discard_worktree_changes(&[Path::new("new.txt")])
.unwrap();
assert!(!repo.join("new.txt").exists());
let status = opened.status().unwrap();
assert!(!status.staged.iter().any(|e| e.path == Path::new("new.txt")));
assert!(
!status
.unstaged
.iter()
.any(|e| e.path == Path::new("new.txt"))
);
}
#[test]
fn discard_worktree_changes_removes_untracked_file() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "untracked.txt", "new\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.discard_worktree_changes(&[Path::new("untracked.txt")])
.unwrap();
assert!(!repo.join("untracked.txt").exists());
let status = opened.status().unwrap();
assert!(
!status
.unstaged
.iter()
.any(|e| e.path == Path::new("untracked.txt"))
);
}
#[test]
fn discard_worktree_changes_supports_mixed_selection() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "one\n");
write(repo, "b.txt", "two\n");
run_git(repo, &["add", "a.txt", "b.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
write(repo, "a.txt", "one!\n");
fs::remove_file(repo.join("b.txt")).unwrap();
write(repo, "c.txt", "three\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
opened
.discard_worktree_changes(&[Path::new("a.txt"), Path::new("b.txt"), Path::new("c.txt")])
.unwrap();
assert_eq!(fs::read_to_string(repo.join("a.txt")).unwrap(), "one\n");
assert_eq!(fs::read_to_string(repo.join("b.txt")).unwrap(), "two\n");
assert!(!repo.join("c.txt").exists());
let status = opened.status().unwrap();
assert!(status.staged.is_empty());
assert!(status.unstaged.is_empty());
}
#[test]
fn stage_hunk_applies_only_part_of_a_file_to_index() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
let mut base = String::new();
for i in 1..=30 {
base.push_str(&format!("L{i:02}\n"));
}
write(repo, "a.txt", &base);
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let modified = base
.replace("L02\n", "L02-mod\n")
.replace("L25\n", "L25-mod\n");
write(repo, "a.txt", &modified);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let unstaged_before = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap();
let hunk_count_before = unstaged_before
.lines()
.filter(|l| l.starts_with("@@"))
.count();
assert_eq!(
hunk_count_before, 2,
"expected two hunks:\n{unstaged_before}"
);
let lines = unstaged_before.lines().collect::<Vec<_>>();
let file_start = lines
.iter()
.position(|l| l.starts_with("diff --git "))
.unwrap_or(0);
let first_hunk = lines
.iter()
.position(|l| l.starts_with("@@"))
.expect("first hunk header");
let second_hunk = (first_hunk + 1..lines.len())
.find(|&ix| lines.get(ix).is_some_and(|l| l.starts_with("@@")))
.expect("second hunk header");
let patch = lines[file_start..first_hunk]
.iter()
.chain(lines[first_hunk..second_hunk].iter())
.cloned()
.collect::<Vec<_>>()
.join("\n")
+ "\n";
opened
.apply_unified_patch_to_index_with_output(&patch, false)
.unwrap();
let staged_after = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap();
assert_eq!(
staged_after.lines().filter(|l| l.starts_with("@@")).count(),
1,
"expected one staged hunk:\n{staged_after}"
);
assert!(staged_after.contains("-L02"));
assert!(staged_after.contains("+L02-mod"));
assert!(!staged_after.contains("L25-mod"));
let unstaged_after = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap();
assert_eq!(
unstaged_after
.lines()
.filter(|l| l.starts_with("@@"))
.count(),
1,
"expected one remaining unstaged hunk:\n{unstaged_after}"
);
assert!(!unstaged_after.contains("L02-mod"));
assert!(unstaged_after.contains("-L25"));
assert!(unstaged_after.contains("+L25-mod"));
}
#[test]
fn unstage_hunk_reverts_only_that_part_in_index() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
let mut base = String::new();
for i in 1..=30 {
base.push_str(&format!("L{i:02}\n"));
}
write(repo, "a.txt", &base);
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "init"],
);
let modified = base
.replace("L02\n", "L02-mod\n")
.replace("L25\n", "L25-mod\n");
write(repo, "a.txt", &modified);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let unstaged_before = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap();
assert_eq!(
unstaged_before
.lines()
.filter(|l| l.starts_with("@@"))
.count(),
2,
"expected two hunks:\n{unstaged_before}"
);
let lines = unstaged_before.lines().collect::<Vec<_>>();
let file_start = lines
.iter()
.position(|l| l.starts_with("diff --git "))
.unwrap_or(0);
let first_hunk = lines
.iter()
.position(|l| l.starts_with("@@"))
.expect("first hunk header");
let second_hunk = (first_hunk + 1..lines.len())
.find(|&ix| lines.get(ix).is_some_and(|l| l.starts_with("@@")))
.expect("second hunk header");
let patch = lines[file_start..first_hunk]
.iter()
.chain(lines[first_hunk..second_hunk].iter())
.cloned()
.collect::<Vec<_>>()
.join("\n")
+ "\n";
opened
.apply_unified_patch_to_index_with_output(&patch, false)
.unwrap();
let staged_after_stage = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap();
assert_eq!(
staged_after_stage
.lines()
.filter(|l| l.starts_with("@@"))
.count(),
1,
"expected one staged hunk:\n{staged_after_stage}"
);
opened
.apply_unified_patch_to_index_with_output(&patch, true)
.unwrap();
let staged_after_unstage = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Staged,
})
.unwrap();
assert!(
staged_after_unstage.trim().is_empty(),
"expected staged diff to be empty:\n{staged_after_unstage}"
);
let unstaged_after_unstage = opened
.diff_unified(&DiffTarget::WorkingTree {
path: PathBuf::from("a.txt"),
area: DiffArea::Unstaged,
})
.unwrap();
assert_eq!(
unstaged_after_unstage
.lines()
.filter(|l| l.starts_with("@@"))
.count(),
2,
"expected two unstaged hunks:\n{unstaged_after_unstage}"
);
assert!(unstaged_after_unstage.contains("+L02-mod"));
assert!(unstaged_after_unstage.contains("+L25-mod"));
}
// ---------------------------------------------------------------------------
// End-to-end conflict resolution workflow tests
// ---------------------------------------------------------------------------
/// End-to-end test: create a merge conflict, load the conflict session,
/// resolve all regions manually, generate resolved text, write it to disk,
/// stage the file, and verify the conflict is fully resolved.
#[test]
fn resolve_conflict_write_and_stage_clears_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
// Create a BothModified conflict: both sides change the same lines.
let base_content = "header\nconflict-line\nfooter\n";
let ours_content = "header\nours-version\nfooter\n";
let theirs_content = "header\ntheirs-version\nfooter\n";
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "doc.txt", base_content);
run_git(repo, &["add", "doc.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "doc.txt", theirs_content);
run_git(repo, &["add", "doc.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "theirs"],
);
run_git(repo, &["checkout", "-"]);
write(repo, "doc.txt", ours_content);
run_git(repo, &["add", "doc.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "ours"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// 1. Verify file is in conflict status
let status = opened.status().unwrap();
let entry = status
.unstaged
.iter()
.find(|e| e.path == Path::new("doc.txt"))
.expect("expected conflict entry");
assert_eq!(entry.kind, FileStatusKind::Conflicted);
assert_eq!(entry.conflict, Some(FileConflictKind::BothModified));
// 2. Load conflict session via backend API
let session = opened
.conflict_session(Path::new("doc.txt"))
.unwrap()
.expect("conflict session");
assert_eq!(session.strategy, ConflictResolverStrategy::FullTextResolver);
assert_eq!(session.conflict_kind, FileConflictKind::BothModified);
// 3. Verify worktree file contains conflict markers
let worktree_content = fs::read_to_string(repo.join("doc.txt")).unwrap();
let validation = gitcomet_core::services::validate_conflict_resolution_text(&worktree_content);
assert!(
validation.has_conflict_markers,
"worktree file should contain conflict markers"
);
// 4. Write manually resolved content (pick ours version)
let resolved_content = "header\nours-version\nfooter\n";
let resolved_validation =
gitcomet_core::services::validate_conflict_resolution_text(resolved_content);
assert!(
!resolved_validation.has_conflict_markers,
"resolved content should have no conflict markers"
);
// 5. Write resolved text to worktree and stage
fs::write(repo.join("doc.txt"), resolved_content).unwrap();
opened.stage(&[Path::new("doc.txt")]).unwrap();
// 6. Verify conflict is resolved — no more conflict status
let status_after = opened.status().unwrap();
assert!(
!status_after
.unstaged
.iter()
.any(|e| e.path == Path::new("doc.txt") && e.kind == FileStatusKind::Conflicted),
"doc.txt should no longer be conflicted after staging resolved content"
);
}
#[test]
fn resolve_both_added_conflict_write_and_stage_clears_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
setup_both_added_text_conflict(repo, "new.txt", "ours added\n", "theirs added\n");
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
let before = opened.status().unwrap();
let conflict_entry = before
.unstaged
.iter()
.find(|e| e.path == Path::new("new.txt"))
.expect("expected both-added conflict path in unstaged status");
assert_eq!(conflict_entry.kind, FileStatusKind::Conflicted);
assert_eq!(conflict_entry.conflict, Some(FileConflictKind::BothAdded));
let merged_before = fs::read_to_string(repo.join("new.txt")).unwrap();
assert!(
merged_before.contains("<<<<<<<"),
"expected merge markers before resolution"
);
let session = opened
.conflict_session(Path::new("new.txt"))
.unwrap()
.expect("conflict session for both-added path");
assert_eq!(session.strategy, ConflictResolverStrategy::FullTextResolver);
assert_eq!(session.conflict_kind, FileConflictKind::BothAdded);
assert_eq!(session.total_regions(), 1);
assert_eq!(session.unsolved_count(), 1);
let resolved = "resolved both-added\n";
write(repo, "new.txt", resolved);
opened.stage(&[Path::new("new.txt")]).unwrap();
let validation = gitcomet_core::services::validate_conflict_resolution_text(resolved);
assert!(!validation.has_conflict_markers);
assert_eq!(validation.marker_lines, 0);
let after = opened.status().unwrap();
assert!(
after
.unstaged
.iter()
.all(|e| e.path != Path::new("new.txt")),
"expected conflict path to be removed from unstaged after save+stage; status={after:?}"
);
assert!(
after.staged.iter().any(|e| {
e.path == Path::new("new.txt")
&& matches!(e.kind, FileStatusKind::Modified | FileStatusKind::Added)
}),
"expected resolved both-added file to be staged as modified/added; status={after:?}"
);
assert_eq!(fs::read_to_string(repo.join("new.txt")).unwrap(), resolved);
}
/// End-to-end test: autosolve Pass 1 correctly resolves trivial regions
/// using synthetic conflict stages where some regions are trivially
/// resolvable (one side equals base) while others are genuine conflicts.
#[test]
fn autosolve_safe_resolves_trivial_conflict_regions_end_to_end() {
if !require_git_shell_for_status_integration_tests() {
return;
}
use gitcomet_core::conflict_session::{
ConflictPayload, ConflictRegionResolution, ConflictSession,
};
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
// Create a BothModified conflict using synthetic stages.
// Write a worktree file with conflict markers containing three regions:
// Region 0: only ours changed (trivial → OnlyOursChanged)
// Region 1: both changed differently (genuine conflict)
// Region 2: both sides identical (trivial → IdenticalSides)
let base_blob = hash_blob(repo, b"base-r0\nbase-r1\nbase-r2\n");
let ours_blob = hash_blob(repo, b"ours-r0\nours-r1\nsame-r2\n");
let theirs_blob = hash_blob(repo, b"base-r0\ntheirs-r1\nsame-r2\n");
set_unmerged_stages(
repo,
"multi.txt",
Some(&base_blob),
Some(&ours_blob),
Some(&theirs_blob),
);
// Write worktree file with three conflict marker blocks
let merged_markers = concat!(
"<<<<<<< HEAD\n",
"ours-r0\n",
"||||||| base\n",
"base-r0\n",
"=======\n",
"base-r0\n",
">>>>>>> feature\n",
"<<<<<<< HEAD\n",
"ours-r1\n",
"||||||| base\n",
"base-r1\n",
"=======\n",
"theirs-r1\n",
">>>>>>> feature\n",
"<<<<<<< HEAD\n",
"same-r2\n",
"||||||| base\n",
"base-r2\n",
"=======\n",
"same-r2\n",
">>>>>>> feature\n",
);
write(repo, "multi.txt", merged_markers);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// Build a ConflictSession from the backend
let session_opt = opened.conflict_session(Path::new("multi.txt")).unwrap();
// The backend may or may not build the session (depending on status
// detection of the synthetic stages). Build one manually if needed.
let mut session = session_opt.unwrap_or_else(|| {
ConflictSession::from_merged_text(
PathBuf::from("multi.txt"),
FileConflictKind::BothModified,
ConflictPayload::Text("base-r0\nbase-r1\nbase-r2\n".into()),
ConflictPayload::Text("ours-r0\nours-r1\nsame-r2\n".into()),
ConflictPayload::Text("base-r0\ntheirs-r1\nsame-r2\n".into()),
merged_markers,
)
});
assert_eq!(session.strategy, ConflictResolverStrategy::FullTextResolver);
assert_eq!(session.total_regions(), 3);
assert_eq!(
session.unsolved_count(),
3,
"all regions should start unresolved"
);
// Apply auto-resolve Pass 1
let auto_resolved = session.auto_resolve_safe();
assert_eq!(
auto_resolved, 2,
"expected 2 trivial regions to be auto-resolved"
);
assert_eq!(
session.unsolved_count(),
1,
"1 genuine conflict should remain"
);
// Verify specific rules
match &session.regions[0].resolution {
ConflictRegionResolution::AutoResolved { rule, content, .. } => {
assert_eq!(
*rule,
gitcomet_core::conflict_session::AutosolveRule::OnlyOursChanged,
);
assert_eq!(content, "ours-r0\n");
}
other => panic!("region 0 should be auto-resolved, got {:?}", other),
}
assert!(
!session.regions[1].resolution.is_resolved(),
"region 1 (genuine conflict) should remain unresolved"
);
match &session.regions[2].resolution {
ConflictRegionResolution::AutoResolved { rule, content, .. } => {
assert_eq!(
*rule,
gitcomet_core::conflict_session::AutosolveRule::IdenticalSides,
);
assert_eq!(content, "same-r2\n");
}
other => panic!("region 2 should be auto-resolved, got {:?}", other),
}
// Navigation should point to the remaining unresolved region
assert_eq!(session.next_unresolved_after(0), Some(1));
assert_eq!(session.prev_unresolved_before(2), Some(1));
}
/// End-to-end test: conflict session for a modify/delete conflict
/// produces correct strategy and payloads, and the "keep" side can be
/// staged to resolve the conflict.
#[test]
fn conflict_session_modify_delete_keep_resolves_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base content\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
// Feature branch modifies the file
run_git(repo, &["checkout", "-b", "feature"]);
write(repo, "a.txt", "modified by feature\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "modify"],
);
// Main branch deletes the file
run_git(repo, &["checkout", "-"]);
run_git(repo, &["rm", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "delete"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// Verify conflict session for modify/delete
let session = opened
.conflict_session(Path::new("a.txt"))
.unwrap()
.expect("conflict session for modify/delete");
assert_eq!(
session.strategy,
ConflictResolverStrategy::TwoWayKeepDelete,
"modify/delete conflicts should use TwoWayKeepDelete strategy"
);
assert_eq!(session.conflict_kind, FileConflictKind::DeletedByUs);
// Ours deleted (absent), theirs has content
assert!(
session.ours.is_absent(),
"ours (delete side) should be absent"
);
assert!(
session.theirs.as_text().is_some(),
"theirs (modify side) should have text"
);
assert_eq!(
session.unsolved_count(),
1,
"two-way non-marker conflict sessions should expose one unresolved decision region"
);
assert_eq!(session.regions[0].ours, "");
assert_eq!(session.regions[0].theirs, "modified by feature\n");
// Resolve by keeping theirs (the modified version)
opened
.checkout_conflict_side(Path::new("a.txt"), ConflictSide::Theirs)
.unwrap();
// Verify file is restored and no longer conflicted
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"modified by feature\n"
);
let status = opened.status().unwrap();
assert!(
!status
.unstaged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Conflicted),
"a.txt should no longer be conflicted after keeping theirs"
);
}
/// Validates the safety gate: `validate_conflict_resolution_text` correctly
/// detects remaining markers in partially-resolved text.
#[test]
fn validate_conflict_resolution_detects_partial_resolution() {
if !require_git_shell_for_status_integration_tests() {
return;
}
use gitcomet_core::services::validate_conflict_resolution_text;
// Fully resolved text — no markers
let clean = "line1\nline2\nline3\n";
assert!(!validate_conflict_resolution_text(clean).has_conflict_markers);
// Partially resolved — one conflict block remains
let partial = concat!(
"resolved section\n",
"<<<<<<< HEAD\n",
"ours\n",
"=======\n",
"theirs\n",
">>>>>>> feature\n",
"another resolved section\n",
);
let v = validate_conflict_resolution_text(partial);
assert!(v.has_conflict_markers);
assert_eq!(v.marker_lines, 3); // <<<<<<<, =======, >>>>>>>
// diff3-style markers
let diff3 = concat!(
"<<<<<<< HEAD\n",
"ours\n",
"||||||| base\n",
"base\n",
"=======\n",
"theirs\n",
">>>>>>> feature\n",
);
let v3 = validate_conflict_resolution_text(diff3);
assert!(v3.has_conflict_markers);
assert_eq!(v3.marker_lines, 4); // <<<<<<<, |||||||, =======, >>>>>>>
}
/// End-to-end test: BothDeleted text conflict session uses DecisionOnly
/// strategy, and restoring from base via `checkout_conflict_side(Base)`
/// resolves the conflict.
#[test]
fn conflict_session_both_deleted_restore_from_base_resolves_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
// BothDeleted: only base stage present, no ours or theirs
let base_blob = hash_blob(repo, b"original content\n");
set_unmerged_stages(repo, "removed.txt", Some(base_blob.as_str()), None, None);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// Verify conflict session
let session = opened
.conflict_session(Path::new("removed.txt"))
.unwrap()
.expect("conflict session for BothDeleted");
assert_eq!(session.conflict_kind, FileConflictKind::BothDeleted);
assert_eq!(session.strategy, ConflictResolverStrategy::DecisionOnly);
assert!(matches!(session.base, ConflictPayload::Text(ref t) if t == "original content\n"));
assert!(session.ours.is_absent());
assert!(session.theirs.is_absent());
assert_eq!(session.unsolved_count(), 1);
// Resolve by accepting deletion
opened
.accept_conflict_deletion(Path::new("removed.txt"))
.unwrap();
// Verify conflict is resolved
let status = opened.status().unwrap();
assert!(
!status
.unstaged
.iter()
.any(|e| e.path == Path::new("removed.txt") && e.kind == FileStatusKind::Conflicted),
"removed.txt should no longer be conflicted after accepting deletion"
);
assert!(
!repo.join("removed.txt").exists(),
"file should be deleted after accepting deletion"
);
}
/// End-to-end test: AddedByUs conflict session uses TwoWayKeepDelete
/// strategy, and keeping the file via `checkout_conflict_side(Ours)`
/// resolves the conflict.
#[test]
fn conflict_session_added_by_us_keep_resolves_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
// AddedByUs: only ours stage present (no base, no theirs)
let ours_blob = hash_blob(repo, b"added by us\n");
set_unmerged_stages(repo, "new.txt", None, Some(ours_blob.as_str()), None);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// Verify status
let status = opened.status().unwrap();
let entry = status
.unstaged
.iter()
.find(|e| e.path == Path::new("new.txt"))
.expect("expected AddedByUs conflict entry");
assert_eq!(entry.kind, FileStatusKind::Conflicted);
assert_eq!(entry.conflict, Some(FileConflictKind::AddedByUs));
// Verify conflict session
let session = opened
.conflict_session(Path::new("new.txt"))
.unwrap()
.expect("conflict session for AddedByUs");
assert_eq!(session.conflict_kind, FileConflictKind::AddedByUs);
assert_eq!(session.strategy, ConflictResolverStrategy::TwoWayKeepDelete);
assert!(session.base.is_absent());
assert!(matches!(session.ours, ConflictPayload::Text(ref t) if t == "added by us\n"));
assert!(session.theirs.is_absent());
assert_eq!(session.unsolved_count(), 1);
// Resolve by keeping ours (the added file)
opened
.checkout_conflict_side(Path::new("new.txt"), ConflictSide::Ours)
.unwrap();
// Verify file exists and conflict is resolved
assert_eq!(
fs::read_to_string(repo.join("new.txt")).unwrap(),
"added by us\n"
);
let status_after = opened.status().unwrap();
assert!(
!status_after
.unstaged
.iter()
.any(|e| e.path == Path::new("new.txt") && e.kind == FileStatusKind::Conflicted),
"new.txt should no longer be conflicted after keeping ours"
);
assert!(
status_after
.staged
.iter()
.any(|e| e.path == Path::new("new.txt")),
"new.txt should be staged after resolution"
);
}
/// End-to-end test: AddedByThem conflict session uses TwoWayKeepDelete
/// strategy, and keeping the file via `checkout_conflict_side(Theirs)`
/// resolves the conflict.
#[test]
fn conflict_session_added_by_them_keep_resolves_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "seed.txt", "seed\n");
run_git(repo, &["add", "seed.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "seed"],
);
// AddedByThem: only theirs stage present (no base, no ours)
let theirs_blob = hash_blob(repo, b"added by them\n");
set_unmerged_stages(
repo,
"their_new.txt",
None,
None,
Some(theirs_blob.as_str()),
);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// Verify status
let status = opened.status().unwrap();
let entry = status
.unstaged
.iter()
.find(|e| e.path == Path::new("their_new.txt"))
.expect("expected AddedByThem conflict entry");
assert_eq!(entry.kind, FileStatusKind::Conflicted);
assert_eq!(entry.conflict, Some(FileConflictKind::AddedByThem));
// Verify conflict session
let session = opened
.conflict_session(Path::new("their_new.txt"))
.unwrap()
.expect("conflict session for AddedByThem");
assert_eq!(session.conflict_kind, FileConflictKind::AddedByThem);
assert_eq!(session.strategy, ConflictResolverStrategy::TwoWayKeepDelete);
assert!(session.base.is_absent());
assert!(session.ours.is_absent());
assert!(matches!(session.theirs, ConflictPayload::Text(ref t) if t == "added by them\n"));
assert_eq!(session.unsolved_count(), 1);
// Resolve by keeping theirs (the added file)
opened
.checkout_conflict_side(Path::new("their_new.txt"), ConflictSide::Theirs)
.unwrap();
// Verify file exists and conflict is resolved
assert_eq!(
fs::read_to_string(repo.join("their_new.txt")).unwrap(),
"added by them\n"
);
let status_after = opened.status().unwrap();
assert!(
!status_after
.unstaged
.iter()
.any(|e| e.path == Path::new("their_new.txt") && e.kind == FileStatusKind::Conflicted),
"their_new.txt should no longer be conflicted after keeping theirs"
);
assert!(
status_after
.staged
.iter()
.any(|e| e.path == Path::new("their_new.txt")),
"their_new.txt should be staged after resolution"
);
}
/// End-to-end test: DeletedByThem conflict session uses TwoWayKeepDelete
/// strategy (base+ours present, theirs absent), and keeping ours
/// via `checkout_conflict_side(Ours)` resolves the conflict.
#[test]
fn conflict_session_deleted_by_them_keep_ours_resolves_conflict() {
if !require_git_shell_for_status_integration_tests() {
return;
}
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "you@example.com"]);
run_git(repo, &["config", "user.name", "You"]);
run_git(repo, &["config", "commit.gpgsign", "false"]);
write(repo, "a.txt", "base content\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "base"],
);
// Feature branch deletes the file
run_git(repo, &["checkout", "-b", "feature"]);
run_git(repo, &["rm", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "delete"],
);
// Main branch modifies the file
run_git(repo, &["checkout", "-"]);
write(repo, "a.txt", "modified by us\n");
run_git(repo, &["add", "a.txt"]);
run_git(
repo,
&["-c", "commit.gpgsign=false", "commit", "-m", "modify"],
);
run_git_expect_failure(repo, &["merge", "feature"]);
let backend = GixBackend;
let opened = backend.open(repo).unwrap();
// Verify status shows DeletedByThem
let status = opened.status().unwrap();
let entry = status
.unstaged
.iter()
.find(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Conflicted)
.expect("expected DeletedByThem conflict entry");
assert_eq!(entry.conflict, Some(FileConflictKind::DeletedByThem));
// Verify conflict session
let session = opened
.conflict_session(Path::new("a.txt"))
.unwrap()
.expect("conflict session for DeletedByThem");
assert_eq!(session.conflict_kind, FileConflictKind::DeletedByThem);
assert_eq!(session.strategy, ConflictResolverStrategy::TwoWayKeepDelete);
assert!(session.base.as_text().is_some());
assert!(
matches!(session.ours, ConflictPayload::Text(ref t) if t == "modified by us\n"),
"ours (modified side) should have text"
);
assert!(
session.theirs.is_absent(),
"theirs (delete side) should be absent"
);
assert_eq!(session.unsolved_count(), 1);
assert_eq!(session.regions[0].ours, "modified by us\n");
assert_eq!(session.regions[0].theirs, "");
// Resolve by keeping ours (the modified version)
opened
.checkout_conflict_side(Path::new("a.txt"), ConflictSide::Ours)
.unwrap();
// Verify file is kept and conflict is resolved
assert_eq!(
fs::read_to_string(repo.join("a.txt")).unwrap(),
"modified by us\n"
);
let status_after = opened.status().unwrap();
assert!(
!status_after
.unstaged
.iter()
.any(|e| e.path == Path::new("a.txt") && e.kind == FileStatusKind::Conflicted),
"a.txt should no longer be conflicted after keeping ours"
);
}