grit-lib 0.5.21

Core library for the grit Git implementation
//! Patch-ID and hooks library integration tests (task #1479).

use grit_lib::config::environment::RepositoryOptions;
use grit_lib::diff::patch_ids::PatchIdMode;
use grit_lib::objects::ObjectId;
use grit_lib::repo::Repository;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
use tempfile::TempDir;

fn git_in(dir: &std::path::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", "Test")
        .env("GIT_AUTHOR_EMAIL", "t@example.com")
        .env("GIT_COMMITTER_NAME", "Test")
        .env("GIT_COMMITTER_EMAIL", "t@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).into_owned()
}

fn git_patch_ids_from_log(dir: &std::path::Path) -> Vec<(ObjectId, ObjectId)> {
    let log_p = git_in(dir, &["log", "-p", "--reverse"]);
    let mut child = Command::new("git")
        .current_dir(dir)
        .args(["patch-id", "--stable"])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .expect("git patch-id");
    child
        .stdin
        .take()
        .expect("stdin")
        .write_all(log_p.as_bytes())
        .expect("write log");
    let out = child.wait_with_output().expect("wait");
    assert!(out.status.success());
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter(|l| !l.is_empty())
        .map(|line| {
            let mut parts = line.split_whitespace();
            let patch = ObjectId::from_hex(parts.next().expect("patch")).expect("patch hex");
            let commit = ObjectId::from_hex(parts.next().expect("commit")).expect("commit hex");
            (patch, commit)
        })
        .collect()
}

#[test]
fn patch_id_matches_git_patch_id_stable() {
    let dir = TempDir::new().expect("tempdir");
    git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
    std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
    git_in(dir.path(), &["add", "f.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "first"]);
    std::fs::write(dir.path().join("f.txt"), b"one\ntwo\n").unwrap();
    git_in(dir.path(), &["add", "f.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "second"]);
    std::fs::write(dir.path().join("g.txt"), b"new\n").unwrap();
    git_in(dir.path(), &["add", "g.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "third"]);
    std::fs::write(dir.path().join("bin.dat"), &[0u8, 1, 2, 3]).unwrap();
    git_in(dir.path(), &["add", "bin.dat"]);
    git_in(dir.path(), &["commit", "-q", "-m", "binary"]);
    std::fs::write(dir.path().join("h.txt"), b"a\n").unwrap();
    git_in(dir.path(), &["add", "h.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "add-h"]);
    git_in(dir.path(), &["mv", "h.txt", "i.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "rename"]);
    let mut perms = std::fs::metadata(dir.path().join("f.txt"))
        .expect("f.txt")
        .permissions();
    perms.set_mode(0o100755);
    std::fs::set_permissions(dir.path().join("f.txt"), perms).expect("chmod");
    git_in(dir.path(), &["add", "f.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "mode-only"]);
    std::fs::write(dir.path().join("j.txt"), b"j\n").unwrap();
    git_in(dir.path(), &["add", "j.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "add-j"]);
    std::fs::write(dir.path().join("k.txt"), b"k\n").unwrap();
    git_in(dir.path(), &["add", "k.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "add-k"]);
    std::fs::write(dir.path().join("l.txt"), b"l\n").unwrap();
    git_in(dir.path(), &["add", "l.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "add-l"]);

    let repo = Repository::open(
        &dir.path().join(".git"),
        &RepositoryOptions::empty().with_work_tree(dir.path()),
    )
    .expect("open");

    let git_ids = git_patch_ids_from_log(dir.path());
    assert!(
        git_ids.len() >= 10,
        "expected at least 10 patch-ids, got {}",
        git_ids.len()
    );

    for (git_patch, commit) in git_ids {
        let grit = repo
            .patch_id(&commit, PatchIdMode::Stable)
            .expect("patch_id")
            .expect("non-merge commit should have patch-id");
        assert_eq!(grit, git_patch, "patch-id for {}", commit.to_hex());
    }
}

fn git_patch_id_from_show(dir: &std::path::Path, flag: &str) -> ObjectId {
    let show_p = git_in(dir, &["show", "-p", "HEAD"]);
    use std::io::Write;
    let mut child = Command::new("git")
        .current_dir(dir)
        .args(["patch-id", flag])
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .spawn()
        .expect("spawn git patch-id");
    child
        .stdin
        .take()
        .unwrap()
        .write_all(show_p.as_bytes())
        .unwrap();
    let out = child.wait_with_output().unwrap();
    assert!(out.status.success(), "git patch-id {flag}");
    ObjectId::from_hex(
        String::from_utf8_lossy(&out.stdout)
            .split_whitespace()
            .next()
            .expect("patch id"),
    )
    .expect("hex")
}

#[test]
fn patch_id_verbatim_matches_git_whitespace_sensitive() {
    let dir = TempDir::new().expect("tempdir");
    git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
    std::fs::write(dir.path().join("f.txt"), b"one\n").unwrap();
    git_in(dir.path(), &["add", "f.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "base"]);
    std::fs::write(dir.path().join("f.txt"), b"one  \ntwo\n").unwrap();
    git_in(dir.path(), &["add", "f.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "trailing-space"]);

    let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
        .trim()
        .parse()
        .unwrap();

    let git_stable = git_patch_id_from_show(dir.path(), "--stable");
    let git_verbatim = git_patch_id_from_show(dir.path(), "--verbatim");
    assert_ne!(git_stable, git_verbatim, "fixture must differ by mode");

    let repo = Repository::open(
        &dir.path().join(".git"),
        &RepositoryOptions::empty().with_work_tree(dir.path()),
    )
    .expect("open");

    let grit_stable = repo
        .patch_id(&commit, PatchIdMode::Stable)
        .expect("stable")
        .expect("some");
    let grit_verbatim = repo
        .patch_id(&commit, PatchIdMode::Verbatim)
        .expect("verbatim")
        .expect("some");

    assert_eq!(grit_stable, git_stable, "stable ODB vs git");
    assert_eq!(grit_verbatim, git_verbatim, "verbatim ODB vs git");
    assert_ne!(grit_stable, grit_verbatim);
}

#[test]
fn patch_id_stable_and_unstable_differ_for_multi_file_commit() {
    let dir = TempDir::new().expect("tempdir");
    git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
    std::fs::write(dir.path().join("a.txt"), b"a\n").unwrap();
    git_in(dir.path(), &["add", "a.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "base"]);
    std::fs::write(dir.path().join("a.txt"), b"a\nchanged\n").unwrap();
    std::fs::write(dir.path().join("b.txt"), b"b\n").unwrap();
    git_in(dir.path(), &["add", "a.txt", "b.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "two-file"]);

    let commit: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
        .trim()
        .parse()
        .unwrap();

    let git_stable_id = git_patch_id_from_show(dir.path(), "--stable");
    let git_unstable_id = git_patch_id_from_show(dir.path(), "--unstable");
    assert_ne!(
        git_stable_id, git_unstable_id,
        "git stable and unstable must differ for this fixture"
    );

    let repo = Repository::open(
        &dir.path().join(".git"),
        &RepositoryOptions::empty().with_work_tree(dir.path()),
    )
    .expect("open");

    let grit_stable = repo
        .patch_id(&commit, PatchIdMode::Stable)
        .expect("stable")
        .expect("some");
    let grit_unstable = repo
        .patch_id(&commit, PatchIdMode::Unstable)
        .expect("unstable")
        .expect("some");

    assert_eq!(
        grit_stable, git_stable_id,
        "stable ODB vs git patch-id --stable"
    );
    assert_eq!(
        grit_unstable, git_unstable_id,
        "unstable ODB vs git patch-id --unstable"
    );
    assert_ne!(grit_stable, grit_unstable);
}

#[test]
fn merge_commit_has_no_patch_id() {
    let dir = TempDir::new().expect("tempdir");
    git_in(dir.path(), &["init", "-q", "--initial-branch=main", "."]);
    std::fs::write(dir.path().join("f.txt"), b"x\n").unwrap();
    git_in(dir.path(), &["add", "f.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "base"]);
    git_in(dir.path(), &["checkout", "-q", "-b", "side"]);
    std::fs::write(dir.path().join("side.txt"), b"s\n").unwrap();
    git_in(dir.path(), &["add", "side.txt"]);
    git_in(dir.path(), &["commit", "-q", "-m", "side"]);
    git_in(dir.path(), &["checkout", "-q", "main"]);
    git_in(dir.path(), &["merge", "-q", "--no-ff", "side"]);
    let merge_tip: ObjectId = git_in(dir.path(), &["rev-parse", "HEAD"])
        .trim()
        .parse()
        .unwrap();
    let repo = Repository::open(
        &dir.path().join(".git"),
        &RepositoryOptions::empty().with_work_tree(dir.path()),
    )
    .expect("open");
    assert!(repo
        .patch_id(&merge_tip, PatchIdMode::Stable)
        .expect("read")
        .is_none());
}

#[test]
fn hooks_list_respects_core_hooks_path() {
    let dir = TempDir::new().expect("tempdir");
    git_in(dir.path(), &["init", "-q", "."]);
    let custom = dir.path().join("my-hooks");
    std::fs::create_dir_all(&custom).expect("mkdir");
    let hook_path = custom.join("pre-commit");
    std::fs::write(&hook_path, b"#!/bin/sh\nexit 0\n").expect("write hook");
    let mut perms = std::fs::metadata(&hook_path).expect("meta").permissions();
    perms.set_mode(0o100755);
    std::fs::set_permissions(&hook_path, perms).expect("chmod");
    git_in(
        dir.path(),
        &["config", "core.hooksPath", custom.to_str().expect("utf8")],
    );

    let repo = Repository::open(
        &dir.path().join(".git"),
        &RepositoryOptions::empty().with_work_tree(dir.path()),
    )
    .expect("open");
    let hooks = repo.hooks().list(None).expect("list").items;
    assert_eq!(hooks.len(), 1);
    assert_eq!(hooks[0].name, "pre-commit");
    assert_eq!(hooks[0].path, hook_path);
    assert!(hooks[0].executable);
    assert_eq!(
        hooks[0].source,
        grit_lib::repo_handles::HookSource::CoreHooksPath
    );
}

#[test]
fn non_executable_hook_reported() {
    let dir = TempDir::new().expect("tempdir");
    git_in(dir.path(), &["init", "-q", "."]);
    let hooks_dir = dir.path().join("custom-hooks");
    std::fs::create_dir_all(&hooks_dir).expect("mkdir");
    git_in(
        dir.path(),
        &[
            "config",
            "core.hooksPath",
            hooks_dir.to_str().expect("utf8"),
        ],
    );
    let hook_path = hooks_dir.join("pre-push");
    std::fs::write(&hook_path, b"#!/bin/sh\nexit 0\n").expect("write");
    std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o100644)).expect("chmod");

    let repo = Repository::open(
        &dir.path().join(".git"),
        &RepositoryOptions::empty().with_work_tree(dir.path()),
    )
    .expect("open");
    let hooks = repo.hooks().list(None).expect("list").items;
    assert_eq!(hooks.len(), 1);
    assert_eq!(hooks[0].name, "pre-push");
    assert!(!hooks[0].executable);
    assert_eq!(
        hooks[0].source,
        grit_lib::repo_handles::HookSource::CoreHooksPath
    );
}