grit-lib 0.5.21

Core library for the grit Git implementation
//! `stash push --staged`, `--keep-index`, and `--dry-run` vs system `git`.

use grit_lib::config::environment::RepositoryOptions;
use grit_lib::objects::ObjectId;
use grit_lib::repo::Repository;
use grit_lib::stash::{StashMode, StashPushOptions};
use std::path::{Path, PathBuf};
use std::process::Command;

fn git_cmd(repo: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .args(args)
        .current_dir(repo)
        .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("git");
    assert!(
        out.status.success(),
        "git {:?} failed: {}",
        args,
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

fn init_repo(root: &Path) {
    git_cmd(root, &["init", "-q", "-b", "main", "."]);
    git_cmd(root, &["config", "user.email", "t@example.com"]);
    git_cmd(root, &["config", "user.name", "Test"]);
}

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

fn stash_opts(mode: StashMode, include_untracked: bool) -> StashPushOptions {
    StashPushOptions {
        message: None,
        include_untracked,
        identity: "Test <t@example.com> 1000000000 +0000"
            .parse()
            .expect("signature"),
        selection: None,
        pathspecs: Vec::new(),
        dry_run: false,
        mode,
    }
}

fn porcelain_v2(root: &Path) -> String {
    git_cmd(root, &["status", "--porcelain=v2"])
        .trim()
        .to_owned()
}

fn index_tree_oid(root: &Path) -> ObjectId {
    let hex = git_cmd(root, &["write-tree"]).trim().to_owned();
    ObjectId::from_hex(&hex).expect("write-tree")
}

fn read_repo_file(root: &Path, rel: &str) -> Option<String> {
    let path = root.join(rel);
    std::fs::read_to_string(path).ok()
}

fn count_loose_objects(git_dir: &Path) -> usize {
    let objects = git_dir.join("objects");
    let mut n = 0usize;
    for ent in std::fs::read_dir(&objects).expect("objects dir") {
        let ent = ent.expect("entry");
        if ent.file_type().expect("type").is_dir() {
            let name = ent.file_name();
            let name = name.to_string_lossy();
            if name == "info" || name == "pack" {
                continue;
            }
            for obj in std::fs::read_dir(ent.path()).expect("shard") {
                n += 1;
                let _ = obj;
            }
        }
    }
    n
}

fn stash_ref_oid(root: &Path) -> Option<ObjectId> {
    let path = root.join(".git/refs/stash");
    let text = std::fs::read_to_string(path).ok()?;
    ObjectId::from_hex(text.trim()).ok()
}

fn setup_staged_fixture(root: &Path) {
    init_repo(root);
    std::fs::write(root.join("tracked.txt"), "base\n").expect("write");
    std::fs::write(root.join("other.txt"), "base\n").expect("write");
    git_cmd(root, &["add", "tracked.txt", "other.txt"]);
    git_cmd(root, &["commit", "-qm", "init"]);
    std::fs::write(root.join("tracked.txt"), "unstaged\n").expect("modify tracked");
    std::fs::write(root.join("staged_new.txt"), "staged\n").expect("new staged");
    git_cmd(root, &["add", "staged_new.txt"]);
}

fn setup_keep_index_fixture(root: &Path) {
    init_repo(root);
    std::fs::write(root.join("f.txt"), "base\n").expect("write");
    git_cmd(root, &["add", "f.txt"]);
    git_cmd(root, &["commit", "-qm", "init"]);
    std::fs::write(root.join("f.txt"), "unstaged\n").expect("unstaged");
    std::fs::write(root.join("staged.txt"), "new\n").expect("staged new");
    git_cmd(root, &["add", "staged.txt"]);
}

fn twin_dirs(tag: &str) -> (tempfile::TempDir, PathBuf, tempfile::TempDir, PathBuf) {
    let grit_tmp = tempfile::tempdir().expect("tempdir");
    let git_tmp = tempfile::TempDir::new().expect("tempdir");
    let grit_path = grit_tmp.path().join(tag);
    let git_path = git_tmp.path().join(tag);
    std::fs::create_dir_all(&grit_path).expect("mkdir");
    std::fs::create_dir_all(&git_path).expect("mkdir");
    (grit_tmp, grit_path, git_tmp, git_path)
}

#[test]
fn stash_staged_only_matches_git_trees() {
    let (_g, grit_root, _gi, git_root) = twin_dirs("staged");
    setup_staged_fixture(&grit_root);
    setup_staged_fixture(&git_root);

    let repo = open_grit(&grit_root);
    repo.stash()
        .push(&stash_opts(StashMode::StagedOnly, false))
        .expect("push")
        .expect("had staged changes");
    git_cmd(&git_root, &["stash", "push", "--staged"]);

    assert_eq!(
        porcelain_v2(&grit_root),
        porcelain_v2(&git_root),
        "post-push porcelain must match git stash --staged"
    );
    assert_eq!(
        index_tree_oid(&grit_root),
        index_tree_oid(&git_root),
        "index tree must match git"
    );
    assert_eq!(
        read_repo_file(&grit_root, "tracked.txt"),
        read_repo_file(&git_root, "tracked.txt")
    );
    assert!(!grit_root.join("staged_new.txt").exists());
    assert!(!git_root.join("staged_new.txt").exists());

    let show = git_cmd(&grit_root, &["stash", "show", "-p", "stash@{0}"]);
    let git_show = git_cmd(&git_root, &["stash", "show", "-p", "stash@{0}"]);
    assert_eq!(show.trim(), git_show.trim(), "git reads grit stash patch");
}

#[test]
fn stash_keep_index_leaves_index_and_worktree_like_git() {
    let (_g, grit_root, _gi, git_root) = twin_dirs("keep");
    setup_keep_index_fixture(&grit_root);
    setup_keep_index_fixture(&git_root);

    let repo = open_grit(&grit_root);
    repo.stash()
        .push(&stash_opts(StashMode::KeepIndex, false))
        .expect("push")
        .expect("had changes");
    git_cmd(&git_root, &["stash", "push", "--keep-index"]);

    assert_eq!(
        porcelain_v2(&grit_root),
        porcelain_v2(&git_root),
        "post-push porcelain must match git --keep-index"
    );
    assert_eq!(
        index_tree_oid(&grit_root),
        index_tree_oid(&git_root),
        "index tree must match git"
    );
    assert_eq!(
        read_repo_file(&grit_root, "f.txt"),
        read_repo_file(&git_root, "f.txt")
    );
    assert_eq!(
        read_repo_file(&grit_root, "staged.txt"),
        read_repo_file(&git_root, "staged.txt")
    );
}

#[test]
fn stash_keep_index_pop_matches_git() {
    let (_g, grit_root, _gi, git_root) = twin_dirs("keep-pop");
    setup_keep_index_fixture(&grit_root);
    setup_keep_index_fixture(&git_root);

    let repo = open_grit(&grit_root);
    repo.stash()
        .push(&stash_opts(StashMode::KeepIndex, false))
        .expect("push")
        .expect("had changes");
    git_cmd(&git_root, &["stash", "push", "--keep-index"]);

    git_cmd(&grit_root, &["stash", "pop"]);
    git_cmd(&git_root, &["stash", "pop"]);

    assert_eq!(
        porcelain_v2(&grit_root),
        porcelain_v2(&git_root),
        "after pop, porcelain must match git"
    );
}

#[test]
fn stash_dry_run_writes_no_objects_or_refs() {
    let dir = tempfile::tempdir().expect("tempdir");
    setup_staged_fixture(dir.path());
    let repo = open_grit(dir.path());
    let git_dir = dir.path().join(".git");
    let objects_before = count_loose_objects(&git_dir);
    let stash_before = stash_ref_oid(dir.path());

    let mut opts = stash_opts(StashMode::All, false);
    opts.dry_run = true;
    let preview = repo
        .stash()
        .preview(&opts)
        .expect("preview")
        .expect("would stash");
    assert!(
        preview.paths.iter().any(|p| p == "staged_new.txt"),
        "preview lists staged path"
    );

    assert_eq!(count_loose_objects(&git_dir), objects_before);
    assert_eq!(stash_ref_oid(dir.path()), stash_before);
    assert_eq!(
        porcelain_v2(dir.path()),
        git_cmd(dir.path(), &["status", "--porcelain=v2"]).trim(),
        "dry-run leaves worktree unchanged"
    );
}

#[test]
fn stash_staged_with_pathspec_matches_git() {
    let (_g, grit_root, _gi, git_root) = twin_dirs("staged-path");
    init_repo(&grit_root);
    init_repo(&git_root);
    for root in [&grit_root, &git_root] {
        std::fs::write(root.join("a"), "a\n").expect("write");
        std::fs::write(root.join("b"), "b\n").expect("write");
        git_cmd(root, &["add", "."]);
        git_cmd(root, &["commit", "-qm", "i"]);
        std::fs::write(root.join("a"), "a2\n").expect("a");
        std::fs::write(root.join("b"), "b2\n").expect("b");
        git_cmd(root, &["add", "a", "b"]);
    }

    let repo = open_grit(&grit_root);
    let mut opts = stash_opts(StashMode::StagedOnly, false);
    opts.pathspecs = vec!["a".to_owned()];
    repo.stash().push(&opts).expect("push").expect("changes");
    git_cmd(&git_root, &["stash", "push", "--staged", "--", "a"]);

    assert_eq!(
        read_repo_file(&grit_root, "a"),
        read_repo_file(&git_root, "a")
    );
    assert_eq!(
        read_repo_file(&grit_root, "b"),
        read_repo_file(&git_root, "b")
    );
    assert_eq!(index_tree_oid(&grit_root), index_tree_oid(&git_root));
    let git_show = git_cmd(&git_root, &["stash", "show", "-p", "stash@{0}"]);
    let grit_show = git_cmd(&grit_root, &["stash", "show", "-p", "stash@{0}"]);
    assert_eq!(grit_show.trim(), git_show.trim());
}

#[test]
fn stash_staged_deletion_restores_head_like_git() {
    let (_g, grit_root, _gi, git_root) = twin_dirs("staged-del");
    for root in [&grit_root, &git_root] {
        init_repo(root);
        std::fs::write(root.join("f"), "x\n").expect("write");
        git_cmd(root, &["add", "f"]);
        git_cmd(root, &["commit", "-qm", "i"]);
        git_cmd(root, &["rm", "f"]);
    }

    let repo = open_grit(&grit_root);
    repo.stash()
        .push(&stash_opts(StashMode::StagedOnly, false))
        .expect("push")
        .expect("changes");
    git_cmd(&git_root, &["stash", "push", "--staged"]);

    assert_eq!(porcelain_v2(&grit_root), porcelain_v2(&git_root));
    assert_eq!(
        read_repo_file(&grit_root, "f"),
        read_repo_file(&git_root, "f")
    );
    let show = git_cmd(&grit_root, &["stash", "show", "-p", "stash@{0}"]);
    assert!(
        show.contains("deleted file"),
        "stash records deletion:\n{show}"
    );
}

#[test]
fn stash_staged_rejects_untracked_combo() {
    let dir = tempfile::tempdir().expect("tempdir");
    init_repo(dir.path());
    std::fs::write(dir.path().join("f"), "x\n").expect("write");
    git_cmd(dir.path(), &["add", "f"]);
    git_cmd(dir.path(), &["commit", "-qm", "i"]);
    git_cmd(dir.path(), &["add", "f"]);
    std::fs::write(dir.path().join("u.txt"), "u\n").expect("untracked");

    let repo = open_grit(dir.path());
    let err = repo
        .stash()
        .push(&stash_opts(StashMode::StagedOnly, true))
        .expect_err("staged + untracked");
    assert!(
        err.to_string().contains("include-untracked"),
        "expected staged/untracked conflict, got: {err}"
    );
}