use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
const TTL: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileState {
Modified,
Staged,
Untracked,
Conflicted,
}
#[derive(Debug, Clone, Default)]
pub struct Snapshot {
pub branch: Option<String>,
pub ahead: usize,
pub behind: usize,
pub modified: usize,
pub staged: usize,
pub untracked: usize,
pub conflicts: usize,
pub added: usize,
pub changed: usize,
pub removed: usize,
pub files: HashMap<PathBuf, FileState>,
pub line_changes: super::diff::LineSigns,
pub provider_icon: Option<&'static str>,
}
impl Snapshot {
pub fn change_count(&self) -> usize {
self.modified + self.staged + self.untracked + self.conflicts
}
}
#[derive(Debug)]
pub struct GitStatus {
workspace: PathBuf,
snapshot: Snapshot,
probed_at: Option<Instant>,
}
impl GitStatus {
pub fn new(workspace: &Path) -> Self {
let mut g = GitStatus {
workspace: workspace.to_path_buf(),
snapshot: Snapshot::default(),
probed_at: None,
};
g.refresh();
g
}
pub fn snapshot(&self) -> &Snapshot {
&self.snapshot
}
pub fn tick(&mut self) {
let stale = self.probed_at.map(|t| t.elapsed() >= TTL).unwrap_or(true);
if stale {
self.refresh();
}
}
pub fn refresh(&mut self) {
self.snapshot = probe(&self.workspace);
self.probed_at = Some(Instant::now());
}
pub fn retarget(&mut self, workspace: &Path) {
self.workspace = workspace.to_path_buf();
self.refresh();
}
}
fn probe(workspace: &Path) -> Snapshot {
let mut snap = Snapshot {
provider_icon: super::browse::provider_icon_for(workspace),
..Default::default()
};
if let Ok(out) = Command::new("git")
.args(["symbolic-ref", "--short", "-q", "HEAD"])
.current_dir(workspace)
.output()
&& out.status.success()
{
let b = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !b.is_empty() {
snap.branch = Some(b);
}
}
if snap.branch.is_none()
&& let Ok(out) = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(workspace)
.output()
&& out.status.success()
{
let h = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !h.is_empty() {
snap.branch = Some(format!("@{h}"));
}
}
if let Ok(out) = Command::new("git")
.args(["status", "--porcelain", "-b"])
.current_dir(workspace)
.output()
&& out.status.success()
{
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Some(rest) = line.strip_prefix("## ") {
let num_after = |needle: &str| -> usize {
rest.find(needle)
.and_then(|i| {
rest[i + needle.len()..]
.split([',', ']'])
.next()?
.trim()
.parse()
.ok()
})
.unwrap_or(0)
};
snap.ahead = num_after("ahead ");
snap.behind = num_after("behind ");
continue;
}
if line.len() < 3 {
continue;
}
let bytes = line.as_bytes();
let (x, y) = (bytes[0] as char, bytes[1] as char);
let path_part = line[3..].trim();
let rel_raw = path_part
.rsplit(" -> ")
.next()
.unwrap_or(path_part)
.trim_matches('"');
let rel_decoded = decode_git_path(rel_raw);
let rel = rel_decoded.as_str();
let abs = workspace.join(rel);
let state = if x == 'U' || y == 'U' || (x == 'D' && y == 'D') || (x == 'A' && y == 'A')
{
snap.conflicts += 1;
FileState::Conflicted
} else if x == '?' && y == '?' {
snap.untracked += 1;
snap.added += 1;
FileState::Untracked
} else {
if x != ' ' && x != '?' {
snap.staged += 1;
}
if y != ' ' && y != '?' {
snap.modified += 1;
}
if x == 'A' || y == 'A' {
snap.added += 1;
} else if x == 'D' || y == 'D' {
snap.removed += 1;
} else if x == 'M' || y == 'M' || x == 'R' || y == 'R' {
snap.changed += 1;
}
if y != ' ' {
FileState::Modified
} else {
FileState::Staged
}
};
snap.files.insert(abs, state);
}
}
snap.line_changes = super::diff::line_signs(workspace);
snap
}
fn decode_git_path(s: &str) -> String {
let inner = s
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(s);
let bytes = inner.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
let n = bytes[i + 1];
if (b'0'..=b'7').contains(&n)
&& i + 3 < bytes.len()
&& (b'0'..=b'7').contains(&bytes[i + 2])
&& (b'0'..=b'7').contains(&bytes[i + 3])
{
let v =
(bytes[i + 1] - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
out.push(v);
i += 4;
continue;
}
let mapped = match n {
b'\\' => Some(b'\\'),
b'"' => Some(b'"'),
b't' => Some(b'\t'),
b'n' => Some(b'\n'),
b'r' => Some(b'\r'),
_ => None,
};
if let Some(m) = mapped {
out.push(m);
i += 2;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_repo_is_quiet() {
let d = tempfile::tempdir().unwrap();
let g = GitStatus::new(d.path());
assert!(g.snapshot().branch.is_none());
assert_eq!(g.snapshot().change_count(), 0);
}
#[test]
fn decode_git_path_reverses_octal_escapes() {
let escaped = r#""weird-\360\237\230\200.txt""#;
let decoded = decode_git_path(escaped);
assert_eq!(decoded, "weird-😀.txt");
}
#[test]
fn decode_git_path_passes_ascii_through_unchanged() {
assert_eq!(decode_git_path("foo/bar.txt"), "foo/bar.txt");
assert_eq!(decode_git_path("\"quoted/ascii.txt\""), "quoted/ascii.txt");
}
#[test]
fn decode_git_path_handles_c_style_escapes() {
assert_eq!(decode_git_path(r#""a\tb""#), "a\tb");
assert_eq!(decode_git_path(r#""a\\b""#), "a\\b");
assert_eq!(decode_git_path(r#""a\"b""#), "a\"b");
}
}