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