diff --git a/codex-rs/core/src/git_info_tests.rs b/codex-rs/core/src/git_info_tests.rs index d056a6e626..560f315a64 100644 --- a/codex-rs/core/src/git_info_tests.rs +++ b/codex-rs/core/src/git_info_tests.rs @@ -621,6 +621,7 @@ async fn get_git_repo_root_with_fs_starts_at_parent_for_file() { let proj = tmp.path().join("proj"); let nested = proj.join("nested"); std::fs::create_dir_all(proj.join(".git")).unwrap(); + std::fs::write(proj.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); std::fs::create_dir_all(&nested).unwrap(); let file = nested.join("file.txt"); std::fs::write(&file, "contents").unwrap(); @@ -637,6 +638,7 @@ async fn get_git_repo_root_with_fs_ignores_metadata_errors() { let proj = tmp.path().join("proj"); let nested = proj.join("nested"); std::fs::create_dir_all(proj.join(".git")).unwrap(); + std::fs::write(proj.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); std::fs::create_dir_all(&nested).unwrap(); let fs = FailingMetadataFileSystem { path: PathUri::from_abs_path(&nested.join(".git").abs()), @@ -654,6 +656,7 @@ async fn get_git_repo_root_with_fs_supports_windows_namespace_paths() { let tmp = TempDir::new().expect("tempdir"); let repo = tmp.path().join("repo"); std::fs::create_dir_all(repo.join(".git")).unwrap(); + std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); std::fs::create_dir_all(repo.join("nested")).unwrap(); let namespace_repo = PathBuf::from(format!(r"\\?\{}", repo.display())); @@ -855,3 +858,37 @@ fn test_git_info_serialization_with_nones() { assert!(!parsed.as_object().unwrap().contains_key("branch")); assert!(!parsed.as_object().unwrap().contains_key("repository_url")); } + +#[tokio::test] +async fn get_git_repo_root_with_fs_ignores_empty_git_dir() { + skip_if_sandbox!(); + let temp_root = TempDir::new().unwrap(); + + // Create an empty .git directory (e.g. from a bubblewrap mount) + let dot_git = temp_root.path().join(".git"); + fs::create_dir(&dot_git).unwrap(); + + let nested = temp_root.path().join("nested"); + fs::create_dir(&nested).unwrap(); + + assert_eq!( + get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await, + None + ); +} + +#[tokio::test] +async fn get_git_repo_root_with_fs_skips_empty_git_dir_for_valid_parent() { + skip_if_sandbox!(); + let temp_root = TempDir::new().unwrap(); + let repo = temp_root.path().join("repo"); + let nested = repo.join("nested"); + fs::create_dir_all(repo.join(".git")).unwrap(); + fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + fs::create_dir_all(nested.join(".git")).unwrap(); + + assert_eq!( + get_git_repo_root_with_fs(LOCAL_FS.as_ref(), &nested.abs()).await, + Some(repo.abs()) + ); +} diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index 115e26110d..958a79c560 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -50,19 +50,38 @@ pub async fn get_git_repo_root_with_fs( cwd: &AbsolutePathBuf, ) -> Option { let cwd_uri = PathUri::from_abs_path(cwd); - let base = match fs.get_metadata(&cwd_uri, /*sandbox*/ None).await { + let mut search_base = match fs.get_metadata(&cwd_uri, /*sandbox*/ None).await { Ok(metadata) if metadata.is_directory => cwd.clone(), _ => cwd.parent()?, }; - find_nearest_native_ancestor_with_markers( - fs, - &base, - vec![".git".to_string()], - FindUpErrorPolicy::Ignore, - /*sandbox*/ None, - ) - .await - .ok()? + + loop { + let repo_root = find_nearest_native_ancestor_with_markers( + fs, + &search_base, + vec![".git".to_string()], + FindUpErrorPolicy::Ignore, + /*sandbox*/ None, + ) + .await + .ok()??; + let dot_git = repo_root.join(".git"); + let dot_git_uri = PathUri::from_abs_path(&dot_git); + let head_uri = PathUri::from_abs_path(&dot_git.join("HEAD")); + let objects_uri = PathUri::from_abs_path(&dot_git.join("objects")); + let (dot_git_metadata, head_metadata, objects_metadata) = tokio::join!( + fs.get_metadata(&dot_git_uri, /*sandbox*/ None), + fs.get_metadata(&head_uri, /*sandbox*/ None), + fs.get_metadata(&objects_uri, /*sandbox*/ None), + ); + match dot_git_metadata { + Ok(metadata) if !metadata.is_directory => return Some(repo_root), + Ok(_) if head_metadata.is_ok() || objects_metadata.is_ok() => return Some(repo_root), + _ => {} + } + + search_base = repo_root.parent()?; + } } /// Timeout for git commands to prevent freezing on large repositories @@ -848,7 +867,13 @@ fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> { loop { let dot_git = dir.join(".git"); if dot_git.exists() { - return Some((dir, dot_git)); + if dot_git.is_dir() { + if dot_git.join("HEAD").exists() || dot_git.join("objects").exists() { + return Some((dir, dot_git)); + } + } else { + return Some((dir, dot_git)); + } } // Pop one component (go up one directory). `pop` returns false when @@ -948,6 +973,31 @@ mod tests { } } + #[test] + fn get_git_repo_root_ignores_empty_git_directory() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + std::fs::create_dir(temp_dir.path().join(".git")).expect("create empty .git"); + let nested = temp_dir.path().join("nested"); + std::fs::create_dir(&nested).expect("create nested directory"); + + assert_eq!(get_git_repo_root(&nested), None); + } + + #[test] + fn get_git_repo_root_accepts_git_directory_with_head() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let dot_git = temp_dir.path().join(".git"); + std::fs::create_dir(&dot_git).expect("create .git"); + std::fs::write(dot_git.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD"); + let nested = temp_dir.path().join("nested"); + std::fs::create_dir(&nested).expect("create nested directory"); + + assert_eq!( + get_git_repo_root(&nested), + Some(temp_dir.path().to_path_buf()) + ); + } + #[tokio::test] async fn local_git_branches_excludes_detached_head_entry() { let temp_dir = tempfile::tempdir().expect("create temp dir"); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index ac87d717d6..fb00b98a03 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1285,6 +1285,7 @@ pub(super) fn strip_osc8_for_snapshot(text: &str) -> String { pub(super) fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { std::env::temp_dir() + .join("codex_mock_cwd") .join("codex-plugin-menu-tests") .join(path) .abs() diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index b1f3360c0d..3611ab16ca 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -1381,7 +1381,7 @@ async fn review_branch_picker_escape_navigates_back_then_dismisses() { chat.open_review_popup(); // Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine. - let cwd = std::env::temp_dir(); + let cwd = std::env::temp_dir().join("codex_mock_cwd"); chat.show_review_branch_picker(&cwd).await; // Verify child view header. diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index c7a4ca801f..077d7120ac 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -33,7 +33,7 @@ use rmcp::model::Content; const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; async fn test_config() -> Config { - let codex_home = std::env::temp_dir(); + let codex_home = std::env::temp_dir().join("codex_mock_cwd"); ConfigBuilder::default() .codex_home(codex_home.clone()) .build() @@ -44,7 +44,7 @@ async fn test_config() -> Config { fn test_cwd() -> PathBuf { // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or // Windows-specific root semantics into the fixtures. - std::env::temp_dir() + std::env::temp_dir().join("codex_mock_cwd") } #[test] @@ -1534,7 +1534,7 @@ fn session_header_includes_reasoning_level_when_present() { "gpt-4o".to_string(), Some(ReasoningEffortConfig::High), /*show_fast_status*/ true, - std::env::temp_dir(), + std::env::temp_dir().join("codex_mock_cwd"), "test", ); @@ -1554,7 +1554,7 @@ fn session_header_hides_fast_status_when_disabled() { "gpt-4o".to_string(), Some(ReasoningEffortConfig::High), /*show_fast_status*/ false, - std::env::temp_dir(), + std::env::temp_dir().join("codex_mock_cwd"), "test", ); diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index c3f2474b5b..3057ca6704 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -195,7 +195,7 @@ impl MarkdownStreamCollector { fn test_cwd() -> PathBuf { // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or // Windows-specific root semantics into the fixtures. - std::env::temp_dir() + std::env::temp_dir().join("codex_mock_cwd") } #[cfg(test)] diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 95a3b72269..32f43f9d57 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -737,7 +737,7 @@ mod tests { fn test_cwd() -> PathBuf { // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or // Windows-specific root semantics into the fixtures. - std::env::temp_dir() + std::env::temp_dir().join("codex_mock_cwd") } fn stream_controller(width: Option) -> StreamController { diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index 576277aa3e..c3c697eedc 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -109,7 +109,7 @@ mod tests { fn test_cwd() -> PathBuf { // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or // Windows-specific root semantics into the fixtures. - std::env::temp_dir() + std::env::temp_dir().join("codex_mock_cwd") } #[test] diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 0fe4408444..b300aabb01 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -85,9 +85,12 @@ mod tests { #[test] fn maps_install_context_to_update_action() { - let native_release_dir = - AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join("native-release")) - .expect("temp dir path should be absolute"); + let native_release_dir = AbsolutePathBuf::from_absolute_path( + std::env::temp_dir() + .join("codex_mock_cwd") + .join("native-release"), + ) + .expect("temp dir path should be absolute"); assert_eq!( UpdateAction::from_install_context(&InstallContext {