1use anyhow::{Result, anyhow, bail};
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18pub fn run_git(root: &Path, args: &[&str]) -> Result<String> {
19 let output = Command::new("git")
20 .args(args)
21 .current_dir(root)
22 .output()
23 .map_err(|err| anyhow!("git {:?} failed to spawn: {err}", args))?;
24 if !output.status.success() {
25 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
26 if stderr.is_empty() {
27 bail!("git {:?} failed", args);
28 }
29 bail!("{stderr}");
30 }
31 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
32}
33
34pub fn current_branch(root: &Path) -> Result<Option<String>> {
35 let branch = run_git(root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
36 if branch.is_empty() || branch == "HEAD" {
37 return Ok(None);
40 }
41 Ok(Some(branch))
42}
43
44pub fn current_head_sha(root: &Path) -> Result<String> {
45 run_git(root, &["rev-parse", "HEAD"])
46}
47
48pub fn resolve_default_branch_ref(root: &Path) -> Result<String> {
49 if let Ok(sym) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
50 && let Some(name) = sym.strip_prefix("refs/remotes/origin/")
51 {
52 let remote = format!("origin/{name}");
53 if git_ref_exists(root, &remote) {
54 return Ok(remote);
55 }
56 if git_ref_exists(root, name) {
57 return Ok(name.to_string());
58 }
59 }
60 for candidate in ["origin/main", "origin/master", "main", "master"] {
61 if git_ref_exists(root, candidate) {
62 return Ok(candidate.to_string());
63 }
64 }
65 bail!("unable to resolve default branch (tried origin/main, origin/master, main, master)");
66}
67
68pub fn git_ref_exists(root: &Path, reference: &str) -> bool {
74 Command::new("git")
75 .args(["rev-parse", "--verify", "--quiet", reference])
76 .current_dir(root)
77 .output()
78 .map(|o| o.status.success())
79 .unwrap_or(false)
80}
81
82pub fn is_default_branch(root: &Path, branch: &str) -> Result<bool> {
83 let default_ref = resolve_default_branch_ref(root)?;
84 let default_name = default_ref
85 .strip_prefix("origin/")
86 .unwrap_or(default_ref.as_str());
87 Ok(branch == default_name || branch == default_ref)
88}
89
90pub fn working_tree_clean(root: &Path) -> Result<bool> {
91 let status = run_git(root, &["status", "--porcelain"])?;
92 Ok(status.trim().is_empty())
93}
94
95pub fn merge_base_sha(root: &Path, base_ref: &str) -> Result<String> {
96 run_git(root, &["merge-base", base_ref, "HEAD"])
97}
98
99pub fn branch_diff(root: &Path, base_sha: &str) -> Result<String> {
100 run_git(
101 root,
102 &["diff", "--find-renames", &format!("{base_sha}...HEAD")],
103 )
104}
105
106pub fn branch_has_upstream(root: &Path) -> Result<bool> {
107 let output = Command::new("git")
108 .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
109 .current_dir(root)
110 .output()
111 .map_err(|err| anyhow!("git upstream check failed: {err}"))?;
112 Ok(output.status.success())
113}
114
115pub fn git_common_dir(root: &Path) -> Result<PathBuf> {
117 let output = Command::new("git")
118 .args(["rev-parse", "--git-common-dir"])
119 .current_dir(root)
120 .output()
121 .map_err(|e| anyhow!("git rev-parse --git-common-dir failed to spawn: {e}"))?;
122
123 if !output.status.success() {
124 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
125 bail!("not a git repository: {stderr}");
126 }
127
128 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
129 let path = Path::new(&raw);
130 if path.is_absolute() {
131 Ok(path.to_path_buf())
132 } else {
133 Ok(root.join(path))
134 }
135}
136
137pub fn worktree_exists(root: &Path, path: &Path) -> Result<bool> {
139 let output = Command::new("git")
140 .args(["worktree", "list", "--porcelain"])
141 .current_dir(root)
142 .output()
143 .map_err(|e| anyhow!("git worktree list failed to spawn: {e}"))?;
144
145 let stdout = String::from_utf8_lossy(&output.stdout);
146 let target = path.display().to_string();
147 Ok(stdout
148 .lines()
149 .any(|line| line.starts_with("worktree ") && line.contains(&target)))
150}
151
152pub fn worktree_add(root: &Path, path: &Path, branch: &str, base_sha: &str) -> Result<()> {
154 let output = Command::new("git")
155 .args([
156 "worktree",
157 "add",
158 path.to_str().unwrap(),
159 "-b",
160 branch,
161 base_sha,
162 ])
163 .current_dir(root)
164 .output()
165 .map_err(|e| anyhow!("git worktree add failed to spawn: {e}"))?;
166
167 if !output.status.success() {
168 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
169 bail!("worktree create failed: {stderr}");
170 }
171 Ok(())
172}
173
174pub fn find_git_root(start: &Path) -> Option<PathBuf> {
176 let mut current = start.to_path_buf();
177 loop {
178 if is_git_root(¤t) {
179 return Some(current);
180 }
181 if !current.pop() {
182 break;
183 }
184 }
185 None
186}
187
188fn is_git_root(path: &Path) -> bool {
189 let git = path.join(".git");
190 if let Ok(metadata) = fs::symlink_metadata(&git) {
191 return metadata.is_dir() || metadata.is_file();
192 }
193 false
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use tempfile::TempDir;
200
201 #[test]
202 fn test_find_git_root() {
203 let temp = TempDir::new().expect("temp dir");
204 let root = temp.path().join("repo");
205 let nested = root.join("a").join("b");
206 fs::create_dir_all(&nested).expect("create nested dirs");
207 fs::create_dir_all(root.join(".git")).expect("create git dir");
208
209 let found = find_git_root(&nested).expect("git root");
210 assert_eq!(found, root);
211 }
212
213 #[test]
214 fn test_find_git_root_none() {
215 let temp = TempDir::new().expect("temp dir");
216 let root = temp.path().join("repo");
217 fs::create_dir_all(&root).expect("create dir");
218 let found = find_git_root(&root);
219 assert!(found.is_none());
220 }
221
222 fn git(root: &Path, args: &[&str]) {
223 let out = Command::new("git")
224 .args(args)
225 .current_dir(root)
226 .output()
227 .expect("git");
228 assert!(
229 out.status.success(),
230 "git {args:?} failed: {}",
231 String::from_utf8_lossy(&out.stderr)
232 );
233 }
234
235 fn init_repo_with_commit(root: &Path) {
236 fs::create_dir_all(root).expect("create repo dir");
237 git(root, &["init", "-q", "-b", "main"]);
238 git(
239 root,
240 &[
241 "-c",
242 "user.email=t@t",
243 "-c",
244 "user.name=t",
245 "commit",
246 "--allow-empty",
247 "-m",
248 "init",
249 ],
250 );
251 }
252
253 #[test]
254 fn test_current_branch_some_on_branch() {
255 let temp = TempDir::new().expect("temp dir");
256 let root = temp.path().join("repo");
257 init_repo_with_commit(&root);
258 let branch = current_branch(&root)
259 .expect("current_branch")
260 .expect("branch");
261 assert_eq!(branch, "main");
262 }
263
264 #[test]
265 fn test_current_branch_none_when_detached() {
266 let temp = TempDir::new().expect("temp dir");
267 let root = temp.path().join("repo");
268 init_repo_with_commit(&root);
269 git(&root, &["checkout", "-q", "--detach"]);
270 let branch = current_branch(&root).expect("current_branch");
271 assert!(
272 branch.is_none(),
273 "detached HEAD must map to None, got {branch:?}"
274 );
275 }
276}