use std::path::Path;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use crate::error::GitError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ChangeType {
Modified,
Added,
Deleted,
Renamed,
Copied,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileStatus {
pub path: String,
pub change_type: ChangeType,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StatusReport {
pub branch: String,
pub ahead: u32,
pub behind: u32,
pub staged: Vec<FileStatus>,
pub unstaged: Vec<FileStatus>,
pub untracked: Vec<String>,
pub is_clean: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitEntry {
pub hash: String,
pub short_hash: String,
pub message: String,
pub author: String,
pub date: String,
pub date_relative: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchInfo {
pub current: String,
pub local: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitResult {
pub hash: String,
pub short_hash: String,
pub summary: String,
}
async fn git_run(args: &[&str], repo: &Path) -> Result<String, GitError> {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
GitError::BackendNotAvailable("git binary not found in PATH".into())
} else {
GitError::Io(e)
}
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
Err(GitError::BackendFailed {
exit_code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
async fn git_run_tolerant(args: &[&str], repo: &Path) -> Result<String, GitError> {
let output = Command::new("git")
.args(args)
.current_dir(repo)
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
GitError::BackendNotAvailable("git binary not found in PATH".into())
} else {
GitError::Io(e)
}
})?;
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn validate_path(path: &str) -> Result<(), GitError> {
if path.starts_with('/') || path.contains("..") {
return Err(GitError::PathTraversal(path.to_string()));
}
Ok(())
}
fn parse_xy(x: char, y: char) -> (Option<ChangeType>, Option<ChangeType>) {
let map_char = |c: char| match c {
'M' => Some(ChangeType::Modified),
'A' => Some(ChangeType::Added),
'D' => Some(ChangeType::Deleted),
'R' => Some(ChangeType::Renamed),
'C' => Some(ChangeType::Copied),
_ => None,
};
(map_char(x), map_char(y))
}
pub async fn git_status(repo: &Path) -> Result<StatusReport, GitError> {
let raw = git_run(&["status", "--porcelain=v1", "-b"], repo).await?;
parse_status_output(&raw)
}
pub fn parse_status_output(raw: &str) -> Result<StatusReport, GitError> {
let mut lines = raw.lines();
let branch_line = lines.next().unwrap_or("");
let branch_line = branch_line.trim_start_matches("## ");
let mut ahead: u32 = 0;
let mut behind: u32 = 0;
let branch: String;
if branch_line.starts_with("No commits yet on ") {
branch = branch_line
.trim_start_matches("No commits yet on ")
.to_string();
} else {
let (branch_part, tracking_part) = if let Some(idx) = branch_line.find("...") {
(&branch_line[..idx], Some(&branch_line[idx + 3..]))
} else {
(branch_line, None)
};
branch = branch_part.to_string();
if let Some(tracking) = tracking_part {
if let Some(bracket_start) = tracking.find('[') {
let inside = &tracking[bracket_start + 1..];
let inside = inside.trim_end_matches(']');
for part in inside.split(',') {
let part = part.trim();
if let Some(n) = part.strip_prefix("ahead ") {
ahead = n.trim().parse().unwrap_or(0);
} else if let Some(n) = part.strip_prefix("behind ") {
behind = n.trim().parse().unwrap_or(0);
}
}
}
}
}
let mut staged: Vec<FileStatus> = Vec::new();
let mut unstaged: Vec<FileStatus> = Vec::new();
let mut untracked: Vec<String> = Vec::new();
for line in lines {
if line.len() < 4 {
continue;
}
let x = line.chars().next().unwrap_or(' ');
let y = line.chars().nth(1).unwrap_or(' ');
let rest = &line[3..];
if x == '?' && y == '?' {
untracked.push(rest.to_string());
continue;
}
let (staged_change, unstaged_change) = parse_xy(x, y);
let (path, old_path) = if (x == 'R' || x == 'C' || y == 'R' || y == 'C')
&& rest.contains(" -> ")
{
let mut parts = rest.splitn(2, " -> ");
let dest = parts.next().unwrap_or(rest).to_string();
let orig = parts.next().map(str::to_string);
(dest, orig)
} else {
(rest.to_string(), None)
};
if let Some(ct) = staged_change {
staged.push(FileStatus {
path: path.clone(),
change_type: ct,
old_path: old_path.clone(),
});
}
if let Some(ct) = unstaged_change {
unstaged.push(FileStatus {
path: path.clone(),
change_type: ct,
old_path,
});
}
}
let is_clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
Ok(StatusReport {
branch,
ahead,
behind,
staged,
unstaged,
untracked,
is_clean,
})
}
pub async fn git_log(repo: &Path, limit: u32) -> Result<Vec<CommitEntry>, GitError> {
let cap = limit.min(100);
let cap_str = cap.to_string();
let format = "%H\x1F%h\x1F%s\x1F%an\x1F%aI\x1F%ar";
let raw = match git_run(
&["log", &format!("--format={format}"), "-n", &cap_str],
repo,
)
.await
{
Ok(v) => v,
Err(GitError::BackendFailed { ref stderr, .. }) if stderr.contains("does not have any commits") || stderr.contains("bad default revision") || stderr.contains("fatal: your current branch") => {
return Ok(Vec::new());
}
Err(e) => return Err(e),
};
if raw.trim().is_empty() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for line in raw.lines() {
let parts: Vec<&str> = line.splitn(6, '\x1F').collect();
if parts.len() < 6 {
continue;
}
entries.push(CommitEntry {
hash: parts[0].to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
author: parts[3].to_string(),
date: parts[4].to_string(),
date_relative: parts[5].to_string(),
});
}
Ok(entries)
}
pub async fn git_diff(repo: &Path, path: Option<&str>, staged: bool) -> Result<String, GitError> {
if let Some(p) = path {
validate_path(p)?;
}
let mut args: Vec<&str> = vec!["diff", "-U5"];
if staged {
args.push("--cached");
}
if let Some(p) = path {
args.push("--");
args.push(p);
}
git_run(&args, repo).await
}
pub async fn git_add(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
if all {
git_run(&["add", "-A"], repo).await?;
} else {
for p in paths {
validate_path(p)?;
}
let mut args = vec!["add", "--"];
let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
args.extend_from_slice(&path_refs);
git_run(&args, repo).await?;
}
Ok(())
}
pub async fn git_unstage(repo: &Path, paths: &[String], all: bool) -> Result<(), GitError> {
if all {
git_run_tolerant(&["reset", "HEAD", "--", "."], repo).await?;
} else {
for p in paths {
validate_path(p)?;
}
let mut args = vec!["reset", "HEAD", "--"];
let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
args.extend_from_slice(&path_refs);
git_run_tolerant(&args, repo).await?;
}
Ok(())
}
pub async fn git_commit(
repo: &Path,
message: &str,
author_name: &str,
author_email: &str,
) -> Result<CommitResult, GitError> {
let author_str = format!("{author_name} <{author_email}>");
git_run(
&["commit", "-m", message, "--author", &author_str],
repo,
)
.await?;
let hash = git_run(&["rev-parse", "HEAD"], repo).await?;
let hash = hash.trim().to_string();
let short_hash = if hash.len() >= 7 {
hash[..7].to_string()
} else {
hash.clone()
};
let summary = git_run(&["log", "-1", "--format=%s", &hash], repo)
.await
.unwrap_or_else(|_| message.to_string());
let summary = summary.trim().to_string();
Ok(CommitResult {
hash,
short_hash,
summary,
})
}
pub async fn git_branches(repo: &Path) -> Result<BranchInfo, GitError> {
let raw = match git_run(
&["branch", "--format=%(HEAD) %(refname:short)"],
repo,
)
.await
{
Ok(v) => v,
Err(GitError::BackendFailed { ref stderr, .. })
if stderr.contains("does not have any commits")
|| stderr.contains("bad default revision") =>
{
return Ok(BranchInfo {
current: "main".to_string(),
local: vec![],
});
}
Err(e) => return Err(e),
};
if raw.trim().is_empty() {
return Ok(BranchInfo {
current: "main".to_string(),
local: vec![],
});
}
let mut current = String::from("main");
let mut local: Vec<String> = Vec::new();
for line in raw.lines() {
let is_current = line.starts_with("* ");
let name = line.trim_start_matches("* ").trim_start_matches(" ").trim();
if name.is_empty() {
continue;
}
if is_current {
current = name.to_string();
}
local.push(name.to_string());
}
Ok(BranchInfo { current, local })
}
pub async fn git_create_branch(repo: &Path, name: &str) -> Result<(), GitError> {
if name.contains("..") || name.contains(' ') || name.starts_with('-') {
return Err(GitError::PathTraversal(format!(
"invalid branch name: {name}"
)));
}
git_run(&["checkout", "-b", name], repo).await?;
Ok(())
}
pub async fn git_discard(repo: &Path, paths: &[String]) -> Result<(), GitError> {
for p in paths {
validate_path(p)?;
}
let mut args = vec!["checkout", "--"];
let path_refs: Vec<&str> = paths.iter().map(String::as_str).collect();
args.extend_from_slice(&path_refs);
git_run(&args, repo).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_status(raw: &str) -> StatusReport {
parse_status_output(raw).expect("parse failed")
}
#[test]
fn parse_clean_repo() {
let raw = "## main...origin/main\n";
let s = make_status(raw);
assert_eq!(s.branch, "main");
assert_eq!(s.ahead, 0);
assert_eq!(s.behind, 0);
assert!(s.is_clean);
assert!(s.staged.is_empty());
assert!(s.unstaged.is_empty());
assert!(s.untracked.is_empty());
}
#[test]
fn parse_ahead_behind() {
let raw = "## main...origin/main [ahead 3, behind 1]\n";
let s = make_status(raw);
assert_eq!(s.branch, "main");
assert_eq!(s.ahead, 3);
assert_eq!(s.behind, 1);
}
#[test]
fn parse_ahead_only() {
let raw = "## feature...origin/feature [ahead 2]\n";
let s = make_status(raw);
assert_eq!(s.branch, "feature");
assert_eq!(s.ahead, 2);
assert_eq!(s.behind, 0);
}
#[test]
fn parse_no_commits_yet() {
let raw = "## No commits yet on main\n";
let s = make_status(raw);
assert_eq!(s.branch, "main");
assert_eq!(s.ahead, 0);
assert_eq!(s.behind, 0);
assert!(s.is_clean);
}
#[test]
fn parse_no_commits_yet_with_staged() {
let raw = "## No commits yet on main\nA README.md\n";
let s = make_status(raw);
assert_eq!(s.branch, "main");
assert_eq!(s.staged.len(), 1);
assert_eq!(s.staged[0].change_type, ChangeType::Added);
assert_eq!(s.staged[0].path, "README.md");
assert!(!s.is_clean);
}
#[test]
fn parse_modified_staged_and_unstaged() {
let raw = "## main\nMM src/lib.rs\n";
let s = make_status(raw);
assert_eq!(s.staged.len(), 1);
assert_eq!(s.staged[0].change_type, ChangeType::Modified);
assert_eq!(s.unstaged.len(), 1);
assert_eq!(s.unstaged[0].change_type, ChangeType::Modified);
}
#[test]
fn parse_untracked() {
let raw = "## main\n?? newfile.txt\n";
let s = make_status(raw);
assert_eq!(s.untracked, vec!["newfile.txt"]);
assert!(!s.is_clean);
}
#[test]
fn parse_deleted_staged() {
let raw = "## main\nD old.txt\n";
let s = make_status(raw);
assert_eq!(s.staged.len(), 1);
assert_eq!(s.staged[0].change_type, ChangeType::Deleted);
assert_eq!(s.staged[0].path, "old.txt");
}
#[test]
fn parse_renamed_staged() {
let raw = "## main\nR new.txt -> old.txt\n";
let s = make_status(raw);
assert_eq!(s.staged.len(), 1);
assert_eq!(s.staged[0].change_type, ChangeType::Renamed);
assert_eq!(s.staged[0].path, "new.txt");
assert_eq!(s.staged[0].old_path.as_deref(), Some("old.txt"));
}
#[test]
fn parse_branch_no_tracking() {
let raw = "## detached-head\nM foo.rs\n";
let s = make_status(raw);
assert_eq!(s.branch, "detached-head");
assert_eq!(s.ahead, 0);
assert_eq!(s.behind, 0);
}
#[test]
fn validate_path_rejects_dotdot() {
assert!(validate_path("../etc/passwd").is_err());
assert!(validate_path("foo/../../bar").is_err());
}
#[test]
fn validate_path_rejects_absolute() {
assert!(validate_path("/etc/passwd").is_err());
}
#[test]
fn validate_path_accepts_normal() {
assert!(validate_path("src/lib.rs").is_ok());
assert!(validate_path("README.md").is_ok());
}
}