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> {
54 for candidate in ["main", "master"] {
55 if git_ref_exists(root, candidate) {
56 return Ok(candidate.to_string());
57 }
58 }
59 if let Ok(sym) = run_git(root, &["symbolic-ref", "refs/remotes/origin/HEAD"])
60 && let Some(name) = sym.strip_prefix("refs/remotes/origin/")
61 {
62 let remote = format!("origin/{name}");
63 if git_ref_exists(root, &remote) {
64 return Ok(remote);
65 }
66 if git_ref_exists(root, name) {
67 return Ok(name.to_string());
68 }
69 }
70 for candidate in ["origin/main", "origin/master"] {
71 if git_ref_exists(root, candidate) {
72 return Ok(candidate.to_string());
73 }
74 }
75 bail!("unable to resolve default branch (tried main, master, origin/main, origin/master)");
76}
77
78fn hint_local_ahead_of_remote(root: &Path, default_ref: &str) {
83 use std::sync::atomic::{AtomicBool, Ordering};
84 static HINT_SHOWN: AtomicBool = AtomicBool::new(false);
85 if HINT_SHOWN.swap(true, Ordering::Relaxed) {
86 return;
87 }
88 let Some(remote) = default_ref
89 .strip_prefix("main")
90 .map(|_| "origin/main")
91 .or_else(|| default_ref.strip_prefix("master").map(|_| "origin/master"))
92 else {
93 return;
94 };
95 if !git_ref_exists(root, remote) {
96 return;
97 }
98 let local_ahead = run_git(
99 root,
100 &["rev-list", "--count", &format!("{remote}..{default_ref}")],
101 )
102 .map(|s| s.trim().parse::<i64>().unwrap_or(0))
103 .unwrap_or(0);
104 if local_ahead <= 0 {
105 return;
106 }
107 let remote_ahead = run_git(
108 root,
109 &["rev-list", "--count", &format!("{default_ref}..{remote}")],
110 )
111 .map(|s| s.trim().parse::<i64>().unwrap_or(0))
112 .unwrap_or(0);
113 if remote_ahead > 0 {
114 eprintln!(
115 "INFO: local `{default_ref}` diverged from `{remote}` (local +{local_ahead}/remote +{remote_ahead}); range anchors use the local ref"
116 );
117 } else {
118 eprintln!(
119 "INFO: local `{default_ref}` is ahead of `{remote}` (+{local_ahead}); range anchors use the local ref"
120 );
121 }
122}
123
124pub fn effective_range_base(root: &Path) -> Result<String> {
133 let default_ref = resolve_default_branch_ref(root)?;
134 if default_ref.starts_with("main") || default_ref.starts_with("master") {
135 hint_local_ahead_of_remote(root, &default_ref);
136 }
137 merge_base_sha(root, &default_ref)
138}
139
140pub fn git_ref_exists(root: &Path, reference: &str) -> bool {
146 Command::new("git")
147 .args(["rev-parse", "--verify", "--quiet", reference])
148 .current_dir(root)
149 .output()
150 .map(|o| o.status.success())
151 .unwrap_or(false)
152}
153
154pub fn is_default_branch(root: &Path, branch: &str) -> Result<bool> {
155 let default_ref = resolve_default_branch_ref(root)?;
156 let default_name = default_ref
157 .strip_prefix("origin/")
158 .unwrap_or(default_ref.as_str());
159 Ok(branch == default_name || branch == default_ref)
160}
161
162pub fn working_tree_clean(root: &Path) -> Result<bool> {
163 let status = run_git(root, &["status", "--porcelain"])?;
164 Ok(status.trim().is_empty())
165}
166
167pub fn merge_base_sha(root: &Path, base_ref: &str) -> Result<String> {
168 run_git(root, &["merge-base", base_ref, "HEAD"])
169}
170
171pub fn branch_diff(root: &Path, base_sha: &str) -> Result<String> {
172 run_git(
173 root,
174 &["diff", "--find-renames", &format!("{base_sha}...HEAD")],
175 )
176}
177
178pub fn branch_has_upstream(root: &Path) -> Result<bool> {
179 let output = Command::new("git")
180 .args(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
181 .current_dir(root)
182 .output()
183 .map_err(|err| anyhow!("git upstream check failed: {err}"))?;
184 Ok(output.status.success())
185}
186
187pub fn git_common_dir(root: &Path) -> Result<PathBuf> {
189 let output = Command::new("git")
190 .args(["rev-parse", "--git-common-dir"])
191 .current_dir(root)
192 .output()
193 .map_err(|e| anyhow!("git rev-parse --git-common-dir failed to spawn: {e}"))?;
194
195 if !output.status.success() {
196 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
197 bail!("not a git repository: {stderr}");
198 }
199
200 let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
201 let path = Path::new(&raw);
202 if path.is_absolute() {
203 Ok(path.to_path_buf())
204 } else {
205 Ok(root.join(path))
206 }
207}
208
209pub fn worktree_exists(root: &Path, path: &Path) -> Result<bool> {
211 let output = Command::new("git")
212 .args(["worktree", "list", "--porcelain"])
213 .current_dir(root)
214 .output()
215 .map_err(|e| anyhow!("git worktree list failed to spawn: {e}"))?;
216
217 let stdout = String::from_utf8_lossy(&output.stdout);
218 let target = path.display().to_string();
219 Ok(stdout
220 .lines()
221 .any(|line| line.starts_with("worktree ") && line.contains(&target)))
222}
223
224pub fn worktree_add(root: &Path, path: &Path, branch: &str, base_sha: &str) -> Result<()> {
226 let output = Command::new("git")
227 .args([
228 "worktree",
229 "add",
230 path.to_str().unwrap(),
231 "-b",
232 branch,
233 base_sha,
234 ])
235 .current_dir(root)
236 .output()
237 .map_err(|e| anyhow!("git worktree add failed to spawn: {e}"))?;
238
239 if !output.status.success() {
240 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
241 bail!("worktree create failed: {stderr}");
242 }
243 Ok(())
244}
245
246pub fn find_git_root(start: &Path) -> Option<PathBuf> {
248 let mut current = start.to_path_buf();
249 loop {
250 if is_git_root(¤t) {
251 return Some(current);
252 }
253 if !current.pop() {
254 break;
255 }
256 }
257 None
258}
259
260fn is_git_root(path: &Path) -> bool {
261 let git = path.join(".git");
262 if let Ok(metadata) = fs::symlink_metadata(&git) {
263 return metadata.is_dir() || metadata.is_file();
264 }
265 false
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use tempfile::TempDir;
272
273 #[test]
274 fn test_find_git_root() {
275 let temp = TempDir::new().expect("temp dir");
276 let root = temp.path().join("repo");
277 let nested = root.join("a").join("b");
278 fs::create_dir_all(&nested).expect("create nested dirs");
279 fs::create_dir_all(root.join(".git")).expect("create git dir");
280
281 let found = find_git_root(&nested).expect("git root");
282 assert_eq!(found, root);
283 }
284
285 #[test]
286 fn test_find_git_root_none() {
287 let temp = TempDir::new().expect("temp dir");
288 let root = temp.path().join("repo");
289 fs::create_dir_all(&root).expect("create dir");
290 let found = find_git_root(&root);
291 assert!(found.is_none());
292 }
293
294 fn git(root: &Path, args: &[&str]) {
295 let out = Command::new("git")
296 .args(args)
297 .current_dir(root)
298 .output()
299 .expect("git");
300 assert!(
301 out.status.success(),
302 "git {args:?} failed: {}",
303 String::from_utf8_lossy(&out.stderr)
304 );
305 }
306
307 fn init_repo_with_commit(root: &Path) {
308 fs::create_dir_all(root).expect("create repo dir");
309 git(root, &["init", "-q", "-b", "main"]);
310 git(
311 root,
312 &[
313 "-c",
314 "user.email=t@t",
315 "-c",
316 "user.name=t",
317 "commit",
318 "--allow-empty",
319 "-m",
320 "init",
321 ],
322 );
323 }
324
325 #[test]
326 fn test_current_branch_some_on_branch() {
327 let temp = TempDir::new().expect("temp dir");
328 let root = temp.path().join("repo");
329 init_repo_with_commit(&root);
330 let branch = current_branch(&root)
331 .expect("current_branch")
332 .expect("branch");
333 assert_eq!(branch, "main");
334 }
335
336 #[test]
337 fn test_current_branch_none_when_detached() {
338 let temp = TempDir::new().expect("temp dir");
339 let root = temp.path().join("repo");
340 init_repo_with_commit(&root);
341 git(&root, &["checkout", "-q", "--detach"]);
342 let branch = current_branch(&root).expect("current_branch");
343 assert!(
344 branch.is_none(),
345 "detached HEAD must map to None, got {branch:?}"
346 );
347 }
348
349 #[test]
353 fn effective_range_base_is_local_first_and_live() {
354 let temp = TempDir::new().expect("temp dir");
355 let root = temp.path().join("repo");
356 init_repo_with_commit(&root);
357 git(&root, &["remote", "add", "origin", root.to_str().unwrap()]);
360 git(&root, &["fetch", "-q", "origin"]);
361 git(
362 &root,
363 &[
364 "-c",
365 "user.email=t@t",
366 "-c",
367 "user.name=t",
368 "commit",
369 "--allow-empty",
370 "-qm",
371 "local-only",
372 ],
373 );
374 let local_main = run_git(&root, &["rev-parse", "main"]).unwrap();
375 assert_eq!(
376 resolve_default_branch_ref(&root).unwrap(),
377 "main",
378 "local main must win over origin/main"
379 );
380 assert_eq!(
381 effective_range_base(&root).unwrap(),
382 local_main,
383 "effective base must be the LOCAL main merge-base (== HEAD here)"
384 );
385 git(&root, &["checkout", "-q", "-b", "sdd/c1"]);
387 git(
388 &root,
389 &[
390 "-c",
391 "user.email=t@t",
392 "-c",
393 "user.name=t",
394 "commit",
395 "--allow-empty",
396 "-qm",
397 "branch work",
398 ],
399 );
400 assert_eq!(
401 effective_range_base(&root).unwrap(),
402 local_main,
403 "fork point stays the live merge-base after branch commits"
404 );
405 git(&root, &["checkout", "-q", "main"]);
407 git(
408 &root,
409 &[
410 "-c",
411 "user.email=t@t",
412 "-c",
413 "user.name=t",
414 "commit",
415 "--allow-empty",
416 "-qm",
417 "main moves again",
418 ],
419 );
420 let new_main = run_git(&root, &["rev-parse", "main"]).unwrap();
421 git(&root, &["checkout", "-q", "sdd/c1"]);
422 git(
423 &root,
424 &[
425 "-c",
426 "user.email=t@t",
427 "-c",
428 "user.name=t",
429 "merge",
430 "-q",
431 "--no-edit",
432 "main",
433 ],
434 );
435 assert_eq!(
436 effective_range_base(&root).unwrap(),
437 new_main,
438 "after merge, base must shrink to the new main tip (merge-base)"
439 );
440 }
441}