Skip to main content

etz/
git.rs

1use std::{ffi::OsStr, path::Path, process::Command};
2
3use anyhow::{Context, Result, bail};
4
5#[derive(Debug)]
6pub struct GitOutput {
7    pub success: bool,
8    pub stdout: String,
9    pub stderr: String,
10}
11
12#[derive(Debug, Clone, Copy)]
13pub struct StatusCounts {
14    pub staged: u32,
15    pub unstaged: u32,
16    pub untracked: u32,
17}
18
19pub fn git<I, S>(repo: &Path, args: I) -> Result<GitOutput>
20where
21    I: IntoIterator<Item = S>,
22    S: AsRef<OsStr>,
23{
24    let output = Command::new("git")
25        .args(args)
26        .current_dir(repo)
27        .output()
28        .with_context(|| format!("failed to run git in {}", repo.display()))?;
29
30    Ok(GitOutput {
31        success: output.status.success(),
32        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
33        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
34    })
35}
36
37pub fn git_ok<I, S>(repo: &Path, args: I) -> Result<String>
38where
39    I: IntoIterator<Item = S>,
40    S: AsRef<OsStr>,
41{
42    let args_vec: Vec<String> = args
43        .into_iter()
44        .map(|x| x.as_ref().to_string_lossy().to_string())
45        .collect();
46    let out = git(repo, args_vec.iter().map(String::as_str))?;
47    if out.success {
48        Ok(out.stdout.trim().to_string())
49    } else {
50        let joined = args_vec.join(" ");
51        let stderr = out.stderr.trim().to_string();
52        if stderr.is_empty() {
53            bail!("git command failed in {}: git {}", repo.display(), joined,);
54        }
55        bail!(
56            "git command failed in {}: git {}\n{}",
57            repo.display(),
58            joined,
59            stderr,
60        );
61    }
62}
63
64pub fn looks_like_git_repo(path: &Path) -> bool {
65    path.join(".git").exists()
66}
67
68pub fn is_git_repo(path: &Path) -> bool {
69    git(path, ["rev-parse", "--is-inside-work-tree"])
70        .map(|o| o.success)
71        .unwrap_or(false)
72}
73
74/// Detect the repo's default (trunk) branch.
75///
76/// Priority:
77/// 1. `origin/HEAD` symbolic ref — the remote's declared default
78/// 2. Local or remote `main` / `master` — conventional trunk names
79/// 3. Currently checked-out branch — last resort fallback
80/// 4. Hard fallback to `"main"`
81pub fn detect_default_branch(repo: &Path) -> Result<String> {
82    if let Ok(head) = git_ok(
83        repo,
84        [
85            "symbolic-ref",
86            "--quiet",
87            "--short",
88            "refs/remotes/origin/HEAD",
89        ],
90    ) {
91        let trimmed = head.trim();
92        if let Some(branch) = trimmed.strip_prefix("origin/") {
93            return Ok(branch.to_string());
94        }
95    }
96
97    for candidate in ["main", "master"] {
98        if branch_exists_local(repo, candidate)? || branch_exists_remote(repo, candidate)? {
99            return Ok(candidate.to_string());
100        }
101    }
102
103    if let Ok(branch) = current_branch(repo) {
104        if let Some(branch) = branch {
105            return Ok(branch);
106        }
107    }
108
109    Ok("main".to_string())
110}
111
112pub fn branch_exists_local(repo: &Path, branch: &str) -> Result<bool> {
113    Ok(git(
114        repo,
115        [
116            "show-ref",
117            "--verify",
118            "--quiet",
119            &format!("refs/heads/{branch}"),
120        ],
121    )?
122    .success)
123}
124
125pub fn branch_exists_remote(repo: &Path, branch: &str) -> Result<bool> {
126    Ok(git(
127        repo,
128        [
129            "show-ref",
130            "--verify",
131            "--quiet",
132            &format!("refs/remotes/origin/{branch}"),
133        ],
134    )?
135    .success)
136}
137
138pub fn current_branch(repo: &Path) -> Result<Option<String>> {
139    let out = git(repo, ["symbolic-ref", "--quiet", "--short", "HEAD"])?;
140    if out.success {
141        Ok(Some(out.stdout.trim().to_string()))
142    } else {
143        Ok(None)
144    }
145}
146
147pub fn has_conflicts(repo: &Path) -> Result<bool> {
148    let out = git_ok(repo, ["diff", "--name-only", "--diff-filter=U"])?;
149    Ok(!out.trim().is_empty())
150}
151
152pub fn is_dirty(repo: &Path) -> Result<bool> {
153    let out = git_ok(repo, ["status", "--porcelain"])?;
154    Ok(!out.trim().is_empty())
155}
156
157pub fn has_staged_changes(repo: &Path) -> Result<bool> {
158    let out = git(repo, ["diff", "--cached", "--quiet"])?;
159    if out.success { Ok(false) } else { Ok(true) }
160}
161
162pub fn has_unstaged_tracked_changes(repo: &Path) -> Result<bool> {
163    let out = git_ok(repo, ["diff", "--name-only"])?;
164    Ok(!out.trim().is_empty())
165}
166
167pub fn status_counts(repo: &Path) -> Result<StatusCounts> {
168    let out = git(repo, ["status", "--porcelain"])?;
169    if !out.success {
170        bail!("failed to read git status for {}", repo.display());
171    }
172
173    let mut counts = StatusCounts {
174        staged: 0,
175        unstaged: 0,
176        untracked: 0,
177    };
178
179    for line in out.stdout.lines() {
180        if line.len() < 2 {
181            continue;
182        }
183
184        let bytes = line.as_bytes();
185        let x = bytes[0] as char;
186        let y = bytes[1] as char;
187
188        if x == '?' && y == '?' {
189            counts.untracked += 1;
190            continue;
191        }
192
193        if x != ' ' {
194            counts.staged += 1;
195        }
196        if y != ' ' {
197            counts.unstaged += 1;
198        }
199    }
200
201    Ok(counts)
202}
203
204pub fn ahead_behind(repo: &Path) -> Result<Option<(u32, u32)>> {
205    let out = git(
206        repo,
207        ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
208    )?;
209    if !out.success {
210        return Ok(None);
211    }
212
213    let mut parts = out.stdout.split_whitespace();
214    let behind = parts.next().and_then(|v| v.parse::<u32>().ok());
215    let ahead = parts.next().and_then(|v| v.parse::<u32>().ok());
216
217    match (ahead, behind) {
218        (Some(ahead), Some(behind)) => Ok(Some((ahead, behind))),
219        _ => Ok(None),
220    }
221}