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
10pub 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}