1use std::path::Path;
11use std::process::Command;
12use std::time::{Duration, SystemTime};
13
14use anyhow::{Context, Result};
15use walkdir::WalkDir;
16
17const EXCLUDED_DIRS: &[&str] = &[".git", "node_modules", ".venv", "venv", "target", "vendor"];
19
20const MAX_MTIME_SCAN_DEPTH: usize = 8;
27
28pub fn get_last_commit_time(repo_path: &Path) -> Result<Option<SystemTime>> {
33 let output = Command::new("git")
34 .args(["log", "-1", "--format=%ct"])
35 .current_dir(repo_path)
36 .output()
37 .context("Failed to execute git log")?;
38
39 if !output.status.success() {
40 return Ok(None);
41 }
42
43 let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
44 if timestamp_str.is_empty() {
45 return Ok(None);
46 }
47
48 let timestamp: u64 = timestamp_str
49 .parse()
50 .with_context(|| format!("Failed to parse git timestamp: {timestamp_str}"))?;
51
52 Ok(Some(
53 SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp),
54 ))
55}
56
57pub fn get_mtime_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
62 let mut latest: Option<SystemTime> = None;
63
64 let now = SystemTime::now();
65 let walker = WalkDir::new(repo_path)
66 .follow_links(false)
67 .max_depth(MAX_MTIME_SCAN_DEPTH)
68 .into_iter()
69 .filter_entry(|entry| {
70 let name = entry.file_name().to_string_lossy();
71 !EXCLUDED_DIRS.contains(&name.as_ref())
72 });
73
74 for entry in walker.flatten() {
75 if entry.file_type().is_file() {
76 if let Ok(metadata) = entry.metadata() {
77 if let Ok(mtime) = metadata.modified() {
78 let mtime = mtime.min(now);
82 latest = Some(match latest {
83 Some(current) if mtime > current => mtime,
84 Some(current) => current,
85 None => mtime,
86 });
87 }
88 }
89 }
90 }
91
92 Ok(latest)
93}
94
95pub fn get_last_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
101 let commit_time = get_last_commit_time(repo_path)?;
102 let mtime = get_mtime_activity(repo_path)?;
103
104 match (commit_time, mtime) {
105 (Some(c), Some(m)) => Ok(Some(c.max(m))),
106 (Some(c), None) => Ok(Some(c)),
107 (None, Some(m)) => Ok(Some(m)),
108 (None, None) => Ok(None),
109 }
110}
111
112pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
120 match last_activity {
121 Some(activity_time) => {
122 let idle_duration = Duration::from_secs(idle_days.saturating_mul(24 * 60 * 60));
126 let Some(threshold) = SystemTime::now().checked_sub(idle_duration) else {
127 return false;
128 };
129 activity_time < threshold
130 }
131 None => true,
133 }
134}
135
136pub fn is_repo_idle(repo_path: &Path, idle_days: u64) -> Result<bool> {
138 Ok(is_idle_at(get_last_activity(repo_path)?, idle_days))
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use std::fs;
145 use tempfile::TempDir;
146
147 fn git(path: &Path) -> Command {
155 let mut cmd = Command::new("git");
156 cmd.current_dir(path)
157 .env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
158 .env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
159 cmd
160 }
161
162 fn create_git_repo(path: &Path) {
164 fs::create_dir_all(path).unwrap();
165 git(path).args(["init"]).output().unwrap();
166 }
167
168 fn create_git_repo_with_commit(path: &Path) {
170 create_git_repo(path);
171 fs::write(path.join("README.md"), "# Test").unwrap();
172 git(path).args(["add", "."]).output().unwrap();
173 git(path)
174 .args([
175 "-c",
176 "user.name=Test",
177 "-c",
178 "user.email=test@test.com",
179 "commit",
180 "-m",
181 "initial",
182 ])
183 .output()
184 .unwrap();
185 }
186
187 #[test]
188 fn test_get_last_commit_time_with_commits() {
189 let tmp = TempDir::new().unwrap();
190 let repo = tmp.path().join("repo");
191 create_git_repo_with_commit(&repo);
192 let time = get_last_commit_time(&repo).unwrap();
193 assert!(time.is_some());
194 }
195
196 #[test]
197 fn test_get_last_commit_time_empty_repo() {
198 let tmp = TempDir::new().unwrap();
199 let repo = tmp.path().join("repo");
200 create_git_repo(&repo);
201 let time = get_last_commit_time(&repo).unwrap();
202 assert!(time.is_none());
203 }
204
205 #[test]
206 fn test_get_mtime_activity() {
207 let tmp = TempDir::new().unwrap();
208 fs::write(tmp.path().join("file.txt"), "hello").unwrap();
209 let activity = get_mtime_activity(tmp.path()).unwrap();
210 assert!(activity.is_some());
211 }
212
213 #[test]
214 fn test_get_mtime_activity_excludes_git() {
215 let tmp = TempDir::new().unwrap();
216 let git_dir = tmp.path().join(".git");
217 fs::create_dir(&git_dir).unwrap();
218 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
219 let activity = get_mtime_activity(tmp.path()).unwrap();
221 assert!(activity.is_some() || activity.is_none());
224 }
225
226 #[test]
227 fn test_get_last_activity_with_commits() {
228 let tmp = TempDir::new().unwrap();
229 let repo = tmp.path().join("repo");
230 create_git_repo_with_commit(&repo);
231 let activity = get_last_activity(&repo).unwrap();
232 assert!(activity.is_some());
233 }
234
235 #[test]
236 fn test_get_last_activity_empty_repo_with_files() {
237 let tmp = TempDir::new().unwrap();
238 let repo = tmp.path().join("repo");
239 create_git_repo(&repo);
240 fs::write(repo.join("main.py"), "print('hello')").unwrap();
241 let activity = get_last_activity(&repo).unwrap();
242 assert!(activity.is_some());
243 }
244
245 #[test]
246 fn test_is_repo_idle_recent() {
247 let tmp = TempDir::new().unwrap();
248 let repo = tmp.path().join("repo");
249 create_git_repo_with_commit(&repo);
250 assert!(!is_repo_idle(&repo, 15).unwrap());
252 }
253
254 #[test]
255 fn is_idle_at_agrees_with_the_repo_level_check() {
256 let tmp = TempDir::new().unwrap();
259 let repo = tmp.path().join("repo");
260 create_git_repo_with_commit(&repo);
261
262 let activity = get_last_activity(&repo).unwrap();
263 assert_eq!(is_idle_at(activity, 15), is_repo_idle(&repo, 15).unwrap());
264 assert!(!is_idle_at(activity, 15));
265 assert!(is_idle_at(activity, 0));
266 }
267
268 #[test]
269 fn a_repo_with_no_activity_at_all_is_idle() {
270 assert!(is_idle_at(None, 15));
271 }
272
273 #[test]
274 fn an_absurd_idle_threshold_means_never_idle_rather_than_a_panic() {
275 assert!(!is_idle_at(Some(SystemTime::UNIX_EPOCH), u64::MAX));
277 }
278
279 #[test]
280 fn test_is_repo_idle_no_activity() {
281 let tmp = TempDir::new().unwrap();
282 let repo = tmp.path().join("repo");
283 create_git_repo(&repo);
284 let result = is_repo_idle(&repo, 0);
288 assert!(result.is_ok());
289 }
290}