1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use std::sync::Mutex;
6
7pub struct GitRepo {
8 pub repo: Mutex<git2::Repository>,
9 pub root: PathBuf,
10 pub common_dir: PathBuf,
11}
12
13impl GitRepo {
14 pub fn discover(start_path: Option<&Path>) -> Result<Self> {
15 let working_directory = match start_path {
16 Some(p) => PathBuf::from(p),
17 None => std::env::current_dir().context("Failed to determine current directory")?,
18 };
19
20 let repo = git2::Repository::discover(&working_directory)
21 .context("Failed to discover git repository")?;
22
23 let root = repo
24 .workdir()
25 .map(PathBuf::from)
26 .unwrap_or_else(|| repo.path().to_path_buf());
27
28 let common_dir = repo.path().to_path_buf();
30
31 Ok(Self {
32 repo: Mutex::new(repo),
33 root: root.canonicalize().unwrap_or(root),
34 common_dir: common_dir.canonicalize().unwrap_or(common_dir),
35 })
36 }
37
38 pub fn run_git(&self, args: &[&str]) -> Result<String> {
39 run_git_command(Some(&self.root), args)
40 }
41
42 #[allow(dead_code)]
43 pub fn run_git_in(&self, worktree_path: &Path, args: &[&str]) -> Result<String> {
44 run_git_command(Some(worktree_path), args)
45 }
46
47 pub fn origin_url(&self) -> Option<String> {
48 self.repo
49 .lock()
50 .ok()?
51 .find_remote("origin")
52 .ok()
53 .and_then(|remote| remote.url().map(|s| s.to_string()))
54 }
55
56 pub fn default_branch(&self) -> Option<String> {
57 const FALLBACK_BRANCHES: [&str; 2] = ["main", "master"];
58 let repo = self.repo.lock().ok()?;
59
60 for branch in FALLBACK_BRANCHES {
61 if repo.find_branch(branch, git2::BranchType::Local).is_ok() {
62 return Some(branch.to_string());
63 }
64 }
65 None
66 }
67
68 pub fn branch_exists(&self, branch_name: &str) -> bool {
69 let repo = match self.repo.lock() {
70 Ok(r) => r,
71 Err(_) => return false,
72 };
73 let exists = repo
74 .find_branch(branch_name, git2::BranchType::Local)
75 .is_ok();
76 exists
77 }
78
79 pub fn is_merged(&self, branch: &str, base: &str) -> Result<bool> {
80 let repo = self
81 .repo
82 .lock()
83 .map_err(|_| anyhow::anyhow!("Failed to lock repository"))?;
84
85 let branch_oid = match repo.revparse_single(branch) {
86 Ok(obj) => obj.id(),
87 Err(_) => return Ok(false),
88 };
89
90 if let Ok(base_obj) = repo.revparse_single(base) {
92 if let Ok(true) = repo.graph_descendant_of(base_obj.id(), branch_oid) {
93 return Ok(true);
94 }
95 }
96
97 let remote_base = format!("origin/{}", base);
99 if let Ok(remote_base_obj) = repo.revparse_single(&remote_base) {
100 if let Ok(true) = repo.graph_descendant_of(remote_base_obj.id(), branch_oid) {
101 return Ok(true);
102 }
103 }
104
105 Ok(false)
106 }
107}
108
109pub fn run_git_command(working_directory: Option<&Path>, args: &[&str]) -> Result<String> {
110 let mut cmd = Command::new("git");
111 if let Some(directory) = working_directory {
112 cmd.current_dir(directory);
113 }
114 cmd.args(args);
115
116 let output = cmd.output().context("Failed to execute git command")?;
117
118 if !output.status.success() {
119 let stderr = String::from_utf8_lossy(&output.stderr);
120 bail!("git {} failed: {}", args.join(" "), stderr.trim());
121 }
122
123 Ok(String::from_utf8_lossy(&output.stdout).to_string())
124}
125
126pub fn is_git_installed() -> bool {
127 Command::new("git")
128 .arg("--version")
129 .output()
130 .map(|o| o.status.success())
131 .unwrap_or(false)
132}
133
134pub fn is_in_git_repo(path: &Path) -> bool {
135 Command::new("git")
136 .current_dir(path)
137 .args(["rev-parse", "--git-dir"])
138 .output()
139 .map(|o| o.status.success())
140 .unwrap_or(false)
141}