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
74pub 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}