grit-lib 0.5.21

Core library for the grit Git implementation
//! Rename detection for index↔HEAD and index↔worktree diffs (issue #1012).

use std::io::Write;
use std::path::Path;
use std::process::Command;

use grit_lib::config::environment::RepositoryOptions;
use grit_lib::diff::tree_oid_of_commit;
use grit_lib::diff::{
    apply_rename_detection_to_diff_entries, diff_index_to_tree_with_renames,
    diff_tree_to_worktree_with_git_dir, DiffStatus,
};
use grit_lib::repo::Repository;
use grit_lib::state::resolve_head;

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

fn init_repo_with_large_file(root: &Path) {
    git_run(root, &["init", "-q", "-b", "main"]);
    let body: String = (1..=40)
        .map(|i| i.to_string())
        .collect::<Vec<_>>()
        .join("\n");
    std::fs::write(root.join("a.txt"), format!("{body}\n")).unwrap();
    git_run(root, &["add", "."]);
    git_run(root, &["commit", "-qm", "one"]);
}

#[test]
fn diff_index_to_tree_with_renames_reports_staged_mv() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    init_repo_with_large_file(root);
    git_run(root, &["mv", "a.txt", "b.txt"]);

    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    let head = resolve_head(repo.git_dir()).expect("head");
    let head_commit = head.oid().expect("commit");
    let head_tree = tree_oid_of_commit(repo.odb(), head_commit).expect("tree");
    let index = repo.index_handle().load().expect("index");
    let config = repo.config().expect("config");
    let policy = repo.thread_policy();

    let entries = diff_index_to_tree_with_renames(
        repo.odb(),
        &index,
        Some(&head_tree),
        false,
        50,
        config.as_ref(),
        &policy,
    )
    .expect("staged diff");

    let renames: Vec<_> = entries
        .iter()
        .filter(|e| e.status == DiffStatus::Renamed)
        .collect();
    assert_eq!(
        renames.len(),
        1,
        "expected one staged rename, got: {entries:?}"
    );
    assert_eq!(renames[0].old_path.as_deref(), Some("a.txt"));
    assert_eq!(renames[0].new_path.as_deref(), Some("b.txt"));
}

#[test]
fn apply_rename_detection_matches_git_cached_name_status() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    init_repo_with_large_file(root);
    git_run(root, &["mv", "a.txt", "b.txt"]);

    let git_out = Command::new("git")
        .current_dir(root)
        .args(["diff", "--cached", "--name-status", "-M"])
        .output()
        .expect("git diff");
    assert!(git_out.status.success());
    let git_line = String::from_utf8_lossy(&git_out.stdout);
    assert!(
        git_line.contains("R100\t") || git_line.contains("R"),
        "git should report rename: {git_line}"
    );

    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    let head = resolve_head(repo.git_dir()).expect("head");
    let head_commit = head.oid().expect("commit");
    let head_tree = tree_oid_of_commit(repo.odb(), head_commit).expect("tree");
    let index = repo.index_handle().load().expect("index");
    let config = repo.config().expect("config");
    let policy = repo.thread_policy();
    let raw = grit_lib::diff::diff_index_to_tree(repo.odb(), &index, Some(&head_tree), false)
        .expect("raw");
    let entries =
        apply_rename_detection_to_diff_entries(repo.odb(), raw, None, 50, config.as_ref(), &policy)
            .expect("renames");
    assert!(
        entries.iter().any(|e| e.status == DiffStatus::Renamed),
        "grit should pair rename like git: {entries:?}"
    );
}

#[test]
fn worktree_rename_detection_with_small_unstaged_edit() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    init_repo_with_large_file(root);
    git_run(root, &["mv", "a.txt", "b.txt"]);
    std::fs::OpenOptions::new()
        .append(true)
        .open(root.join("b.txt"))
        .expect("open b.txt")
        .write_all(b"41\n")
        .expect("append");

    let git_out = Command::new("git")
        .current_dir(root)
        .args(["diff", "HEAD", "--name-status", "-M"])
        .output()
        .expect("git diff");
    assert!(git_out.status.success());
    let git_line = String::from_utf8_lossy(&git_out.stdout);
    assert!(
        git_line.starts_with('R'),
        "git should report inexact rename, got: {git_line}"
    );

    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    let head = resolve_head(repo.git_dir()).expect("head");
    let head_commit = head.oid().expect("commit");
    let head_tree = tree_oid_of_commit(repo.odb(), head_commit).expect("tree");
    let index = repo.index_handle().load().expect("index");
    let config = repo.config().expect("config");
    let policy = repo.thread_policy();
    let work_tree = repo.work_tree().expect("worktree");

    let raw = diff_tree_to_worktree_with_git_dir(
        repo.odb(),
        Some(&head_tree),
        work_tree,
        repo.git_dir(),
        &index,
        Some(config.as_ref()),
    )
    .expect("tree to worktree");
    let entries = apply_rename_detection_to_diff_entries(
        repo.odb(),
        raw,
        Some(work_tree),
        50,
        config.as_ref(),
        &policy,
    )
    .expect("renames");

    let renames: Vec<_> = entries
        .iter()
        .filter(|e| e.status == DiffStatus::Renamed)
        .collect();
    assert_eq!(
        renames.len(),
        1,
        "expected one worktree rename, got: {entries:?}"
    );
    assert_eq!(renames[0].old_path.as_deref(), Some("a.txt"));
    assert_eq!(renames[0].new_path.as_deref(), Some("b.txt"));
    assert!(
        renames[0].score.is_some_and(|s| s >= 50),
        "rename score should meet -M50 threshold: {:?}",
        renames[0].score
    );
}

#[test]
fn plain_filesystem_mv_pairs_via_untracked_worktree_candidate() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    init_repo_with_large_file(root);
    std::fs::rename(root.join("a.txt"), root.join("b.txt")).expect("mv");

    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    let mut index = repo.index_handle().load().expect("index");
    let work_tree = repo.work_tree().expect("worktree");
    let rules = grit_lib::worktree::worktree_rules::WorktreeRules::from_repository(&repo, &index)
        .expect("rules");

    let (entries, _) = grit_lib::diff::diff_index_to_worktree_with_renames(
        &repo,
        &mut index,
        work_tree,
        grit_lib::diff::DiffIndexToWorktreeOptions {
            repository_git_dir: Some(repo.git_dir().to_path_buf()),
            config: repo.config().ok(),
            worktree_rules: Some(std::sync::Arc::new(std::sync::Mutex::new(rules))),
            ..grit_lib::diff::DiffIndexToWorktreeOptions::default()
        },
        50,
    )
    .expect("unstaged diff");

    let renames: Vec<_> = entries
        .iter()
        .filter(|e| e.status == DiffStatus::Renamed)
        .collect();
    assert_eq!(
        renames.len(),
        1,
        "plain mv should rename via untracked b.txt, got: {entries:?}"
    );
    assert_eq!(renames[0].old_path.as_deref(), Some("a.txt"));
    assert_eq!(renames[0].new_path.as_deref(), Some("b.txt"));
}

#[test]
fn unrelated_untracked_not_listed_when_tracked_file_deleted() {
    let tmp = tempfile::tempdir().expect("tempdir");
    let root = tmp.path();
    init_repo_with_large_file(root);
    std::fs::remove_file(root.join("a.txt")).expect("delete tracked");
    std::fs::write(root.join("unrelated.txt"), "noise\n").expect("untracked");

    let git_out = Command::new("git")
        .current_dir(root)
        .args(["diff", "--name-status"])
        .output()
        .expect("git diff");
    assert!(git_out.status.success());
    let git_line = String::from_utf8_lossy(&git_out.stdout);
    assert!(
        !git_line.contains("unrelated.txt"),
        "git should not list unrelated untracked in diff: {git_line}"
    );
    assert!(
        git_line.contains('D'),
        "git should show deletion: {git_line}"
    );

    let repo = Repository::discover(Some(root), &RepositoryOptions::empty()).expect("open");
    let mut index = repo.index_handle().load().expect("index");
    let work_tree = repo.work_tree().expect("worktree");
    let rules = grit_lib::worktree::worktree_rules::WorktreeRules::from_repository(&repo, &index)
        .expect("rules");

    let (entries, _) = grit_lib::diff::diff_index_to_worktree_with_renames(
        &repo,
        &mut index,
        work_tree,
        grit_lib::diff::DiffIndexToWorktreeOptions {
            repository_git_dir: Some(repo.git_dir().to_path_buf()),
            config: repo.config().ok(),
            worktree_rules: Some(std::sync::Arc::new(std::sync::Mutex::new(rules))),
            ..grit_lib::diff::DiffIndexToWorktreeOptions::default()
        },
        50,
    )
    .expect("unstaged diff");

    assert!(
        !entries.iter().any(|e| e.path() == "unrelated.txt"),
        "unrelated untracked must not appear in diff: {entries:?}"
    );
    assert_eq!(
        entries
            .iter()
            .filter(|e| e.status == DiffStatus::Deleted)
            .count(),
        1,
        "expected single deletion: {entries:?}"
    );
}