mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
1377 lines
49 KiB
Markdown
1377 lines
49 KiB
Markdown
# PR #2476: Diff command
|
|
|
|
- URL: https://github.com/openai/codex/pull/2476
|
|
- Author: gpeal
|
|
- Created: 2025-08-20 00:09:10 UTC
|
|
- Updated: 2025-08-20 02:50:37 UTC
|
|
- Changes: +553/-1, Files changed: 4, Commits: 8
|
|
|
|
## Description
|
|
|
|
(No description.)
|
|
|
|
## Full Diff
|
|
|
|
```diff
|
|
diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs
|
|
index ccb43ae55a..5f25d8fe7b 100644
|
|
--- a/codex-rs/core/src/git_info.rs
|
|
+++ b/codex-rs/core/src/git_info.rs
|
|
@@ -1,11 +1,16 @@
|
|
+use std::collections::HashSet;
|
|
use std::path::Path;
|
|
|
|
+use codex_protocol::mcp_protocol::GitSha;
|
|
+use futures::future::join_all;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use tokio::process::Command;
|
|
use tokio::time::Duration as TokioDuration;
|
|
use tokio::time::timeout;
|
|
|
|
+use crate::util::is_inside_git_repo;
|
|
+
|
|
/// Timeout for git commands to prevent freezing on large repositories
|
|
const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5);
|
|
|
|
@@ -22,6 +27,12 @@ pub struct GitInfo {
|
|
pub repository_url: Option<String>,
|
|
}
|
|
|
|
+#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
+pub struct GitDiffToRemote {
|
|
+ pub sha: GitSha,
|
|
+ pub diff: String,
|
|
+}
|
|
+
|
|
/// Collect git repository information from the given working directory using command-line git.
|
|
/// Returns None if no git repository is found or if git operations fail.
|
|
/// Uses timeouts to prevent freezing on large repositories.
|
|
@@ -80,6 +91,23 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
|
Some(git_info)
|
|
}
|
|
|
|
+/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
|
+pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
|
+ if !is_inside_git_repo(cwd) {
|
|
+ return None;
|
|
+ }
|
|
+
|
|
+ let remotes = get_git_remotes(cwd).await?;
|
|
+ let branches = branch_ancestry(cwd).await?;
|
|
+ let base_sha = find_closest_sha(cwd, &branches, &remotes).await?;
|
|
+ let diff = diff_against_sha(cwd, &base_sha).await?;
|
|
+
|
|
+ Some(GitDiffToRemote {
|
|
+ sha: base_sha,
|
|
+ diff,
|
|
+ })
|
|
+}
|
|
+
|
|
/// Run a git command with a timeout to prevent blocking on large repositories
|
|
async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::process::Output> {
|
|
let result = timeout(
|
|
@@ -94,6 +122,309 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::
|
|
}
|
|
}
|
|
|
|
+async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
|
|
+ let output = run_git_command_with_timeout(&["remote"], cwd).await?;
|
|
+ if !output.status.success() {
|
|
+ return None;
|
|
+ }
|
|
+ let mut remotes: Vec<String> = String::from_utf8(output.stdout)
|
|
+ .ok()?
|
|
+ .lines()
|
|
+ .map(|s| s.to_string())
|
|
+ .collect();
|
|
+ if let Some(pos) = remotes.iter().position(|r| r == "origin") {
|
|
+ let origin = remotes.remove(pos);
|
|
+ remotes.insert(0, origin);
|
|
+ }
|
|
+ Some(remotes)
|
|
+}
|
|
+
|
|
+/// Attempt to determine the repository's default branch name.
|
|
+///
|
|
+/// Preference order:
|
|
+/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
|
|
+/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
|
|
+/// 3) Local fallback to existing `main` or `master` if present
|
|
+async fn get_default_branch(cwd: &Path) -> Option<String> {
|
|
+ // Prefer the first remote (with origin prioritized)
|
|
+ let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
|
+ for remote in remotes {
|
|
+ // Try symbolic-ref, which returns something like: refs/remotes/origin/main
|
|
+ if let Some(symref_output) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "symbolic-ref",
|
|
+ "--quiet",
|
|
+ &format!("refs/remotes/{remote}/HEAD"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ && symref_output.status.success()
|
|
+ && let Ok(sym) = String::from_utf8(symref_output.stdout)
|
|
+ {
|
|
+ let trimmed = sym.trim();
|
|
+ if let Some((_, name)) = trimmed.rsplit_once('/') {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Fall back to parsing `git remote show <remote>` output
|
|
+ if let Some(show_output) =
|
|
+ run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
|
|
+ && show_output.status.success()
|
|
+ && let Ok(text) = String::from_utf8(show_output.stdout)
|
|
+ {
|
|
+ for line in text.lines() {
|
|
+ let line = line.trim();
|
|
+ if let Some(rest) = line.strip_prefix("HEAD branch:") {
|
|
+ let name = rest.trim();
|
|
+ if !name.is_empty() {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // No remote-derived default; try common local defaults if they exist
|
|
+ for candidate in ["main", "master"] {
|
|
+ if let Some(verify) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "rev-parse",
|
|
+ "--verify",
|
|
+ "--quiet",
|
|
+ &format!("refs/heads/{candidate}"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ && verify.status.success()
|
|
+ {
|
|
+ return Some(candidate.to_string());
|
|
+ }
|
|
+ }
|
|
+
|
|
+ None
|
|
+}
|
|
+
|
|
+/// Build an ancestry of branches starting at the current branch and ending at the
|
|
+/// repository's default branch (if determinable)..
|
|
+async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
|
|
+ // Discover current branch (ignore detached HEAD by treating it as None)
|
|
+ let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
|
+ .await
|
|
+ .and_then(|o| {
|
|
+ if o.status.success() {
|
|
+ String::from_utf8(o.stdout).ok()
|
|
+ } else {
|
|
+ None
|
|
+ }
|
|
+ })
|
|
+ .map(|s| s.trim().to_string())
|
|
+ .filter(|s| s != "HEAD");
|
|
+
|
|
+ // Discover default branch
|
|
+ let default_branch = get_default_branch(cwd).await;
|
|
+
|
|
+ let mut ancestry: Vec<String> = Vec::new();
|
|
+ let mut seen: HashSet<String> = HashSet::new();
|
|
+ if let Some(cb) = current_branch.clone() {
|
|
+ seen.insert(cb.clone());
|
|
+ ancestry.push(cb);
|
|
+ }
|
|
+ if let Some(db) = default_branch
|
|
+ && !seen.contains(&db)
|
|
+ {
|
|
+ seen.insert(db.clone());
|
|
+ ancestry.push(db);
|
|
+ }
|
|
+
|
|
+ // Expand candidates: include any remote branches that already contain HEAD.
|
|
+ // This addresses cases where we're on a new local-only branch forked from a
|
|
+ // remote branch that isn't the repository default. We prioritize remotes in
|
|
+ // the order returned by get_git_remotes (origin first).
|
|
+ let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
|
+ for remote in remotes {
|
|
+ if let Some(output) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "for-each-ref",
|
|
+ "--format=%(refname:short)",
|
|
+ "--contains=HEAD",
|
|
+ &format!("refs/remotes/{remote}"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ && output.status.success()
|
|
+ && let Ok(text) = String::from_utf8(output.stdout)
|
|
+ {
|
|
+ for line in text.lines() {
|
|
+ let short = line.trim();
|
|
+ // Expect format like: "origin/feature"; extract the branch path after "remote/"
|
|
+ if let Some(stripped) = short.strip_prefix(&format!("{remote}/"))
|
|
+ && !stripped.is_empty()
|
|
+ && !seen.contains(stripped)
|
|
+ {
|
|
+ seen.insert(stripped.to_string());
|
|
+ ancestry.push(stripped.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Ensure we return Some vector, even if empty, to allow caller logic to proceed
|
|
+ Some(ancestry)
|
|
+}
|
|
+
|
|
+// Helper for a single branch: return the remote SHA if present on any remote
|
|
+// and the distance (commits ahead of HEAD) for that branch. The first item is
|
|
+// None if the branch is not present on any remote. Returns None if distance
|
|
+// could not be computed due to git errors/timeouts.
|
|
+async fn branch_remote_and_distance(
|
|
+ cwd: &Path,
|
|
+ branch: &str,
|
|
+ remotes: &[String],
|
|
+) -> Option<(Option<GitSha>, usize)> {
|
|
+ // Try to find the first remote ref that exists for this branch (origin prioritized by caller).
|
|
+ let mut found_remote_sha: Option<GitSha> = None;
|
|
+ let mut found_remote_ref: Option<String> = None;
|
|
+ for remote in remotes {
|
|
+ let remote_ref = format!("refs/remotes/{remote}/{branch}");
|
|
+ let Some(verify_output) =
|
|
+ run_git_command_with_timeout(&["rev-parse", "--verify", "--quiet", &remote_ref], cwd)
|
|
+ .await
|
|
+ else {
|
|
+ // Mirror previous behavior: if the verify call times out/fails at the process level,
|
|
+ // treat the entire branch as unusable.
|
|
+ return None;
|
|
+ };
|
|
+ if !verify_output.status.success() {
|
|
+ continue;
|
|
+ }
|
|
+ let Ok(sha) = String::from_utf8(verify_output.stdout) else {
|
|
+ // Mirror previous behavior and skip the entire branch on parse failure.
|
|
+ return None;
|
|
+ };
|
|
+ found_remote_sha = Some(GitSha::new(sha.trim()));
|
|
+ found_remote_ref = Some(remote_ref);
|
|
+ break;
|
|
+ }
|
|
+
|
|
+ // Compute distance as the number of commits HEAD is ahead of the branch.
|
|
+ // Prefer local branch name if it exists; otherwise fall back to the remote ref (if any).
|
|
+ let count_output = if let Some(local_count) =
|
|
+ run_git_command_with_timeout(&["rev-list", "--count", &format!("{branch}..HEAD")], cwd)
|
|
+ .await
|
|
+ {
|
|
+ if local_count.status.success() {
|
|
+ local_count
|
|
+ } else if let Some(remote_ref) = &found_remote_ref {
|
|
+ match run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(remote_count) => remote_count,
|
|
+ None => return None,
|
|
+ }
|
|
+ } else {
|
|
+ return None;
|
|
+ }
|
|
+ } else if let Some(remote_ref) = &found_remote_ref {
|
|
+ match run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(remote_count) => remote_count,
|
|
+ None => return None,
|
|
+ }
|
|
+ } else {
|
|
+ return None;
|
|
+ };
|
|
+
|
|
+ if !count_output.status.success() {
|
|
+ return None;
|
|
+ }
|
|
+ let Ok(distance_str) = String::from_utf8(count_output.stdout) else {
|
|
+ return None;
|
|
+ };
|
|
+ let Ok(distance) = distance_str.trim().parse::<usize>() else {
|
|
+ return None;
|
|
+ };
|
|
+
|
|
+ Some((found_remote_sha, distance))
|
|
+}
|
|
+
|
|
+// Finds the closest sha that exist on any of branches and also exists on any of the remotes.
|
|
+async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<GitSha> {
|
|
+ // A sha and how many commits away from HEAD it is.
|
|
+ let mut closest_sha: Option<(GitSha, usize)> = None;
|
|
+ for branch in branches {
|
|
+ let Some((maybe_remote_sha, distance)) =
|
|
+ branch_remote_and_distance(cwd, branch, remotes).await
|
|
+ else {
|
|
+ continue;
|
|
+ };
|
|
+ let Some(remote_sha) = maybe_remote_sha else {
|
|
+ // Preserve existing behavior: skip branches that are not present on a remote.
|
|
+ continue;
|
|
+ };
|
|
+ match &closest_sha {
|
|
+ None => closest_sha = Some((remote_sha, distance)),
|
|
+ Some((_, best_distance)) if distance < *best_distance => {
|
|
+ closest_sha = Some((remote_sha, distance));
|
|
+ }
|
|
+ _ => {}
|
|
+ }
|
|
+ }
|
|
+ closest_sha.map(|(sha, _)| sha)
|
|
+}
|
|
+
|
|
+async fn diff_against_sha(cwd: &Path, sha: &GitSha) -> Option<String> {
|
|
+ let output = run_git_command_with_timeout(&["diff", &sha.0], cwd).await?;
|
|
+ // 0 is success and no diff.
|
|
+ // 1 is success but there is a diff.
|
|
+ let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
|
|
+ if !exit_ok {
|
|
+ return None;
|
|
+ }
|
|
+ let mut diff = String::from_utf8(output.stdout).ok()?;
|
|
+
|
|
+ if let Some(untracked_output) =
|
|
+ run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await
|
|
+ && untracked_output.status.success()
|
|
+ {
|
|
+ let untracked: Vec<String> = String::from_utf8(untracked_output.stdout)
|
|
+ .ok()?
|
|
+ .lines()
|
|
+ .map(|s| s.to_string())
|
|
+ .filter(|s| !s.is_empty())
|
|
+ .collect();
|
|
+
|
|
+ if !untracked.is_empty() {
|
|
+ let futures_iter = untracked.into_iter().map(|file| async move {
|
|
+ let file_owned = file;
|
|
+ let args_vec: Vec<&str> =
|
|
+ vec!["diff", "--binary", "--no-index", "/dev/null", &file_owned];
|
|
+ run_git_command_with_timeout(&args_vec, cwd).await
|
|
+ });
|
|
+ let results = join_all(futures_iter).await;
|
|
+ for extra in results.into_iter().flatten() {
|
|
+ if extra.status.code().is_some_and(|c| c == 0 || c == 1)
|
|
+ && let Ok(s) = String::from_utf8(extra.stdout)
|
|
+ {
|
|
+ diff.push_str(&s);
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ Some(diff)
|
|
+}
|
|
+
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
@@ -104,7 +435,8 @@ mod tests {
|
|
|
|
// Helper function to create a test git repository
|
|
async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf {
|
|
- let repo_path = temp_dir.path().to_path_buf();
|
|
+ let repo_path = temp_dir.path().join("repo");
|
|
+ fs::create_dir(&repo_path).expect("Failed to create repo dir");
|
|
let envs = vec![
|
|
("GIT_CONFIG_GLOBAL", "/dev/null"),
|
|
("GIT_CONFIG_NOSYSTEM", "1"),
|
|
@@ -159,6 +491,41 @@ mod tests {
|
|
repo_path
|
|
}
|
|
|
|
+ async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
|
|
+ let repo_path = create_test_git_repo(temp_dir).await;
|
|
+ let remote_path = temp_dir.path().join("remote.git");
|
|
+
|
|
+ Command::new("git")
|
|
+ .args(["init", "--bare", remote_path.to_str().unwrap()])
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to init bare remote");
|
|
+
|
|
+ Command::new("git")
|
|
+ .args(["remote", "add", "origin", remote_path.to_str().unwrap()])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to add remote");
|
|
+
|
|
+ let output = Command::new("git")
|
|
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to get branch");
|
|
+ let branch = String::from_utf8(output.stdout).unwrap().trim().to_string();
|
|
+
|
|
+ Command::new("git")
|
|
+ .args(["push", "-u", "origin", &branch])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to push initial commit");
|
|
+
|
|
+ (repo_path, branch)
|
|
+ }
|
|
+
|
|
#[tokio::test]
|
|
async fn test_collect_git_info_non_git_directory() {
|
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
@@ -272,6 +639,136 @@ mod tests {
|
|
assert_eq!(git_info.branch, Some("feature-branch".to_string()));
|
|
}
|
|
|
|
+ #[tokio::test]
|
|
+ async fn test_get_git_working_tree_state_clean_repo() {
|
|
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
+ let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
+
|
|
+ let remote_sha = Command::new("git")
|
|
+ .args(["rev-parse", &format!("origin/{branch}")])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to rev-parse remote");
|
|
+ let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
+ .unwrap()
|
|
+ .trim()
|
|
+ .to_string();
|
|
+
|
|
+ let state = git_diff_to_remote(&repo_path)
|
|
+ .await
|
|
+ .expect("Should collect working tree state");
|
|
+ assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
+ assert!(state.diff.is_empty());
|
|
+ }
|
|
+
|
|
+ #[tokio::test]
|
|
+ async fn test_get_git_working_tree_state_with_changes() {
|
|
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
+ let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
+
|
|
+ let tracked = repo_path.join("test.txt");
|
|
+ fs::write(&tracked, "modified").unwrap();
|
|
+ fs::write(repo_path.join("untracked.txt"), "new").unwrap();
|
|
+
|
|
+ let remote_sha = Command::new("git")
|
|
+ .args(["rev-parse", &format!("origin/{branch}")])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to rev-parse remote");
|
|
+ let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
+ .unwrap()
|
|
+ .trim()
|
|
+ .to_string();
|
|
+
|
|
+ let state = git_diff_to_remote(&repo_path)
|
|
+ .await
|
|
+ .expect("Should collect working tree state");
|
|
+ assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
+ assert!(state.diff.contains("test.txt"));
|
|
+ assert!(state.diff.contains("untracked.txt"));
|
|
+ }
|
|
+
|
|
+ #[tokio::test]
|
|
+ async fn test_get_git_working_tree_state_branch_fallback() {
|
|
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
+ let (repo_path, _branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
+
|
|
+ Command::new("git")
|
|
+ .args(["checkout", "-b", "feature"])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to create feature branch");
|
|
+ Command::new("git")
|
|
+ .args(["push", "-u", "origin", "feature"])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to push feature branch");
|
|
+
|
|
+ Command::new("git")
|
|
+ .args(["checkout", "-b", "local-branch"])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to create local branch");
|
|
+
|
|
+ let remote_sha = Command::new("git")
|
|
+ .args(["rev-parse", "origin/feature"])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to rev-parse remote");
|
|
+ let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
+ .unwrap()
|
|
+ .trim()
|
|
+ .to_string();
|
|
+
|
|
+ let state = git_diff_to_remote(&repo_path)
|
|
+ .await
|
|
+ .expect("Should collect working tree state");
|
|
+ assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
+ }
|
|
+
|
|
+ #[tokio::test]
|
|
+ async fn test_get_git_working_tree_state_unpushed_commit() {
|
|
+ let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
|
+ let (repo_path, branch) = create_test_git_repo_with_remote(&temp_dir).await;
|
|
+
|
|
+ let remote_sha = Command::new("git")
|
|
+ .args(["rev-parse", &format!("origin/{branch}")])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to rev-parse remote");
|
|
+ let remote_sha = String::from_utf8(remote_sha.stdout)
|
|
+ .unwrap()
|
|
+ .trim()
|
|
+ .to_string();
|
|
+
|
|
+ fs::write(repo_path.join("test.txt"), "updated").unwrap();
|
|
+ Command::new("git")
|
|
+ .args(["add", "test.txt"])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to add file");
|
|
+ Command::new("git")
|
|
+ .args(["commit", "-m", "local change"])
|
|
+ .current_dir(&repo_path)
|
|
+ .output()
|
|
+ .await
|
|
+ .expect("Failed to commit");
|
|
+
|
|
+ let state = git_diff_to_remote(&repo_path)
|
|
+ .await
|
|
+ .expect("Should collect working tree state");
|
|
+ assert_eq!(state.sha, GitSha::new(&remote_sha));
|
|
+ assert!(state.diff.contains("updated"));
|
|
+ }
|
|
+
|
|
#[test]
|
|
fn test_git_info_serialization() {
|
|
let git_info = GitInfo {
|
|
diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs
|
|
index 1decf11da6..07e06d66eb 100644
|
|
--- a/codex-rs/mcp-server/src/codex_message_processor.rs
|
|
+++ b/codex-rs/mcp-server/src/codex_message_processor.rs
|
|
@@ -8,11 +8,13 @@ use codex_core::ConversationManager;
|
|
use codex_core::NewConversation;
|
|
use codex_core::config::Config;
|
|
use codex_core::config::ConfigOverrides;
|
|
+use codex_core::git_info::git_diff_to_remote;
|
|
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
|
use codex_core::protocol::Event;
|
|
use codex_core::protocol::EventMsg;
|
|
use codex_core::protocol::ExecApprovalRequestEvent;
|
|
use codex_core::protocol::ReviewDecision;
|
|
+use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
|
|
use mcp_types::JSONRPCErrorError;
|
|
use mcp_types::RequestId;
|
|
use tokio::sync::Mutex;
|
|
@@ -126,6 +128,9 @@ impl CodexMessageProcessor {
|
|
ClientRequest::CancelLoginChatGpt { request_id, params } => {
|
|
self.cancel_login_chatgpt(request_id, params.login_id).await;
|
|
}
|
|
+ ClientRequest::GitDiffToRemote { request_id, params } => {
|
|
+ self.git_diff_to_origin(request_id, params.cwd).await;
|
|
+ }
|
|
}
|
|
}
|
|
|
|
@@ -514,6 +519,27 @@ impl CodexMessageProcessor {
|
|
}
|
|
}
|
|
}
|
|
+
|
|
+ async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) {
|
|
+ let diff = git_diff_to_remote(&cwd).await;
|
|
+ match diff {
|
|
+ Some(value) => {
|
|
+ let response = GitDiffToRemoteResponse {
|
|
+ sha: value.sha,
|
|
+ diff: value.diff,
|
|
+ };
|
|
+ self.outgoing.send_response(request_id, response).await;
|
|
+ }
|
|
+ None => {
|
|
+ let error = JSONRPCErrorError {
|
|
+ code: INVALID_REQUEST_ERROR_CODE,
|
|
+ message: format!("failed to compute git diff to remote for cwd: {cwd:?}"),
|
|
+ data: None,
|
|
+ };
|
|
+ self.outgoing.send_error(request_id, error).await;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
}
|
|
|
|
async fn apply_bespoke_event_handling(
|
|
diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs
|
|
index c2b196dada..6bbc926988 100644
|
|
--- a/codex-rs/protocol-ts/src/lib.rs
|
|
+++ b/codex-rs/protocol-ts/src/lib.rs
|
|
@@ -36,6 +36,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
|
codex_protocol::mcp_protocol::LoginChatGptCompleteNotification::export_all_to(out_dir)?;
|
|
codex_protocol::mcp_protocol::CancelLoginChatGptParams::export_all_to(out_dir)?;
|
|
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
|
|
+ codex_protocol::mcp_protocol::GitDiffToRemoteParams::export_all_to(out_dir)?;
|
|
codex_protocol::mcp_protocol::ApplyPatchApprovalParams::export_all_to(out_dir)?;
|
|
codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?;
|
|
codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?;
|
|
diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs
|
|
index 383b2033d7..68f5c01d07 100644
|
|
--- a/codex-rs/protocol/src/mcp_protocol.rs
|
|
+++ b/codex-rs/protocol/src/mcp_protocol.rs
|
|
@@ -26,6 +26,16 @@ impl Display for ConversationId {
|
|
}
|
|
}
|
|
|
|
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)]
|
|
+#[ts(type = "string")]
|
|
+pub struct GitSha(pub String);
|
|
+
|
|
+impl GitSha {
|
|
+ pub fn new(sha: &str) -> Self {
|
|
+ Self(sha.to_string())
|
|
+ }
|
|
+}
|
|
+
|
|
/// Request from the client to the server.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
|
#[serde(tag = "method", rename_all = "camelCase")]
|
|
@@ -69,6 +79,11 @@ pub enum ClientRequest {
|
|
request_id: RequestId,
|
|
params: CancelLoginChatGptParams,
|
|
},
|
|
+ GitDiffToRemote {
|
|
+ #[serde(rename = "id")]
|
|
+ request_id: RequestId,
|
|
+ params: GitDiffToRemoteParams,
|
|
+ },
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
|
|
@@ -139,6 +154,13 @@ pub struct LoginChatGptResponse {
|
|
pub auth_url: String,
|
|
}
|
|
|
|
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
|
+#[serde(rename_all = "camelCase")]
|
|
+pub struct GitDiffToRemoteResponse {
|
|
+ pub sha: GitSha,
|
|
+ pub diff: String,
|
|
+}
|
|
+
|
|
// Event name for notifying client of login completion or failure.
|
|
pub const LOGIN_CHATGPT_COMPLETE_EVENT: &str = "codex/event/login_chatgpt_complete";
|
|
|
|
@@ -157,6 +179,12 @@ pub struct CancelLoginChatGptParams {
|
|
pub login_id: Uuid,
|
|
}
|
|
|
|
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
|
+#[serde(rename_all = "camelCase")]
|
|
+pub struct GitDiffToRemoteParams {
|
|
+ pub cwd: PathBuf,
|
|
+}
|
|
+
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CancelLoginChatGptResponse {}
|
|
```
|
|
|
|
## Review Comments
|
|
|
|
### codex-rs/core/src/git_info.rs
|
|
|
|
- Created: 2025-08-20 00:23:29 UTC | Link: https://github.com/openai/codex/pull/2476#discussion_r2286681026
|
|
|
|
```diff
|
|
@@ -83,6 +89,27 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
|
Some(git_info)
|
|
}
|
|
|
|
+/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
|
+pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
|
+ let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
|
|
```
|
|
|
|
> Hmm, should `run_git_command_with_timeout()` add these env vars like we do in the unit tests:
|
|
>
|
|
> ```
|
|
> let envs = vec![
|
|
> ("GIT_CONFIG_GLOBAL", "/dev/null"),
|
|
> ("GIT_CONFIG_NOSYSTEM", "1"),
|
|
> ];
|
|
> ```
|
|
|
|
- Created: 2025-08-20 00:28:17 UTC | Link: https://github.com/openai/codex/pull/2476#discussion_r2286687051
|
|
|
|
```diff
|
|
@@ -22,6 +22,12 @@ pub struct GitInfo {
|
|
pub repository_url: Option<String>,
|
|
}
|
|
|
|
+#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
+pub struct GitDiffToRemote {
|
|
+ pub sha: String,
|
|
```
|
|
|
|
> Consider `sha1::Digest` as the type instead of `String`.
|
|
|
|
- Created: 2025-08-20 00:29:50 UTC | Link: https://github.com/openai/codex/pull/2476#discussion_r2286688849
|
|
|
|
```diff
|
|
@@ -83,6 +89,27 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
|
|
Some(git_info)
|
|
}
|
|
|
|
+/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
|
|
+pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
|
|
+ let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd)
|
|
+ .await?
|
|
+ .status
|
|
+ .success();
|
|
+ if !is_git_repo {
|
|
```
|
|
|
|
> This `is_git_repo` check should be its own helper function?
|
|
>
|
|
> Note we have the following, which I would like to eliminate (or at least remove from `util.rs`):
|
|
>
|
|
> https://github.com/openai/codex/blob/bc298e47cabc80c00a08cef8828cff9c1a9b519a/codex-rs/core/src/util.rs#L28
|
|
|
|
- Created: 2025-08-20 00:39:57 UTC | Link: https://github.com/openai/codex/pull/2476#discussion_r2286700021
|
|
|
|
```diff
|
|
@@ -97,6 +124,249 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::
|
|
}
|
|
}
|
|
|
|
+async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
|
|
+ let output = run_git_command_with_timeout(&["remote"], cwd).await?;
|
|
+ if !output.status.success() {
|
|
+ return None;
|
|
+ }
|
|
+ let mut remotes: Vec<String> = String::from_utf8(output.stdout)
|
|
+ .ok()?
|
|
+ .lines()
|
|
+ .map(|s| s.to_string())
|
|
+ .collect();
|
|
+ if let Some(pos) = remotes.iter().position(|r| r == "origin") {
|
|
+ let origin = remotes.remove(pos);
|
|
+ remotes.insert(0, origin);
|
|
+ }
|
|
+ Some(remotes)
|
|
+}
|
|
+
|
|
+/// Attempt to determine the repository's default branch name.
|
|
+///
|
|
+/// Preference order:
|
|
+/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
|
|
+/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
|
|
+/// 3) Local fallback to existing `main` or `master` if present
|
|
+async fn get_default_branch(cwd: &Path) -> Option<String> {
|
|
+ // Prefer the first remote (with origin prioritized)
|
|
+ let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
|
+ for remote in remotes {
|
|
+ // Try symbolic-ref, which returns something like: refs/remotes/origin/main
|
|
+ if let Some(symref_output) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "symbolic-ref",
|
|
+ "--quiet",
|
|
+ &format!("refs/remotes/{remote}/HEAD"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if symref_output.status.success() {
|
|
+ if let Ok(sym) = String::from_utf8(symref_output.stdout) {
|
|
+ let trimmed = sym.trim();
|
|
+ if let Some((_, name)) = trimmed.rsplit_once('/') {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Fall back to parsing `git remote show <remote>` output
|
|
+ if let Some(show_output) =
|
|
+ run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
|
|
+ {
|
|
+ if show_output.status.success() {
|
|
+ if let Ok(text) = String::from_utf8(show_output.stdout) {
|
|
+ for line in text.lines() {
|
|
+ let line = line.trim();
|
|
+ if let Some(rest) = line.strip_prefix("HEAD branch:") {
|
|
+ let name = rest.trim();
|
|
+ if !name.is_empty() {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // No remote-derived default; try common local defaults if they exist
|
|
+ for candidate in ["main", "master"] {
|
|
+ if let Some(verify) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "rev-parse",
|
|
+ "--verify",
|
|
+ "--quiet",
|
|
+ &format!("refs/heads/{candidate}"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if verify.status.success() {
|
|
+ return Some(candidate.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ None
|
|
+}
|
|
+
|
|
+/// Build an ancestry of branches starting at the current branch and ending at the
|
|
+/// repository's default branch (if determinable)..
|
|
+async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
|
|
+ // Discover current branch (ignore detached HEAD by treating it as None)
|
|
+ let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
|
+ .await
|
|
+ .and_then(|o| {
|
|
+ if o.status.success() {
|
|
+ String::from_utf8(o.stdout).ok()
|
|
+ } else {
|
|
+ None
|
|
+ }
|
|
+ })
|
|
+ .map(|s| s.trim().to_string())
|
|
+ .filter(|s| s != "HEAD");
|
|
+
|
|
+ // Discover default branch
|
|
+ let default_branch = get_default_branch(cwd).await;
|
|
+
|
|
+ let mut ancestry: Vec<String> = Vec::new();
|
|
+ if let Some(cb) = current_branch.clone() {
|
|
+ ancestry.push(cb);
|
|
+ }
|
|
+ if let Some(db) = default_branch {
|
|
+ if ancestry.first().map(|b| b != &db).unwrap_or(true) {
|
|
+ ancestry.push(db);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Ensure we return Some vector, even if empty, to allow caller logic to proceed
|
|
+ Some(ancestry)
|
|
+}
|
|
+
|
|
+async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<String> {
|
|
+ // A sha and how many commits away from HEAD it is.
|
|
+ let mut closest_sha: Option<(String, usize)> = None;
|
|
+ 'branch_loop: for branch in branches {
|
|
+ for remote in remotes {
|
|
+ let remote_ref = format!("refs/remotes/{remote}/{branch}");
|
|
+ let verify_output = match run_git_command_with_timeout(
|
|
+ &["rev-parse", "--verify", "--quiet", &remote_ref],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(output) => output,
|
|
+ None => continue 'branch_loop,
|
|
+ };
|
|
+ if !verify_output.status.success() {
|
|
+ continue;
|
|
+ }
|
|
```
|
|
|
|
> As a general practice, I put a blank line after an early return. This function is pretty dense (no blank lines at all), so I think it would be a good move in this case.
|
|
|
|
- Created: 2025-08-20 00:41:26 UTC | Link: https://github.com/openai/codex/pull/2476#discussion_r2286702274
|
|
|
|
```diff
|
|
@@ -97,6 +124,249 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::
|
|
}
|
|
}
|
|
|
|
+async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
|
|
+ let output = run_git_command_with_timeout(&["remote"], cwd).await?;
|
|
+ if !output.status.success() {
|
|
+ return None;
|
|
+ }
|
|
+ let mut remotes: Vec<String> = String::from_utf8(output.stdout)
|
|
+ .ok()?
|
|
+ .lines()
|
|
+ .map(|s| s.to_string())
|
|
+ .collect();
|
|
+ if let Some(pos) = remotes.iter().position(|r| r == "origin") {
|
|
+ let origin = remotes.remove(pos);
|
|
+ remotes.insert(0, origin);
|
|
+ }
|
|
+ Some(remotes)
|
|
+}
|
|
+
|
|
+/// Attempt to determine the repository's default branch name.
|
|
+///
|
|
+/// Preference order:
|
|
+/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
|
|
+/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
|
|
+/// 3) Local fallback to existing `main` or `master` if present
|
|
+async fn get_default_branch(cwd: &Path) -> Option<String> {
|
|
+ // Prefer the first remote (with origin prioritized)
|
|
+ let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
|
+ for remote in remotes {
|
|
+ // Try symbolic-ref, which returns something like: refs/remotes/origin/main
|
|
+ if let Some(symref_output) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "symbolic-ref",
|
|
+ "--quiet",
|
|
+ &format!("refs/remotes/{remote}/HEAD"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if symref_output.status.success() {
|
|
+ if let Ok(sym) = String::from_utf8(symref_output.stdout) {
|
|
+ let trimmed = sym.trim();
|
|
+ if let Some((_, name)) = trimmed.rsplit_once('/') {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Fall back to parsing `git remote show <remote>` output
|
|
+ if let Some(show_output) =
|
|
+ run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
|
|
+ {
|
|
+ if show_output.status.success() {
|
|
+ if let Ok(text) = String::from_utf8(show_output.stdout) {
|
|
+ for line in text.lines() {
|
|
+ let line = line.trim();
|
|
+ if let Some(rest) = line.strip_prefix("HEAD branch:") {
|
|
+ let name = rest.trim();
|
|
+ if !name.is_empty() {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // No remote-derived default; try common local defaults if they exist
|
|
+ for candidate in ["main", "master"] {
|
|
+ if let Some(verify) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "rev-parse",
|
|
+ "--verify",
|
|
+ "--quiet",
|
|
+ &format!("refs/heads/{candidate}"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if verify.status.success() {
|
|
+ return Some(candidate.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ None
|
|
+}
|
|
+
|
|
+/// Build an ancestry of branches starting at the current branch and ending at the
|
|
+/// repository's default branch (if determinable)..
|
|
+async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
|
|
+ // Discover current branch (ignore detached HEAD by treating it as None)
|
|
+ let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
|
+ .await
|
|
+ .and_then(|o| {
|
|
+ if o.status.success() {
|
|
+ String::from_utf8(o.stdout).ok()
|
|
+ } else {
|
|
+ None
|
|
+ }
|
|
+ })
|
|
+ .map(|s| s.trim().to_string())
|
|
+ .filter(|s| s != "HEAD");
|
|
+
|
|
+ // Discover default branch
|
|
+ let default_branch = get_default_branch(cwd).await;
|
|
+
|
|
+ let mut ancestry: Vec<String> = Vec::new();
|
|
+ if let Some(cb) = current_branch.clone() {
|
|
+ ancestry.push(cb);
|
|
+ }
|
|
+ if let Some(db) = default_branch {
|
|
+ if ancestry.first().map(|b| b != &db).unwrap_or(true) {
|
|
+ ancestry.push(db);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Ensure we return Some vector, even if empty, to allow caller logic to proceed
|
|
+ Some(ancestry)
|
|
+}
|
|
+
|
|
+async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<String> {
|
|
+ // A sha and how many commits away from HEAD it is.
|
|
+ let mut closest_sha: Option<(String, usize)> = None;
|
|
+ 'branch_loop: for branch in branches {
|
|
+ for remote in remotes {
|
|
+ let remote_ref = format!("refs/remotes/{remote}/{branch}");
|
|
+ let verify_output = match run_git_command_with_timeout(
|
|
+ &["rev-parse", "--verify", "--quiet", &remote_ref],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(output) => output,
|
|
+ None => continue 'branch_loop,
|
|
+ };
|
|
+ if !verify_output.status.success() {
|
|
+ continue;
|
|
+ }
|
|
+ let remote_sha = match String::from_utf8(verify_output.stdout) {
|
|
+ Ok(s) => s.trim().to_string(),
|
|
+ Err(_) => continue 'branch_loop,
|
|
+ };
|
|
+ // Compute distance as the number of commits HEAD is ahead of the candidate branch.
|
|
+ // Prefer local branch name if it exists; otherwise fall back to the remote ref.
|
|
+ let count_output = if let Some(local_count) = run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{branch}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if local_count.status.success() {
|
|
+ local_count
|
|
+ } else {
|
|
+ // Try remote ref as a fallback for branches not present locally
|
|
+ match run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(remote_count) => remote_count,
|
|
+ None => continue 'branch_loop,
|
|
+ }
|
|
+ }
|
|
+ } else {
|
|
+ // No local output; try remote directly
|
|
+ match run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(remote_count) => remote_count,
|
|
+ None => continue 'branch_loop,
|
|
+ }
|
|
+ };
|
|
+ if !count_output.status.success() {
|
|
+ continue;
|
|
+ }
|
|
+ let distance = match String::from_utf8(count_output.stdout) {
|
|
+ Ok(s) => match s.trim().parse::<usize>() {
|
|
+ Ok(value) => value,
|
|
+ Err(_) => continue 'branch_loop,
|
|
+ },
|
|
+ Err(_) => continue 'branch_loop,
|
|
+ };
|
|
+ match &closest_sha {
|
|
+ None => closest_sha = Some((remote_sha.clone(), distance)),
|
|
+ Some((_, best_distance)) if distance < *best_distance => {
|
|
+ closest_sha = Some((remote_sha.clone(), distance));
|
|
+ }
|
|
+ _ => {}
|
|
+ }
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+ closest_sha.map(|(sha, _)| sha)
|
|
+}
|
|
+
|
|
+async fn diff_against_sha(cwd: &Path, sha: &str) -> Option<String> {
|
|
+ let output = run_git_command_with_timeout(&["diff", sha], cwd).await?;
|
|
+ let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
|
|
```
|
|
|
|
> Comment why both 0 and 1 are OK in this case?
|
|
|
|
- Created: 2025-08-20 00:42:19 UTC | Link: https://github.com/openai/codex/pull/2476#discussion_r2286703402
|
|
|
|
```diff
|
|
@@ -97,6 +124,249 @@ async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option<std::
|
|
}
|
|
}
|
|
|
|
+async fn get_git_remotes(cwd: &Path) -> Option<Vec<String>> {
|
|
+ let output = run_git_command_with_timeout(&["remote"], cwd).await?;
|
|
+ if !output.status.success() {
|
|
+ return None;
|
|
+ }
|
|
+ let mut remotes: Vec<String> = String::from_utf8(output.stdout)
|
|
+ .ok()?
|
|
+ .lines()
|
|
+ .map(|s| s.to_string())
|
|
+ .collect();
|
|
+ if let Some(pos) = remotes.iter().position(|r| r == "origin") {
|
|
+ let origin = remotes.remove(pos);
|
|
+ remotes.insert(0, origin);
|
|
+ }
|
|
+ Some(remotes)
|
|
+}
|
|
+
|
|
+/// Attempt to determine the repository's default branch name.
|
|
+///
|
|
+/// Preference order:
|
|
+/// 1) The symbolic ref at `refs/remotes/<remote>/HEAD` for the first remote (origin prioritized)
|
|
+/// 2) `git remote show <remote>` parsed for "HEAD branch: <name>"
|
|
+/// 3) Local fallback to existing `main` or `master` if present
|
|
+async fn get_default_branch(cwd: &Path) -> Option<String> {
|
|
+ // Prefer the first remote (with origin prioritized)
|
|
+ let remotes = get_git_remotes(cwd).await.unwrap_or_default();
|
|
+ for remote in remotes {
|
|
+ // Try symbolic-ref, which returns something like: refs/remotes/origin/main
|
|
+ if let Some(symref_output) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "symbolic-ref",
|
|
+ "--quiet",
|
|
+ &format!("refs/remotes/{remote}/HEAD"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if symref_output.status.success() {
|
|
+ if let Ok(sym) = String::from_utf8(symref_output.stdout) {
|
|
+ let trimmed = sym.trim();
|
|
+ if let Some((_, name)) = trimmed.rsplit_once('/') {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Fall back to parsing `git remote show <remote>` output
|
|
+ if let Some(show_output) =
|
|
+ run_git_command_with_timeout(&["remote", "show", &remote], cwd).await
|
|
+ {
|
|
+ if show_output.status.success() {
|
|
+ if let Ok(text) = String::from_utf8(show_output.stdout) {
|
|
+ for line in text.lines() {
|
|
+ let line = line.trim();
|
|
+ if let Some(rest) = line.strip_prefix("HEAD branch:") {
|
|
+ let name = rest.trim();
|
|
+ if !name.is_empty() {
|
|
+ return Some(name.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // No remote-derived default; try common local defaults if they exist
|
|
+ for candidate in ["main", "master"] {
|
|
+ if let Some(verify) = run_git_command_with_timeout(
|
|
+ &[
|
|
+ "rev-parse",
|
|
+ "--verify",
|
|
+ "--quiet",
|
|
+ &format!("refs/heads/{candidate}"),
|
|
+ ],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if verify.status.success() {
|
|
+ return Some(candidate.to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ None
|
|
+}
|
|
+
|
|
+/// Build an ancestry of branches starting at the current branch and ending at the
|
|
+/// repository's default branch (if determinable)..
|
|
+async fn branch_ancestry(cwd: &Path) -> Option<Vec<String>> {
|
|
+ // Discover current branch (ignore detached HEAD by treating it as None)
|
|
+ let current_branch = run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
|
+ .await
|
|
+ .and_then(|o| {
|
|
+ if o.status.success() {
|
|
+ String::from_utf8(o.stdout).ok()
|
|
+ } else {
|
|
+ None
|
|
+ }
|
|
+ })
|
|
+ .map(|s| s.trim().to_string())
|
|
+ .filter(|s| s != "HEAD");
|
|
+
|
|
+ // Discover default branch
|
|
+ let default_branch = get_default_branch(cwd).await;
|
|
+
|
|
+ let mut ancestry: Vec<String> = Vec::new();
|
|
+ if let Some(cb) = current_branch.clone() {
|
|
+ ancestry.push(cb);
|
|
+ }
|
|
+ if let Some(db) = default_branch {
|
|
+ if ancestry.first().map(|b| b != &db).unwrap_or(true) {
|
|
+ ancestry.push(db);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Ensure we return Some vector, even if empty, to allow caller logic to proceed
|
|
+ Some(ancestry)
|
|
+}
|
|
+
|
|
+async fn find_closest_sha(cwd: &Path, branches: &[String], remotes: &[String]) -> Option<String> {
|
|
+ // A sha and how many commits away from HEAD it is.
|
|
+ let mut closest_sha: Option<(String, usize)> = None;
|
|
+ 'branch_loop: for branch in branches {
|
|
+ for remote in remotes {
|
|
+ let remote_ref = format!("refs/remotes/{remote}/{branch}");
|
|
+ let verify_output = match run_git_command_with_timeout(
|
|
+ &["rev-parse", "--verify", "--quiet", &remote_ref],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(output) => output,
|
|
+ None => continue 'branch_loop,
|
|
+ };
|
|
+ if !verify_output.status.success() {
|
|
+ continue;
|
|
+ }
|
|
+ let remote_sha = match String::from_utf8(verify_output.stdout) {
|
|
+ Ok(s) => s.trim().to_string(),
|
|
+ Err(_) => continue 'branch_loop,
|
|
+ };
|
|
+ // Compute distance as the number of commits HEAD is ahead of the candidate branch.
|
|
+ // Prefer local branch name if it exists; otherwise fall back to the remote ref.
|
|
+ let count_output = if let Some(local_count) = run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{branch}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ if local_count.status.success() {
|
|
+ local_count
|
|
+ } else {
|
|
+ // Try remote ref as a fallback for branches not present locally
|
|
+ match run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(remote_count) => remote_count,
|
|
+ None => continue 'branch_loop,
|
|
+ }
|
|
+ }
|
|
+ } else {
|
|
+ // No local output; try remote directly
|
|
+ match run_git_command_with_timeout(
|
|
+ &["rev-list", "--count", &format!("{remote_ref}..HEAD")],
|
|
+ cwd,
|
|
+ )
|
|
+ .await
|
|
+ {
|
|
+ Some(remote_count) => remote_count,
|
|
+ None => continue 'branch_loop,
|
|
+ }
|
|
+ };
|
|
+ if !count_output.status.success() {
|
|
+ continue;
|
|
+ }
|
|
+ let distance = match String::from_utf8(count_output.stdout) {
|
|
+ Ok(s) => match s.trim().parse::<usize>() {
|
|
+ Ok(value) => value,
|
|
+ Err(_) => continue 'branch_loop,
|
|
+ },
|
|
+ Err(_) => continue 'branch_loop,
|
|
+ };
|
|
+ match &closest_sha {
|
|
+ None => closest_sha = Some((remote_sha.clone(), distance)),
|
|
+ Some((_, best_distance)) if distance < *best_distance => {
|
|
+ closest_sha = Some((remote_sha.clone(), distance));
|
|
+ }
|
|
+ _ => {}
|
|
+ }
|
|
+ break;
|
|
+ }
|
|
+ }
|
|
+ closest_sha.map(|(sha, _)| sha)
|
|
+}
|
|
+
|
|
+async fn diff_against_sha(cwd: &Path, sha: &str) -> Option<String> {
|
|
+ let output = run_git_command_with_timeout(&["diff", sha], cwd).await?;
|
|
+ let exit_ok = output.status.code().is_some_and(|c| c == 0 || c == 1);
|
|
+ if !exit_ok {
|
|
+ return None;
|
|
+ }
|
|
+ let mut diff = String::from_utf8(output.stdout).ok()?;
|
|
+
|
|
+ if let Some(untracked_output) =
|
|
+ run_git_command_with_timeout(&["ls-files", "--others", "--exclude-standard"], cwd).await
|
|
+ {
|
|
+ if untracked_output.status.success() {
|
|
+ let untracked: Vec<String> = String::from_utf8(untracked_output.stdout)
|
|
+ .ok()?
|
|
+ .lines()
|
|
+ .map(|s| s.to_string())
|
|
+ .collect();
|
|
+ for file in untracked {
|
|
```
|
|
|
|
> Consider running these in parallel?
|
|
|
|
### codex-rs/protocol/src/mcp_protocol.rs
|
|
|
|
- Created: 2025-08-20 00:45:17 UTC | Link: https://github.com/openai/codex/pull/2476#discussion_r2286706469
|
|
|
|
```diff
|
|
@@ -157,6 +162,12 @@ pub struct CancelLoginChatGptParams {
|
|
pub login_id: Uuid,
|
|
}
|
|
|
|
+#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
|
+#[serde(rename_all = "camelCase")]
|
|
+pub struct GitDiffToRemoteParams {
|
|
```
|
|
|
|
> Create a specific response type? |