1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5pub struct GitRepo {
6 pub root: PathBuf,
7 pub common_dir: PathBuf,
8}
9
10impl GitRepo {
11 pub fn discover(start_path: Option<&Path>) -> Result<Self> {
12 let working_directory = start_path
13 .map(PathBuf::from)
14 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
15
16 let root = git_rev_parse(&working_directory, &["--show-toplevel"])?;
17 let common_dir = git_rev_parse(&working_directory, &["--git-common-dir"])?;
18
19 let root = PathBuf::from(root.trim());
20 let common_dir_str = common_dir.trim();
21
22 let common_dir = if Path::new(common_dir_str).is_absolute() {
23 PathBuf::from(common_dir_str)
24 } else {
25 root.join(common_dir_str)
26 };
27
28 Ok(Self {
29 root: root.canonicalize().unwrap_or(root),
30 common_dir: common_dir.canonicalize().unwrap_or(common_dir),
31 })
32 }
33
34 pub fn run_git(&self, args: &[&str]) -> Result<String> {
35 run_git_command(Some(&self.root), args)
36 }
37
38 #[allow(dead_code)]
39 pub fn run_git_in(&self, worktree_path: &Path, args: &[&str]) -> Result<String> {
40 run_git_command(Some(worktree_path), args)
41 }
42
43 pub fn origin_url(&self) -> Option<String> {
44 self.run_git(&["remote", "get-url", "origin"])
45 .ok()
46 .map(|s| s.trim().to_string())
47 }
48
49 pub fn default_branch(&self) -> Option<String> {
56 let head_path = self.common_dir.join("HEAD");
59 if let Ok(contents) = std::fs::read_to_string(head_path) {
60 if let Some(ref_name) = contents.strip_prefix("ref: refs/heads/") {
61 return Some(ref_name.trim().to_string());
62 }
63 }
64
65 const FALLBACK_BRANCHES: [&str; 2] = ["main", "master"];
67 for branch in FALLBACK_BRANCHES {
68 if branch_exists(self, branch) {
69 return Some(branch.to_string());
70 }
71 }
72
73 None
74 }
75}
76
77fn git_rev_parse(working_directory: &Path, args: &[&str]) -> Result<String> {
78 let mut cmd_args = vec!["rev-parse"];
79 cmd_args.extend(args);
80 run_git_command(Some(working_directory), &cmd_args)
81}
82
83pub fn run_git_command(working_directory: Option<&Path>, args: &[&str]) -> Result<String> {
84 let mut cmd = Command::new("git");
85 if let Some(directory) = working_directory {
86 cmd.current_dir(directory);
87 }
88 cmd.args(args);
89
90 let output = cmd.output().context("Failed to execute git command")?;
91
92 if !output.status.success() {
93 let stderr = String::from_utf8_lossy(&output.stderr);
94 bail!(
95 "git {} failed: {}",
96 args.first().unwrap_or(&""),
97 stderr.trim()
98 );
99 }
100
101 Ok(String::from_utf8_lossy(&output.stdout).to_string())
102}
103
104pub fn is_git_installed() -> bool {
105 Command::new("git")
106 .arg("--version")
107 .output()
108 .map(|o| o.status.success())
109 .unwrap_or(false)
110}
111
112pub fn is_in_git_repo(path: &Path) -> bool {
113 Command::new("git")
114 .current_dir(path)
115 .args(["rev-parse", "--git-dir"])
116 .output()
117 .map(|o| o.status.success())
118 .unwrap_or(false)
119}
120
121pub fn branch_exists(repo: &GitRepo, branch: &str) -> bool {
122 repo.run_git(&["rev-parse", "--verify", &format!("refs/heads/{}", branch)])
123 .is_ok()
124}
125
126pub fn is_ancestor(repo: &GitRepo, ancestor: &str, descendant: &str) -> Result<bool> {
127 let result = Command::new("git")
128 .current_dir(&repo.root)
129 .args(["merge-base", "--is-ancestor", ancestor, descendant])
130 .output()
131 .context("Failed to check ancestry")?;
132 Ok(result.status.success())
133}