Codex Handbook
core/src/git_info_tests.rs 693 lines
use codex_exec_server::LOCAL_FS;use codex_git_utils::GitInfo;use codex_git_utils::GitSha;use codex_git_utils::collect_git_info;use codex_git_utils::get_git_repo_root_with_fs;use codex_git_utils::get_has_changes;use codex_git_utils::git_diff_to_remote;use codex_git_utils::recent_commits;use codex_git_utils::resolve_root_git_project_for_trust;use codex_utils_path::normalize_for_path_comparison;use core_test_support::PathBufExt;use core_test_support::PathExt;use core_test_support::skip_if_sandbox;use std::fs;#[cfg(unix)]use std::os::unix::fs::PermissionsExt;use std::path::PathBuf;use tempfile::TempDir;use tokio::process::Command;// Helper function to create a test git repositoryasync fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {    let repo_path = temp_dir.path().join("repo");    fs::create_dir(&repo_path).expect("Failed to create repo dir");    let envs = vec![        ("GIT_CONFIG_GLOBAL", "/dev/null"),        ("GIT_CONFIG_NOSYSTEM", "1"),    ];    // Initialize git repo    Command::new("git")        .envs(envs.clone())        .args(["init"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to init git repo");    // Configure git user (required for commits)    Command::new("git")        .envs(envs.clone())        .args(["config", "user.name", "Test User"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to set git user name");    Command::new("git")        .envs(envs.clone())        .args(["config", "user.email", "test@example.com"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to set git user email");    // Create a test file and commit it    let test_file = repo_path.join("test.txt");    fs::write(&test_file, "test content").expect("Failed to write test file");    Command::new("git")        .envs(envs.clone())        .args(["add", "."])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to add files");    Command::new("git")        .envs(envs.clone())        .args(["commit", "-m", "Initial commit"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to commit");    repo_path}#[tokio::test]async fn test_recent_commits_non_git_directory_returns_empty() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let entries = recent_commits(temp_dir.path(), /*limit*/ 10).await;    assert!(entries.is_empty(), "expected no commits outside a git repo");}#[tokio::test]async fn test_recent_commits_orders_and_limits() {    skip_if_sandbox!();    use tokio::time::Duration;    use tokio::time::sleep;    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    // Make three distinct commits with small delays to ensure ordering by timestamp.    fs::write(repo_path.join("file.txt"), "one").unwrap();    Command::new("git")        .args(["add", "file.txt"])        .current_dir(&repo_path)        .output()        .await        .expect("git add");    Command::new("git")        .args(["commit", "-m", "first change"])        .current_dir(&repo_path)        .output()        .await        .expect("git commit 1");    sleep(Duration::from_millis(1100)).await;    fs::write(repo_path.join("file.txt"), "two").unwrap();    Command::new("git")        .args(["add", "file.txt"])        .current_dir(&repo_path)        .output()        .await        .expect("git add 2");    Command::new("git")        .args(["commit", "-m", "second change"])        .current_dir(&repo_path)        .output()        .await        .expect("git commit 2");    sleep(Duration::from_millis(1100)).await;    fs::write(repo_path.join("file.txt"), "three").unwrap();    Command::new("git")        .args(["add", "file.txt"])        .current_dir(&repo_path)        .output()        .await        .expect("git add 3");    Command::new("git")        .args(["commit", "-m", "third change"])        .current_dir(&repo_path)        .output()        .await        .expect("git commit 3");    // Request the latest 3 commits; should be our three changes in reverse time order.    let entries = recent_commits(&repo_path, /*limit*/ 3).await;    assert_eq!(entries.len(), 3);    assert_eq!(entries[0].subject, "third change");    assert_eq!(entries[1].subject, "second change");    assert_eq!(entries[2].subject, "first change");    // Basic sanity on SHA formatting    for e in entries {        assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit()));    }}async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {    let repo_path = create_test_git_repo(temp_dir).await;    let remote_path = temp_dir.path().join("remote.git");    Command::new("git")        .args(["init", "--bare", remote_path.to_str().unwrap()])        .output()        .await        .expect("Failed to init bare remote");    Command::new("git")        .args(["remote", "add", "origin", remote_path.to_str().unwrap()])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to add remote");    let output = Command::new("git")        .args(["rev-parse", "--abbrev-ref", "HEAD"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to get branch");    let branch = String::from_utf8(output.stdout).unwrap().trim().to_string();    Command::new("git")        .args(["push", "-u", "origin", &branch])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to push initial commit");    (repo_path, branch)}#[tokio::test]async fn test_collect_git_info_non_git_directory() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let result = collect_git_info(temp_dir.path()).await;    assert!(result.is_none());}#[tokio::test]async fn test_collect_git_info_git_repository() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    let git_info = collect_git_info(&repo_path)        .await        .expect("Should collect git info from repo");    // Should have commit hash    assert!(git_info.commit_hash.is_some());    let commit_hash = git_info.commit_hash.unwrap().0;    assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters    assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit()));    // Should have branch (likely "main" or "master")    assert!(git_info.branch.is_some());    let branch = git_info.branch.unwrap();    assert!(branch == "main" || branch == "master");    // Repository URL might be None for local repos without remote    // This is acceptable behavior}#[tokio::test]async fn test_collect_git_info_with_remote() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    // Add a remote origin    Command::new("git")        .args([            "remote",            "add",            "origin",            "https://github.com/example/repo.git",        ])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to add remote");    let git_info = collect_git_info(&repo_path)        .await        .expect("Should collect git info from repo");    let remote_url_output = Command::new("git")        .args(["remote", "get-url", "origin"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to read remote url");    // Some dev environments rewrite remotes (e.g., force SSH), so compare against    // whatever URL Git reports instead of a fixed placeholder.    let expected_remote = String::from_utf8(remote_url_output.stdout)        .unwrap()        .trim()        .to_string();    // Should have repository URL    assert_eq!(git_info.repository_url, Some(expected_remote));}#[tokio::test]async fn test_collect_git_info_detached_head() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    // Get the current commit hash    let output = Command::new("git")        .args(["rev-parse", "HEAD"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to get HEAD");    let commit_hash = String::from_utf8(output.stdout).unwrap().trim().to_string();    // Checkout the commit directly (detached HEAD)    Command::new("git")        .args(["checkout", &commit_hash])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to checkout commit");    let git_info = collect_git_info(&repo_path)        .await        .expect("Should collect git info from repo");    // Should have commit hash    assert!(git_info.commit_hash.is_some());    // Branch should be None for detached HEAD (since rev-parse --abbrev-ref HEAD returns "HEAD")    assert!(git_info.branch.is_none());}#[tokio::test]async fn test_collect_git_info_with_branch() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    // Create and checkout a new branch    Command::new("git")        .args(["checkout", "-b", "feature-branch"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to create branch");    let git_info = collect_git_info(&repo_path)        .await        .expect("Should collect git info from repo");    // Should have the new branch name    assert_eq!(git_info.branch, Some("feature-branch".to_string()));}#[tokio::test]async fn test_get_has_changes_non_git_directory_returns_none() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    assert_eq!(get_has_changes(temp_dir.path()).await, None);}#[tokio::test]async fn test_get_has_changes_clean_repo_returns_false() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    assert_eq!(get_has_changes(&repo_path).await, Some(false));}#[tokio::test]async fn test_get_has_changes_with_tracked_change_returns_true() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    fs::write(repo_path.join("test.txt"), "updated tracked file").expect("write tracked file");    assert_eq!(get_has_changes(&repo_path).await, Some(true));}#[tokio::test]async fn test_get_has_changes_with_untracked_change_returns_true() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    fs::write(repo_path.join("new_file.txt"), "untracked").expect("write untracked file");    assert_eq!(get_has_changes(&repo_path).await, Some(true));}#[cfg(unix)]#[tokio::test]async fn test_get_has_changes_ignores_configured_hooks_path() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    let hooks_dir = repo_path.join(".git/hooks-path-test");    let hook_path = hooks_dir.join("post-index-change");    let marker_path = repo_path.join("hook-ran");    fs::create_dir_all(&hooks_dir).expect("create hook dir");    fs::write(        &hook_path,        format!(            "#!/bin/sh\nprintf ran > \"{}\"\n",            marker_path.to_string_lossy()        ),    )    .expect("write post-index-change hook");    let mut permissions = fs::metadata(&hook_path)        .expect("read hook metadata")        .permissions();    permissions.set_mode(0o755);    fs::set_permissions(&hook_path, permissions).expect("mark hook executable");    Command::new("git")        .args([            "config",            "core.hooksPath",            hooks_dir.to_string_lossy().as_ref(),        ])        .current_dir(&repo_path)        .output()        .await        .expect("configure hooks path");    fs::write(repo_path.join("test.txt"), "test content").expect("refresh tracked file");    assert_eq!(get_has_changes(&repo_path).await, Some(false));    assert!(        !marker_path.exists(),        "metadata collection should not invoke configured hook directories"    );}#[tokio::test]async fn test_get_git_working_tree_state_clean_repo() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;    let remote_sha = Command::new("git")        .args(["rev-parse", &format!("origin/{branch}")])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to rev-parse remote");    let remote_sha = String::from_utf8(remote_sha.stdout)        .unwrap()        .trim()        .to_string();    let state = git_diff_to_remote(&repo_path)        .await        .expect("Should collect working tree state");    assert_eq!(state.sha, GitSha::new(&remote_sha));    assert!(state.diff.is_empty());}#[tokio::test]async fn test_get_git_working_tree_state_with_changes() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;    let tracked = repo_path.join("test.txt");    fs::write(&tracked, "modified").unwrap();    fs::write(repo_path.join("untracked.txt"), "new").unwrap();    let remote_sha = Command::new("git")        .args(["rev-parse", &format!("origin/{branch}")])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to rev-parse remote");    let remote_sha = String::from_utf8(remote_sha.stdout)        .unwrap()        .trim()        .to_string();    let state = git_diff_to_remote(&repo_path)        .await        .expect("Should collect working tree state");    assert_eq!(state.sha, GitSha::new(&remote_sha));    assert!(state.diff.contains("test.txt"));    assert!(state.diff.contains("untracked.txt"));}#[tokio::test]async fn test_get_git_working_tree_state_branch_fallback() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await;    Command::new("git")        .args(["checkout", "-b", "feature"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to create feature branch");    Command::new("git")        .args(["push", "-u", "origin", "feature"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to push feature branch");    Command::new("git")        .args(["checkout", "-b", "local-branch"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to create local branch");    let remote_sha = Command::new("git")        .args(["rev-parse", "origin/feature"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to rev-parse remote");    let remote_sha = String::from_utf8(remote_sha.stdout)        .unwrap()        .trim()        .to_string();    let state = git_diff_to_remote(&repo_path)        .await        .expect("Should collect working tree state");    assert_eq!(state.sha, GitSha::new(&remote_sha));}#[tokio::test]async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {    let tmp = TempDir::new().expect("tempdir");    assert!(        resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &tmp.path().abs())            .await            .is_none()    );}#[tokio::test]async fn get_git_repo_root_with_fs_detects_gitdir_pointer() {    let tmp = TempDir::new().expect("tempdir");    let proj = tmp.path().join("proj");    let nested = proj.join("nested");    std::fs::create_dir_all(&nested).unwrap();    std::fs::write(proj.join(".git"), "gitdir: /tmp/fake-worktree\n").unwrap();    assert_eq!(        get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await,        Some(proj.abs())    );}#[tokio::test]async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await.abs();    assert_eq!(        resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &repo_path).await,        Some(repo_path.clone())    );    let nested = repo_path.join("sub/dir");    std::fs::create_dir_all(nested.as_path()).unwrap();    assert_eq!(        resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await,        Some(repo_path)    );}#[tokio::test]async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let repo_path = create_test_git_repo(&temp_dir).await;    // Create a linked worktree    let wt_root = temp_dir.path().join("wt");    let _ = std::process::Command::new("git")        .args([            "worktree",            "add",            wt_root.to_str().unwrap(),            "-b",            "feature/x",        ])        .current_dir(&repo_path)        .output()        .expect("git worktree add");    let expected = normalize_for_path_comparison(&repo_path).unwrap();    let wt_root = wt_root.abs();    let got = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &wt_root).await;    assert_eq!(        got.as_ref()            .map(normalize_for_path_comparison)            .transpose()            .unwrap(),        Some(expected.clone())    );    let nested = wt_root.join("nested/sub");    std::fs::create_dir_all(nested.as_path()).unwrap();    let got_nested = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await;    assert_eq!(        got_nested            .as_ref()            .map(normalize_for_path_comparison)            .transpose()            .unwrap(),        Some(expected)    );}#[tokio::test]async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {    let tmp = TempDir::new().expect("tempdir");    let repo_root = tmp.path().join("repo");    let common_dir = repo_root.join(".git");    let worktree_git_dir = common_dir.join("worktrees").join("feature-x");    let worktree_root = tmp.path().join("wt");    std::fs::create_dir_all(&worktree_git_dir).unwrap();    std::fs::create_dir_all(&worktree_root).unwrap();    std::fs::create_dir_all(worktree_root.join("nested")).unwrap();    std::fs::write(        worktree_root.join(".git"),        format!("gitdir: {}\n", worktree_git_dir.display()),    )    .unwrap();    let expected = repo_root.abs();    let worktree_root = worktree_root.abs();    assert_eq!(        resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root).await,        Some(expected.clone())    );    let nested = worktree_root.join("nested");    assert_eq!(        resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested).await,        Some(expected)    );}#[tokio::test]async fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {    let tmp = TempDir::new().expect("tempdir");    let proj = tmp.path().join("proj");    std::fs::create_dir_all(proj.join("nested")).unwrap();    // `.git` is a file but does not point to a worktrees path    std::fs::write(        proj.join(".git"),        format!(            "gitdir: {}\n",            tmp.path().join("some/other/location").display()        ),    )    .unwrap();    let proj = proj.abs();    assert!(        resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &proj)            .await            .is_none()    );    let nested = proj.join("nested");    assert!(        resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested)            .await            .is_none()    );}#[tokio::test]async fn test_get_git_working_tree_state_unpushed_commit() {    let temp_dir = TempDir::new().expect("Failed to create temp dir");    let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;    let remote_sha = Command::new("git")        .args(["rev-parse", &format!("origin/{branch}")])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to rev-parse remote");    let remote_sha = String::from_utf8(remote_sha.stdout)        .unwrap()        .trim()        .to_string();    fs::write(repo_path.join("test.txt"), "updated").unwrap();    Command::new("git")        .args(["add", "test.txt"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to add file");    Command::new("git")        .args(["commit", "-m", "local change"])        .current_dir(&repo_path)        .output()        .await        .expect("Failed to commit");    let state = git_diff_to_remote(&repo_path)        .await        .expect("Should collect working tree state");    assert_eq!(state.sha, GitSha::new(&remote_sha));    assert!(state.diff.contains("updated"));}#[test]fn test_git_info_serialization() {    let git_info = GitInfo {        commit_hash: Some(GitSha::new("abc123def456")),        branch: Some("main".to_string()),        repository_url: Some("https://github.com/example/repo.git".to_string()),    };    let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");    let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");    assert_eq!(parsed["commit_hash"], "abc123def456");    assert_eq!(parsed["branch"], "main");    assert_eq!(        parsed["repository_url"],        "https://github.com/example/repo.git"    );}#[test]fn test_git_info_serialization_with_nones() {    let git_info = GitInfo {        commit_hash: None,        branch: None,        repository_url: None,    };    let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo");    let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON");    // Fields with None values should be omitted due to skip_serializing_if    assert!(!parsed.as_object().unwrap().contains_key("commit_hash"));    assert!(!parsed.as_object().unwrap().contains_key("branch"));    assert!(!parsed.as_object().unwrap().contains_key("repository_url"));}