use std::io::Write;
use std::path::Path;
use std::process::Command;
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::objects::{parse_commit, ObjectId};
use grit_lib::porcelain::add::{stage, StageOptions};
use grit_lib::porcelain::commit::{create_commit, CommitRequest, HookPolicy};
use grit_lib::progress::NullProgress;
use grit_lib::repo::Repository;
use grit_test_support::git;
fn ident() -> String {
"Compat Test <compat@example.com> 1700000000 +0000".to_owned()
}
fn commit_req(message: &str) -> CommitRequest {
let id = ident();
CommitRequest {
message: message.to_owned(),
author: id.clone(),
committer: id,
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 amend_req(message: &str) -> CommitRequest {
let mut req = commit_req(message);
req.amend = true;
req
}
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", "Compat Test")
.env("GIT_AUTHOR_EMAIL", "compat@example.com")
.env("GIT_COMMITTER_NAME", "Compat Test")
.env("GIT_COMMITTER_EMAIL", "compat@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 fsck(dir: &Path) {
let out = Command::new("git")
.current_dir(dir)
.args(["fsck", "--strict"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git fsck");
assert!(
out.status.success(),
"git fsck: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn grit_first_commit_subdirs_index_sorted_for_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
std::fs::create_dir_all(root.join("d")).unwrap();
std::fs::write(root.join("d/x.txt"), b"2\n").unwrap();
std::fs::write(root.join("z.txt"), b"3\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &commit_req("base"), &mut NullProgress).expect("commit");
let staged = git_out(root, &["ls-files", "--stage"]);
let paths: Vec<&str> = staged
.lines()
.map(|line| line.split_whitespace().nth(3).expect("path"))
.collect();
assert_eq!(paths, ["a.txt", "d/x.txt", "z.txt"]);
assert!(
git_out(root, &["status", "--short"]).is_empty(),
"git status should be clean"
);
fsck(root);
}
#[test]
fn grit_create_commit_initial_validated_by_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root).unwrap();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("README.md"), b"# hi\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
let outcome =
create_commit(&repo, &commit_req("initial grit"), &mut NullProgress).expect("commit");
fsck(root);
let show = git_out(root, &["show", "-s", "--format=%H %s", "HEAD"]);
assert!(show.contains(&outcome.oid.to_hex()));
assert!(show.contains("initial grit"));
assert_eq!(
git_out(root, &["rev-parse", "refs/heads/main"]),
outcome.oid.to_hex()
);
let head_reflog = git_out(root, &["reflog", "show", "-1", "HEAD"]);
assert!(
head_reflog.contains("initial grit"),
"HEAD reflog: {head_reflog:?}"
);
}
#[test]
fn grit_create_commit_second_validated_by_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
git(root, &["add", "a.txt"]);
git(root, &["commit", "-q", "-m", "seed"]);
std::fs::write(root.join("a.txt"), b"2\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
let outcome =
create_commit(&repo, &commit_req("second grit"), &mut NullProgress).expect("commit");
fsck(root);
assert_eq!(git_out(root, &["rev-parse", "HEAD"]), outcome.oid.to_hex());
let parent = git_out(root, &["rev-parse", "HEAD^"]);
assert_ne!(parent, outcome.oid.to_hex());
}
#[test]
fn git_commits_readable_by_grit_create_commit_chain() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("f.txt"), b"v1\n").unwrap();
git(root, &["add", "f.txt"]);
git(root, &["commit", "-q", "-m", "from git"]);
std::fs::write(root.join("f.txt"), b"v2\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
let outcome =
create_commit(&repo, &commit_req("from grit"), &mut NullProgress).expect("commit");
fsck(root);
let git_head = ObjectId::from_hex(&git_out(root, &["rev-parse", "HEAD"])).expect("oid");
assert_eq!(git_head, outcome.oid);
let obj = repo.odb().read(&outcome.oid).expect("read commit");
let parsed = parse_commit(&obj.data).expect("parse");
assert!(parsed.message.contains("from grit"));
}
fn author_signature(root: &Path) -> String {
git_out(root, &["log", "-1", "--format=%an%ae%at"])
}
fn assert_amend_reflog(root: &Path, subject: &str) {
let reflog = git_out(root, &["reflog", "-1", "HEAD"]);
assert!(
reflog.contains(&format!("commit (amend): {subject}")),
"reflog: {reflog:?}"
);
}
#[test]
fn amend_rewrites_tip() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
git(root, &["add", "a.txt"]);
git(root, &["commit", "-q", "-m", "first"]);
let first = git_out(root, &["rev-parse", "HEAD"]);
std::fs::write(root.join("a.txt"), b"2\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &commit_req("second"), &mut NullProgress).expect("commit");
let old_second = git_out(root, &["rev-parse", "HEAD"]);
let author_before = author_signature(root);
std::fs::write(root.join("a.txt"), b"3\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &amend_req("second amended"), &mut NullProgress).expect("amend");
fsck(root);
assert_eq!(git_out(root, &["rev-parse", "HEAD^"]), first);
assert_ne!(git_out(root, &["rev-parse", "HEAD"]), old_second);
assert_eq!(author_signature(root), author_before);
assert_amend_reflog(root, "second amended");
}
#[test]
fn amend_keeps_author_and_message() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &commit_req("keep me"), &mut NullProgress).expect("commit");
let before_author = author_signature(root);
create_commit(&repo, &amend_req(""), &mut NullProgress).expect("amend");
fsck(root);
assert_eq!(author_signature(root), before_author);
assert_eq!(git_out(root, &["log", "-1", "--format=%s"]), "keep me");
let reflog = git_out(root, &["reflog", "-1", "HEAD"]);
assert!(
reflog.contains("commit (amend): keep me"),
"reflog: {reflog:?}"
);
}
fn commit_message_bytes(root: &Path) -> Vec<u8> {
let out = Command::new("git")
.current_dir(root)
.args(["cat-file", "commit", "HEAD"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git cat-file");
assert!(out.status.success(), "cat-file commit failed");
let raw = out.stdout;
let sep = raw
.windows(2)
.position(|w| w == b"\n\n")
.expect("commit headers");
raw[sep + 2..].to_vec()
}
#[test]
fn amend_preserves_message_bytes() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"a\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &commit_req("oneline subject"), &mut NullProgress).expect("commit");
let baseline = commit_message_bytes(root);
assert_eq!(baseline, b"oneline subject\n");
for extra in 1..=2 {
std::fs::OpenOptions::new()
.append(true)
.open(root.join("a.txt"))
.unwrap()
.write_all(format!("{extra}\n").as_bytes())
.unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &amend_req(""), &mut NullProgress).expect("amend");
fsck(root);
assert_eq!(
commit_message_bytes(root),
baseline,
"message changed after amend {extra}"
);
}
}
#[test]
fn amend_with_new_message() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &commit_req("old subject"), &mut NullProgress).expect("commit");
let author_before = author_signature(root);
create_commit(&repo, &amend_req("new subject"), &mut NullProgress).expect("amend");
fsck(root);
assert_eq!(git_out(root, &["log", "-1", "--format=%s"]), "new subject");
assert_eq!(author_signature(root), author_before);
assert_amend_reflog(root, "new subject");
}
#[test]
fn amend_root_commit() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &commit_req("root"), &mut NullProgress).expect("commit");
let before_author = author_signature(root);
std::fs::write(root.join("b.txt"), b"2\n").unwrap();
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
create_commit(&repo, &amend_req("root plus b"), &mut NullProgress).expect("amend");
fsck(root);
assert_eq!(author_signature(root), before_author);
let out = Command::new("git")
.current_dir(root)
.args(["rev-parse", "HEAD^"])
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.expect("git rev-parse HEAD^");
assert!(!out.status.success(), "root amend must not gain a parent");
assert_eq!(git_out(root, &["log", "-1", "--format=%s"]), "root plus b");
assert_amend_reflog(root, "root plus b");
}
#[test]
fn grit_allow_empty_commit_round_trips_with_git() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
let first =
create_commit(&repo, &commit_req("first"), &mut NullProgress).expect("first commit");
let mut empty_req = commit_req("empty marker");
empty_req.allow_empty = true;
let empty = create_commit(&repo, &empty_req, &mut NullProgress).expect("allow-empty commit");
fsck(root);
assert_eq!(empty.changes, 0);
assert_eq!(empty.parent, Some(first.oid));
let git_head = ObjectId::from_hex(&git_out(root, &["rev-parse", "HEAD"])).expect("head");
assert_eq!(git_head, empty.oid);
let obj = repo.odb().read(&empty.oid).expect("read commit");
let parsed = parse_commit(&obj.data).expect("parse");
assert_eq!(parsed.parents.len(), 1);
assert_eq!(parsed.parents[0], first.oid);
let parent_obj = repo.odb().read(&first.oid).expect("read parent");
let parent_parsed = parse_commit(&parent_obj.data).expect("parse parent");
assert_eq!(parsed.tree, parent_parsed.tree);
let show = git_out(root, &["show", "-s", "--format=%s", "HEAD"]);
assert!(show.contains("empty marker"));
}
#[test]
fn amend_unborn_errors() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
git(root, &["init", "-q", "-b", "main", "."]);
git(root, &["config", "core.logAllRefUpdates", "true"]);
std::fs::write(root.join("a.txt"), b"1\n").unwrap();
let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
stage(&repo, &StageOptions::default(), &mut NullProgress).expect("stage");
let err = create_commit(&repo, &amend_req("nope"), &mut NullProgress).unwrap_err();
assert!(matches!(err, grit_lib::error::Error::AmendUnborn));
}