Skip to main content

codex_git_utils/
info.rs

1use std::collections::BTreeMap;
2use std::collections::HashSet;
3use std::ffi::OsStr;
4use std::path::Path;
5use std::path::PathBuf;
6use std::process::Stdio;
7
8use codex_file_system::ExecutorFileSystem;
9use codex_file_system::FindUpErrorPolicy;
10use codex_file_system::find_nearest_native_ancestor_with_markers;
11use codex_utils_absolute_path::AbsolutePathBuf;
12use codex_utils_path_uri::PathUri;
13use futures::future::join_all;
14use schemars::JsonSchema;
15use serde::Deserialize;
16use serde::Serialize;
17use tokio::process::Command;
18use tokio::time::Duration as TokioDuration;
19use tokio::time::timeout;
20use ts_rs::TS;
21
22use crate::GitSha;
23
24/// Return `true` if the project folder specified by the `Config` is inside a
25/// Git repository.
26///
27/// The check walks up the directory hierarchy looking for a `.git` file or
28/// directory (note `.git` can be a file that contains a `gitdir` entry). This
29/// approach does **not** require the `git` binary or the `git2` crate and is
30/// therefore fairly lightweight.
31///
32/// Note that this does **not** detect *work‑trees* created with
33/// `git worktree add` where the checkout lives outside the main repository
34/// directory. If you need Codex to work from such a checkout simply pass the
35/// `--allow-no-git-exec` CLI flag that disables the repo requirement.
36pub fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
37    let base = if base_dir.is_dir() {
38        base_dir
39    } else {
40        base_dir.parent()?
41    };
42    find_ancestor_git_entry(base).map(|(repo_root, _)| repo_root)
43}
44
45/// Return the repository root for `cwd` using the provided filesystem.
46///
47/// This mirrors [`get_git_repo_root`] for local paths, but works when `cwd`
48/// only exists inside a selected remote environment.
49pub async fn get_git_repo_root_with_fs(
50    fs: &dyn ExecutorFileSystem,
51    cwd: &AbsolutePathBuf,
52) -> Option<AbsolutePathBuf> {
53    let cwd_uri = PathUri::from_abs_path(cwd);
54    let base = match fs.get_metadata(&cwd_uri, /*sandbox*/ None).await {
55        Ok(metadata) if metadata.is_directory => cwd.clone(),
56        _ => cwd.parent()?,
57    };
58    find_nearest_native_ancestor_with_markers(
59        fs,
60        &base,
61        vec![".git".to_string()],
62        FindUpErrorPolicy::Ignore,
63        /*sandbox*/ None,
64    )
65    .await
66    .ok()?
67}
68
69/// Timeout for git commands to prevent freezing on large repositories
70const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
71const DISABLED_HOOKS_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
72
73#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)]
74pub struct GitInfo {
75    /// Current commit hash (SHA)
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub commit_hash: Option<GitSha>,
78    /// Current branch name
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub branch: Option<String>,
81    /// Repository URL (if available from remote)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub repository_url: Option<String>,
84}
85
86#[derive(Serialize, Deserialize, Clone, Debug)]
87pub struct GitDiffToRemote {
88    pub sha: GitSha,
89    pub diff: String,
90}
91
92/// Collect git repository information from the given working directory using command-line git.
93/// Returns None if no git repository is found or if git operations fail.
94/// Uses timeouts to prevent freezing on large repositories.
95/// All git commands (except the initial repo check) run in parallel for better performance.
96pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
97    // Check if we're in a git repository first
98    let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
99        .await?
100        .status
101        .success();
102
103    if !is_git_repo {
104        return None;
105    }
106
107    // Run all git info collection commands in parallel
108    let (commit_result, branch_result, url_result) = tokio::join!(
109        run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd),
110        run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd),
111        run_git_command_with_timeout(&["remote", "get-url", "origin"], cwd)
112    );
113
114    let mut git_info = GitInfo {
115        commit_hash: None,
116        branch: None,
117        repository_url: None,
118    };
119
120    // Process commit hash
121    if let Some(output) = commit_result
122        && output.status.success()
123        && let Ok(hash) = String::from_utf8(output.stdout)
124    {
125        git_info.commit_hash = Some(GitSha::new(hash.trim()));
126    }
127
128    // Process branch name
129    if let Some(output) = branch_result
130        && output.status.success()
131        && let Ok(branch) = String::from_utf8(output.stdout)
132    {
133        let branch = branch.trim();
134        if branch != "HEAD" {
135            git_info.branch = Some(branch.to_string());
136        }
137    }
138
139    // Process repository URL
140    if let Some(output) = url_result
141        && output.status.success()
142        && let Ok(url) = String::from_utf8(output.stdout)
143    {
144        git_info.repository_url = Some(url.trim().to_string());
145    }
146
147    Some(git_info)
148}
149
150/// Collect fetch remotes in a multi-root-friendly format: {"origin": "https://..."}.
151pub async fn get_git_remote_urls(cwd: &Path) -> Option<BTreeMap<String, String>> {
152    let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
153        .await?
154        .status
155        .success();
156    if !is_git_repo {
157        return None;
158    }
159
160    get_git_remote_urls_assume_git_repo(cwd).await
161}
162
163/// Collect fetch remotes without checking whether `cwd` is in a git repo.
164pub async fn get_git_remote_urls_assume_git_repo(cwd: &Path) -> Option<BTreeMap<String, String>> {
165    let output = run_git_command_with_timeout(&["remote", "-v"], cwd).await?;
166    if !output.status.success() {
167        return None;
168    }
169
170    let stdout = String::from_utf8(output.stdout).ok()?;
171    parse_git_remote_urls(stdout.as_str())
172}
173
174/// Return the current HEAD commit hash without checking whether `cwd` is in a git repo.
175pub async fn get_head_commit_hash(cwd: &Path) -> Option<GitSha> {
176    let output = run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd).await?;
177    if !output.status.success() {
178        return None;
179    }
180
181    let stdout = String::from_utf8(output.stdout).ok()?;
182    let hash = stdout.trim();
183    if hash.is_empty() {
184        None
185    } else {
186        Some(GitSha::new(hash))
187    }
188}
189
190pub fn canonicalize_git_remote_url(url: &str) -> Option<String> {
191    let url = trim_git_suffix(url.trim().trim_end_matches('/'));
192    if url.is_empty() {
193        return None;
194    }
195
196    if let Some((scheme, rest)) = url.split_once("://") {
197        return canonicalize_git_url_like_remote(scheme, rest);
198    }
199
200    if let Some((host_part, path)) = parse_scp_like_remote(url) {
201        return canonicalize_git_remote_host_path(host_part, path, /*default_port*/ None);
202    }
203
204    let (host_part, path) = url.split_once('/')?;
205    canonicalize_git_remote_host_path(host_part, path, /*default_port*/ None)
206}
207
208fn canonicalize_git_url_like_remote(scheme: &str, rest: &str) -> Option<String> {
209    let default_port = match scheme {
210        "git" => Some("9418"),
211        "http" => Some("80"),
212        "https" => Some("443"),
213        "ssh" => Some("22"),
214        _ => return None,
215    };
216
217    let rest = rest
218        .find(['?', '#'])
219        .map_or(rest, |suffix_index| &rest[..suffix_index]);
220    let (host_part, path) = rest.split_once('/')?;
221    canonicalize_git_remote_host_path(host_part, path, default_port)
222}
223
224fn parse_scp_like_remote(remote: &str) -> Option<(&str, &str)> {
225    if remote.contains('/')
226        && remote
227            .find('/')
228            .is_some_and(|slash| remote.find(':').is_none_or(|colon| slash < colon))
229    {
230        return None;
231    }
232
233    let (host_part, path) = remote.split_once(':')?;
234    if host_part.is_empty() || path.is_empty() {
235        return None;
236    }
237    Some((host_part, path))
238}
239
240fn canonicalize_git_remote_host_path(
241    host_part: &str,
242    path: &str,
243    default_port: Option<&str>,
244) -> Option<String> {
245    let host = normalize_remote_host(
246        host_part
247            .rsplit_once('@')
248            .map_or(host_part, |(_, host)| host)
249            .trim()
250            .trim_end_matches('/'),
251        default_port,
252    );
253    if host.is_empty() {
254        return None;
255    }
256
257    let path = trim_git_suffix(path.trim().trim_matches('/'));
258    let components = path
259        .split('/')
260        .filter(|component| !component.is_empty())
261        .collect::<Vec<_>>();
262    let [owner, repo, ..] = components.as_slice() else {
263        return None;
264    };
265    if matches!((*owner, *repo), ("." | "..", _) | (_, "." | "..")) {
266        return None;
267    }
268    let path = components.join("/");
269
270    if host == "github.com" {
271        Some(format!("{host}/{}", path.to_ascii_lowercase()))
272    } else {
273        Some(format!("{host}/{path}"))
274    }
275}
276
277fn normalize_remote_host(host: &str, default_port: Option<&str>) -> String {
278    let host = host.to_ascii_lowercase();
279    if let Some(default_port) = default_port
280        && let Some((host_without_port, port)) = host.rsplit_once(':')
281        && port == default_port
282    {
283        return host_without_port.to_string();
284    }
285    host
286}
287
288fn trim_git_suffix(value: &str) -> &str {
289    value.strip_suffix(".git").unwrap_or(value)
290}
291
292pub async fn get_has_changes(cwd: &Path) -> Option<bool> {
293    let git = Path::new("git");
294    let fsmonitor = detect_local_fsmonitor_override(git, cwd).await;
295    let output =
296        run_git_command_with_timeout_from(git, &["status", "--porcelain"], cwd, fsmonitor).await?;
297    if !output.status.success() {
298        return None;
299    }
300
301    Some(!output.stdout.is_empty())
302}
303
304fn parse_git_remote_urls(stdout: &str) -> Option<BTreeMap<String, String>> {
305    let mut remotes = BTreeMap::new();
306    for line in stdout.lines() {
307        let Some(fetch_line) = line.strip_suffix(" (fetch)") else {
308            continue;
309        };
310
311        let Some((name, url_part)) = fetch_line
312            .split_once('\t')
313            .or_else(|| fetch_line.split_once(' '))
314        else {
315            continue;
316        };
317
318        let url = url_part.trim_start();
319        if !url.is_empty() {
320            remotes.insert(name.to_string(), url.to_string());
321        }
322    }
323
324    if remotes.is_empty() {
325        None
326    } else {
327        Some(remotes)
328    }
329}
330
331/// A minimal commit summary entry used for pickers (subject + timestamp + sha).
332#[derive(Clone, Debug, Serialize, Deserialize)]
333pub struct CommitLogEntry {
334    pub sha: String,
335    /// Unix timestamp (seconds since epoch) of the commit time (committer time).
336    pub timestamp: i64,
337    /// Single-line subject of the commit message.
338    pub subject: String,
339}
340
341/// Return the last `limit` commits reachable from HEAD for the current branch.
342/// Each entry contains the SHA, commit timestamp (seconds), and subject line.
343/// Returns an empty vector if not in a git repo or on error/timeout.
344pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec<CommitLogEntry> {
345    // Ensure we're in a git repo first to avoid noisy errors.
346    let Some(out) = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd).await else {
347        return Vec::new();
348    };
349    if !out.status.success() {
350        return Vec::new();
351    }
352
353    let fmt = "%H%x1f%ct%x1f%s"; // <sha> <US> <commit_time> <US> <subject>
354    let limit_arg = (limit > 0).then(|| limit.to_string());
355    let mut args: Vec<String> = vec!["log".to_string()];
356    if let Some(n) = &limit_arg {
357        args.push("-n".to_string());
358        args.push(n.clone());
359    }
360    args.push(format!("--pretty=format:{fmt}"));
361    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
362    let Some(log_out) = run_git_command_with_timeout(&arg_refs, cwd).await else {
363        return Vec::new();
364    };
365    if !log_out.status.success() {
366        return Vec::new();
367    }
368
369    let text = String::from_utf8_lossy(&log_out.stdout);
370    let mut entries: Vec<CommitLogEntry> = Vec::new();
371    for line in text.lines() {
372        let mut parts = line.split('\u{001f}');
373        let sha = parts.next().unwrap_or("").trim();
374        let ts_s = parts.next().unwrap_or("").trim();
375        let subject = parts.next().unwrap_or("").trim();
376        if sha.is_empty() || ts_s.is_empty() {
377            continue;
378        }
379        let timestamp = ts_s.parse::<i64>().unwrap_or(0);
380        entries.push(CommitLogEntry {
381            sha: sha.to_string(),
382            timestamp,
383            subject: subject.to_string(),
384        });
385    }
386
387    entries
388}
389
390/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
391pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
392    get_git_repo_root(cwd)?;
393
394    let remotes = get_git_remotes(cwd).await?;
395    let branches = branch_ancestry(cwd).await?;
396    let base_sha = find_closest_sha(cwd, &branches, &remotes).await?;
397    let diff = diff_against_sha(cwd, &base_sha).await?;
398
399    Some(GitDiffToRemote {
400        sha: base_sha,
401        diff,
402    })
403}
404
405/// Run a git command with a timeout to prevent blocking on large repositories
406async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
407    // These callers only inspect repository metadata. Worktree workflows probe
408    // once and pass their override directly to the lower-level runner.
409    run_git_command_with_timeout_from(
410        Path::new("git"),
411        args,
412        cwd,
413        crate::FsmonitorOverride::Disabled,
414    )
415    .await
416}
417
418struct LocalFsmonitorProbeRunner<'a> {
419    git: &'a Path,
420    cwd: &'a Path,
421}
422
423impl crate::FsmonitorProbeRunner for LocalFsmonitorProbeRunner<'_> {
424    async fn run_probe(&mut self, args: &[&str]) -> Option<Vec<u8>> {
425        // Both probes are fast, bounded metadata queries that do not inspect the
426        // worktree or index, so do not reduce the requested command's timeout.
427        let mut command = Command::new(self.git);
428        command
429            .args(args)
430            .current_dir(self.cwd)
431            .stdin(Stdio::null())
432            .kill_on_drop(true);
433        match timeout(GIT_COMMAND_TIMEOUT, command.output()).await {
434            Ok(Ok(output)) if output.status.success() => Some(output.stdout),
435            _ => None,
436        }
437    }
438}
439
440async fn detect_local_fsmonitor_override(git: &Path, cwd: &Path) -> crate::FsmonitorOverride {
441    let mut runner = LocalFsmonitorProbeRunner { git, cwd };
442    crate::detect_fsmonitor_override(&mut runner).await
443}
444
445async fn run_git_command_with_timeout_from(
446    git: &Path,
447    args: &[&str],
448    cwd: &Path,
449    fsmonitor: crate::FsmonitorOverride,
450) -> Option<std::process::Output> {
451    let mut command = Command::new(git);
452    command
453        .env("GIT_OPTIONAL_LOCKS", "0")
454        // Keep internal Git commands independent of repository-selected hooks
455        // and fsmonitor helpers while preserving built-in fsmonitor acceleration.
456        .args(["-c", &format!("core.hooksPath={DISABLED_HOOKS_PATH}")])
457        .args(["-c", fsmonitor.git_config_arg()])
458        .args(args)
459        .current_dir(cwd)
460        .stdin(Stdio::null())
461        .kill_on_drop(true);
462    let result = timeout(GIT_COMMAND_TIMEOUT, command.output()).await;
463
464    match result {
465        Ok(Ok(output)) => Some(output),
466        _ => None, // Timeout or error
467    }
468}
469
470async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
471    let output = run_git_command_with_timeout(&["remote"], cwd).await?;
472    if !output.status.success() {
473        return None;
474    }
475    let mut remotes: Vec<String> = String::from_utf8(output.stdout)
476        .ok()?
477        .lines()
478        .map(str::to_string)
479        .collect();
480    if let Some(pos) = remotes.iter().position(|r| r == "origin") {
481        let origin = remotes.remove(pos);
482        remotes.insert(0, origin);
483    }
484    Some(remotes)
485}
486
487/// Attempt to determine the repository's default branch name.
488///
489/// Preference order:
490/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
491/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
492/// 3) Local fallback to existing `main` or `master` if present
493async fn get_default_branch(cwd: &Path) -> Option<String> {
494    // Prefer the first remote (with origin prioritized)
495    let remotes = get_git_remotes(cwd).await.unwrap_or_default();
496    for remote in remotes {
497        // Try symbolic-ref, which returns something like: refs/remotes/origin/main
498        if let Some(symref_output) = run_git_command_with_timeout(
499            &[
500                "symbolic-ref",
501                "--quiet",
502                &format!("refs/remotes/{remote}/HEAD"),
503            ],
504            cwd,
505        )
506        .await
507            && symref_output.status.success()
508            && let Ok(sym) = String::from_utf8(symref_output.stdout)
509        {
510            let trimmed = sym.trim();
511            if let Some((_, name)) = trimmed.rsplit_once('/') {
512                return Some(name.to_string());
513            }
514        }
515
516        // Fall back to parsing `git remote show <remote>` output
517        if let Some(show_output) =
518            run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
519            && show_output.status.success()
520            && let Ok(text) = String::from_utf8(show_output.stdout)
521        {
522            for line in text.lines() {
523                let line = line.trim();
524                if let Some(rest) = line.strip_prefix("HEAD branch:") {
525                    let name = rest.trim();
526                    if !name.is_empty() {
527                        return Some(name.to_string());
528                    }
529                }
530            }
531        }
532    }
533
534    // No remote-derived default; try common local defaults if they exist
535    get_default_branch_local(cwd).await
536}
537
538/// Determine the repository's default branch name, if available.
539///
540/// This inspects remote configuration first (including the symbolic `HEAD`
541/// reference) and falls back to common local defaults such as `main` or
542/// `master`. Returns `None` when the information cannot be determined, for
543/// example when the current directory is not inside a Git repository.
544pub async fn default_branch_name(cwd: &Path) -> Option<String> {
545    get_default_branch(cwd).await
546}
547
548/// Attempt to determine the repository's default branch name from local branches.
549async fn get_default_branch_local(cwd: &Path) -> Option<String> {
550    for candidate in ["main", "master"] {
551        if let Some(verify) = run_git_command_with_timeout(
552            &[
553                "rev-parse",
554                "--verify",
555                "--quiet",
556                &format!("refs/heads/{candidate}"),
557            ],
558            cwd,
559        )
560        .await
561            && verify.status.success()
562        {
563            return Some(candidate.to_string());
564        }
565    }
566
567    None
568}
569
570/// Build an ancestry of branches starting at the current branch and ending at the
571/// repository's default branch (if determinable)..
572async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
573    // Discover current branch (ignore detached HEAD by treating it as None)
574    let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
575        .await
576        .and_then(|o| {
577            if o.status.success() {
578                String::from_utf8(o.stdout).ok()
579            } else {
580                None
581            }
582        })
583        .map(|s| s.trim().to_string())
584        .filter(|s| s != "HEAD");
585
586    // Discover default branch
587    let default_branch = get_default_branch(cwd).await;
588
589    let mut ancestry: Vec<String> = Vec::new();
590    let mut seen: HashSet<String> = HashSet::new();
591    if let Some(cb) = current_branch.clone() {
592        seen.insert(cb.clone());
593        ancestry.push(cb);
594    }
595    if let Some(db) = default_branch
596        && !seen.contains(&db)
597    {
598        seen.insert(db.clone());
599        ancestry.push(db);
600    }
601
602    // Expand candidates: include any remote branches that already contain HEAD.
603    // This addresses cases where we're on a new local-only branch forked from a
604    // remote branch that isn't the repository default. We prioritize remotes in
605    // the order returned by get_git_remotes (origin first).
606    let remotes = get_git_remotes(cwd).await.unwrap_or_default();
607    for remote in remotes {
608        if let Some(output) = run_git_command_with_timeout(
609            &[
610                "for-each-ref",
611                "--format=%(refname:short)",
612                "--contains=HEAD",
613                &format!("refs/remotes/{remote}"),
614            ],
615            cwd,
616        )
617        .await
618            && output.status.success()
619            && let Ok(text) = String::from_utf8(output.stdout)
620        {
621            for line in text.lines() {
622                let short = line.trim();
623                // Expect format like: "origin/feature"; extract the branch path after "remote/"
624                if let Some(stripped) = short.strip_prefix(&format!("{remote}/"))
625                    && !stripped.is_empty()
626                    && !seen.contains(stripped)
627                {
628                    seen.insert(stripped.to_string());
629                    ancestry.push(stripped.to_string());
630                }
631            }
632        }
633    }
634
635    // Ensure we return Some vector, even if empty, to allow caller logic to proceed
636    Some(ancestry)
637}
638
639// Helper for a single branch: return the remote SHA if present on any remote
640// and the distance (commits ahead of HEAD) for that branch. The first item is
641// None if the branch is not present on any remote. Returns None if distance
642// could not be computed due to git errors/timeouts.
643async fn branch_remote_and_distance(
644    cwd: &Path,
645    branch: &str,
646    remotes: &[String],
647) -> Option<(Option<GitSha>, usize)> {
648    // Try to find the first remote ref that exists for this branch (origin prioritized by caller).
649    let mut found_remote_sha: Option<GitSha> = None;
650    let mut found_remote_ref: Option<String> = None;
651    for remote in remotes {
652        let remote_ref = format!("refs/remotes/{remote}/{branch}");
653        let Some(verify_output) =
654            run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", &remote_ref], cwd)
655                .await
656        else {
657            // Mirror previous behavior: if the verify call times out/fails at the process level,
658            // treat the entire branch as unusable.
659            return None;
660        };
661        if !verify_output.status.success() {
662            continue;
663        }
664        let Ok(sha) = String::from_utf8(verify_output.stdout) else {
665            // Mirror previous behavior and skip the entire branch on parse failure.
666            return None;
667        };
668        found_remote_sha = Some(GitSha::new(sha.trim()));
669        found_remote_ref = Some(remote_ref);
670        break;
671    }
672
673    // Compute distance as the number of commits HEAD is ahead of the branch.
674    // Prefer local branch name if it exists; otherwise fall back to the remote ref (if any).
675    let count_output = if let Some(local_count) =
676        run_git_command_with_timeout(&["rev-list", "--count", &format!("{branch}..HEAD")], cwd)
677            .await
678    {
679        if local_count.status.success() {
680            local_count
681        } else if let Some(remote_ref) = &found_remote_ref {
682            match run_git_command_with_timeout(
683                &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
684                cwd,
685            )
686            .await
687            {
688                Some(remote_count) => remote_count,
689                None => return None,
690            }
691        } else {
692            return None;
693        }
694    } else if let Some(remote_ref) = &found_remote_ref {
695        match run_git_command_with_timeout(
696            &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
697            cwd,
698        )
699        .await
700        {
701            Some(remote_count) => remote_count,
702            None => return None,
703        }
704    } else {
705        return None;
706    };
707
708    if !count_output.status.success() {
709        return None;
710    }
711    let Ok(distance_str) = String::from_utf8(count_output.stdout) else {
712        return None;
713    };
714    let Ok(distance) = distance_str.trim().parse::<usize>() else {
715        return None;
716    };
717
718    Some((found_remote_sha, distance))
719}
720
721// Finds the closest sha that exist on any of branches and also exists on any of the remotes.
722async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
723    // A sha and how many commits away from HEAD it is.
724    let mut closest_sha: Option<(GitSha, usize)> = None;
725    for branch in branches {
726        let Some((maybe_remote_sha, distance)) =
727            branch_remote_and_distance(cwd, branch, remotes).await
728        else {
729            continue;
730        };
731        let Some(remote_sha) = maybe_remote_sha else {
732            // Preserve existing behavior: skip branches that are not present on a remote.
733            continue;
734        };
735        match &closest_sha {
736            None => closest_sha = Some((remote_sha, distance)),
737            Some((_, best_distance)) if distance < *best_distance => {
738                closest_sha = Some((remote_sha, distance));
739            }
740            _ => {}
741        }
742    }
743    closest_sha.map(|(sha, _)| sha)
744}
745
746async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
747    let git = Path::new("git");
748    let fsmonitor = detect_local_fsmonitor_override(git, cwd).await;
749    let output = run_git_command_with_timeout_from(
750        git,
751        &["diff", "--no-textconv", "--no-ext-diff", &sha.0],
752        cwd,
753        fsmonitor,
754    )
755    .await?;
756    // 0 is success and no diff.
757    // 1 is success but there is a diff.
758    let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
759    if !exit_ok {
760        return None;
761    }
762    let mut diff = String::from_utf8(output.stdout).ok()?;
763
764    if let Some(untracked_output) = run_git_command_with_timeout_from(
765        git,
766        &["ls-files", "--others", "--exclude-standard"],
767        cwd,
768        fsmonitor,
769    )
770    .await
771        && untracked_output.status.success()
772    {
773        let untracked: Vec<String> = String::from_utf8(untracked_output.stdout)
774            .ok()?
775            .lines()
776            .map(str::to_string)
777            .filter(|s| !s.is_empty())
778            .collect();
779
780        if !untracked.is_empty() {
781            // Use platform-appropriate null device and guard paths with `--`.
782            let null_device: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
783            let futures_iter = untracked.into_iter().map(|file| async move {
784                let file_owned = file;
785                let args_vec: Vec<&str> = vec![
786                    "diff",
787                    "--no-textconv",
788                    "--no-ext-diff",
789                    "--binary",
790                    "--no-index",
791                    // -- ensures that filenames that start with - are not treated as options.
792                    "--",
793                    null_device,
794                    &file_owned,
795                ];
796                run_git_command_with_timeout_from(git, &args_vec, cwd, fsmonitor).await
797            });
798            let results = join_all(futures_iter).await;
799            for extra in results.into_iter().flatten() {
800                if extra.status.code().is_some_and(|c| c == 0 || c == 1)
801                    && let Ok(s) = String::from_utf8(extra.stdout)
802                {
803                    diff.push_str(&s);
804                }
805            }
806        }
807    }
808
809    Some(diff)
810}
811
812/// Resolve the path that should be used for trust checks. Similar to
813/// `[get_git_repo_root]`, but resolves to the root of the main
814/// repository. Handles worktrees via filesystem inspection without invoking
815/// the `git` executable.
816pub async fn resolve_root_git_project_for_trust(
817    fs: &dyn ExecutorFileSystem,
818    cwd: &AbsolutePathBuf,
819) -> Option<AbsolutePathBuf> {
820    let repo_root = get_git_repo_root_with_fs(fs, cwd).await?;
821    let dot_git = repo_root.join(".git");
822    let dot_git_uri = PathUri::from_abs_path(&dot_git);
823    if fs
824        .get_metadata(&dot_git_uri, /*sandbox*/ None)
825        .await
826        .ok()?
827        .is_directory
828    {
829        return Some(repo_root);
830    }
831
832    let git_dir_s = fs
833        .read_file_text(&dot_git_uri, /*sandbox*/ None)
834        .await
835        .ok()?;
836    let git_dir_rel = git_dir_s.trim().strip_prefix("gitdir:")?.trim();
837    if git_dir_rel.is_empty() {
838        return None;
839    }
840
841    let git_dir_path = AbsolutePathBuf::resolve_path_against_base(git_dir_rel, repo_root.as_path());
842    let worktrees_dir = git_dir_path.parent()?;
843    if worktrees_dir.as_path().file_name() != Some(OsStr::new("worktrees")) {
844        return None;
845    }
846
847    let common_dir = worktrees_dir.parent()?;
848    common_dir.parent()
849}
850
851fn find_ancestor_git_entry(base_dir: &Path) -> Option<(PathBuf, PathBuf)> {
852    let mut dir = base_dir.to_path_buf();
853
854    loop {
855        let dot_git = dir.join(".git");
856        if dot_git.exists() {
857            return Some((dir, dot_git));
858        }
859
860        // Pop one component (go up one directory). `pop` returns false when
861        // we have reached the filesystem root.
862        if !dir.pop() {
863            break;
864        }
865    }
866
867    None
868}
869
870/// Returns a list of local git branches.
871/// Includes the default branch at the beginning of the list, if it exists.
872pub async fn local_git_branches(cwd: &Path) -> Vec<String> {
873    let mut branches: Vec<String> = if let Some(out) = run_git_command_with_timeout(
874        &["for-each-ref", "--format=%(refname:short)", "refs/heads"],
875        cwd,
876    )
877    .await
878        && out.status.success()
879    {
880        String::from_utf8_lossy(&out.stdout)
881            .lines()
882            .map(|s| s.trim().to_string())
883            .filter(|s| !s.is_empty())
884            .collect()
885    } else {
886        Vec::new()
887    };
888
889    branches.sort_unstable();
890
891    if let Some(base) = get_default_branch_local(cwd).await
892        && let Some(pos) = branches.iter().position(|name| name == &base)
893    {
894        let base_branch = branches.remove(pos);
895        branches.insert(0, base_branch);
896    }
897
898    branches
899}
900
901/// Returns the current checked out branch name.
902pub async fn current_branch_name(cwd: &Path) -> Option<String> {
903    let out = run_git_command_with_timeout(&["branch", "--show-current"], cwd).await?;
904    if !out.status.success() {
905        return None;
906    }
907    String::from_utf8(out.stdout)
908        .ok()
909        .map(|s| s.trim().to_string())
910        .filter(|name| !name.is_empty())
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916    use pretty_assertions::assert_eq;
917    #[cfg(unix)]
918    use std::os::unix::fs::PermissionsExt;
919
920    #[tokio::test]
921    async fn git_metadata_commands_do_not_inherit_stdin() {
922        const CHILD_ENV: &str = "CODEX_GIT_UTILS_STDIN_CHILD";
923
924        if std::env::var_os(CHILD_ENV).is_some() {
925            let temp_dir = tempfile::tempdir().expect("create temp dir");
926            let status = Command::new("git")
927                .args(["init", "-q"])
928                .current_dir(temp_dir.path())
929                .stdin(Stdio::null())
930                .status()
931                .await
932                .expect("initialize test repository");
933            assert!(status.success(), "initialize test repository");
934
935            let git = Path::new("git");
936            let mut runner = LocalFsmonitorProbeRunner {
937                git,
938                cwd: temp_dir.path(),
939            };
940            assert!(
941                crate::FsmonitorProbeRunner::run_probe(&mut runner, &["cat-file", "--batch"])
942                    .await
943                    .is_some()
944            );
945            assert!(
946                run_git_command_with_timeout_from(
947                    git,
948                    &["cat-file", "--batch"],
949                    temp_dir.path(),
950                    crate::FsmonitorOverride::Disabled,
951                )
952                .await
953                .is_some()
954            );
955            return;
956        }
957
958        let mut child =
959            Command::new(std::env::current_exe().expect("find current test executable"))
960                .args(["git_metadata_commands_do_not_inherit_stdin", "--nocapture"])
961                .env(CHILD_ENV, "1")
962                .stdin(Stdio::piped())
963                .spawn()
964                .expect("spawn child test process");
965        let stdin = child.stdin.take().expect("hold child stdin open");
966        let status = child.wait().await.expect("wait for child test process");
967        drop(stdin);
968
969        assert!(status.success(), "child test process failed: {status}");
970    }
971
972    #[test]
973    fn canonicalize_git_remote_url_normalizes_github_variants() {
974        for remote in [
975            "git@github.com:OpenAI/Codex.git",
976            "ssh://git@github.com/openai/codex.git",
977            "ssh://git@github.com:22/OpenAI/Codex.git",
978            "https://github.com/openai/codex.git",
979            "https://github.com:443/openai/codex.git",
980            "https://token@github.com/openai/codex/",
981            "github.com/OpenAI/Codex.git",
982        ] {
983            assert_eq!(
984                canonicalize_git_remote_url(remote),
985                Some("github.com/openai/codex".to_string())
986            );
987        }
988    }
989
990    #[test]
991    fn canonicalize_git_remote_url_handles_ghe_without_lowercasing_path() {
992        assert_eq!(
993            canonicalize_git_remote_url("git@ghe.company.com:Org/Repo.git"),
994            Some("ghe.company.com/Org/Repo".to_string())
995        );
996        assert_eq!(
997            canonicalize_git_remote_url("ssh://git@ghe.company.com:2222/Org/Repo.git"),
998            Some("ghe.company.com:2222/Org/Repo".to_string())
999        );
1000    }
1001
1002    #[test]
1003    fn canonicalize_git_remote_url_rejects_non_repository_values() {
1004        for remote in ["", "file:///tmp/repo", "github.com/openai", "/tmp/repo"] {
1005            assert_eq!(canonicalize_git_remote_url(remote), None);
1006        }
1007    }
1008
1009    #[tokio::test]
1010    async fn local_git_branches_excludes_detached_head_entry() {
1011        let temp_dir = tempfile::tempdir().expect("create temp dir");
1012        let repo = temp_dir.path();
1013        let envs = vec![
1014            ("GIT_CONFIG_GLOBAL", "/dev/null"),
1015            ("GIT_CONFIG_NOSYSTEM", "1"),
1016        ];
1017        let run_git = |args: &[&str]| {
1018            let status = std::process::Command::new("git")
1019                .envs(envs.clone())
1020                .args(args)
1021                .current_dir(repo)
1022                .status()
1023                .expect("run Git command");
1024            assert_eq!(status.code(), Some(0), "Git command failed: {args:?}");
1025        };
1026
1027        run_git(&["init", "-q", "--initial-branch=main"]);
1028        run_git(&[
1029            "-c",
1030            "user.name=Codex Tests",
1031            "-c",
1032            "user.email=codex-tests@example.com",
1033            "commit",
1034            "--allow-empty",
1035            "-q",
1036            "-m",
1037            "initial",
1038        ]);
1039        run_git(&["branch", "feature/local"]);
1040        run_git(&["checkout", "--detach", "-q"]);
1041
1042        assert_eq!(
1043            local_git_branches(repo).await,
1044            vec!["main".to_string(), "feature/local".to_string()]
1045        );
1046    }
1047
1048    #[cfg(unix)]
1049    #[tokio::test]
1050    async fn fsmonitor_override_rejects_configured_helper() {
1051        let temp_dir = tempfile::tempdir().expect("create temp dir");
1052        let git = temp_dir.path().join("git");
1053        let log = temp_dir.path().join("git.log");
1054        std::fs::write(
1055            &git,
1056            "#!/bin/sh\n\
1057             printf '%s\\n' \"$*\" >>\"$0.log\"\n\
1058             case \"$1\" in\n\
1059             config) printf '/tmp/fsmonitor-helper\\000' ;;\n\
1060             *) printf 'worktree output\\n' ;;\n\
1061             esac\n",
1062        )
1063        .expect("write fake Git");
1064        let mut permissions = std::fs::metadata(&git)
1065            .expect("read fake Git metadata")
1066            .permissions();
1067        permissions.set_mode(0o755);
1068        std::fs::set_permissions(&git, permissions).expect("mark fake Git executable");
1069
1070        // The config response mirrors:
1071        // git -c core.fsmonitor=/tmp/fsmonitor-helper \
1072        //   config --null --get core.fsmonitor
1073        let fsmonitor = detect_local_fsmonitor_override(&git, temp_dir.path()).await;
1074        let output = run_git_command_with_timeout_from(
1075            &git,
1076            &["status", "--porcelain"],
1077            temp_dir.path(),
1078            fsmonitor,
1079        )
1080        .await
1081        .expect("run fake Git");
1082
1083        assert_eq!(
1084            (output.status.code(), output.stdout),
1085            (Some(0), b"worktree output\n".to_vec())
1086        );
1087        let disabled_hooks = format!("core.hooksPath={DISABLED_HOOKS_PATH}");
1088        assert_eq!(
1089            std::fs::read_to_string(log)
1090                .expect("read fake Git log")
1091                .lines()
1092                .map(str::to_string)
1093                .collect::<Vec<_>>(),
1094            vec![
1095                "config --null --get core.fsmonitor".to_string(),
1096                "config --null --type=bool --fixed-value --get core.fsmonitor /tmp/fsmonitor-helper"
1097                    .to_string(),
1098                format!("-c {disabled_hooks} -c core.fsmonitor=false status --porcelain"),
1099            ]
1100        );
1101    }
1102
1103    #[cfg(unix)]
1104    #[tokio::test]
1105    async fn fsmonitor_override_uses_effective_layered_config_value() {
1106        let temp_dir = tempfile::tempdir().expect("create temp dir");
1107        let repo = temp_dir.path().join("repo");
1108        std::fs::create_dir(&repo).expect("create repository directory");
1109        let init_status = std::process::Command::new("git")
1110            .args(["init", "-q"])
1111            .current_dir(&repo)
1112            .status()
1113            .expect("initialize test repository");
1114        assert_eq!(init_status.code(), Some(0), "initialize test repository");
1115
1116        let git = temp_dir.path().join("git");
1117        let global_config = temp_dir.path().join("git.global");
1118        let log = temp_dir.path().join("git.log");
1119        std::fs::write(
1120            &git,
1121            "#!/bin/sh\n\
1122             printf '%s\\n' \"$*\" >>\"$0.log\"\n\
1123             case \"$1\" in\n\
1124             config)\n\
1125               GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=\"$0.global\" exec git \"$@\"\n\
1126               ;;\n\
1127             version) printf 'feature: fsmonitor--daemon\\n' ;;\n\
1128             *) printf 'worktree output\\n' ;;\n\
1129             esac\n",
1130        )
1131        .expect("write layered-config Git");
1132        let mut permissions = std::fs::metadata(&git)
1133            .expect("read layered-config Git metadata")
1134            .permissions();
1135        permissions.set_mode(0o755);
1136        std::fs::set_permissions(&git, permissions).expect("mark layered-config Git executable");
1137
1138        let global_status = std::process::Command::new("git")
1139            .args([
1140                "config",
1141                "--file",
1142                global_config.to_str().expect("global config path"),
1143                "core.fsmonitor",
1144                "/tmp/fsmonitor-helper",
1145            ])
1146            .status()
1147            .expect("write global fsmonitor helper");
1148        assert_eq!(
1149            global_status.code(),
1150            Some(0),
1151            "write global fsmonitor helper"
1152        );
1153        let local_status = std::process::Command::new("git")
1154            .args(["config", "core.fsmonitor", "true"])
1155            .current_dir(&repo)
1156            .status()
1157            .expect("write local built-in fsmonitor config");
1158        assert_eq!(
1159            local_status.code(),
1160            Some(0),
1161            "write local built-in fsmonitor config"
1162        );
1163
1164        let fsmonitor = detect_local_fsmonitor_override(&git, repo.as_path()).await;
1165        let output = run_git_command_with_timeout_from(
1166            &git,
1167            &["status", "--porcelain"],
1168            repo.as_path(),
1169            fsmonitor,
1170        )
1171        .await
1172        .expect("run Git with layered config");
1173        assert_eq!(
1174            (output.status.code(), output.stdout),
1175            (Some(0), b"worktree output\n".to_vec())
1176        );
1177
1178        let actual = std::fs::read_to_string(log).expect("read layered-config Git log");
1179        let disabled_hooks = format!("core.hooksPath={DISABLED_HOOKS_PATH}");
1180        assert_eq!(
1181            actual.lines().map(str::to_string).collect::<Vec<_>>(),
1182            vec![
1183                "config --null --get core.fsmonitor".to_string(),
1184                "version --build-options".to_string(),
1185                format!("-c {disabled_hooks} -c core.fsmonitor=true status --porcelain"),
1186            ]
1187        );
1188    }
1189}