Skip to main content

git_workty/
git.rs

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        // git2 path() returns the effective .git directory
29        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        // 1. Check against local base
91        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        // 2. Check against remote base (e.g. origin/main)
98        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}