use serde::Serialize;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const WAIT: Duration = Duration::from_secs(2);
const FRESH: Duration = Duration::from_secs(30);
pub const WEEKS: i64 = 8;
const MOST: usize = 400;
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct State {
pub branch: String,
pub changed: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub ahead: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last: Option<Commit>,
#[serde(skip)]
pub commits: Vec<Commit>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Commit {
pub at: i64,
pub hash: String,
pub subject: String,
}
type Reading = (Instant, Option<Arc<State>>);
#[derive(Default)]
pub struct Cache(Mutex<HashMap<PathBuf, Reading>>);
impl Cache {
pub fn read(&self, root: &Path, now: i64) -> Option<Arc<State>> {
let key = root.to_path_buf();
if let Some((at, s)) = self.0.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if at.elapsed() < FRESH {
return s.clone();
}
}
let s = read(root, now).map(Arc::new);
let mut map = self.0.lock().unwrap_or_else(|e| e.into_inner());
map.retain(|_, (at, _)| at.elapsed() < FRESH * 4);
map.insert(key, (Instant::now(), s.clone()));
s
}
}
pub fn read(root: &Path, now: i64) -> Option<State> {
let top = repository(root)?;
if dirs::home_dir().is_some_and(|h| same(&h, &top)) {
return None;
}
let status = run(root, &["status", "--porcelain=v2", "--branch"])?;
let mut s = parse_status(&status);
let since = now - WEEKS * 7 * 86_400;
let log = run(
root,
&[
"log",
"HEAD",
"--no-merges",
&format!("--since={since}"),
&format!("-n{MOST}"),
"--format=%ct%x09%h%x09%s",
],
)
.unwrap_or_default();
s.commits = parse_log(&log);
s.last = s.commits.last().cloned().or_else(|| {
run(root, &["log", "-1", "--format=%ct%x09%h%x09%s"]).and_then(|l| parse_log(&l).pop())
});
Some(s)
}
fn repository(dir: &Path) -> Option<PathBuf> {
let dir = dir.canonicalize().ok()?;
dir.ancestors()
.find(|d| d.join(".git").exists())
.map(Path::to_path_buf)
}
fn same(a: &Path, b: &Path) -> bool {
a.canonicalize().ok().as_deref().unwrap_or(a) == b
}
fn run(dir: &Path, args: &[&str]) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(["-c", "core.fsmonitor=false", "--no-optional-locks"])
.args(args)
.current_dir(dir)
.env("GIT_TERMINAL_PROMPT", "0")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::platform::CREATE_NO_WINDOW);
}
let mut child = cmd.spawn().ok()?;
let mut out = child.stdout.take()?;
let reader = std::thread::spawn(move || {
let mut s = String::new();
let _ = out.read_to_string(&mut s);
s
});
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(st)) => break st,
Ok(None) if start.elapsed() < WAIT => std::thread::sleep(Duration::from_millis(10)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
};
let text = reader.join().ok()?;
status.success().then_some(text)
}
fn parse_status(out: &str) -> State {
let mut s = State::default();
let mut oid = String::new();
for line in out.lines() {
if let Some(h) = line.strip_prefix("# branch.head ") {
s.branch = h.to_string();
} else if let Some(o) = line.strip_prefix("# branch.oid ") {
oid = o.chars().take(7).collect();
} else if let Some(ab) = line.strip_prefix("# branch.ab ") {
s.ahead = ab
.split_whitespace()
.next()
.and_then(|a| a.strip_prefix('+'))
.and_then(|a| a.parse().ok());
} else if !line.starts_with('#') && !line.is_empty() {
s.changed += 1;
}
}
if s.branch == "(detached)" {
s.branch = oid;
}
s
}
fn parse_log(out: &str) -> Vec<Commit> {
let mut v: Vec<Commit> = out
.lines()
.filter_map(|l| {
let mut f = l.splitn(3, '\t');
Some(Commit {
at: f.next()?.parse().ok()?,
hash: f.next()?.to_string(),
subject: f.next().unwrap_or("").to_string(),
})
})
.collect();
v.reverse();
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_says_the_branch_what_is_ahead_and_how_much_changed() {
let out = "# branch.oid 9adceca1234\n# branch.head claude/limits\n# branch.upstream origin/claude/limits\n# branch.ab +2 -0\n1 .M N... 100644 100644 100644 a b src/a.rs\n? notes.md\n";
let s = parse_status(out);
assert_eq!(s.branch, "claude/limits");
assert_eq!(s.ahead, Some(2));
assert_eq!(s.changed, 2);
}
#[test]
fn a_branch_with_no_upstream_has_nothing_to_be_ahead_of() {
let s = parse_status("# branch.oid 9adceca1234\n# branch.head main\n");
assert_eq!(s.ahead, None);
assert_eq!(s.changed, 0);
}
#[test]
fn a_detached_head_is_the_commit_it_is_on() {
let s = parse_status("# branch.oid 9adceca1234\n# branch.head (detached)\n");
assert_eq!(s.branch, "9adceca");
}
#[test]
fn the_log_comes_back_oldest_first_and_keeps_tabs_in_a_subject() {
let v = parse_log("200\tb2\tsecond\twith a tab\n100\ta1\tfirst\n");
assert_eq!(v.len(), 2);
assert_eq!(v[0].hash, "a1");
assert_eq!(v[1].subject, "second\twith a tab");
}
#[test]
fn a_folder_in_no_repository_is_not_read() {
let dir = crate::store::tempdir::Dir::new("snyvi-git");
if repository(&dir.path).is_none() {
assert!(read(&dir.path, 0).is_none());
}
}
}