#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::SystemTime;
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::diff::merge_file::{ConflictStyle, MergeFavor};
use grit_lib::objects::ObjectId;
use grit_lib::porcelain::replay::ReplayDirection;
use grit_lib::porcelain::replay_sequence::{
predict_replay_sequence, start_replay_sequence, ReplayEmptyPolicy, ReplayOptions,
ReplaySequenceRequest, SequenceOutcome, SequencePrediction,
};
use grit_lib::repo::Repository;
const IDENT: &str = "Test User <t@example.com> 1700000000 +0000";
fn git(root: &Path, args: &[&str]) -> String {
let home = root.join(".git-home");
let _ = fs::create_dir_all(&home);
let out = Command::new("git")
.current_dir(root)
.args(args)
.env("HOME", &home)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn init_repo(root: &Path) {
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "user.email", "t@example.com"]);
git(root, &["config", "user.name", "Test"]);
git(root, &["config", "grit.changeIds", "false"]);
git(root, &["config", "commit.gpgsign", "false"]);
}
fn open_repo(root: &Path) -> Repository {
Repository::open(
&root.join(".git"),
&RepositoryOptions::empty().with_work_tree(root),
)
.expect("open")
}
fn commit_file(root: &Path, path: &str, contents: &str, msg: &str) -> ObjectId {
let full = root.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("mkdir");
}
fs::write(&full, contents).expect("write");
git(root, &["add", path]);
git(root, &["commit", "-qm", msg]);
oid_from_rev(root, "HEAD")
}
fn oid_from_rev(root: &Path, rev: &str) -> ObjectId {
let hex = git(root, &["rev-parse", rev]).trim().to_owned();
ObjectId::from_hex(&hex).expect("oid")
}
fn head_tree(_repo: &Repository, root: &Path) -> ObjectId {
let hex = git(root, &["rev-parse", "HEAD^{tree}"]).trim().to_owned();
ObjectId::from_hex(&hex).expect("tree oid")
}
fn index_tree_from_git(root: &Path) -> ObjectId {
let hex = git(root, &["write-tree"]).trim().to_owned();
ObjectId::from_hex(&hex).expect("tree oid")
}
fn count_loose_objects(git_dir: &Path) -> usize {
let objects = git_dir.join("objects");
let mut count = 0usize;
if let Ok(entries) = fs::read_dir(&objects) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.len() != 2 || name == "pack" || name == "info" {
continue;
}
if let Ok(sub) = fs::read_dir(entry.path()) {
count += sub.flatten().count();
}
}
}
count
}
fn index_mtime(git_dir: &Path) -> SystemTime {
fs::metadata(git_dir.join("index"))
.expect("index metadata")
.modified()
.expect("index mtime")
}
fn pick_req(commits: Vec<ObjectId>, opts: ReplayOptions) -> ReplaySequenceRequest {
ReplaySequenceRequest {
commits,
direction: ReplayDirection::Pick,
committer: IDENT.to_owned(),
options: opts,
}
}
#[test]
fn predict_empty_keep_intrinsic_advances_head_for_following_pick() {
let root = tempfile::tempdir().expect("tempdir");
init_repo(root.path());
commit_file(root.path(), "f.txt", "base\n", "base");
let h = oid_from_rev(root.path(), "HEAD");
git(root.path(), &["switch", "-c", "topic"]);
git(root.path(), &["commit", "--allow-empty", "-qm", "empty"]);
let e = oid_from_rev(root.path(), "HEAD");
git(root.path(), &["switch", "main"]);
let repo = open_repo(root.path());
let mut opts = ReplayOptions::default();
opts.empty = ReplayEmptyPolicy::Keep;
let req = pick_req(vec![e, h], opts.clone());
let prediction =
predict_replay_sequence(&repo, &req).expect("predict must not treat HEAD as stale");
assert!(
matches!(prediction, SequencePrediction::Clean { .. }),
"expected clean prediction after empty keep then replay of original HEAD, got {prediction:?}"
);
let out = start_replay_sequence(&repo, &req).expect("pick");
assert!(matches!(out, SequenceOutcome::Completed { .. }));
assert_ne!(
oid_from_rev(root.path(), "HEAD"),
h,
"empty keep must advance HEAD before the second pick"
);
}
#[test]
fn predict_no_commit_chains_index_tree_across_pick_range() {
let root = tempfile::tempdir().expect("tempdir");
init_repo(root.path());
commit_file(root.path(), "base.txt", "base\n", "base");
git(root.path(), &["switch", "-c", "topic"]);
commit_file(root.path(), "one.txt", "one\n", "one");
commit_file(root.path(), "two.txt", "two\n", "two");
let c1 = oid_from_rev(root.path(), "topic~1");
let c2 = oid_from_rev(root.path(), "topic");
git(root.path(), &["switch", "main"]);
let repo = open_repo(root.path());
let opts = ReplayOptions {
no_commit: true,
..ReplayOptions::default()
};
let req = pick_req(vec![c1, c2], opts.clone());
let out = start_replay_sequence(&repo, &req).expect("pick");
assert!(matches!(out, SequenceOutcome::Completed { .. }));
let applied_index_tree = index_tree_from_git(root.path());
let prediction = predict_replay_sequence(&repo, &req).expect("predict");
let SequencePrediction::Clean { tree, .. } = prediction else {
panic!("expected clean prediction");
};
assert_eq!(
tree, applied_index_tree,
"predict --no-commit must chain trees across steps like the real index"
);
}
#[test]
fn predict_no_commit_chains_index_tree_across_revert_range() {
let root = tempfile::tempdir().expect("tempdir");
init_repo(root.path());
commit_file(root.path(), "base.txt", "base\n", "base");
let c1 = commit_file(root.path(), "one.txt", "one\n", "one");
let c2 = commit_file(root.path(), "two.txt", "two\n", "two");
let repo = open_repo(root.path());
let opts = ReplayOptions {
no_commit: true,
..ReplayOptions::default()
};
let req = ReplaySequenceRequest {
commits: vec![c2, c1],
direction: ReplayDirection::Revert,
committer: IDENT.to_owned(),
options: opts.clone(),
};
let out = start_replay_sequence(&repo, &req).expect("revert");
assert!(matches!(out, SequenceOutcome::Completed { .. }));
let applied_index_tree = index_tree_from_git(root.path());
let prediction = predict_replay_sequence(&repo, &req).expect("predict");
let SequencePrediction::Clean { tree, .. } = prediction else {
panic!("expected clean prediction");
};
assert_eq!(tree, applied_index_tree);
}
#[test]
fn predict_pick_range_first_conflict() {
let root = tempfile::tempdir().expect("tempdir");
init_repo(root.path());
commit_file(root.path(), "base.txt", "base\n", "base");
git(root.path(), &["switch", "-c", "topic"]);
commit_file(root.path(), "b.txt", "b\n", "add b");
commit_file(root.path(), "f.txt", "topic\n", "topic f");
let c1 = oid_from_rev(root.path(), "topic~1");
let c2 = oid_from_rev(root.path(), "topic");
git(root.path(), &["switch", "main"]);
fs::write(root.path().join("f.txt"), "main\n").unwrap();
git(root.path(), &["add", "f.txt"]);
git(root.path(), &["commit", "-qm", "main f"]);
let repo = open_repo(root.path());
let prediction =
predict_replay_sequence(&repo, &pick_req(vec![c1, c2], ReplayOptions::default()))
.expect("predict");
match prediction {
SequencePrediction::Conflicts {
commit,
subject,
conflicts,
} => {
assert_eq!(commit, c2);
assert_eq!(subject, "topic f");
assert!(conflicts.iter().any(|c| c.path == "f.txt"));
}
other => panic!("expected conflict on second pick, got {other:?}"),
}
}
#[test]
fn predict_replay_writes_no_objects() {
let root = tempfile::tempdir().expect("tempdir");
init_repo(root.path());
commit_file(root.path(), "a.txt", "base\n", "base");
git(root.path(), &["switch", "-c", "topic"]);
commit_file(root.path(), "b.txt", "one\n", "one");
let pick = oid_from_rev(root.path(), "HEAD");
git(root.path(), &["switch", "main"]);
commit_file(root.path(), "c.txt", "main\n", "main");
let repo = open_repo(root.path());
let git_dir = repo.git_dir();
let loose_before = count_loose_objects(git_dir);
let index_before = index_mtime(git_dir);
predict_replay_sequence(&repo, &pick_req(vec![pick], ReplayOptions::default()))
.expect("predict");
assert_eq!(count_loose_objects(git_dir), loose_before);
assert_eq!(index_mtime(git_dir), index_before);
}
#[test]
fn x_theirs_pick_matches_git_cherry_pick_X_theirs_tree() {
let root = tempfile::tempdir().expect("tempdir");
init_repo(root.path());
commit_file(root.path(), "conflict.txt", "base\n", "base");
git(root.path(), &["switch", "-c", "topic"]);
commit_file(root.path(), "conflict.txt", "topic\n", "topic side");
let pick = oid_from_rev(root.path(), "HEAD");
git(root.path(), &["switch", "main"]);
commit_file(root.path(), "conflict.txt", "main\n", "main side");
let git_wt = tempfile::tempdir().expect("git copy");
fs::create_dir_all(git_wt.path()).unwrap();
Command::new("cp")
.args([
"-a",
&format!("{}/.", root.path().display()),
&git_wt.path().to_string_lossy(),
])
.status()
.unwrap();
git(
git_wt.path(),
&["cherry-pick", "-X", "theirs", &pick.to_hex()],
);
let repo = open_repo(root.path());
let opts = ReplayOptions {
favor: MergeFavor::Theirs,
..ReplayOptions::default()
};
let out = start_replay_sequence(&repo, &pick_req(vec![pick], opts)).expect("pick");
assert!(matches!(out, SequenceOutcome::Completed { .. }));
assert_eq!(
head_tree(&repo, root.path()),
head_tree(&open_repo(git_wt.path()), git_wt.path())
);
}
#[test]
fn diff3_pick_markers_byte_equal_git() {
let root = tempfile::tempdir().expect("tempdir");
init_repo(root.path());
git(root.path(), &["config", "merge.conflictStyle", "diff3"]);
commit_file(root.path(), "f.txt", "base\n", "base");
git(root.path(), &["switch", "-c", "topic"]);
fs::write(root.path().join("f.txt"), "topic\n").unwrap();
git(root.path(), &["commit", "-qam", "topic"]);
let pick = oid_from_rev(root.path(), "HEAD");
git(root.path(), &["switch", "main"]);
fs::write(root.path().join("f.txt"), "main\n").unwrap();
git(root.path(), &["commit", "-qam", "main"]);
let git_wt = tempfile::tempdir().expect("git copy");
fs::create_dir_all(git_wt.path()).unwrap();
Command::new("cp")
.args([
"-a",
&format!("{}/.", root.path().display()),
&git_wt.path().to_string_lossy(),
])
.status()
.unwrap();
git(git_wt.path(), &["config", "merge.conflictStyle", "diff3"]);
let cp = Command::new("git")
.current_dir(git_wt.path())
.args(["cherry-pick", &pick.to_hex()])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("cherry-pick");
assert!(!cp.status.success(), "expected git conflict");
let repo = open_repo(root.path());
let opts = ReplayOptions {
conflict_style: ConflictStyle::Diff3,
..ReplayOptions::default()
};
let out = start_replay_sequence(&repo, &pick_req(vec![pick], opts)).expect("pick");
assert!(matches!(out, SequenceOutcome::Conflicts { .. }));
let grit_markers = fs::read_to_string(root.path().join("f.txt")).expect("grit f");
let git_markers = fs::read_to_string(git_wt.path().join("f.txt")).expect("git f");
assert_eq!(grit_markers, git_markers);
}