use git2::Repository;
use std::path::PathBuf;
use std::process::Command;
use super::cache::STATUS_CACHE;
use super::{get_current_branch, open_repo, path_exists, GitError};
use crate::core::repo::RepoInfo;
use crate::util::log_cmd;
#[derive(Debug, Clone)]
pub struct RepoStatusInfo {
pub current_branch: String,
pub is_clean: bool,
pub staged: Vec<String>,
pub modified: Vec<String>,
pub untracked: Vec<String>,
pub ahead: usize,
pub behind: usize,
}
#[derive(Debug, Clone)]
pub struct RepoStatus {
pub name: String,
pub branch: String,
pub clean: bool,
pub staged: usize,
pub modified: usize,
pub untracked: usize,
pub ahead: usize,
pub behind: usize,
pub ahead_main: usize,
pub behind_main: usize,
pub exists: bool,
}
pub fn get_status_info(repo: &Repository) -> Result<RepoStatusInfo, GitError> {
let current_branch = get_current_branch(repo)?;
let repo_path = super::get_workdir(repo);
let mut cmd = Command::new("git");
cmd.args(["status", "--porcelain=v1"])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd
.output()
.map_err(|e| GitError::OperationFailed(e.to_string()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let mut staged = Vec::new();
let mut modified = Vec::new();
let mut untracked = Vec::new();
for line in stdout.lines() {
if line.len() < 3 {
continue;
}
let index_status = line.chars().next().unwrap_or(' ');
let worktree_status = line.chars().nth(1).unwrap_or(' ');
let path = line[3..].to_string();
if matches!(index_status, 'A' | 'M' | 'D' | 'R' | 'C') {
staged.push(path.clone());
}
if matches!(worktree_status, 'M' | 'D') {
modified.push(path.clone());
}
if index_status == '?' && worktree_status == '?' {
untracked.push(path);
}
}
let is_clean = staged.is_empty() && modified.is_empty() && untracked.is_empty();
let (ahead, behind) = get_ahead_behind_git(repo_path).unwrap_or((0, 0));
Ok(RepoStatusInfo {
current_branch,
is_clean,
staged,
modified,
untracked,
ahead,
behind,
})
}
pub fn get_cached_status(repo_path: &PathBuf) -> Result<RepoStatusInfo, GitError> {
if let Some(status) = STATUS_CACHE.get(repo_path) {
return Ok(status);
}
let repo = open_repo(repo_path)?;
let status = get_status_info(&repo)?;
STATUS_CACHE.set(repo_path.clone(), status.clone());
Ok(status)
}
fn get_ahead_behind_git(repo_path: &std::path::Path) -> Option<(usize, usize)> {
let mut cmd = Command::new("git");
cmd.args(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd.output().ok()?;
if !output.status.success() {
return Some((0, 0));
}
parse_ahead_behind(&output.stdout)
}
fn get_ahead_behind_branch(
repo_path: &std::path::Path,
base_branch: &str,
) -> Option<(usize, usize)> {
let remote_ref = format!("origin/{}", base_branch);
let mut cmd = Command::new("git");
cmd.args([
"rev-list",
"--left-right",
"--count",
&format!("{}...HEAD", remote_ref),
])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd.output().ok()?;
if !output.status.success() {
let mut cmd = Command::new("git");
cmd.args([
"rev-list",
"--left-right",
"--count",
&format!("{}...HEAD", base_branch),
])
.current_dir(repo_path);
log_cmd(&cmd);
let output = cmd.output().ok()?;
if !output.status.success() {
return Some((0, 0));
}
return parse_ahead_behind(&output.stdout);
}
parse_ahead_behind(&output.stdout)
}
fn parse_ahead_behind(stdout: &[u8]) -> Option<(usize, usize)> {
let stdout = String::from_utf8_lossy(stdout);
let parts: Vec<&str> = stdout.trim().split('\t').collect();
if parts.len() == 2 {
let behind = parts[0].parse().unwrap_or(0);
let ahead = parts[1].parse().unwrap_or(0);
Some((ahead, behind))
} else {
Some((0, 0))
}
}
pub fn get_repo_status(repo_info: &RepoInfo) -> RepoStatus {
if !path_exists(&repo_info.absolute_path) {
return RepoStatus {
name: repo_info.name.clone(),
branch: String::new(),
clean: true,
staged: 0,
modified: 0,
untracked: 0,
ahead: 0,
behind: 0,
ahead_main: 0,
behind_main: 0,
exists: false,
};
}
match get_cached_status(&repo_info.absolute_path) {
Ok(status) => {
let (ahead_main, behind_main) =
get_ahead_behind_branch(&repo_info.absolute_path, &repo_info.default_branch)
.unwrap_or((0, 0));
RepoStatus {
name: repo_info.name.clone(),
branch: status.current_branch,
clean: status.is_clean,
staged: status.staged.len(),
modified: status.modified.len(),
untracked: status.untracked.len(),
ahead: status.ahead,
behind: status.behind,
ahead_main,
behind_main,
exists: true,
}
}
Err(_) => RepoStatus {
name: repo_info.name.clone(),
branch: "error".to_string(),
clean: true,
staged: 0,
modified: 0,
untracked: 0,
ahead: 0,
behind: 0,
ahead_main: 0,
behind_main: 0,
exists: true,
},
}
}
pub fn get_all_repo_status(repos: &[RepoInfo]) -> Vec<RepoStatus> {
repos.iter().map(get_repo_status).collect()
}
pub fn get_changed_files(repo: &Repository) -> Result<Vec<String>, GitError> {
let status = get_status_info(repo)?;
let mut files = status.staged;
files.extend(status.modified);
files.extend(status.untracked);
Ok(files)
}
pub fn has_uncommitted_changes(repo: &Repository) -> Result<bool, GitError> {
let status = get_status_info(repo)?;
Ok(!status.is_clean)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn setup_test_repo() -> (TempDir, Repository) {
let temp = TempDir::new().unwrap();
Command::new("git")
.args(["init"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(temp.path())
.output()
.unwrap();
let repo = open_repo(temp.path()).unwrap();
(temp, repo)
}
#[test]
fn test_clean_repo() {
let (temp, repo) = setup_test_repo();
fs::write(temp.path().join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp.path())
.output()
.unwrap();
let status = get_status_info(&repo).unwrap();
assert!(status.is_clean);
assert!(status.staged.is_empty());
assert!(status.modified.is_empty());
assert!(status.untracked.is_empty());
}
#[test]
fn test_untracked_file() {
let (temp, repo) = setup_test_repo();
fs::write(temp.path().join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp.path())
.output()
.unwrap();
fs::write(temp.path().join("new_file.txt"), "content").unwrap();
let status = get_status_info(&repo).unwrap();
assert!(!status.is_clean);
assert!(status.staged.is_empty());
assert!(status.modified.is_empty());
assert_eq!(status.untracked.len(), 1);
assert!(status.untracked.contains(&"new_file.txt".to_string()));
}
#[test]
fn test_staged_file() {
let (temp, repo) = setup_test_repo();
fs::write(temp.path().join("README.md"), "# Test").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(temp.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(temp.path())
.output()
.unwrap();
fs::write(temp.path().join("staged.txt"), "content").unwrap();
Command::new("git")
.args(["add", "staged.txt"])
.current_dir(temp.path())
.output()
.unwrap();
let status = get_status_info(&repo).unwrap();
assert!(!status.is_clean);
assert_eq!(status.staged.len(), 1);
assert!(status.staged.contains(&"staged.txt".to_string()));
}
}