use std::{
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, PartialEq)]
pub enum VcsStatus {
Modified,
Added,
Deleted,
Renamed,
Untracked,
}
#[derive(Debug, Clone)]
pub struct VcsEntry {
pub rel_path: String,
pub status: VcsStatus,
}
#[must_use]
pub fn is_git_repo(dir: &Path) -> bool {
Command::new("git")
.args([
"-C",
&dir.to_string_lossy(),
"rev-parse",
"--is-inside-work-tree",
])
.output()
.is_ok_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
}
#[must_use]
pub fn repo_root(dir: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["-C", &dir.to_string_lossy(), "rev-parse", "--show-toplevel"])
.output()
.ok()?;
if output.status.success() {
Some(PathBuf::from(
String::from_utf8_lossy(&output.stdout).trim(),
))
} else {
None
}
}
#[must_use]
pub fn changed_files(repo_root: &Path) -> Vec<VcsEntry> {
let output = match Command::new("git")
.args([
"-C",
&repo_root.to_string_lossy(),
"status",
"--porcelain",
"-z",
])
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
parse_porcelain_nul(&output.stdout)
}
#[must_use]
pub fn parse_porcelain_nul(raw: &[u8]) -> Vec<VcsEntry> {
let mut entries = Vec::new();
let mut i = 0;
while i < raw.len() {
let next_nul = raw[i..]
.iter()
.position(|&b| b == 0)
.map_or(raw.len(), |p| i + p);
let entry_str = String::from_utf8_lossy(&raw[i..next_nul]);
if entry_str.len() < 3 {
i = next_nul + 1;
continue;
}
let xy = &entry_str[..2];
let path = entry_str[2..].trim_start().to_string();
let status = match xy {
"??" => VcsStatus::Untracked,
_ if xy.starts_with('R') => VcsStatus::Renamed,
_ if xy.starts_with('D') || xy.ends_with('D') => VcsStatus::Deleted,
_ if xy.starts_with('A') => VcsStatus::Added,
_ if xy.contains('M') => VcsStatus::Modified,
_ => {
i = next_nul + 1;
continue;
}
};
let rel_path = if status == VcsStatus::Renamed {
let next_start = next_nul + 1;
let next_nul2 = raw[next_start..]
.iter()
.position(|&b| b == 0)
.map_or(raw.len(), |p| next_start + p);
i = next_nul2 + 1;
path } else {
i = next_nul + 1;
path
};
entries.push(VcsEntry { rel_path, status });
}
entries
}
#[must_use]
pub fn head_content(repo_root: &Path, rel_path: &str) -> Option<String> {
let arg = format!("HEAD:{rel_path}");
let output = Command::new("git")
.args(["-C", &repo_root.to_string_lossy(), "show", &arg])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
None
}
}
#[must_use]
pub fn discard_changes(repo_root: &Path, rel_path: &str) -> bool {
Command::new("git")
.args([
"-C",
&repo_root.to_string_lossy(),
"checkout",
"HEAD",
"--",
rel_path,
])
.status()
.is_ok_and(|s| s.success())
}
#[must_use]
pub fn stage_file(repo_root: &Path, rel_path: &str) -> bool {
Command::new("git")
.args(["-C", &repo_root.to_string_lossy(), "add", rel_path])
.status()
.is_ok_and(|s| s.success())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_nul_modified() {
let entries = parse_porcelain_nul(b" M src/main.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Modified);
assert_eq!(entries[0].rel_path, "src/main.rs");
}
#[test]
fn test_parse_nul_staged_modified() {
let entries = parse_porcelain_nul(b"M src/main.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Modified);
}
#[test]
fn test_parse_nul_added() {
let entries = parse_porcelain_nul(b"A new_file.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Added);
assert_eq!(entries[0].rel_path, "new_file.rs");
}
#[test]
fn test_parse_nul_deleted() {
let entries = parse_porcelain_nul(b"D old_file.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Deleted);
}
#[test]
fn test_parse_nul_unstaged_delete() {
let entries = parse_porcelain_nul(b" D old_file.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Deleted);
}
#[test]
fn test_parse_nul_renamed() {
let entries = parse_porcelain_nul(b"R new.rs\0old.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Renamed);
assert_eq!(entries[0].rel_path, "new.rs");
}
#[test]
fn test_parse_nul_untracked() {
let entries = parse_porcelain_nul(b"?? untracked.txt\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Untracked);
assert_eq!(entries[0].rel_path, "untracked.txt");
}
#[test]
fn test_parse_nul_multiple() {
let input = b" M src/main.rs\0A new.rs\0?? tmp.txt\0";
let entries = parse_porcelain_nul(input);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].status, VcsStatus::Modified);
assert_eq!(entries[1].status, VcsStatus::Added);
assert_eq!(entries[2].status, VcsStatus::Untracked);
}
#[test]
fn test_parse_nul_empty() {
assert!(parse_porcelain_nul(b"").is_empty());
}
#[test]
fn test_parse_nul_unknown_status_ignored() {
let entries = parse_porcelain_nul(b"!! ignored_file\0");
assert!(entries.is_empty());
}
#[test]
fn test_parse_nul_path_with_spaces() {
let entries = parse_porcelain_nul(b" M path with spaces/file name.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Modified);
assert_eq!(entries[0].rel_path, "path with spaces/file name.rs");
}
#[test]
fn test_parse_nul_path_containing_arrow() {
let entries = parse_porcelain_nul(b" M docs/a -> b/readme.md\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Modified);
assert_eq!(entries[0].rel_path, "docs/a -> b/readme.md");
}
#[test]
fn test_parse_nul_rename_with_spaces() {
let entries = parse_porcelain_nul(b"R new name.rs\0old name.rs\0");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, VcsStatus::Renamed);
assert_eq!(entries[0].rel_path, "new name.rs");
}
}