use std::fs;
use std::path::Path;
use std::process::Command;
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::error::Error;
use grit_lib::objects::{parse_commit, ObjectId};
use grit_lib::porcelain::add::stage_for_commit;
use grit_lib::porcelain::commit::{commit_with_scope, create_commit, CommitRequest, CommitScope};
use grit_lib::porcelain::merge::{integrate_branch, IntegrateBranchOutcome};
use grit_lib::progress::NullProgress;
use grit_lib::repo::Repository;
use grit_test_support::git;
fn ident() -> String {
"Merge Test <merge@example.com> 1700000000 +0000".to_owned()
}
fn commit_req(message: &str) -> CommitRequest {
let id = ident();
CommitRequest {
message: message.to_owned(),
author: Some(id.parse().expect("signature")),
committer: Some(id.parse().expect("signature")),
allow_empty: false,
sign_override: None,
amend: false,
assume_worktree_synced: false,
skip_pre_commit_tree_match: false,
staged_change_count: None,
..Default::default()
}
}
fn git_out(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "Merge Test")
.env("GIT_AUTHOR_EMAIL", "merge@example.com")
.env("GIT_COMMITTER_NAME", "Merge Test")
.env("GIT_COMMITTER_EMAIL", "merge@example.com")
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
fn git_fails(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.current_dir(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "Merge Test")
.env("GIT_AUTHOR_EMAIL", "merge@example.com")
.env("GIT_COMMITTER_NAME", "Merge Test")
.env("GIT_COMMITTER_EMAIL", "merge@example.com")
.output()
.expect("spawn git");
assert!(
!out.status.success(),
"git {:?} unexpectedly succeeded",
args
);
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn init_conflict_merge(root: &Path) -> Repository {
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "user.name", "T"]);
git(root, &["config", "user.email", "t@e.com"]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("f"), b"base\n").unwrap();
git(root, &["add", "f"]);
git(root, &["commit", "-qm", "base"]);
git(root, &["checkout", "-qb", "side"]);
std::fs::write(root.join("f"), b"side\n").unwrap();
git(root, &["commit", "-qam", "side"]);
git(root, &["checkout", "-q", "main"]);
std::fs::write(root.join("f"), b"main\n").unwrap();
git(root, &["commit", "-qam", "main"]);
let merge_out = Command::new("git")
.current_dir(root)
.args(["merge", "side"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git merge");
assert!(!merge_out.status.success(), "expected merge conflict");
Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open grit repo")
}
fn init_grit_conflict_merge(root: &Path) -> Repository {
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "user.name", "T"]);
git(root, &["config", "user.email", "t@e.com"]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("repo");
std::fs::write(root.join("f"), b"base\n").unwrap();
git(root, &["add", "f"]);
git(root, &["commit", "-qm", "base"]);
git(root, &["checkout", "-qb", "side"]);
std::fs::write(root.join("f"), b"side\n").unwrap();
git(root, &["commit", "-qam", "side"]);
let side = ObjectId::from_hex(&git_out(root, &["rev-parse", "side"])).expect("side oid");
git(root, &["checkout", "-q", "main"]);
std::fs::write(root.join("f"), b"main\n").unwrap();
git(root, &["commit", "-qam", "main"]);
let main = ObjectId::from_hex(&git_out(root, &["rev-parse", "HEAD"])).expect("main oid");
let outcome = integrate_branch(&repo, "refs/heads/main", main, side, "side").expect("merge");
assert!(matches!(outcome, IntegrateBranchOutcome::Conflicts { .. }));
repo
}
fn init_git_conflict_merge_custom_marker_size(root: &Path) -> Repository {
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "user.name", "T"]);
git(root, &["config", "user.email", "t@e.com"]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
fs::write(root.join(".gitattributes"), "f conflict-marker-size=10\n").unwrap();
git(root, &["add", ".gitattributes"]);
git(root, &["commit", "-qm", "attrs"]);
fs::write(root.join("f"), b"base\n").unwrap();
git(root, &["add", "f"]);
git(root, &["commit", "-qm", "base"]);
git(root, &["checkout", "-qb", "side"]);
fs::write(root.join("f"), b"side\n").unwrap();
git(root, &["commit", "-qam", "side"]);
git(root, &["checkout", "-q", "main"]);
fs::write(root.join("f"), b"main\n").unwrap();
git(root, &["commit", "-qam", "main"]);
let merge_out = Command::new("git")
.current_dir(root)
.args(["merge", "side"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git merge");
assert!(!merge_out.status.success(), "expected merge conflict");
Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open grit repo")
}
#[test]
fn commit_auto_stage_rejects_custom_conflict_marker_size() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let repo = init_git_conflict_merge_custom_marker_size(root);
let body = fs::read_to_string(root.join("f")).expect("conflicted worktree");
assert!(
body.contains("<<<<<<<<<<"),
"git must emit custom-width markers: {body}"
);
let err = commit_with_scope(
&repo,
&commit_req("marker-commit"),
CommitScope::AutoStage,
&mut NullProgress,
)
.unwrap_err();
assert!(
matches!(err, Error::IndexUnmerged),
"auto-stage commit must refuse custom-width markers: {err}"
);
assert!(repo
.index_handle()
.load()
.expect("index")
.has_unmerged_entries());
assert!(repo.git_dir().join("MERGE_HEAD").exists());
}
#[test]
fn commit_with_auto_stage_rejects_unresolved_conflict_markers() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let repo = init_grit_conflict_merge(root);
assert!(
repo.index_handle()
.load()
.expect("index")
.has_unmerged_entries(),
"grit merge should leave unmerged index entries"
);
let body = fs::read_to_string(root.join("f")).expect("conflicted worktree");
assert!(
body.contains("<<<<<<<"),
"expected conflict markers in worktree: {body}"
);
let err = commit_with_scope(
&repo,
&commit_req("oops"),
CommitScope::AutoStage,
&mut NullProgress,
)
.unwrap_err();
assert!(
matches!(err, Error::IndexUnmerged),
"auto-stage commit must refuse while markers remain: {err}"
);
assert!(
repo.index_handle()
.load()
.expect("index")
.has_unmerged_entries(),
"refusal must not collapse unmerged stages"
);
assert!(
repo.git_dir().join("MERGE_HEAD").exists(),
"MERGE_HEAD must remain after refused commit"
);
}
#[test]
fn create_commit_rejects_unmerged_index_like_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let repo = init_conflict_merge(root);
let index = repo.index_handle().load().expect("index");
assert!(
index.has_unmerged_entries(),
"git merge should leave unmerged index entries"
);
assert!(
repo.git_dir().join("MERGE_HEAD").exists(),
"MERGE_HEAD should remain during conflict"
);
let git_stderr = git_fails(root, &["commit", "-m", "oops"]);
assert!(
git_stderr.contains("unmerged"),
"git should refuse unmerged commit: {git_stderr}"
);
let err = create_commit(&repo, &commit_req("oops"), &mut NullProgress).unwrap_err();
assert!(
matches!(err, Error::IndexUnmerged),
"grit should refuse unmerged commit: {err}"
);
let index_after = repo.index_handle().load().expect("index");
assert!(
index_after.has_unmerged_entries(),
"refusal must not clear unmerged stages"
);
assert!(
repo.git_dir().join("MERGE_HEAD").exists(),
"MERGE_HEAD must remain after refused commit"
);
}
#[test]
fn stage_for_commit_then_create_commit_concludes_resolved_merge_without_prior_add() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let repo = init_conflict_merge(root);
std::fs::write(root.join("f"), b"resolved\n").unwrap();
stage_for_commit(&repo, &mut NullProgress).expect("stage resolved merge");
assert!(
!repo
.index_handle()
.load()
.expect("index")
.has_unmerged_entries(),
"staging resolved worktree should collapse unmerged stages"
);
let side_tip = ObjectId::from_hex(&git_out(root, &["rev-parse", "side"])).expect("side oid");
let main_before = ObjectId::from_hex(&git_out(root, &["rev-parse", "HEAD"])).expect("main oid");
create_commit(
&repo,
&commit_req("merge side without add"),
&mut NullProgress,
)
.expect("merge commit");
let parents_line = git_out(root, &["rev-list", "--parents", "-1", "HEAD"]);
let parts: Vec<&str> = parents_line.split_whitespace().collect();
assert_eq!(
parts.len(),
3,
"merge commit should have two parents: {parents_line}"
);
assert_eq!(parts[1], main_before.to_hex());
assert_eq!(parts[2], side_tip.to_hex());
assert!(!repo.git_dir().join("MERGE_HEAD").exists());
}
#[test]
fn create_commit_concludes_resolved_merge_with_two_parents() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let repo = init_conflict_merge(root);
std::fs::write(root.join("f"), b"resolved\n").unwrap();
git(root, &["add", "f"]);
let side_tip = ObjectId::from_hex(&git_out(root, &["rev-parse", "side"])).expect("side oid");
let main_before = ObjectId::from_hex(&git_out(root, &["rev-parse", "HEAD"])).expect("main oid");
let outcome =
create_commit(&repo, &commit_req("merge side"), &mut NullProgress).expect("merge commit");
assert_eq!(outcome.parent, Some(main_before));
let parents_line = git_out(root, &["rev-list", "--parents", "-1", "HEAD"]);
let parts: Vec<&str> = parents_line.split_whitespace().collect();
assert_eq!(
parts.len(),
3,
"merge commit should have two parents: {parents_line}"
);
assert_eq!(parts[1], main_before.to_hex());
assert_eq!(parts[2], side_tip.to_hex());
let blob = git_out(root, &["show", "HEAD:f"]);
assert_eq!(blob, "resolved");
assert!(
!repo.git_dir().join("MERGE_HEAD").exists(),
"MERGE_HEAD should be cleared after merge commit"
);
let commit_obj = repo.odb().read(&outcome.oid).expect("commit");
let parsed = parse_commit(&commit_obj.data).expect("parse");
assert_eq!(parsed.parents.len(), 2);
let fsck = Command::new("git")
.current_dir(root)
.args(["fsck", "--strict"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("fsck");
assert!(
fsck.status.success(),
"git fsck: {}",
String::from_utf8_lossy(&fsck.stderr)
);
}
#[test]
fn create_commit_concludes_merge_when_resolved_as_ours() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
let repo = init_conflict_merge(root);
git(root, &["checkout", "--ours", "--", "f"]);
git(root, &["add", "f"]);
let side_tip = ObjectId::from_hex(&git_out(root, &["rev-parse", "side"])).expect("side oid");
let main_before = ObjectId::from_hex(&git_out(root, &["rev-parse", "HEAD"])).expect("main oid");
let main_tree_before = git_out(root, &["rev-parse", "HEAD^{tree}"]);
create_commit(&repo, &commit_req("resolve as ours"), &mut NullProgress)
.expect("merge commit with unchanged tree vs first parent");
let parents_line = git_out(root, &["rev-list", "--parents", "-1", "HEAD"]);
let parts: Vec<&str> = parents_line.split_whitespace().collect();
assert_eq!(
parts.len(),
3,
"merge commit should have two parents like git: {parents_line}"
);
assert_eq!(parts[1], main_before.to_hex());
assert_eq!(parts[2], side_tip.to_hex());
let tree_at_head = git_out(root, &["rev-parse", "HEAD^{tree}"]);
assert_eq!(
tree_at_head, main_tree_before,
"ours resolution should keep the first-parent tree"
);
assert_eq!(git_out(root, &["show", "HEAD:f"]), "main");
assert!(
!repo.git_dir().join("MERGE_HEAD").exists(),
"MERGE_HEAD should be cleared after merge commit"
);
}
#[test]
fn create_commit_applies_merge_autostash_like_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "user.name", "T"]);
git(root, &["config", "user.email", "t@e.com"]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("f"), b"base\n").unwrap();
std::fs::write(root.join("g"), b"base-g\n").unwrap();
git(root, &["add", "f", "g"]);
git(root, &["commit", "-qm", "base"]);
git(root, &["checkout", "-qb", "side"]);
std::fs::write(root.join("f"), b"side\n").unwrap();
git(root, &["commit", "-qam", "side"]);
git(root, &["checkout", "-q", "main"]);
std::fs::write(root.join("f"), b"main\n").unwrap();
git(root, &["commit", "-qam", "main"]);
std::fs::write(root.join("g"), b"local-g\n").unwrap();
let merge_out = Command::new("git")
.current_dir(root)
.args(["merge", "--autostash", "side"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git merge --autostash");
assert!(!merge_out.status.success(), "expected merge conflict");
assert!(
root.join(".git/MERGE_AUTOSTASH").exists(),
"merge --autostash should record MERGE_AUTOSTASH"
);
git(root, &["checkout", "--ours", "--", "f"]);
git(root, &["add", "f"]);
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
create_commit(
&repo,
&commit_req("merge with autostash"),
&mut NullProgress,
)
.expect("merge commit");
assert!(
!root.join(".git/MERGE_AUTOSTASH").exists(),
"MERGE_AUTOSTASH must be removed after merge commit"
);
assert_eq!(
git_out(root, &["show", "HEAD:g"]),
"base-g",
"committed tree should still reflect ours for g"
);
assert_eq!(
std::fs::read_to_string(root.join("g")).unwrap(),
"local-g\n",
"autostash should restore unstaged local change to g"
);
let status = git_out(root, &["status", "--short"]);
assert!(
status.contains(" M g") || status.starts_with("M g"),
"g should be unstaged modified after autostash restore: {status:?}"
);
}
#[test]
fn create_commit_clears_merge_rerere_state_like_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "user.name", "T"]);
git(root, &["config", "user.email", "t@e.com"]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
git(root, &["config", "rerere.enabled", "true"]);
std::fs::write(root.join("f"), b"base\n").unwrap();
git(root, &["add", "f"]);
git(root, &["commit", "-qm", "base"]);
git(root, &["checkout", "-qb", "side"]);
std::fs::write(root.join("f"), b"side\n").unwrap();
git(root, &["commit", "-qam", "side"]);
git(root, &["checkout", "-q", "main"]);
std::fs::write(root.join("f"), b"main\n").unwrap();
git(root, &["commit", "-qam", "main"]);
let merge_out = Command::new("git")
.current_dir(root)
.args(["merge", "side"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git merge");
assert!(!merge_out.status.success(), "expected merge conflict");
git(root, &["rerere"]);
git(root, &["checkout", "--ours", "--", "f"]);
git(root, &["add", "f"]);
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
create_commit(&repo, &commit_req("merge with rerere"), &mut NullProgress)
.expect("merge commit");
let merge_rr_len = fs::metadata(root.join(".git/MERGE_RR"))
.map(|m| m.len())
.unwrap_or(0);
assert_eq!(
merge_rr_len, 0,
"MERGE_RR should be empty after merge commit like git"
);
let rerere_status = git_out(root, &["rerere", "status"]);
assert!(
rerere_status.is_empty(),
"git rerere status should be empty, got: {rerere_status:?}"
);
}