grit-lib 0.5.21

Core library for the grit Git implementation
//! Dry-run bundle plans match written bundles and leave repositories unchanged.

use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

use grit_lib::bundle::bundle_remote::{
    plan_create_with_options, resolve_bundle_spec, BundleCreateOptions,
};
use grit_lib::bundle::{plan_bundle_create, Bundle, BundleSpec};
use grit_lib::config::environment::RepositoryOptions;
use grit_lib::maintenance::{CommitGraphOptions, MidxOptions, PackRefsOptions, PruneOptions};
use grit_lib::repo::Repository;

fn git_ok(dir: &Path, args: &[&str]) {
    let out = Command::new("git")
        .current_dir(dir)
        .args(args)
        .env("GIT_AUTHOR_NAME", "T")
        .env("GIT_AUTHOR_EMAIL", "t@example.com")
        .env("GIT_COMMITTER_NAME", "T")
        .env("GIT_COMMITTER_EMAIL", "t@example.com")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .expect("git");
    assert!(
        out.status.success(),
        "git {args:?}: {}",
        String::from_utf8_lossy(&out.stderr)
    );
}

fn init_two_commit_repo(dir: &Path) {
    git_ok(dir, &["init", "-q", "-b", "main", "."]);
    std::fs::write(dir.join("a.txt"), b"one\n").unwrap();
    git_ok(dir, &["add", "a.txt"]);
    git_ok(dir, &["commit", "-q", "-m", "first"]);
    std::fs::write(dir.join("b.txt"), b"two\n").unwrap();
    git_ok(dir, &["add", "b.txt"]);
    git_ok(dir, &["commit", "-q", "-m", "second"]);
}

fn ref_map(header: &grit_lib::bundle::BundleHeader) -> HashMap<String, String> {
    header
        .refs
        .iter()
        .map(|(name, oid)| (name.clone(), oid.to_hex()))
        .collect()
}

fn git_dir_fingerprint(git_dir: &Path) -> String {
    let mut parts = Vec::new();
    for entry in walkdir(git_dir) {
        parts.push(entry);
    }
    parts.sort();
    parts.join("\n")
}

fn walkdir(root: &Path) -> Vec<String> {
    let mut out = Vec::new();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(read) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in read.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if let Ok(meta) = entry.metadata() {
                let rel = path.strip_prefix(root).unwrap_or(&path);
                out.push(format!(
                    "{}:{}:{}",
                    rel.display(),
                    meta.len(),
                    meta.modified()
                        .ok()
                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                        .map(|d| d.as_nanos())
                        .unwrap_or(0)
                ));
            }
        }
    }
    out
}

#[test]
fn bundle_plan_create_matches_written_bundle() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let src = tmp.path();
    init_two_commit_repo(src);
    let repo = Repository::discover(Some(src), &RepositoryOptions::empty()).expect("open");
    let options = BundleCreateOptions {
        all_refs: false,
        rev_tokens: vec!["HEAD~1..HEAD".to_owned()],
    };
    let plan = repo
        .bundles()
        .plan_create_with_options(&options)
        .expect("plan");
    let spec = resolve_bundle_spec(&repo, &options).expect("spec");
    let bundle_path = src.join("out.bundle");
    repo.bundles()
        .create(&spec, &bundle_path)
        .expect("write bundle");

    git_ok(src, &["bundle", "verify", bundle_path.to_str().unwrap()]);

    let written = Bundle::open(&bundle_path).expect("open written");
    let header = written.header();
    assert_eq!(plan.refs.len(), header.refs.len());
    assert_eq!(
        plan.refs
            .iter()
            .map(|r| (r.name.as_str(), r.oid.to_hex()))
            .collect::<Vec<_>>(),
        header
            .refs
            .iter()
            .map(|(name, oid)| (name.as_str(), oid.to_hex()))
            .collect::<Vec<_>>()
    );
    assert_eq!(
        plan.prerequisites
            .iter()
            .map(|p| (p.oid.to_hex(), p.subject.clone()))
            .collect::<Vec<_>>(),
        header
            .prerequisites
            .iter()
            .map(|(oid, subject)| (oid.to_hex(), subject.clone()))
            .collect::<Vec<_>>()
    );
    let written_count = written.pack_object_count().expect("pack count");
    assert_eq!(plan.object_count, written_count);
    let _ = ref_map(header);
}

#[test]
fn bundle_plan_unbundle_writes_nothing() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let src = tmp.path();
    init_two_commit_repo(src);
    let bundle_path = src.join("transfer.bundle");
    git_ok(
        src,
        &["bundle", "create", bundle_path.to_str().unwrap(), "HEAD"],
    );

    let dest = tempfile::tempdir().expect("dest");
    git_ok(dest.path(), &["init", "-q", "-b", "main", "."]);
    let dest_repo =
        Repository::discover(Some(dest.path()), &RepositoryOptions::empty()).expect("dest");
    let before = git_dir_fingerprint(dest_repo.git_dir());

    let plan = dest_repo
        .bundles()
        .plan_unbundle(&bundle_path)
        .expect("plan unbundle");
    assert!(plan.verify.is_ok() || !plan.verify.missing_prerequisites.is_empty());

    let after = git_dir_fingerprint(dest_repo.git_dir());
    assert_eq!(before, after, "dry-run unbundle must not touch .git");

    git_ok(dest.path(), &["fsck", "--strict"]);
}

#[test]
fn bundle_and_maintenance_dry_run_public_apis_smoke() {
    let tmp = tempfile::tempdir().expect("tempdir");
    init_two_commit_repo(tmp.path());
    let repo = Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("open");
    let options = BundleCreateOptions {
        all_refs: false,
        rev_tokens: vec!["HEAD".to_owned()],
    };
    let spec = resolve_bundle_spec(&repo, &options).expect("spec");
    let _ = repo.bundles().plan_create(&spec).expect("plan_create");
    let _ = plan_create_with_options(&repo, &options).expect("plan_create_with_options");
    let _ = plan_bundle_create(
        &repo,
        &BundleSpec {
            include: spec.include,
            ..Default::default()
        },
    )
    .expect("plan_bundle_create");

    let _ = CommitGraphOptions::with_dry_run(true);
    let _ = MidxOptions::with_dry_run(true);
    let _ = PackRefsOptions::with_dry_run(true);
    let mut prune = PruneOptions::default();
    prune.dry_run = true;
    let _ = grit_lib::maintenance::prune_loose_objects(&repo, &prune).expect("prune dry-run");
    let roots = grit_lib::maintenance::collect_maintenance_object_roots(&repo).expect("roots");
    let _ =
        grit_lib::refs::gc::prune_loose_unreachable_with_dry_run(repo.odb(), &roots, None, true)
            .expect("prune_loose dry-run");
}