1use std::process::Command;
9
10use crate::win_process::NoWindow;
11
12#[derive(serde::Serialize)]
14pub struct GitState {
15 pub is_repo: bool,
16 pub branch: Option<String>,
17 pub ahead: u32,
18 pub behind: u32,
19 pub dirty: bool,
20 pub changed_files_count: usize,
21 pub insertions: u32,
22 pub deletions: u32,
23}
24
25fn query_diff_totals(cwd: &str) -> (u32, u32) {
28 let numstat = run_git(cwd, &["diff", "HEAD", "--numstat"]).unwrap_or_default();
29 let mut insertions = 0u32;
30 let mut deletions = 0u32;
31 for line in numstat.lines() {
32 let mut cols = line.split('\t');
33 let adds = cols.next().and_then(|c| c.parse::<u32>().ok());
34 let dels = cols.next().and_then(|c| c.parse::<u32>().ok());
35 if let (Some(a), Some(d)) = (adds, dels) {
36 insertions += a;
37 deletions += d;
38 }
39 }
40 (insertions, deletions)
41}
42
43fn run_git(cwd: &str, args: &[&str]) -> Option<String> {
44 let out = Command::new("git")
45 .args(args)
46 .current_dir(cwd)
47 .no_window()
48 .output()
49 .ok()?;
50 if out.status.success() {
51 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
52 } else {
53 None
54 }
55}
56
57pub fn query_git_state(cwd: &str) -> GitState {
60 let branch = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
62 let is_repo = branch.is_some();
63
64 if !is_repo {
65 return GitState {
66 is_repo: false,
67 branch: None,
68 ahead: 0,
69 behind: 0,
70 dirty: false,
71 changed_files_count: 0,
72 insertions: 0,
73 deletions: 0,
74 };
75 }
76
77 let porcelain = run_git(cwd, &["status", "--porcelain"]).unwrap_or_default();
79 let changed: Vec<&str> = porcelain.lines().filter(|l| !l.is_empty()).collect();
80 let dirty = !changed.is_empty();
81
82 let ahead_behind = run_git(cwd, &["rev-list", "--count", "--left-right", "@{u}...HEAD"]);
85 let (behind, ahead) = parse_ahead_behind(ahead_behind.as_deref());
86
87 let (insertions, deletions) = query_diff_totals(cwd);
88
89 GitState {
90 is_repo: true,
91 branch,
92 ahead,
93 behind,
94 dirty,
95 changed_files_count: changed.len(),
96 insertions,
97 deletions,
98 }
99}
100
101fn parse_ahead_behind(raw: Option<&str>) -> (u32, u32) {
103 let Some(s) = raw else {
104 return (0, 0);
105 };
106 let mut parts = s.split_whitespace();
107 let behind = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
108 let ahead = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
109 (behind, ahead)
110}
111
112#[derive(serde::Serialize)]
114pub struct GitBranches {
115 pub is_repo: bool,
116 pub current: Option<String>,
117 pub branches: Vec<String>,
118}
119
120pub fn list_branches(cwd: &str) -> GitBranches {
123 let current = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
124 if current.is_none() {
125 return GitBranches {
126 is_repo: false,
127 current: None,
128 branches: Vec::new(),
129 };
130 }
131
132 let raw = run_git(cwd, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
133 let branches: Vec<String> = raw
134 .lines()
135 .map(|l| l.trim().to_string())
136 .filter(|l| !l.is_empty())
137 .collect();
138
139 GitBranches {
140 is_repo: true,
141 current,
142 branches,
143 }
144}
145
146pub fn checkout_branch(cwd: &str, branch: &str) -> Result<String, String> {
152 let known = list_branches(cwd);
155 if !known.is_repo {
156 return Err("not a git repository".to_string());
157 }
158 if !known.branches.iter().any(|b| b == branch) {
159 return Err(format!("branch '{branch}' not found"));
160 }
161
162 let out = Command::new("git")
163 .args(["switch", branch])
164 .current_dir(cwd)
165 .no_window()
166 .output()
167 .map_err(|e| format!("failed to run git: {e}"))?;
168
169 if out.status.success() {
170 Ok(branch.to_string())
171 } else {
172 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
173 }
174}
175
176pub fn create_branch(cwd: &str, branch: &str) -> Result<String, String> {
182 if !list_branches(cwd).is_repo {
183 return Err("not a git repository".to_string());
184 }
185 let name = branch.trim();
188 if name.is_empty()
189 || name.starts_with('-')
190 || name.contains("..")
191 || name.chars().any(|c| c.is_whitespace() || c.is_control())
192 {
193 return Err(format!("'{branch}' is not a valid branch name"));
194 }
195
196 let out = Command::new("git")
197 .args(["switch", "-c", name])
198 .current_dir(cwd)
199 .no_window()
200 .output()
201 .map_err(|e| format!("failed to run git: {e}"))?;
202
203 if out.status.success() {
204 Ok(name.to_string())
205 } else {
206 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
207 }
208}
209
210#[derive(serde::Serialize)]
212pub struct CommitPushOutcome {
213 pub success: bool,
214 pub committed: bool,
215 pub pushed: bool,
216 pub commit: Option<String>,
217}
218
219pub fn run_git_action(
224 cwd: &str,
225 message: &str,
226 action: &str,
227 include_unstaged: bool,
228) -> Result<CommitPushOutcome, String> {
229 if run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).is_none() {
231 return Err("not a git repository".to_string());
232 }
233
234 if action != "push" && include_unstaged {
235 let add = Command::new("git")
237 .args(["add", "-A"])
238 .current_dir(cwd)
239 .no_window()
240 .output()
241 .map_err(|e| format!("failed to run git: {e}"))?;
242 if !add.status.success() {
243 return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
244 }
245 }
246
247 let mut committed = false;
248 if action != "push" {
249 let staged_args = ["diff", "--cached", "--name-only"];
250 let has_staged = run_git(cwd, &staged_args)
251 .map(|s| s.lines().any(|l| !l.trim().is_empty()))
252 .unwrap_or(false);
253
254 if !has_staged && include_unstaged {
255 let has_changes = run_git(cwd, &["status", "--porcelain"])
256 .map(|s| s.lines().any(|l| !l.trim().is_empty()))
257 .unwrap_or(false);
258 if has_changes {
259 return Err("no staged changes to commit".to_string());
260 }
261 }
262
263 let commit = Command::new("git")
264 .args(["commit", "-m", message])
265 .current_dir(cwd)
266 .no_window()
267 .output()
268 .map_err(|e| format!("failed to run git: {e}"))?;
269 if has_staged && commit.status.success() {
270 committed = true;
271 } else if has_staged {
272 return Err(String::from_utf8_lossy(&commit.stderr).trim().to_string());
273 }
274 }
275
276 let mut pushed = false;
277 if action != "commit" {
278 let push = Command::new("git")
281 .args(["push"])
282 .current_dir(cwd)
283 .no_window()
284 .output()
285 .map_err(|e| format!("failed to run git: {e}"))?;
286 if !push.status.success() {
287 return Err(String::from_utf8_lossy(&push.stderr).trim().to_string());
288 }
289 pushed = true;
290 }
291
292 let commit = run_git(cwd, &["rev-parse", "--short", "HEAD"]);
293
294 Ok(CommitPushOutcome {
295 success: true,
296 committed,
297 pushed,
298 commit,
299 })
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn parse_ahead_behind_normal() {
308 assert_eq!(parse_ahead_behind(Some("3\t1")), (3, 1));
309 }
310
311 #[test]
312 fn parse_ahead_behind_none() {
313 assert_eq!(parse_ahead_behind(None), (0, 0));
314 }
315
316 #[test]
317 fn parse_ahead_behind_no_upstream() {
318 assert_eq!(parse_ahead_behind(Some("")), (0, 0));
319 }
320}