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 git_in(repo_path: &Path) -> Command {
36 let mut cmd = crate::spawn::command("git");
37 cmd.env_remove("GIT_DIR")
38 .env_remove("GIT_WORK_TREE")
39 .env_remove("GIT_INDEX_FILE")
40 .env_remove("GIT_COMMON_DIR")
41 .env_remove("GIT_OBJECT_DIRECTORY")
42 .current_dir(repo_path);
43 cmd
44}
45
46pub fn get_last_commit_time(repo_path: &Path) -> Result<Option<SystemTime>> {
54 let output = git_in(repo_path)
55 .args(["log", "-1", "--format=%ct"])
56 .output()
57 .context("Failed to execute git log")?;
58
59 if !output.status.success() {
60 let probe = git_in(repo_path)
61 .args(["rev-parse", "--git-dir"])
62 .output()
63 .context("Failed to execute git rev-parse")?;
64 if probe.status.success() {
65 return Ok(None);
66 }
67 anyhow::bail!(
68 "git could not read `{}`: {}",
69 repo_path.display(),
70 String::from_utf8_lossy(&probe.stderr).trim()
71 );
72 }
73
74 let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
75 if timestamp_str.is_empty() {
76 return Ok(None);
77 }
78
79 let timestamp: u64 = timestamp_str
80 .parse()
81 .with_context(|| format!("Failed to parse git timestamp: {timestamp_str}"))?;
82
83 Ok(Some(
84 SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp),
85 ))
86}
87
88pub fn get_mtime_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
93 let mut latest: Option<SystemTime> = None;
94
95 let now = SystemTime::now();
96 let walker = WalkDir::new(repo_path)
97 .follow_links(false)
98 .max_depth(MAX_MTIME_SCAN_DEPTH)
99 .into_iter()
100 .filter_entry(|entry| {
101 let name = entry.file_name().to_string_lossy();
102 !EXCLUDED_DIRS.contains(&name.as_ref())
103 });
104
105 for entry in walker.flatten() {
106 if entry.file_type().is_file()
107 && let Ok(metadata) = entry.metadata()
108 && let Ok(mtime) = metadata.modified()
109 {
110 let mtime = mtime.min(now);
114 latest = Some(match latest {
115 Some(current) if mtime > current => mtime,
116 Some(current) => current,
117 None => mtime,
118 });
119 }
120 }
121
122 Ok(latest)
123}
124
125pub fn get_last_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
131 let commit_time = get_last_commit_time(repo_path)?;
132 let mtime = get_mtime_activity(repo_path)?;
133
134 match (commit_time, mtime) {
135 (Some(c), Some(m)) => Ok(Some(c.max(m))),
136 (Some(c), None) => Ok(Some(c)),
137 (None, Some(m)) => Ok(Some(m)),
138 (None, None) => Ok(None),
139 }
140}
141
142pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
150 match last_activity {
151 Some(activity_time) => {
152 let idle_duration = Duration::from_secs(idle_days.saturating_mul(24 * 60 * 60));
156 let Some(threshold) = SystemTime::now().checked_sub(idle_duration) else {
157 return false;
158 };
159 activity_time < threshold
160 }
161 None => true,
163 }
164}
165
166pub fn is_repo_idle(repo_path: &Path, idle_days: u64) -> Result<bool> {
168 Ok(is_idle_at(get_last_activity(repo_path)?, idle_days))
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use std::fs;
175 use tempfile::TempDir;
176
177 fn git(path: &Path) -> Command {
185 let mut cmd = Command::new("git");
186 cmd.current_dir(path)
187 .env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
188 .env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
189 cmd
190 }
191
192 fn create_git_repo(path: &Path) {
194 fs::create_dir_all(path).unwrap();
195 git(path).args(["init"]).output().unwrap();
196 }
197
198 fn create_git_repo_with_commit(path: &Path) {
200 create_git_repo(path);
201 fs::write(path.join("README.md"), "# Test").unwrap();
202 git(path).args(["add", "."]).output().unwrap();
203 git(path)
204 .args([
205 "-c",
206 "user.name=Test",
207 "-c",
208 "user.email=test@test.com",
209 "commit",
210 "-m",
211 "initial",
212 ])
213 .output()
214 .unwrap();
215 }
216
217 #[test]
218 fn test_get_last_commit_time_with_commits() {
219 let tmp = TempDir::new().unwrap();
220 let repo = tmp.path().join("repo");
221 create_git_repo_with_commit(&repo);
222 let time = get_last_commit_time(&repo).unwrap();
223 assert!(time.is_some());
224 }
225
226 #[test]
227 fn test_get_last_commit_time_empty_repo() {
228 let tmp = TempDir::new().unwrap();
229 let repo = tmp.path().join("repo");
230 create_git_repo(&repo);
231 let time = get_last_commit_time(&repo).unwrap();
232 assert!(time.is_none());
233 }
234
235 #[test]
236 fn test_get_mtime_activity() {
237 let tmp = TempDir::new().unwrap();
238 fs::write(tmp.path().join("file.txt"), "hello").unwrap();
239 let activity = get_mtime_activity(tmp.path()).unwrap();
240 assert!(activity.is_some());
241 }
242
243 #[test]
244 fn test_get_mtime_activity_excludes_git() {
245 let tmp = TempDir::new().unwrap();
246 let git_dir = tmp.path().join(".git");
247 fs::create_dir(&git_dir).unwrap();
248 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
249 let activity = get_mtime_activity(tmp.path()).unwrap();
251 assert!(activity.is_some() || activity.is_none());
254 }
255
256 #[test]
257 fn test_get_last_activity_with_commits() {
258 let tmp = TempDir::new().unwrap();
259 let repo = tmp.path().join("repo");
260 create_git_repo_with_commit(&repo);
261 let activity = get_last_activity(&repo).unwrap();
262 assert!(activity.is_some());
263 }
264
265 #[test]
266 fn test_get_last_activity_empty_repo_with_files() {
267 let tmp = TempDir::new().unwrap();
268 let repo = tmp.path().join("repo");
269 create_git_repo(&repo);
270 fs::write(repo.join("main.py"), "print('hello')").unwrap();
271 let activity = get_last_activity(&repo).unwrap();
272 assert!(activity.is_some());
273 }
274
275 #[test]
276 fn test_is_repo_idle_recent() {
277 let tmp = TempDir::new().unwrap();
278 let repo = tmp.path().join("repo");
279 create_git_repo_with_commit(&repo);
280 assert!(!is_repo_idle(&repo, 15).unwrap());
282 }
283
284 #[test]
285 fn is_idle_at_agrees_with_the_repo_level_check() {
286 let tmp = TempDir::new().unwrap();
289 let repo = tmp.path().join("repo");
290 create_git_repo_with_commit(&repo);
291
292 let activity = get_last_activity(&repo).unwrap();
293 assert_eq!(is_idle_at(activity, 15), is_repo_idle(&repo, 15).unwrap());
294 assert!(!is_idle_at(activity, 15));
295 assert!(is_idle_at(activity, 0));
296 }
297
298 #[test]
299 fn a_repo_with_no_activity_at_all_is_idle() {
300 assert!(is_idle_at(None, 15));
301 }
302
303 #[test]
304 fn an_absurd_idle_threshold_means_never_idle_rather_than_a_panic() {
305 assert!(!is_idle_at(Some(SystemTime::UNIX_EPOCH), u64::MAX));
307 }
308
309 #[test]
310 fn test_is_repo_idle_no_activity() {
311 let tmp = TempDir::new().unwrap();
312 let repo = tmp.path().join("repo");
313 create_git_repo(&repo);
314 let result = is_repo_idle(&repo, 0);
318 assert!(result.is_ok());
319 }
320}