grit-lib 0.5.21

Core library for the grit Git implementation
//! Parallel `stage` must write the same index as a single-threaded run (SHA-1 and SHA-256).

use std::fs;
use std::path::Path;
use std::process::Command;

use filetime::FileTime;
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::parallel::ThreadPolicy;
use grit_lib::porcelain::add::{stage, StageOptions};
use grit_lib::progress::NullProgress;
use grit_lib::repo::Repository;
use grit_test_support::git;

fn init_grit_repo(root: &Path, sha256: bool, threads: Option<usize>) -> Repository {
    let git_dir = root.join(".git");
    fs::create_dir_all(git_dir.join("objects")).expect("objects");
    fs::create_dir_all(git_dir.join("refs/heads")).expect("refs");
    fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("head");
    let config = if sha256 {
        "[core]\n\trepositoryformatversion = 1\n\tbare = false\n\tfilemode = true\n[extensions]\n\tobjectformat = sha256\n[user]\n\temail = t@example.com\n\tname = Test\n"
    } else {
        "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[user]\n\temail = t@example.com\n\tname = Test\n"
    };
    fs::write(git_dir.join("config"), config).expect("config");
    let mut options = RepositoryOptions::empty();
    if let Some(n) = threads {
        options.thread_policy = ThreadPolicy::with_threads_override(Some(n));
    }
    Repository::open(&git_dir, &options.clone().with_work_tree(root)).expect("open grit repo")
}

fn write_wide_tree(root: &Path, count: usize) {
    for i in 0..count {
        let dir = format!("d{:04}", i % 100);
        fs::create_dir_all(root.join(&dir)).expect("mkdir");
        let path = format!("{dir}/f{i:05}.txt");
        fs::write(root.join(&path), format!("payload {i}\n")).expect("write");
    }
}

fn pin_all_file_mtrees(root: &Path, count: usize) {
    let pinned = FileTime::from_unix_time(1_700_000_000, 0);
    for i in 0..count {
        let dir = format!("d{:04}", i % 100);
        let path = root.join(format!("{dir}/f{i:05}.txt"));
        filetime::set_file_mtime(&path, pinned).expect("pin mtime");
    }
}

fn reset_staging_area(repo: &Repository) {
    let git_dir = repo.git_dir();
    let _ = fs::remove_file(git_dir.join("index"));
    let objects = git_dir.join("objects");
    if objects.is_dir() {
        for ent in fs::read_dir(&objects).expect("read objects") {
            let ent = ent.expect("dirent");
            let path = ent.path();
            if path.is_dir() {
                let _ = fs::remove_dir_all(path);
            } else {
                let _ = fs::remove_file(path);
            }
        }
    }
}

fn stage_all(repo: &Repository) -> Vec<u8> {
    stage(repo, &StageOptions::default(), &mut NullProgress).expect("stage");
    fs::read(repo.index_handle().path()).expect("read index")
}

fn git_fsck_ok(repo: &Path) -> bool {
    Command::new("git")
        .args(["-C", repo.to_str().expect("utf-8 path"), "fsck"])
        .status()
        .expect("spawn git fsck")
        .success()
}

fn assert_serial_parallel_index_match(sha256: bool) {
    const N: usize = 10_000;
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    write_wide_tree(root, N);
    pin_all_file_mtrees(root, N);
    let repo_serial = init_grit_repo(root, sha256, Some(1));
    let serial_index = stage_all(&repo_serial);

    reset_staging_area(&repo_serial);
    let parallel_threads = std::thread::available_parallelism()
        .map(|n| n.get().max(2))
        .unwrap_or(2);
    let repo_parallel = init_grit_repo(root, sha256, Some(parallel_threads));
    let parallel_index = stage_all(&repo_parallel);

    if serial_index != parallel_index {
        let diff_at = serial_index
            .iter()
            .zip(parallel_index.iter())
            .position(|(a, b)| a != b)
            .unwrap_or(serial_index.len().min(parallel_index.len()));
        panic!(
            "parallel and serial stage must produce identical index bytes (len {} vs {}, first diff at {})",
            serial_index.len(),
            parallel_index.len(),
            diff_at
        );
    }

    assert!(git_fsck_ok(root), "git fsck after grit index");
    assert!(
        git(root, &["diff-files"]).trim().is_empty(),
        "git diff-files must be clean after grit add"
    );
    git(root, &["commit", "-qm", "initial"]);
    assert!(
        git(root, &["status", "--porcelain"]).trim().is_empty(),
        "git status --porcelain must be clean after commit"
    );
}

fn write_identical_flat_files(root: &Path, count: usize, payload: &[u8]) {
    let pinned = FileTime::from_unix_time(1_700_000_000, 0);
    for i in 0..count {
        let path = root.join(format!("f{i}.txt"));
        fs::write(&path, payload).expect("write");
        filetime::set_file_mtime(&path, pinned).expect("pin mtime");
    }
}

/// Parallel loose writes must not race when many paths hash to the same blob OID.
#[test]
fn parallel_bulk_add_identical_content_no_loose_write_race() {
    const N: usize = 5_000;
    const THREADS: usize = 4;
    const ROUNDS: usize = 20;
    let payload = b"identical payload for shared blob oid\n";
    for round in 0..ROUNDS {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path();
        write_identical_flat_files(root, N, payload);
        let repo = init_grit_repo(root, false, Some(THREADS));
        stage_all(&repo);
        assert!(
            git_fsck_ok(root),
            "git fsck after identical-content add (round {round})"
        );
    }
}

/// Issue #1651: bulk first-time add on an empty index with `core.preloadIndex=false`.
#[test]
fn bulk_untracked_add_empty_index_preload_off_serial_parallel_match() {
    const N: usize = 5_000;
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    write_wide_tree(root, N);
    pin_all_file_mtrees(root, N);

    fn disable_preload_index(repo: &Repository) {
        let path = repo.git_dir().join("config");
        let mut cfg = fs::read_to_string(&path).expect("read config");
        cfg.push_str("\n[core]\n\tpreloadIndex = false\n");
        fs::write(path, cfg).expect("write config");
    }

    let repo_serial = init_grit_repo(root, false, Some(1));
    disable_preload_index(&repo_serial);
    let serial_index = stage_all(&repo_serial);

    reset_staging_area(&repo_serial);
    let parallel_threads = std::thread::available_parallelism()
        .map(|n| n.get().max(2))
        .unwrap_or(2);
    let repo_parallel = init_grit_repo(root, false, Some(parallel_threads));
    disable_preload_index(&repo_parallel);
    let parallel_index = stage_all(&repo_parallel);

    assert_eq!(
        serial_index, parallel_index,
        "serial and parallel bulk add must match with preloadIndex=false"
    );
    assert!(git_fsck_ok(root));
}

#[test]
fn parallel_add_index_matches_serial_sha1() {
    assert_serial_parallel_index_match(false);
}

#[test]
fn parallel_add_index_matches_serial_sha256() {
    assert_serial_parallel_index_match(true);
}