use std::{collections::HashSet, process::Command};
use crate::errors::{GitError, Result, RonaError};
fn unquote_git_path(path: &str) -> String {
if path.starts_with('"') && path.ends_with('"') && path.len() >= 2 {
let inner = &path[1..path.len() - 1];
let mut result: Vec<u8> = Vec::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\\' {
let mut buf = [0u8; 4];
result.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
continue;
}
match chars.next() {
Some('\\') | None => result.push(b'\\'),
Some('"') => result.push(b'"'),
Some('n') => result.push(b'\n'),
Some('t') => result.push(b'\t'),
Some('r') => result.push(b'\r'),
Some(c @ '0'..='7') => {
let mut octal = String::from(c);
for _ in 0..2 {
match chars.peek() {
Some(&d) if d.is_ascii_digit() && d <= '7' => {
octal.push(d);
chars.next();
}
_ => break,
}
}
if let Ok(byte) = u8::from_str_radix(&octal, 8) {
result.push(byte);
}
}
Some(c) => {
result.push(b'\\');
let mut buf = [0u8; 4];
result.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
}
}
}
return String::from_utf8_lossy(&result).into_owned();
}
path.to_string()
}
fn run_git_status() -> Result<Vec<String>> {
let output = Command::new("git")
.args(["status", "--porcelain=v1"])
.output()
.map_err(RonaError::Io)?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
return Ok(stdout.lines().map(String::from).collect());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.to_lowercase().contains("not a git repository") {
return Err(RonaError::Git(GitError::RepositoryNotFound));
}
Err(RonaError::Git(GitError::CommandFailed {
command: "git status".to_string(),
output: stderr.trim().to_string(),
}))
}
fn get_renamed_new_paths() -> Result<Vec<String>> {
let output = Command::new("git")
.args(["diff", "--cached", "--name-status", "--diff-filter=R"])
.output()
.map_err(RonaError::Io)?;
if !output.status.success() {
return Ok(Vec::new());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let paths = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, '\t').collect();
if parts.len() >= 3 {
Some(parts[2].to_string())
} else {
None
}
})
.collect();
Ok(paths)
}
pub fn get_status_files() -> Result<Vec<String>> {
let lines = run_git_status()?;
let mut files: HashSet<String> = HashSet::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let mut chars = line.chars();
let index_char = chars.next().unwrap_or(' ');
let wt_char = chars.next().unwrap_or(' ');
let path = unquote_git_path(&line[3..]);
if index_char == 'D' && wt_char != 'M' && wt_char != '?' {
continue;
}
if wt_char == 'D' {
continue;
}
if index_char == 'R' {
continue;
}
files.insert(path);
}
for path in get_renamed_new_paths()? {
files.insert(path);
}
Ok(files.into_iter().collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusEntry {
pub path: String,
pub status: &'static str,
}
impl std::fmt::Display for StatusEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:<11} {}", self.status, self.path)
}
}
pub fn get_stageable_files() -> Result<Vec<StatusEntry>> {
let lines = run_git_status()?;
let mut entries = Vec::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let mut chars = line.chars();
let index_char = chars.next().unwrap_or(' ');
let wt_char = chars.next().unwrap_or(' ');
let is_untracked = index_char == '?' && wt_char == '?';
if wt_char == ' ' && !is_untracked {
continue;
}
let raw_path = &line[3..];
let path_part = raw_path.rsplit(" -> ").next().unwrap_or(raw_path);
let path = unquote_git_path(path_part);
let status = match wt_char {
'D' => "deleted",
'T' => "type change",
'?' => "untracked",
_ => "modified",
};
entries.push(StatusEntry { path, status });
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
pub fn get_staged_files() -> Result<Vec<StatusEntry>> {
let lines = run_git_status()?;
let mut entries = Vec::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let index_char = line.chars().next().unwrap_or(' ');
if index_char == ' ' || index_char == '?' {
continue;
}
let raw_path = &line[3..];
let path_part = raw_path.rsplit(" -> ").next().unwrap_or(raw_path);
let path = unquote_git_path(path_part);
let status = match index_char {
'A' => "new file",
'D' => "deleted",
'R' => "renamed",
'C' => "copied",
'T' => "type change",
_ => "modified",
};
entries.push(StatusEntry { path, status });
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
pub fn get_restorable_files() -> Result<Vec<StatusEntry>> {
let lines = run_git_status()?;
let mut entries = Vec::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let mut chars = line.chars();
let index_char = chars.next().unwrap_or(' ');
let wt_char = chars.next().unwrap_or(' ');
if index_char == '?' || !matches!(wt_char, 'M' | 'D' | 'T') {
continue;
}
let raw_path = &line[3..];
let path_part = raw_path.rsplit(" -> ").next().unwrap_or(raw_path);
let path = unquote_git_path(path_part);
let status = match wt_char {
'D' => "deleted",
'T' => "type change",
_ => "modified",
};
entries.push(StatusEntry { path, status });
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
pub fn process_deleted_files_for_staging() -> Result<Vec<String>> {
let lines = run_git_status()?;
let mut deleted_files = Vec::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let mut chars = line.chars();
let index_char = chars.next().unwrap_or(' ');
let wt_char = chars.next().unwrap_or(' ');
let path = unquote_git_path(&line[3..]);
if wt_char == 'D' && index_char != 'D' {
deleted_files.push(path);
}
}
Ok(deleted_files)
}
pub fn process_deleted_files_for_commit_message() -> Result<Vec<String>> {
let lines = run_git_status()?;
let mut deleted_files = Vec::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let index_char = line.chars().next().unwrap_or(' ');
let path = unquote_git_path(&line[3..]);
if index_char == 'D' {
deleted_files.push(path);
}
}
Ok(deleted_files)
}
pub fn process_git_status() -> Result<Vec<String>> {
let lines = run_git_status()?;
let mut files = Vec::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let index_char = line.chars().next().unwrap_or(' ');
let path = unquote_git_path(&line[3..]);
match index_char {
'M' | 'A' | 'T' => files.push(path),
_ => {} }
}
files.extend(get_renamed_new_paths()?);
Ok(files)
}
pub fn get_all_staged_file_paths() -> Result<Vec<String>> {
let lines = run_git_status()?;
let mut files: HashSet<String> = HashSet::new();
for line in &lines {
if line.len() < 4 {
continue;
}
let mut chars = line.chars();
let index_char = chars.next().unwrap_or(' ');
if index_char == ' ' || index_char == '?' {
continue;
}
if index_char == 'R' {
continue;
}
let path = unquote_git_path(&line[3..]);
files.insert(path);
}
for path in get_renamed_new_paths()? {
files.insert(path);
}
Ok(files.into_iter().collect())
}
pub fn count_renamed_files() -> Result<usize> {
let lines = run_git_status()?;
let count = lines
.iter()
.filter(|line| !line.is_empty() && line.starts_with('R'))
.count();
Ok(count)
}
#[cfg(test)]
mod tests {
use super::unquote_git_path;
#[test]
fn test_unquote_plain_path() {
assert_eq!(unquote_git_path("src/main.rs"), "src/main.rs");
}
#[test]
fn test_unquote_quoted_path_with_spaces() {
assert_eq!(
unquote_git_path("\"assets/foo bar/file.txt\""),
"assets/foo bar/file.txt"
);
}
#[test]
fn test_unquote_escape_sequences() {
assert_eq!(unquote_git_path("\"a\\\\b\""), "a\\b");
assert_eq!(unquote_git_path("\"a\\\"b\""), "a\"b");
assert_eq!(unquote_git_path("\"a\\nb\""), "a\nb");
}
#[test]
fn test_unquote_octal_escape() {
assert_eq!(unquote_git_path("\"a\\040b\""), "a b");
}
#[test]
fn test_unquote_multibyte_utf8_octal() {
assert_eq!(
unquote_git_path("\"Marags\\303\\242-Display.otf\""),
"Maragsâ-Display.otf"
);
}
}