git_workty/
git.rs

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    /// Attempts to detect the default branch of the repository.
50    ///
51    /// Strategy:
52    /// 1. Check the `HEAD` file in the common git directory (works for bare repos/worktrees).
53    /// 2. Fallback to checking for existence of "main".
54    /// 3. Fallback to checking for existence of "master".
55    pub fn default_branch(&self) -> Option<String> {
56        // 1. Try to read HEAD from the common git directory
57        // This usually points to the default branch if we are in a bare repo or the main worktree
58        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        // 2. Fallbacks
66        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}