use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
pub const DEFAULT_STALE_AFTER_DAYS: i64 = 180;
fn run_git(args: &[&str], cwd: &Path) -> Option<String> {
let output = Command::new("git").args(args).current_dir(cwd).output().ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
pub fn run_git_text(args: &[&str], cwd: &Path) -> Option<String> {
run_git(args, cwd).map(|t| t.replace("\r\n", "\n").replace('\r', "\n"))
}
pub fn repository_root(directory: &Path) -> Option<PathBuf> {
let out = run_git(&["rev-parse", "--show-toplevel"], directory)?;
let root = out.trim();
if root.is_empty() {
None
} else {
Some(PathBuf::from(root))
}
}
pub fn pathspec(repo_root: &Path, path: &Path) -> String {
let abspath = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let root = repo_root.canonicalize().unwrap_or_else(|_| repo_root.to_path_buf());
match abspath.strip_prefix(&root) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => abspath.to_string_lossy().into_owned(),
}
}
pub fn last_committed(repo_root: &Path, path: &Path) -> Option<String> {
let spec = pathspec(repo_root, path);
let out = run_git(&["log", "-1", "--format=%cI", "--", &spec], repo_root)?;
let stamp = out.trim();
if stamp.is_empty() {
None
} else {
Some(stamp.to_string())
}
}
pub fn first_committed(repo_root: &Path, path: &Path) -> Option<String> {
let spec = pathspec(repo_root, path);
let out = run_git(
&["log", "--reverse", "--format=%cI", "--", &spec],
repo_root,
)?;
out.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_string)
}
pub fn last_committed_for_paths(
directory: &Path,
paths: &[PathBuf],
) -> Vec<(PathBuf, Option<String>)> {
match repository_root(directory) {
None => paths.iter().map(|p| (p.clone(), None)).collect(),
Some(root) => last_committed_for_paths_in_repo(&root, paths),
}
}
pub fn last_committed_for_paths_in_repo(
repo_root: &Path,
paths: &[PathBuf],
) -> Vec<(PathBuf, Option<String>)> {
const MAX_PATHSPEC_BYTES: usize = 64 * 1024;
const MAX_PATHS_PER_RUN: usize = 2_048;
let specs: Vec<String> = paths.iter().map(|path| pathspec(repo_root, path)).collect();
let mut unique = Vec::new();
let mut seen = HashSet::new();
for spec in &specs {
if !Path::new(spec).is_absolute() && seen.insert(spec.clone()) {
unique.push(spec.clone());
}
}
let mut committed: HashMap<String, String> = HashMap::new();
let mut start = 0;
while start < unique.len() {
let mut end = start;
let mut bytes = 0;
while end < unique.len() && end - start < MAX_PATHS_PER_RUN {
let next = unique[end].len() + 1;
if end > start && bytes + next > MAX_PATHSPEC_BYTES {
break;
}
bytes += next;
end += 1;
}
collect_last_committed(repo_root, &unique[start..end], &mut committed);
start = end;
}
paths
.iter()
.zip(specs)
.map(|(path, spec)| (path.clone(), committed.get(&spec).cloned()))
.collect()
}
fn collect_last_committed(
repo_root: &Path,
specs: &[String],
committed: &mut HashMap<String, String>,
) {
if specs.is_empty() {
return;
}
let mut args = vec!["log", "-z", "--format=%x1e%cI", "--name-only", "--"];
args.extend(specs.iter().map(String::as_str));
let Some(output) = run_git(&args, repo_root) else {
return;
};
let wanted: HashSet<&str> = specs.iter().map(String::as_str).collect();
let mut stamp: Option<&str> = None;
let mut first_name = false;
for token in output.split('\0') {
if let Some(value) = token.strip_prefix('\x1e') {
stamp = Some(value.trim());
first_name = true;
continue;
}
let Some(current) = stamp else {
continue;
};
let name = if first_name {
first_name = false;
token.strip_prefix('\n').unwrap_or(token)
} else {
token
};
if wanted.contains(name) {
committed
.entry(name.to_string())
.or_insert_with(|| current.to_string());
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Staleness {
pub last_committed: Option<String>,
pub age_days: Option<i64>,
pub stale: Option<bool>,
}
impl Staleness {
pub fn unknown() -> Self {
Staleness {
last_committed: None,
age_days: None,
stale: None,
}
}
}
pub fn staleness(
last_committed: Option<&str>,
threshold_days: i64,
reference_epoch_secs: i64,
) -> Staleness {
let stamp = match last_committed {
None => return Staleness::unknown(),
Some(s) => s,
};
let committed_epoch = match parse_iso8601_epoch(stamp) {
Some(e) => e,
None => return Staleness::unknown(),
};
let delta = reference_epoch_secs - committed_epoch;
let age_days = floor_div(delta, 86_400);
Staleness {
last_committed: Some(stamp.to_string()),
age_days: Some(age_days),
stale: Some(age_days > threshold_days),
}
}
fn floor_div(a: i64, b: i64) -> i64 {
let q = a / b;
let r = a % b;
if (r != 0) && ((r < 0) != (b < 0)) {
q - 1
} else {
q
}
}
pub fn isoformat_roundtrip(stamp: &str) -> String {
let mut s = stamp.to_string();
if s.len() > 10 && s.as_bytes()[10] == b' ' {
s.replace_range(10..11, "T");
}
if s.ends_with('Z') || s.ends_with('z') {
s.truncate(s.len() - 1);
s.push_str("+00:00");
return s;
}
if let Some(pos) = s.rfind(['+', '-']) {
if pos > 10 {
let body = &s[pos + 1..];
if body.len() == 4 && body.bytes().all(|b| b.is_ascii_digit()) {
let fixed = format!("{}:{}", &body[..2], &body[2..]);
s.replace_range(pos + 1.., &fixed);
} else if body.len() == 2 && body.bytes().all(|b| b.is_ascii_digit()) {
let fixed = format!("{body}:00");
s.replace_range(pos + 1.., &fixed);
}
}
}
s
}
pub fn parse_iso8601_epoch(s: &str) -> Option<i64> {
let bytes = s.as_bytes();
if bytes.len() < 19 {
return None;
}
let year: i64 = s.get(0..4)?.parse().ok()?;
if bytes[4] != b'-' {
return None;
}
let month: i64 = s.get(5..7)?.parse().ok()?;
if bytes[7] != b'-' {
return None;
}
let day: i64 = s.get(8..10)?.parse().ok()?;
if bytes[10] != b'T' && bytes[10] != b' ' {
return None;
}
let hour: i64 = s.get(11..13)?.parse().ok()?;
if bytes[13] != b':' {
return None;
}
let minute: i64 = s.get(14..16)?.parse().ok()?;
if bytes[16] != b':' {
return None;
}
let second: i64 = s.get(17..19)?.parse().ok()?;
let mut rest = &s[19..];
if let Some(stripped) = rest.strip_prefix('.') {
let non_digit = stripped
.char_indices()
.find(|(_, c)| !c.is_ascii_digit())
.map(|(i, _)| i)
.unwrap_or(stripped.len());
rest = &stripped[non_digit..];
}
let offset_secs = parse_offset(rest)?;
let days = days_from_civil(year, month, day);
let local_secs = days * 86_400 + hour * 3_600 + minute * 60 + second;
Some(local_secs - offset_secs)
}
fn parse_offset(rest: &str) -> Option<i64> {
if rest == "Z" || rest == "z" {
return Some(0);
}
let bytes = rest.as_bytes();
if bytes.is_empty() {
return None; }
let sign = match bytes[0] {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let body = &rest[1..];
let (hh, mm) = if body.len() == 5 && body.as_bytes()[2] == b':' {
(&body[0..2], &body[3..5]) } else if body.len() == 4 {
(&body[0..2], &body[2..4]) } else if body.len() == 2 {
(&body[0..2], "00") } else {
return None;
};
let h: i64 = hh.parse().ok()?;
let m: i64 = mm.parse().ok()?;
Some(sign * (h * 3_600 + m * 60))
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
}