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
25const MAX_UNTRACKED_SCAN_BYTES: u64 = 2 * 1024 * 1024;
29
30fn untracked_insertions(cwd: &str, untracked: &[String]) -> u32 {
39 let root = std::path::Path::new(cwd);
40 let mut insertions = 0u32;
41 for rel in untracked {
42 let path = root.join(rel);
43 let Ok(meta) = std::fs::metadata(&path) else {
44 continue;
45 };
46 if !meta.is_file() || meta.len() > MAX_UNTRACKED_SCAN_BYTES {
47 continue;
48 }
49 let Ok(bytes) = std::fs::read(&path) else {
50 continue;
51 };
52 if bytes.is_empty() || bytes.contains(&0) {
53 continue;
54 }
55 let newlines = bytes.iter().filter(|b| **b == b'\n').count();
56 let lines = if bytes.last() == Some(&b'\n') {
58 newlines
59 } else {
60 newlines + 1
61 };
62 insertions = insertions.saturating_add(lines as u32);
63 }
64 insertions
65}
66
67fn untracked_paths(porcelain: &str) -> Vec<String> {
71 porcelain
72 .lines()
73 .filter_map(|l| l.strip_prefix("?? "))
74 .map(unquote_git_path)
75 .collect()
76}
77
78fn unquote_git_path(raw: &str) -> String {
80 let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
81 return raw.to_string();
82 };
83 let mut out = String::with_capacity(inner.len());
84 let mut chars = inner.chars();
85 while let Some(c) = chars.next() {
86 if c != '\\' {
87 out.push(c);
88 continue;
89 }
90 match chars.next() {
91 Some('n') => out.push('\n'),
92 Some('t') => out.push('\t'),
93 Some('r') => out.push('\r'),
94 Some(other) => out.push(other),
95 None => break,
96 }
97 }
98 out
99}
100
101fn query_diff_totals(cwd: &str) -> (u32, u32) {
104 let numstat = run_git(cwd, &["diff", "HEAD", "--numstat"]).unwrap_or_default();
105 let mut insertions = 0u32;
106 let mut deletions = 0u32;
107 for line in numstat.lines() {
108 let mut cols = line.split('\t');
109 let adds = cols.next().and_then(|c| c.parse::<u32>().ok());
110 let dels = cols.next().and_then(|c| c.parse::<u32>().ok());
111 if let (Some(a), Some(d)) = (adds, dels) {
112 insertions += a;
113 deletions += d;
114 }
115 }
116 (insertions, deletions)
117}
118
119fn run_git(cwd: &str, args: &[&str]) -> Option<String> {
120 let out = Command::new("git")
121 .args(args)
122 .current_dir(cwd)
123 .no_window()
124 .output()
125 .ok()?;
126 if out.status.success() {
127 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
128 } else {
129 None
130 }
131}
132
133pub fn query_git_state(cwd: &str) -> GitState {
136 let branch = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
138 let is_repo = branch.is_some();
139
140 if !is_repo {
141 return GitState {
142 is_repo: false,
143 branch: None,
144 ahead: 0,
145 behind: 0,
146 dirty: false,
147 changed_files_count: 0,
148 insertions: 0,
149 deletions: 0,
150 };
151 }
152
153 let porcelain = run_git(cwd, &["status", "--porcelain", "--untracked-files=all"])
158 .unwrap_or_default();
159 let changed: Vec<&str> = porcelain.lines().filter(|l| !l.is_empty()).collect();
160 let dirty = !changed.is_empty();
161
162 let ahead_behind = run_git(cwd, &["rev-list", "--count", "--left-right", "@{u}...HEAD"]);
165 let (behind, ahead) = parse_ahead_behind(ahead_behind.as_deref());
166
167 let (tracked_insertions, deletions) = query_diff_totals(cwd);
168 let insertions =
169 tracked_insertions.saturating_add(untracked_insertions(cwd, &untracked_paths(&porcelain)));
170
171 GitState {
172 is_repo: true,
173 branch,
174 ahead,
175 behind,
176 dirty,
177 changed_files_count: changed.len(),
178 insertions,
179 deletions,
180 }
181}
182
183fn parse_ahead_behind(raw: Option<&str>) -> (u32, u32) {
185 let Some(s) = raw else {
186 return (0, 0);
187 };
188 let mut parts = s.split_whitespace();
189 let behind = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
190 let ahead = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
191 (behind, ahead)
192}
193
194#[derive(serde::Serialize)]
196pub struct GitBranches {
197 pub is_repo: bool,
198 pub current: Option<String>,
199 pub branches: Vec<String>,
200}
201
202pub fn list_branches(cwd: &str) -> GitBranches {
205 let current = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
206 if current.is_none() {
207 return GitBranches {
208 is_repo: false,
209 current: None,
210 branches: Vec::new(),
211 };
212 }
213
214 let raw = run_git(cwd, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
215 let branches: Vec<String> = raw
216 .lines()
217 .map(|l| l.trim().to_string())
218 .filter(|l| !l.is_empty())
219 .collect();
220
221 GitBranches {
222 is_repo: true,
223 current,
224 branches,
225 }
226}
227
228pub fn checkout_branch(cwd: &str, branch: &str) -> Result<String, String> {
234 let known = list_branches(cwd);
237 if !known.is_repo {
238 return Err("not a git repository".to_string());
239 }
240 if !known.branches.iter().any(|b| b == branch) {
241 return Err(format!("branch '{branch}' not found"));
242 }
243
244 let out = Command::new("git")
245 .args(["switch", branch])
246 .current_dir(cwd)
247 .no_window()
248 .output()
249 .map_err(|e| format!("failed to run git: {e}"))?;
250
251 if out.status.success() {
252 Ok(branch.to_string())
253 } else {
254 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
255 }
256}
257
258pub fn create_branch(cwd: &str, branch: &str) -> Result<String, String> {
264 if !list_branches(cwd).is_repo {
265 return Err("not a git repository".to_string());
266 }
267 let name = branch.trim();
270 if name.is_empty()
271 || name.starts_with('-')
272 || name.contains("..")
273 || name.chars().any(|c| c.is_whitespace() || c.is_control())
274 {
275 return Err(format!("'{branch}' is not a valid branch name"));
276 }
277
278 let out = Command::new("git")
279 .args(["switch", "-c", name])
280 .current_dir(cwd)
281 .no_window()
282 .output()
283 .map_err(|e| format!("failed to run git: {e}"))?;
284
285 if out.status.success() {
286 Ok(name.to_string())
287 } else {
288 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
289 }
290}
291
292#[derive(serde::Serialize)]
294pub struct CommitPushOutcome {
295 pub success: bool,
296 pub committed: bool,
297 pub pushed: bool,
298 pub commit: Option<String>,
299}
300
301pub fn run_git_action(
306 cwd: &str,
307 message: &str,
308 action: &str,
309 include_unstaged: bool,
310) -> Result<CommitPushOutcome, String> {
311 if run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).is_none() {
313 return Err("not a git repository".to_string());
314 }
315
316 if action != "push" && include_unstaged {
317 let add = Command::new("git")
319 .args(["add", "-A"])
320 .current_dir(cwd)
321 .no_window()
322 .output()
323 .map_err(|e| format!("failed to run git: {e}"))?;
324 if !add.status.success() {
325 return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
326 }
327 }
328
329 let mut committed = false;
330 if action != "push" {
331 let staged_args = ["diff", "--cached", "--name-only"];
332 let has_staged = run_git(cwd, &staged_args)
333 .map(|s| s.lines().any(|l| !l.trim().is_empty()))
334 .unwrap_or(false);
335
336 if !has_staged && include_unstaged {
337 let has_changes = run_git(cwd, &["status", "--porcelain"])
338 .map(|s| s.lines().any(|l| !l.trim().is_empty()))
339 .unwrap_or(false);
340 if has_changes {
341 return Err("no staged changes to commit".to_string());
342 }
343 }
344
345 let commit = Command::new("git")
346 .args(["commit", "-m", message])
347 .current_dir(cwd)
348 .no_window()
349 .output()
350 .map_err(|e| format!("failed to run git: {e}"))?;
351 if has_staged && commit.status.success() {
352 committed = true;
353 } else if has_staged {
354 return Err(String::from_utf8_lossy(&commit.stderr).trim().to_string());
355 }
356 }
357
358 let mut pushed = false;
359 if action != "commit" {
360 let push = Command::new("git")
363 .args(["push"])
364 .current_dir(cwd)
365 .no_window()
366 .output()
367 .map_err(|e| format!("failed to run git: {e}"))?;
368 if !push.status.success() {
369 return Err(String::from_utf8_lossy(&push.stderr).trim().to_string());
370 }
371 pushed = true;
372 }
373
374 let commit = run_git(cwd, &["rev-parse", "--short", "HEAD"]);
375
376 Ok(CommitPushOutcome {
377 success: true,
378 committed,
379 pushed,
380 commit,
381 })
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn parse_ahead_behind_normal() {
390 assert_eq!(parse_ahead_behind(Some("3\t1")), (3, 1));
391 }
392
393 #[test]
394 fn parse_ahead_behind_none() {
395 assert_eq!(parse_ahead_behind(None), (0, 0));
396 }
397
398 #[test]
399 fn parse_ahead_behind_no_upstream() {
400 assert_eq!(parse_ahead_behind(Some("")), (0, 0));
401 }
402
403 #[test]
404 fn untracked_paths_picks_only_untracked_rows() {
405 let porcelain = " M src/lib.rs\nA src/new.rs\n?? notes.md\n?? src/scratch.rs\n";
406 assert_eq!(
407 untracked_paths(porcelain),
408 vec!["notes.md".to_string(), "src/scratch.rs".to_string()]
409 );
410 }
411
412 #[test]
413 fn untracked_paths_unquotes_git_quoting() {
414 assert_eq!(untracked_paths("?? \"a\\tb.txt\"\n"), vec!["a\tb.txt"]);
415 }
416
417 #[test]
418 fn untracked_insertions_counts_every_line_of_a_new_file() {
419 let dir = std::env::temp_dir().join(format!(
420 "ryu-untracked-{}-{:?}",
421 std::process::id(),
422 std::thread::current().id()
423 ));
424 std::fs::create_dir_all(&dir).unwrap();
425 std::fs::write(dir.join("new.txt"), b"a\nb\nc").unwrap();
427 std::fs::write(dir.join("blob.bin"), b"a\0b\n").unwrap();
429
430 let counted = untracked_insertions(
431 dir.to_str().unwrap(),
432 &["new.txt".to_string(), "blob.bin".to_string()],
433 );
434 std::fs::remove_dir_all(&dir).ok();
435
436 assert_eq!(counted, 3);
437 }
438}