codex_git/
branch.rs

1use std::ffi::OsString;
2use std::path::Path;
3
4use crate::GitToolingError;
5use crate::operations::ensure_git_repository;
6use crate::operations::resolve_head;
7use crate::operations::resolve_repository_root;
8use crate::operations::run_git_for_stdout;
9
10/// Returns the merge-base commit between `HEAD` and the provided branch, if both exist.
11///
12/// The function mirrors `git merge-base HEAD <branch>` but returns `Ok(None)` when
13/// the repository has no `HEAD` yet or when the branch cannot be resolved.
14pub fn merge_base_with_head(
15    repo_path: &Path,
16    branch: &str,
17) -> Result<Option<String>, GitToolingError> {
18    ensure_git_repository(repo_path)?;
19    let repo_root = resolve_repository_root(repo_path)?;
20    let head = match resolve_head(repo_root.as_path())? {
21        Some(head) => head,
22        None => return Ok(None),
23    };
24
25    let branch_ref = match run_git_for_stdout(
26        repo_root.as_path(),
27        vec![
28            OsString::from("rev-parse"),
29            OsString::from("--verify"),
30            OsString::from(branch),
31        ],
32        None,
33    ) {
34        Ok(rev) => rev,
35        Err(GitToolingError::GitCommand { .. }) => return Ok(None),
36        Err(other) => return Err(other),
37    };
38
39    let merge_base = run_git_for_stdout(
40        repo_root.as_path(),
41        vec![
42            OsString::from("merge-base"),
43            OsString::from(head),
44            OsString::from(branch_ref),
45        ],
46        None,
47    )?;
48
49    Ok(Some(merge_base))
50}
51
52#[cfg(test)]
53mod tests {
54    use super::merge_base_with_head;
55    use crate::GitToolingError;
56    use pretty_assertions::assert_eq;
57    use std::path::Path;
58    use std::process::Command;
59    use tempfile::tempdir;
60
61    fn run_git_in(repo_path: &Path, args: &[&str]) {
62        let status = Command::new("git")
63            .current_dir(repo_path)
64            .args(args)
65            .status()
66            .expect("git command");
67        assert!(status.success(), "git command failed: {args:?}");
68    }
69
70    fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String {
71        let output = Command::new("git")
72            .current_dir(repo_path)
73            .args(args)
74            .output()
75            .expect("git command");
76        assert!(output.status.success(), "git command failed: {args:?}");
77        String::from_utf8_lossy(&output.stdout).trim().to_string()
78    }
79
80    fn init_test_repo(repo_path: &Path) {
81        run_git_in(repo_path, &["init", "--initial-branch=main"]);
82        run_git_in(repo_path, &["config", "core.autocrlf", "false"]);
83    }
84
85    fn commit(repo_path: &Path, message: &str) {
86        run_git_in(
87            repo_path,
88            &[
89                "-c",
90                "user.name=Tester",
91                "-c",
92                "user.email=test@example.com",
93                "commit",
94                "-m",
95                message,
96            ],
97        );
98    }
99
100    #[test]
101    fn merge_base_returns_shared_commit() -> Result<(), GitToolingError> {
102        let temp = tempdir()?;
103        let repo = temp.path();
104        init_test_repo(repo);
105
106        std::fs::write(repo.join("base.txt"), "base\n")?;
107        run_git_in(repo, &["add", "base.txt"]);
108        commit(repo, "base commit");
109
110        run_git_in(repo, &["checkout", "-b", "feature"]);
111        std::fs::write(repo.join("feature.txt"), "feature change\n")?;
112        run_git_in(repo, &["add", "feature.txt"]);
113        commit(repo, "feature commit");
114
115        run_git_in(repo, &["checkout", "main"]);
116        std::fs::write(repo.join("main.txt"), "main change\n")?;
117        run_git_in(repo, &["add", "main.txt"]);
118        commit(repo, "main commit");
119
120        run_git_in(repo, &["checkout", "feature"]);
121
122        let expected = run_git_stdout(repo, &["merge-base", "HEAD", "main"]);
123        let merge_base = merge_base_with_head(repo, "main")?;
124        assert_eq!(merge_base, Some(expected));
125
126        Ok(())
127    }
128
129    #[test]
130    fn merge_base_returns_none_when_branch_missing() -> Result<(), GitToolingError> {
131        let temp = tempdir()?;
132        let repo = temp.path();
133        init_test_repo(repo);
134
135        std::fs::write(repo.join("tracked.txt"), "tracked\n")?;
136        run_git_in(repo, &["add", "tracked.txt"]);
137        commit(repo, "initial");
138
139        let merge_base = merge_base_with_head(repo, "missing-branch")?;
140        assert_eq!(merge_base, None);
141
142        Ok(())
143    }
144}