use std::process::Command;
use crate::win_process::NoWindow;
#[derive(serde::Serialize)]
pub struct GitState {
pub is_repo: bool,
pub branch: Option<String>,
pub ahead: u32,
pub behind: u32,
pub dirty: bool,
pub changed_files_count: usize,
pub insertions: u32,
pub deletions: u32,
}
const MAX_UNTRACKED_SCAN_BYTES: u64 = 2 * 1024 * 1024;
fn untracked_insertions(cwd: &str, untracked: &[String]) -> u32 {
let root = std::path::Path::new(cwd);
let mut insertions = 0u32;
for rel in untracked {
let path = root.join(rel);
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
if !meta.is_file() || meta.len() > MAX_UNTRACKED_SCAN_BYTES {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if bytes.is_empty() || bytes.contains(&0) {
continue;
}
let newlines = bytes.iter().filter(|b| **b == b'\n').count();
let lines = if bytes.last() == Some(&b'\n') {
newlines
} else {
newlines + 1
};
insertions = insertions.saturating_add(lines as u32);
}
insertions
}
fn untracked_paths(porcelain: &str) -> Vec<String> {
porcelain
.lines()
.filter_map(|l| l.strip_prefix("?? "))
.map(unquote_git_path)
.collect()
}
fn unquote_git_path(raw: &str) -> String {
let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
return raw.to_string();
};
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some(other) => out.push(other),
None => break,
}
}
out
}
fn query_diff_totals(cwd: &str) -> (u32, u32) {
let numstat = run_git(cwd, &["diff", "HEAD", "--numstat"]).unwrap_or_default();
let mut insertions = 0u32;
let mut deletions = 0u32;
for line in numstat.lines() {
let mut cols = line.split('\t');
let adds = cols.next().and_then(|c| c.parse::<u32>().ok());
let dels = cols.next().and_then(|c| c.parse::<u32>().ok());
if let (Some(a), Some(d)) = (adds, dels) {
insertions += a;
deletions += d;
}
}
(insertions, deletions)
}
fn run_git(cwd: &str, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.args(args)
.current_dir(cwd)
.no_window()
.output()
.ok()?;
if out.status.success() {
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
None
}
}
pub fn query_git_state(cwd: &str) -> GitState {
let branch = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
let is_repo = branch.is_some();
if !is_repo {
return GitState {
is_repo: false,
branch: None,
ahead: 0,
behind: 0,
dirty: false,
changed_files_count: 0,
insertions: 0,
deletions: 0,
};
}
let porcelain = run_git(cwd, &["status", "--porcelain", "--untracked-files=all"])
.unwrap_or_default();
let changed: Vec<&str> = porcelain.lines().filter(|l| !l.is_empty()).collect();
let dirty = !changed.is_empty();
let ahead_behind = run_git(cwd, &["rev-list", "--count", "--left-right", "@{u}...HEAD"]);
let (behind, ahead) = parse_ahead_behind(ahead_behind.as_deref());
let (tracked_insertions, deletions) = query_diff_totals(cwd);
let insertions =
tracked_insertions.saturating_add(untracked_insertions(cwd, &untracked_paths(&porcelain)));
GitState {
is_repo: true,
branch,
ahead,
behind,
dirty,
changed_files_count: changed.len(),
insertions,
deletions,
}
}
fn parse_ahead_behind(raw: Option<&str>) -> (u32, u32) {
let Some(s) = raw else {
return (0, 0);
};
let mut parts = s.split_whitespace();
let behind = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
let ahead = parts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
(behind, ahead)
}
#[derive(serde::Serialize)]
pub struct GitBranches {
pub is_repo: bool,
pub current: Option<String>,
pub branches: Vec<String>,
}
pub fn list_branches(cwd: &str) -> GitBranches {
let current = run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]);
if current.is_none() {
return GitBranches {
is_repo: false,
current: None,
branches: Vec::new(),
};
}
let raw = run_git(cwd, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
let branches: Vec<String> = raw
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
GitBranches {
is_repo: true,
current,
branches,
}
}
pub fn checkout_branch(cwd: &str, branch: &str) -> Result<String, String> {
let known = list_branches(cwd);
if !known.is_repo {
return Err("not a git repository".to_string());
}
if !known.branches.iter().any(|b| b == branch) {
return Err(format!("branch '{branch}' not found"));
}
let out = Command::new("git")
.args(["switch", branch])
.current_dir(cwd)
.no_window()
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if out.status.success() {
Ok(branch.to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub fn create_branch(cwd: &str, branch: &str) -> Result<String, String> {
if !list_branches(cwd).is_repo {
return Err("not a git repository".to_string());
}
let name = branch.trim();
if name.is_empty()
|| name.starts_with('-')
|| name.contains("..")
|| name.chars().any(|c| c.is_whitespace() || c.is_control())
{
return Err(format!("'{branch}' is not a valid branch name"));
}
let out = Command::new("git")
.args(["switch", "-c", name])
.current_dir(cwd)
.no_window()
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if out.status.success() {
Ok(name.to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[derive(serde::Serialize)]
pub struct CommitPushOutcome {
pub success: bool,
pub committed: bool,
pub pushed: bool,
pub commit: Option<String>,
}
pub fn run_git_action(
cwd: &str,
message: &str,
action: &str,
include_unstaged: bool,
) -> Result<CommitPushOutcome, String> {
if run_git(cwd, &["rev-parse", "--abbrev-ref", "HEAD"]).is_none() {
return Err("not a git repository".to_string());
}
if action != "push" && include_unstaged {
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(cwd)
.no_window()
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if !add.status.success() {
return Err(String::from_utf8_lossy(&add.stderr).trim().to_string());
}
}
let mut committed = false;
if action != "push" {
let staged_args = ["diff", "--cached", "--name-only"];
let has_staged = run_git(cwd, &staged_args)
.map(|s| s.lines().any(|l| !l.trim().is_empty()))
.unwrap_or(false);
if !has_staged && include_unstaged {
let has_changes = run_git(cwd, &["status", "--porcelain"])
.map(|s| s.lines().any(|l| !l.trim().is_empty()))
.unwrap_or(false);
if has_changes {
return Err("no staged changes to commit".to_string());
}
}
let commit = Command::new("git")
.args(["commit", "-m", message])
.current_dir(cwd)
.no_window()
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if has_staged && commit.status.success() {
committed = true;
} else if has_staged {
return Err(String::from_utf8_lossy(&commit.stderr).trim().to_string());
}
}
let mut pushed = false;
if action != "commit" {
let push = Command::new("git")
.args(["push"])
.current_dir(cwd)
.no_window()
.output()
.map_err(|e| format!("failed to run git: {e}"))?;
if !push.status.success() {
return Err(String::from_utf8_lossy(&push.stderr).trim().to_string());
}
pushed = true;
}
let commit = run_git(cwd, &["rev-parse", "--short", "HEAD"]);
Ok(CommitPushOutcome {
success: true,
committed,
pushed,
commit,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ahead_behind_normal() {
assert_eq!(parse_ahead_behind(Some("3\t1")), (3, 1));
}
#[test]
fn parse_ahead_behind_none() {
assert_eq!(parse_ahead_behind(None), (0, 0));
}
#[test]
fn parse_ahead_behind_no_upstream() {
assert_eq!(parse_ahead_behind(Some("")), (0, 0));
}
#[test]
fn untracked_paths_picks_only_untracked_rows() {
let porcelain = " M src/lib.rs\nA src/new.rs\n?? notes.md\n?? src/scratch.rs\n";
assert_eq!(
untracked_paths(porcelain),
vec!["notes.md".to_string(), "src/scratch.rs".to_string()]
);
}
#[test]
fn untracked_paths_unquotes_git_quoting() {
assert_eq!(untracked_paths("?? \"a\\tb.txt\"\n"), vec!["a\tb.txt"]);
}
#[test]
fn untracked_insertions_counts_every_line_of_a_new_file() {
let dir = std::env::temp_dir().join(format!(
"ryu-untracked-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("new.txt"), b"a\nb\nc").unwrap();
std::fs::write(dir.join("blob.bin"), b"a\0b\n").unwrap();
let counted = untracked_insertions(
dir.to_str().unwrap(),
&["new.txt".to_string(), "blob.bin".to_string()],
);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(counted, 3);
}
}