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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
}
#[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())
});
s.remote = run(root, &["config", "--get-regexp", r"^remote\..*\.url$"])
.and_then(|out| remote(&out))
.and_then(|url| web_url(&url));
Some(s)
}
fn remote(out: &str) -> Option<String> {
let urls: Vec<(&str, &str)> = out
.lines()
.filter_map(|l| {
let (key, url) = l.split_once(' ')?;
let name = key.strip_prefix("remote.")?.strip_suffix(".url")?;
Some((name, url.trim()))
})
.collect();
urls.iter()
.find(|(n, _)| *n == "origin")
.or(urls.first())
.map(|(_, u)| u.to_string())
}
pub fn web_url(url: &str) -> Option<String> {
let url = url.trim();
let (scheme, rest) = match url.split_once("://") {
Some((scheme, rest)) => (scheme.to_ascii_lowercase(), rest),
None => {
let colon = url.find(':')?;
if url[..colon].contains('/') || colon < 2 {
return None;
}
("ssh".to_string(), url)
}
};
let web = match scheme.as_str() {
"http" => "http",
"https" | "ssh" | "git" | "git+ssh" | "ssh+git" => "https",
_ => return None,
};
let rest = rest.split(['?', '#']).next().unwrap_or("");
let (authority, path) = if url.contains("://") {
rest.split_once('/')?
} else {
rest.split_once(':')?
};
let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
let host = match host.rsplit_once(':') {
Some((h, port))
if !scheme.starts_with("http") && port.bytes().all(|b| b.is_ascii_digit()) =>
{
h
}
_ => host,
};
let path = path.trim_matches('/');
let path = path
.strip_suffix(".git")
.unwrap_or(path)
.trim_end_matches('/');
let ok = |s: &str| !s.is_empty() && !s.contains(char::is_whitespace);
if !ok(host) || !ok(path) || host.contains(['/', '\\']) {
return None;
}
Some(format!("{web}://{host}/{path}"))
}
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_remote_is_its_web_page_and_never_its_credentials() {
let cases = [
(
"git@github.com:snymrova/snyvi.git",
Some("https://github.com/snymrova/snyvi"),
),
("github.com:o/r", Some("https://github.com/o/r")),
(
"ssh://git@gitlab.com:22/group/sub/repo.git",
Some("https://gitlab.com/group/sub/repo"),
),
(
"git+ssh://git@codeberg.org/o/r",
Some("https://codeberg.org/o/r"),
),
("https://github.com/o/r.git", Some("https://github.com/o/r")),
("https://github.com/o/r/", Some("https://github.com/o/r")),
(
"https://x-access-token:ghp_secret@github.com/o/r.git",
Some("https://github.com/o/r"),
),
(
"https://user@bitbucket.org/o/r.git?x=1",
Some("https://bitbucket.org/o/r"),
),
(
"https://git.example.com:8443/o/r",
Some("https://git.example.com:8443/o/r"),
),
(
"http://localhost:3000/o/r.git",
Some("http://localhost:3000/o/r"),
),
("git://example.org/o/r.git", Some("https://example.org/o/r")),
("file:///home/me/r.git", None),
("/home/me/r.git", None),
("../r", None),
("C:\\repos\\r", None),
("C:/repos/r", None),
("https://github.com", None),
("", None),
];
for (url, want) in cases {
assert_eq!(web_url(url).as_deref(), want, "{url}");
}
for (url, _) in cases {
assert!(
!web_url(url).unwrap_or_default().contains("secret"),
"{url}"
);
}
}
#[test]
fn origin_is_the_remote_and_else_the_first() {
let out = "remote.upstream.url https://github.com/a/b\nremote.origin.url git@github.com:o/r.git\n";
assert_eq!(remote(out).as_deref(), Some("git@github.com:o/r.git"));
assert_eq!(
remote("remote.fork.url https://github.com/f/r\nremote.up.url x\n").as_deref(),
Some("https://github.com/f/r")
);
assert_eq!(remote(""), None);
}
#[test]
fn a_repository_says_its_remote_without_asking_it() {
let dir = crate::store::tempdir::Dir::new("snyvi-git-remote");
let git = |args: &[&str]| run(&dir.path, args).is_some();
if !git(&["init", "-q"]) {
return; }
assert_eq!(read(&dir.path, 0).and_then(|s| s.remote), None, "no remote");
assert!(git(&[
"remote",
"add",
"origin",
"https://tok@github.invalid/o/r.git"
]));
assert_eq!(
read(&dir.path, 0).and_then(|s| s.remote).as_deref(),
Some("https://github.invalid/o/r")
);
}
#[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());
}
}
}