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(
220 cwd,
221 &["branch", "--sort=-committerdate", "--format=%(refname:short)"],
222 )
223 .unwrap_or_default();
224 let branches: Vec<String> = raw
225 .lines()
226 .map(|l| l.trim().to_string())
227 .filter(|l| !l.is_empty())
228 .collect();
229
230 GitBranches {
231 is_repo: true,
232 current,
233 branches,
234 }
235}
236
237pub fn checkout_branch(cwd: &str, branch: &str) -> Result<String, String> {
243 let known = list_branches(cwd);
246 if !known.is_repo {
247 return Err("not a git repository".to_string());
248 }
249 if !known.branches.iter().any(|b| b == branch) {
250 return Err(format!("branch '{branch}' not found"));
251 }
252
253 let out = Command::new("git")
254 .args(["switch", branch])
255 .current_dir(cwd)
256 .no_window()
257 .output()
258 .map_err(|e| format!("failed to run git: {e}"))?;
259
260 if out.status.success() {
261 Ok(branch.to_string())
262 } else {
263 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
264 }
265}
266
267pub fn create_branch(cwd: &str, branch: &str) -> Result<String, String> {
273 if !list_branches(cwd).is_repo {
274 return Err("not a git repository".to_string());
275 }
276 let name = branch.trim();
279 if name.is_empty()
280 || name.starts_with('-')
281 || name.contains("..")
282 || name.chars().any(|c| c.is_whitespace() || c.is_control())
283 {
284 return Err(format!("'{branch}' is not a valid branch name"));
285 }
286
287 let out = Command::new("git")
288 .args(["switch", "-c", name])
289 .current_dir(cwd)
290 .no_window()
291 .output()
292 .map_err(|e| format!("failed to run git: {e}"))?;
293
294 if out.status.success() {
295 Ok(name.to_string())
296 } else {
297 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
298 }
299}
300
301#[derive(serde::Serialize)]
303pub struct CommitPushOutcome {
304 pub success: bool,
305 pub committed: bool,
306 pub pushed: bool,
307 pub commit: Option<String>,
308}
309
310pub fn run_git_action(
315 cwd: &str,
316 message: &str,
317 action: &str,
318 include_unstaged: bool,
319) -> Result<CommitPushOutcome, String> {
320 if run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).is_none() {
322 return Err("not a git repository".to_string());
323 }
324
325 if action != "push" && include_unstaged {
326 let add = Command::new("git")
328 .args(["add", "-A"])
329 .current_dir(cwd)
330 .no_window()
331 .output()
332 .map_err(|e| format!("failed to run git: {e}"))?;
333 if !add.status.success() {
334 return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
335 }
336 }
337
338 let mut committed = false;
339 if action != "push" {
340 let staged_args = ["diff", "--cached", "--name-only"];
341 let has_staged = run_git(cwd, &staged_args)
342 .map(|s| s.lines().any(|l| !l.trim().is_empty()))
343 .unwrap_or(false);
344
345 if !has_staged && include_unstaged {
346 let has_changes = run_git(cwd, &["status", "--porcelain"])
347 .map(|s| s.lines().any(|l| !l.trim().is_empty()))
348 .unwrap_or(false);
349 if has_changes {
350 return Err("no staged changes to commit".to_string());
351 }
352 }
353
354 let commit = Command::new("git")
355 .args(["commit", "-m", message])
356 .current_dir(cwd)
357 .no_window()
358 .output()
359 .map_err(|e| format!("failed to run git: {e}"))?;
360 if has_staged && commit.status.success() {
361 committed = true;
362 } else if has_staged {
363 return Err(String::from_utf8_lossy(&commit.stderr).trim().to_string());
364 }
365 }
366
367 let mut pushed = false;
368 if action != "commit" {
369 let push = Command::new("git")
372 .args(["push"])
373 .current_dir(cwd)
374 .no_window()
375 .output()
376 .map_err(|e| format!("failed to run git: {e}"))?;
377 if !push.status.success() {
378 return Err(String::from_utf8_lossy(&push.stderr).trim().to_string());
379 }
380 pushed = true;
381 }
382
383 let commit = run_git(cwd, &["rev-parse", "--short", "HEAD"]);
384
385 Ok(CommitPushOutcome {
386 success: true,
387 committed,
388 pushed,
389 commit,
390 })
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn parse_ahead_behind_normal() {
399 assert_eq!(parse_ahead_behind(Some("3\t1")), (3, 1));
400 }
401
402 #[test]
403 fn parse_ahead_behind_none() {
404 assert_eq!(parse_ahead_behind(None), (0, 0));
405 }
406
407 #[test]
408 fn parse_ahead_behind_no_upstream() {
409 assert_eq!(parse_ahead_behind(Some("")), (0, 0));
410 }
411
412 #[test]
413 fn untracked_paths_picks_only_untracked_rows() {
414 let porcelain = " M src/lib.rs\nA src/new.rs\n?? notes.md\n?? src/scratch.rs\n";
415 assert_eq!(
416 untracked_paths(porcelain),
417 vec!["notes.md".to_string(), "src/scratch.rs".to_string()]
418 );
419 }
420
421 #[test]
422 fn untracked_paths_unquotes_git_quoting() {
423 assert_eq!(untracked_paths("?? \"a\\tb.txt\"\n"), vec!["a\tb.txt"]);
424 }
425
426 #[test]
427 fn untracked_insertions_counts_every_line_of_a_new_file() {
428 let dir = std::env::temp_dir().join(format!(
429 "ryu-untracked-{}-{:?}",
430 std::process::id(),
431 std::thread::current().id()
432 ));
433 std::fs::create_dir_all(&dir).unwrap();
434 std::fs::write(dir.join("new.txt"), b"a\nb\nc").unwrap();
436 std::fs::write(dir.join("blob.bin"), b"a\0b\n").unwrap();
438
439 let counted = untracked_insertions(
440 dir.to_str().unwrap(),
441 &["new.txt".to_string(), "blob.bin".to_string()],
442 );
443 std::fs::remove_dir_all(&dir).ok();
444
445 assert_eq!(counted, 3);
446 }
447}