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