grit-lib 0.5.21

Core library for the grit Git implementation
//! Non-UTF-8 file names: status, diff, add, clone, and restore (issue #1399).

#![cfg(unix)]

use std::ffi::OsStr;
use std::io::Write;
use std::os::unix::ffi::OsStrExt;
use std::process::Command;

use grit_lib::config::environment::{Environment, RepositoryOptions};
use grit_lib::diff::diff_index_to_worktree;
use grit_lib::objects::parse_commit;
use grit_lib::objects::ObjectId;
use grit_lib::porcelain::add::{stage, StageMode, StageOptions};
use grit_lib::porcelain::checkout::checkout_between_trees;
use grit_lib::porcelain::restore::{restore_paths, RestoreOptions, RestoreSource};
use grit_lib::porcelain::status::{status, StatusOptions};
use grit_lib::progress::NeverCancel;
use grit_lib::progress::NullProgress;
use grit_lib::remote::clone::{clone as clone_repo, CloneOptions};
use grit_lib::remote::fetch::NoProgress;
use grit_lib::repo::Repository;
use grit_lib::repo_relpath;
use grit_test_support::git;

const BAD_NAME: &[u8] = b"bad\xffname.txt";
const BAD_GITLINK: &[u8] = b"bad\xffsub";
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
const GITLINK_OID: &str = "855827c583bc30645ba427885caa40c5b81764d2";
const RESERVED_LITERAL: &str = "grit+pathhex:6162";

fn bad_path(root: &std::path::Path) -> std::path::PathBuf {
    root.join(OsStr::from_bytes(BAD_NAME))
}

fn open_repo(root: &std::path::Path) -> Repository {
    Repository::open(
        &root.join(".git"),
        &RepositoryOptions::empty().with_work_tree(root),
    )
    .expect("open grit repo")
}

fn path_is_bad_name(stored: &str) -> bool {
    repo_relpath::storage_bytes(stored) == BAD_NAME
}

fn git_stdout(root: &std::path::Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .current_dir(root)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .args(args)
        .output()
        .expect("git");
    assert!(
        out.status.success(),
        "git {args:?}: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_owned()
}

fn git_porcelain(root: &std::path::Path) -> String {
    let out = Command::new("git")
        .current_dir(root)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .args(["status", "--short"])
        .output()
        .expect("git status");
    assert!(
        out.status.success(),
        "{}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

#[test]
fn non_utf8_gitlink_added_by_checkout_between_trees() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("gitlink-add");
    std::fs::create_dir(&root).expect("mkdir");
    git(&root, &["init", "-q", "-b", "main"]);
    git(&root, &["config", "user.email", "t@example.com"]);
    git(&root, &["config", "user.name", "Test"]);
    let cacheinfo = format!("160000,{GITLINK_OID},");
    let mut cacheinfo_os = std::ffi::OsString::from(cacheinfo);
    cacheinfo_os.push(OsStr::from_bytes(BAD_GITLINK));
    let update = Command::new("git")
        .current_dir(&root)
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .arg("update-index")
        .arg("--add")
        .arg("--cacheinfo")
        .arg(cacheinfo_os)
        .output()
        .expect("git update-index");
    assert!(
        update.status.success(),
        "git update-index: {}",
        String::from_utf8_lossy(&update.stderr)
    );
    let tree_with_gitlink =
        ObjectId::from_hex(&git_stdout(&root, &["write-tree"])).expect("tree oid");
    let empty_tree = ObjectId::from_hex(EMPTY_TREE).expect("empty tree");

    let repo = open_repo(&root);
    checkout_between_trees(&repo, Some(&empty_tree), &tree_with_gitlink)
        .expect("checkout must materialize non-UTF-8 gitlink");

    let gitlink_dir = root.join(OsStr::from_bytes(BAD_GITLINK));
    assert!(
        gitlink_dir.is_dir(),
        "non-UTF-8 gitlink path must exist as directory: {:?}",
        gitlink_dir
    );
    let loaded = repo.index_handle().load().expect("index");
    assert!(
        loaded.get(BAD_GITLINK, 0).is_some(),
        "index must record gitlink at raw byte path"
    );
}

#[test]
fn non_utf8_up_to_date_blob_skipped_on_checkout_between_trees() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("checkout-skip");
    std::fs::create_dir(&root).expect("mkdir");
    git(&root, &["init", "-q", "-b", "main"]);
    git(&root, &["config", "user.email", "t@example.com"]);
    git(&root, &["config", "user.name", "Test"]);
    std::fs::write(bad_path(&root), b"payload\n").expect("write bad");
    git(&root, &["add", "-A"]);
    git(&root, &["commit", "-qm", "with bad"]);
    let tree_with_bad =
        ObjectId::from_hex(&git_stdout(&root, &["rev-parse", "HEAD^{tree}"])).expect("tree oid");
    let empty_tree = ObjectId::from_hex(EMPTY_TREE).expect("empty tree");

    let repo = open_repo(&root);
    std::fs::remove_file(bad_path(&root)).expect("clear worktree");
    checkout_between_trees(&repo, Some(&empty_tree), &tree_with_bad)
        .expect("first checkout creates non-UTF-8 file");
    checkout_between_trees(&repo, Some(&empty_tree), &tree_with_bad)
        .expect("second checkout must skip up-to-date non-UTF-8 blob");
    assert_eq!(
        std::fs::read(bad_path(&root)).expect("read bad"),
        b"payload\n"
    );
}

#[test]
fn non_utf8_file_deleted_by_checkout_between_trees() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path().join("checkout-del");
    std::fs::create_dir(&root).expect("mkdir");
    git(&root, &["init", "-q", "-b", "main"]);
    git(&root, &["config", "user.email", "t@example.com"]);
    git(&root, &["config", "user.name", "Test"]);
    std::fs::write(bad_path(&root), b"v1\n").expect("write bad");
    git(&root, &["add", "-A"]);
    git(&root, &["commit", "-qm", "with bad"]);
    let tree_with_bad =
        ObjectId::from_hex(&git_stdout(&root, &["rev-parse", "HEAD^{tree}"])).expect("tree oid");

    std::fs::remove_file(bad_path(&root)).expect("remove bad");
    git(&root, &["add", "-A"]);
    git(&root, &["commit", "-qm", "without bad"]);
    let tree_without_bad =
        ObjectId::from_hex(&git_stdout(&root, &["rev-parse", "HEAD^{tree}"])).expect("tree oid");

    git(&root, &["reset", "--hard", "HEAD~1"]);
    assert!(
        bad_path(&root).exists(),
        "setup: non-UTF-8 file must exist after reset to first commit"
    );

    let repo = open_repo(&root);
    checkout_between_trees(&repo, Some(&tree_with_bad), &tree_without_bad)
        .expect("checkout_between_trees old→new");
    assert!(
        !bad_path(&root).exists(),
        "deleted non-UTF-8 path must be removed from worktree"
    );
    assert!(
        repo.index_handle()
            .load()
            .expect("index")
            .get(BAD_NAME, 0)
            .is_none(),
        "deleted non-UTF-8 path must be removed from index"
    );
}

#[test]
fn non_utf8_tracked_status_diff_add_clone_and_restore() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let r1 = tmp.path().join("r1");
    std::fs::create_dir(&r1).expect("mkdir");
    git(&r1, &["init", "-q", "-b", "main"]);
    git(&r1, &["config", "user.email", "t@example.com"]);
    git(&r1, &["config", "user.name", "Test"]);
    std::fs::write(bad_path(&r1), b"one\n").expect("write bad");
    std::fs::write(r1.join("ok.txt"), b"ok\n").expect("write ok");
    git(&r1, &["add", "-A"]);
    git(&r1, &["commit", "-qm", "base"]);

    let mut bad = std::fs::OpenOptions::new()
        .append(true)
        .open(bad_path(&r1))
        .expect("open bad");
    bad.write_all(b"two\n").expect("append");
    std::fs::remove_file(r1.join("ok.txt")).expect("rm ok");

    let repo = open_repo(&r1);
    let model = status(
        &repo,
        &StatusOptions::default(),
        &mut NullProgress,
        &NeverCancel,
    )
    .expect("status");
    let unstaged_paths: Vec<_> = model.unstaged.iter().map(|e| e.path().to_owned()).collect();
    assert!(
        unstaged_paths.iter().any(|p| path_is_bad_name(p)),
        "modified non-UTF-8 path must appear in status: {unstaged_paths:?}"
    );
    assert!(
        unstaged_paths.iter().any(|p| p == "ok.txt"),
        "deleted ok.txt must appear: {unstaged_paths:?}"
    );

    let mut index = repo.index_handle().load().expect("index");
    let diff = diff_index_to_worktree(
        repo.odb(),
        &mut index,
        repo.work_tree().expect("work tree"),
        false,
        false,
    )
    .expect("diff index to worktree");
    assert!(
        diff.iter().any(|e| path_is_bad_name(e.path())),
        "diff must include non-UTF-8 path: {:?}",
        diff.iter().map(|e| e.path()).collect::<Vec<_>>()
    );

    let clone_dest = tmp.path().join("r1c");
    let outcome = clone_repo(
        CloneOptions {
            ..CloneOptions::new(r1.to_string_lossy(), clone_dest.clone())
        },
        &mut NoProgress,
        &NeverCancel,
        None,
    )
    .expect("grit clone");
    let commit = outcome
        .repo
        .odb()
        .read(&outcome.checkout_oid)
        .expect("tip commit");
    let parsed = parse_commit(&commit.data).expect("parse commit");
    checkout_between_trees(&outcome.repo, None, &parsed.tree).expect("checkout");
    assert!(
        bad_path(&clone_dest).exists(),
        "clone must create file with original byte name"
    );
    assert!(
        git_porcelain(&clone_dest).trim().is_empty(),
        "clone worktree must be clean for git"
    );

    restore_paths(
        &repo,
        &RestoreOptions {
            pathspecs: vec![".".to_owned()],
            pathspec_sources: vec![".".to_owned()],
            source: RestoreSource::Index,
            staged: false,
            worktree: true,
            stage: None,
            remerge: None,
        },
    )
    .expect("restore worktree from index");
    assert_eq!(
        std::fs::read(bad_path(&r1)).expect("read restored bad"),
        b"one\n",
        "restore must revert non-UTF-8 tracked file to index blob"
    );
    assert!(
        git_porcelain(&r1).trim().is_empty(),
        "worktree must be clean after restore: {}",
        git_porcelain(&r1)
    );

    let r1b = tmp.path().join("r1b");
    std::fs::create_dir(&r1b).expect("mkdir r1b");
    git(&r1b, &["init", "-q", "-b", "main"]);
    std::fs::write(bad_path(&r1b), b"x\n").expect("bad");
    std::fs::write(r1b.join("good.txt"), b"y\n").expect("good");
    let r1b_repo = open_repo(&r1b);
    stage(
        &r1b_repo,
        &StageOptions {
            pathspecs: Vec::new(),
            pathspec_sources: Vec::new(),
            mode: StageMode::All,
            ..StageOptions::default()
        },
        &mut NullProgress,
    )
    .expect("stage all");
    let porcelain = git_porcelain(&r1b);
    assert!(
        porcelain.contains("good.txt"),
        "good.txt should be staged: {porcelain}"
    );
    assert!(
        porcelain.lines().count() >= 2,
        "both paths should be staged: {porcelain}"
    );

    std::fs::write(r1b.join(RESERVED_LITERAL), b"z\n").expect("reserved name file");
    stage(
        &r1b_repo,
        &StageOptions {
            pathspecs: Vec::new(),
            pathspec_sources: Vec::new(),
            mode: StageMode::All,
            ..StageOptions::default()
        },
        &mut NullProgress,
    )
    .expect("stage file whose name looks like hex encoding");
    assert!(
        r1b_repo
            .index_handle()
            .load()
            .expect("index")
            .get(RESERVED_LITERAL.as_bytes(), 0)
            .is_some(),
        "literal grit+pathhex:* filename must stage under its UTF-8 spelling"
    );
}