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
24pub 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
45pub 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, 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 None,
64 )
65 .await
66 .ok()?
67}
68
69const 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 #[serde(skip_serializing_if = "Option::is_none")]
77 pub commit_hash: Option<GitSha>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub branch: Option<String>,
81 #[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
92pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
97 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 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 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 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 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
150pub 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
163pub 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
174pub 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, None);
202 }
203
204 let (host_part, path) = url.split_once('/')?;
205 canonicalize_git_remote_host_path(host_part, path, 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#[derive(Clone, Debug, Serialize, Deserialize)]
333pub struct CommitLogEntry {
334 pub sha: String,
335 pub timestamp: i64,
337 pub subject: String,
339}
340
341pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec<CommitLogEntry> {
345 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"; 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
390pub 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
405async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
407 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 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 .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, }
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
487async fn get_default_branch(cwd: &Path) -> Option<String> {
494 let remotes = get_git_remotes(cwd).await.unwrap_or_default();
496 for remote in remotes {
497 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 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 get_default_branch_local(cwd).await
536}
537
538pub async fn default_branch_name(cwd: &Path) -> Option<String> {
545 get_default_branch(cwd).await
546}
547
548async 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
570async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
573 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 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 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 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 Some(ancestry)
637}
638
639async fn branch_remote_and_distance(
644 cwd: &Path,
645 branch: &str,
646 remotes: &[String],
647) -> Option<(Option<GitSha>, usize)> {
648 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 return None;
660 };
661 if !verify_output.status.success() {
662 continue;
663 }
664 let Ok(sha) = String::from_utf8(verify_output.stdout) else {
665 return None;
667 };
668 found_remote_sha = Some(GitSha::new(sha.trim()));
669 found_remote_ref = Some(remote_ref);
670 break;
671 }
672
673 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
721async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
723 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 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 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 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 "--",
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
812pub 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, 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, 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 if !dir.pop() {
863 break;
864 }
865 }
866
867 None
868}
869
870pub 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
901pub 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 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}