use std::collections::BTreeSet;use std::path::Path;use std::path::PathBuf;use std::process::Output;use std::time::Duration;use codex_git_utils::get_git_repo_root;use tokio::process::Command;use tokio::time::timeout;use super::CheckStatus;use super::DoctorCheck;use super::DoctorIssue;const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2);#[derive(Clone, Debug, Default, Eq, PartialEq)]struct GitCheckInputs { selected_git: Option<PathBuf>, git_candidates: Vec<PathBuf>, git_version: Option<String>, git_exec_path: Option<String>, git_build_options: Option<String>, repo_root: Option<PathBuf>, git_entry: Option<String>, branch: Option<String>, core_fsmonitor: Option<String>,}pub(super) async fn git_check(cwd: &Path) -> DoctorCheck { let selected_git = which::which("git").ok(); let git_candidates = git_candidates(); let repo_root = get_git_repo_root(cwd); let (git_version, git_exec_path, git_build_options, branch, core_fsmonitor) = if let Some(git_path) = selected_git.as_deref() { let (version, exec_path, build_options, branch, fsmonitor) = tokio::join!( git_output(git_path, cwd, &["--version"]), git_output(git_path, cwd, &["--exec-path"]), git_output(git_path, cwd, &["version", "--build-options"]), git_output(git_path, cwd, &["rev-parse", "--abbrev-ref", "HEAD"]), git_output(git_path, cwd, &["config", "--get", "core.fsmonitor"]), ); (version, exec_path, build_options, branch, fsmonitor) } else { (None, None, None, None, None) }; git_check_from_inputs(GitCheckInputs { selected_git, git_candidates, git_version, git_exec_path, git_build_options, git_entry: repo_root.as_deref().map(git_entry_summary), repo_root, branch, core_fsmonitor, })}fn git_check_from_inputs(inputs: GitCheckInputs) -> DoctorCheck { let mut details = Vec::new(); match inputs.selected_git.as_deref() { Some(path) => details.push(format!("selected git: {}", path.display())), None => details.push("selected git: not found".to_string()), } details.push(format!("PATH git entries: {}", inputs.git_candidates.len())); for (index, path) in inputs.git_candidates.iter().enumerate() { details.push(format!("PATH git #{}: {}", index + 1, path.display())); } push_optional_detail(&mut details, "git version", inputs.git_version.as_deref()); push_optional_detail( &mut details, "git exec path", inputs.git_exec_path.as_deref(), ); push_optional_detail( &mut details, "git build options", inputs.git_build_options.as_deref(), ); match inputs.repo_root.as_deref() { Some(root) => { details.push("repo detected: true".to_string()); details.push(format!("repo root: {}", root.display())); } None => details.push("repo detected: false".to_string()), } push_optional_detail(&mut details, ".git entry", inputs.git_entry.as_deref()); push_optional_detail( &mut details, "git branch", normalized_branch(inputs.branch.as_deref()), ); push_optional_detail( &mut details, "core.fsmonitor", inputs .core_fsmonitor .as_deref() .filter(|value| !value.is_empty()), ); let mut check = DoctorCheck::new( "git.environment", "git", CheckStatus::Ok, git_summary(&inputs), ) .details(details); if inputs.selected_git.is_some() && inputs.git_version.is_none() { check.status = CheckStatus::Warning; check.summary = "Git executable found but could not be run".to_string(); check = check.issue( DoctorIssue::new( CheckStatus::Warning, "Git executable was found on PATH but did not return a version", ) .expected("git --version succeeds") .remedy("Fix the selected Git executable or PATH so Codex can inspect Git metadata.") .field("git version") .field("selected git"), ); } else if inputs.selected_git.is_none() && inputs.repo_root.is_some() { check.status = CheckStatus::Warning; check.summary = "Git repository detected but git executable was not found".to_string(); check = check.issue( DoctorIssue::new( CheckStatus::Warning, "Git repository detected but git executable was not found", ) .expected("git available on PATH") .remedy("Install Git or fix PATH so Codex can inspect repository metadata.") .field("selected git"), ); } else if let Some(cause) = old_windows_git_warning(inputs.git_version.as_deref(), cfg!(windows)) { check.status = CheckStatus::Warning; check.summary = cause.clone(); check = check.issue( DoctorIssue::new(CheckStatus::Warning, cause) .measured(inputs.git_version.unwrap_or_else(|| "unknown".to_string())) .expected("current Git for Windows") .remedy( "Update Git for Windows or the bundled Git executable Codex resolves first.", ) .field("git version") .field("selected git"), ); } check}fn git_summary(inputs: &GitCheckInputs) -> String { match inputs.git_version.as_deref() { Some(version) => version.to_string(), None if inputs.selected_git.is_some() => { "git executable found; version unavailable".to_string() } None => "git executable not found".to_string(), }}fn push_optional_detail(details: &mut Vec<String>, label: &str, value: Option<&str>) { if let Some(value) = value { details.push(format!("{label}: {value}")); }}fn normalized_branch(branch: Option<&str>) -> Option<&str> { match branch { Some("HEAD") => Some("detached HEAD"), Some(value) if !value.is_empty() => Some(value), _ => None, }}fn git_candidates() -> Vec<PathBuf> { let Ok(candidates) = which::which_all("git") else { return Vec::new(); }; let mut seen = BTreeSet::new(); candidates .filter(|candidate| seen.insert(candidate.clone())) .collect()}async fn git_output(git_path: &Path, cwd: &Path, args: &[&str]) -> Option<String> { let mut command = Command::new(git_path); command .env("GIT_OPTIONAL_LOCKS", "0") .args(args) .current_dir(cwd) .kill_on_drop(true); let output = timeout(GIT_COMMAND_TIMEOUT, command.output()) .await .ok()? .ok()?; command_output_text(output)}fn command_output_text(output: Output) -> Option<String> { if !output.status.success() { return None; } let stdout = String::from_utf8_lossy(&output.stdout); let normalized = stdout .lines() .map(str::trim) .filter(|line| !line.is_empty()) .collect::<Vec<_>>() .join("; "); if normalized.is_empty() { None } else { Some(normalized) }}fn git_entry_summary(repo_root: &Path) -> String { let entry = repo_root.join(".git"); match std::fs::metadata(&entry) { Ok(metadata) if metadata.is_dir() => "directory".to_string(), Ok(metadata) if metadata.is_file() => std::fs::read_to_string(&entry) .ok() .and_then(|contents| { contents .strip_prefix("gitdir:") .map(str::trim) .map(|path| format!("file -> {path}")) }) .unwrap_or_else(|| "file".to_string()), Ok(_) => "other".to_string(), Err(err) if err.kind() == std::io::ErrorKind::NotFound => "missing".to_string(), Err(err) => format!("unreadable ({err})"), }}fn old_windows_git_warning(version: Option<&str>, is_windows: bool) -> Option<String> { if !is_windows { return None; } let version = version?; if version.to_ascii_lowercase().contains("msysgit") { return Some("old msysgit installation may corrupt Windows TUI rendering".to_string()); } let parsed = parse_git_version(version)?; if parsed.major < 2 || (parsed.major == 2 && parsed.minor <= 34) { return Some("old Git for Windows may corrupt Windows TUI rendering".to_string()); } None}#[derive(Clone, Copy, Debug, Eq, PartialEq)]struct ParsedGitVersion { major: u32, minor: u32, patch: u32,}fn parse_git_version(version: &str) -> Option<ParsedGitVersion> { let version = version.strip_prefix("git version ")?; let numeric = version .split_whitespace() .next()? .split(".windows.") .next() .unwrap_or(version); let mut parts = numeric.split('.'); Some(ParsedGitVersion { major: parts.next()?.parse().ok()?, minor: parts.next()?.parse().ok()?, patch: parts.next().unwrap_or("0").parse().ok()?, })}#[cfg(test)]mod tests { use pretty_assertions::assert_eq; use super::*; #[test] fn parses_git_for_windows_version() { assert_eq!( parse_git_version("git version 2.34.1.windows.1"), Some(ParsedGitVersion { major: 2, minor: 34, patch: 1, }) ); assert_eq!( parse_git_version("git version 2.54.0.windows.1"), Some(ParsedGitVersion { major: 2, minor: 54, patch: 0, }) ); } #[test] fn classifies_old_windows_git() { assert_eq!( old_windows_git_warning( Some("git version 2.34.1.windows.1"), /*is_windows*/ true ) .as_deref(), Some("old Git for Windows may corrupt Windows TUI rendering") ); assert_eq!( old_windows_git_warning( Some("git version 2.54.0.windows.1"), /*is_windows*/ true ), None ); assert_eq!( old_windows_git_warning( Some("git version 2.34.1.windows.1"), /*is_windows*/ false ), None ); } #[test] fn warns_when_git_repo_has_no_git_executable() { let check = git_check_from_inputs(GitCheckInputs { repo_root: Some(PathBuf::from("/repo")), ..GitCheckInputs::default() }); assert_eq!(check.status, CheckStatus::Warning); assert_eq!( check.summary, "Git repository detected but git executable was not found" ); } #[test] fn warns_when_selected_git_cannot_report_version() { let check = git_check_from_inputs(GitCheckInputs { selected_git: Some(PathBuf::from("/usr/bin/git")), repo_root: Some(PathBuf::from("/repo")), ..GitCheckInputs::default() }); assert_eq!(check.status, CheckStatus::Warning); assert_eq!(check.summary, "Git executable found but could not be run"); } #[test] fn reports_git_candidates_and_repo_metadata() { let check = git_check_from_inputs(GitCheckInputs { selected_git: Some(PathBuf::from("/usr/bin/git")), git_candidates: vec![PathBuf::from("/usr/bin/git"), PathBuf::from("/opt/bin/git")], git_version: Some("git version 2.54.0".to_string()), git_exec_path: Some("/usr/libexec/git-core".to_string()), repo_root: Some(PathBuf::from("/repo")), git_entry: Some("directory".to_string()), branch: Some("main".to_string()), core_fsmonitor: Some("false".to_string()), ..GitCheckInputs::default() }); assert_eq!(check.status, CheckStatus::Ok); assert!(check.details.contains(&"PATH git entries: 2".to_string())); assert!(check.details.contains(&"git branch: main".to_string())); assert!(check.details.contains(&"core.fsmonitor: false".to_string())); }}