grit-lib 0.5.20

Core library for the grit Git implementation
Documentation
//! Round-trip `create_commit` with the system `git` binary.

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));
}