use std::path::Path;
use std::process::Command;
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result};
use walkdir::WalkDir;
const EXCLUDED_DIRS: &[&str] = &[
".git",
"node_modules",
".venv",
"venv",
"target",
"vendor",
"__pypackages__",
"Pods",
"deps",
"_build",
".build",
".gradle",
];
const EXCLUDED_FILES: &[&str] = &[crate::constants::PER_REPO_CONFIG_FILE];
const MAX_MTIME_SCAN_DEPTH: usize = 8;
pub fn git_in(repo_path: &Path) -> Command {
let mut cmd = crate::spawn::command("git");
cmd.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_OBJECT_DIRECTORY")
.current_dir(repo_path);
cmd
}
pub fn repo_identity(repo_path: &Path) -> Option<String> {
let output = git_in(repo_path)
.args(["rev-list", "--max-parents=0", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let hash = text.split_whitespace().next_back()?;
(hash.len() >= 7 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
}
pub fn get_last_commit_time(repo_path: &Path) -> Result<Option<SystemTime>> {
let output = git_in(repo_path)
.args(["log", "-1", "--format=%ct"])
.output()
.context("Failed to execute git log")?;
if !output.status.success() {
let probe = git_in(repo_path)
.args(["rev-parse", "--git-dir"])
.output()
.context("Failed to execute git rev-parse")?;
if probe.status.success() {
return Ok(None);
}
anyhow::bail!(
"git could not read `{}`: {}",
repo_path.display(),
String::from_utf8_lossy(&probe.stderr).trim()
);
}
let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
if timestamp_str.is_empty() {
return Ok(None);
}
let timestamp: u64 = timestamp_str
.parse()
.with_context(|| format!("Failed to parse git timestamp: {timestamp_str}"))?;
Ok(Some(
SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp),
))
}
pub fn get_mtime_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
let mut latest: Option<SystemTime> = None;
let now = SystemTime::now();
let walker = WalkDir::new(repo_path)
.follow_links(false)
.max_depth(MAX_MTIME_SCAN_DEPTH)
.into_iter()
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
!EXCLUDED_DIRS.contains(&name.as_ref()) && !EXCLUDED_FILES.contains(&name.as_ref())
});
for entry in walker.flatten() {
if entry.file_type().is_file()
&& let Ok(metadata) = entry.metadata()
&& let Ok(mtime) = metadata.modified()
{
let mtime = mtime.min(now);
latest = Some(match latest {
Some(current) if mtime > current => mtime,
Some(current) => current,
None => mtime,
});
}
}
Ok(latest)
}
pub fn get_last_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
let commit_time = get_last_commit_time(repo_path)?;
let mtime = get_mtime_activity(repo_path)?;
match (commit_time, mtime) {
(Some(c), Some(m)) => Ok(Some(c.max(m))),
(Some(c), None) => Ok(Some(c)),
(None, Some(m)) => Ok(Some(m)),
(None, None) => Ok(None),
}
}
pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
match last_activity {
Some(activity_time) => {
let idle_duration = Duration::from_secs(idle_days.saturating_mul(24 * 60 * 60));
let Some(threshold) = SystemTime::now().checked_sub(idle_duration) else {
return false;
};
activity_time < threshold
}
None => true,
}
}
pub fn is_repo_idle(repo_path: &Path, idle_days: u64) -> Result<bool> {
Ok(is_idle_at(get_last_activity(repo_path)?, idle_days))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn git(path: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(path)
.env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
.env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
cmd
}
fn create_git_repo(path: &Path) {
fs::create_dir_all(path).unwrap();
git(path).args(["init"]).output().unwrap();
}
fn create_git_repo_with_commit(path: &Path) {
create_git_repo(path);
fs::write(path.join("README.md"), "# Test").unwrap();
git(path).args(["add", "."]).output().unwrap();
git(path)
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@test.com",
"commit",
"-m",
"initial",
])
.output()
.unwrap();
}
#[test]
fn test_get_last_commit_time_with_commits() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
let time = get_last_commit_time(&repo).unwrap();
assert!(time.is_some());
}
#[test]
fn test_get_last_commit_time_empty_repo() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo(&repo);
let time = get_last_commit_time(&repo).unwrap();
assert!(time.is_none());
}
#[test]
fn test_get_mtime_activity() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("file.txt"), "hello").unwrap();
let activity = get_mtime_activity(tmp.path()).unwrap();
assert!(activity.is_some());
}
#[test]
fn test_get_mtime_activity_excludes_git() {
let tmp = TempDir::new().unwrap();
let git_dir = tmp.path().join(".git");
fs::create_dir(&git_dir).unwrap();
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
let activity = get_mtime_activity(tmp.path()).unwrap();
assert!(activity.is_some() || activity.is_none());
}
#[test]
fn a_repo_whose_only_new_file_is_dev_prunes_own_config_is_not_active() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
"{}",
)
.unwrap();
assert!(
get_mtime_activity(tmp.path()).unwrap().is_none(),
"`.devprune.json` must not count as user activity"
);
fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
assert!(
get_mtime_activity(tmp.path()).unwrap().is_some(),
"a real source file still counts"
);
}
#[test]
fn test_get_last_activity_with_commits() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
let activity = get_last_activity(&repo).unwrap();
assert!(activity.is_some());
}
#[test]
fn test_get_last_activity_empty_repo_with_files() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo(&repo);
fs::write(repo.join("main.py"), "print('hello')").unwrap();
let activity = get_last_activity(&repo).unwrap();
assert!(activity.is_some());
}
#[test]
fn test_is_repo_idle_recent() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
assert!(!is_repo_idle(&repo, 15).unwrap());
}
#[test]
fn is_idle_at_agrees_with_the_repo_level_check() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo_with_commit(&repo);
let activity = get_last_activity(&repo).unwrap();
assert_eq!(is_idle_at(activity, 15), is_repo_idle(&repo, 15).unwrap());
assert!(!is_idle_at(activity, 15));
assert!(is_idle_at(activity, 0));
}
#[test]
fn a_repo_with_no_activity_at_all_is_idle() {
assert!(is_idle_at(None, 15));
}
#[test]
fn an_absurd_idle_threshold_means_never_idle_rather_than_a_panic() {
assert!(!is_idle_at(Some(SystemTime::UNIX_EPOCH), u64::MAX));
}
#[test]
fn test_is_repo_idle_no_activity() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
create_git_repo(&repo);
let result = is_repo_idle(&repo, 0);
assert!(result.is_ok());
}
}