From fc55fd7a819189a078e1e7d8f569bd40d745a49e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 18 Nov 2025 15:26:09 +0000 Subject: [PATCH] feat: git branch tooling (#6831) --- codex-rs/utils/git/src/branch.rs | 144 +++++++++++++++++++++++++++++++ codex-rs/utils/git/src/lib.rs | 2 + 2 files changed, 146 insertions(+) create mode 100644 codex-rs/utils/git/src/branch.rs diff --git a/codex-rs/utils/git/src/branch.rs b/codex-rs/utils/git/src/branch.rs new file mode 100644 index 000000000..543dffd9c --- /dev/null +++ b/codex-rs/utils/git/src/branch.rs @@ -0,0 +1,144 @@ +use std::ffi::OsString; +use std::path::Path; + +use crate::GitToolingError; +use crate::operations::ensure_git_repository; +use crate::operations::resolve_head; +use crate::operations::resolve_repository_root; +use crate::operations::run_git_for_stdout; + +/// Returns the merge-base commit between `HEAD` and the provided branch, if both exist. +/// +/// The function mirrors `git merge-base HEAD ` but returns `Ok(None)` when +/// the repository has no `HEAD` yet or when the branch cannot be resolved. +pub fn merge_base_with_head( + repo_path: &Path, + branch: &str, +) -> Result, GitToolingError> { + ensure_git_repository(repo_path)?; + let repo_root = resolve_repository_root(repo_path)?; + let head = match resolve_head(repo_root.as_path())? { + Some(head) => head, + None => return Ok(None), + }; + + let branch_ref = match run_git_for_stdout( + repo_root.as_path(), + vec![ + OsString::from("rev-parse"), + OsString::from("--verify"), + OsString::from(branch), + ], + None, + ) { + Ok(rev) => rev, + Err(GitToolingError::GitCommand { .. }) => return Ok(None), + Err(other) => return Err(other), + }; + + let merge_base = run_git_for_stdout( + repo_root.as_path(), + vec![ + OsString::from("merge-base"), + OsString::from(head), + OsString::from(branch_ref), + ], + None, + )?; + + Ok(Some(merge_base)) +} + +#[cfg(test)] +mod tests { + use super::merge_base_with_head; + use crate::GitToolingError; + use pretty_assertions::assert_eq; + use std::path::Path; + use std::process::Command; + use tempfile::tempdir; + + fn run_git_in(repo_path: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(repo_path) + .args(args) + .status() + .expect("git command"); + assert!(status.success(), "git command failed: {args:?}"); + } + + fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .expect("git command"); + assert!(output.status.success(), "git command failed: {args:?}"); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + fn init_test_repo(repo_path: &Path) { + run_git_in(repo_path, &["init", "--initial-branch=main"]); + run_git_in(repo_path, &["config", "core.autocrlf", "false"]); + } + + fn commit(repo_path: &Path, message: &str) { + run_git_in( + repo_path, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + message, + ], + ); + } + + #[test] + fn merge_base_returns_shared_commit() -> Result<(), GitToolingError> { + let temp = tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::write(repo.join("base.txt"), "base\n")?; + run_git_in(repo, &["add", "base.txt"]); + commit(repo, "base commit"); + + run_git_in(repo, &["checkout", "-b", "feature"]); + std::fs::write(repo.join("feature.txt"), "feature change\n")?; + run_git_in(repo, &["add", "feature.txt"]); + commit(repo, "feature commit"); + + run_git_in(repo, &["checkout", "main"]); + std::fs::write(repo.join("main.txt"), "main change\n")?; + run_git_in(repo, &["add", "main.txt"]); + commit(repo, "main commit"); + + run_git_in(repo, &["checkout", "feature"]); + + let expected = run_git_stdout(repo, &["merge-base", "HEAD", "main"]); + let merge_base = merge_base_with_head(repo, "main")?; + assert_eq!(merge_base, Some(expected)); + + Ok(()) + } + + #[test] + fn merge_base_returns_none_when_branch_missing() -> Result<(), GitToolingError> { + let temp = tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::write(repo.join("tracked.txt"), "tracked\n")?; + run_git_in(repo, &["add", "tracked.txt"]); + commit(repo, "initial"); + + let merge_base = merge_base_with_head(repo, "missing-branch")?; + assert_eq!(merge_base, None); + + Ok(()) + } +} diff --git a/codex-rs/utils/git/src/lib.rs b/codex-rs/utils/git/src/lib.rs index cf2887aa8..e96b0eab6 100644 --- a/codex-rs/utils/git/src/lib.rs +++ b/codex-rs/utils/git/src/lib.rs @@ -2,6 +2,7 @@ use std::fmt; use std::path::PathBuf; mod apply; +mod branch; mod errors; mod ghost_commits; mod operations; @@ -13,6 +14,7 @@ pub use apply::apply_git_patch; pub use apply::extract_paths_from_patch; pub use apply::parse_git_apply_output; pub use apply::stage_paths; +pub use branch::merge_base_with_head; pub use errors::GitToolingError; pub use ghost_commits::CreateGhostCommitOptions; pub use ghost_commits::create_ghost_commit;