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"];
19
20pub fn get_last_commit_time(repo_path: &Path) -> Result<Option<SystemTime>> {
25 let output = Command::new("git")
26 .args(["log", "-1", "--format=%ct"])
27 .current_dir(repo_path)
28 .output()
29 .context("Failed to execute git log")?;
30
31 if !output.status.success() {
32 return Ok(None);
33 }
34
35 let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
36 if timestamp_str.is_empty() {
37 return Ok(None);
38 }
39
40 let timestamp: u64 = timestamp_str
41 .parse()
42 .with_context(|| format!("Failed to parse git timestamp: {timestamp_str}"))?;
43
44 Ok(Some(
45 SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp),
46 ))
47}
48
49pub fn get_mtime_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
54 let mut latest: Option<SystemTime> = None;
55
56 let walker = WalkDir::new(repo_path)
57 .follow_links(false)
58 .into_iter()
59 .filter_entry(|entry| {
60 let name = entry.file_name().to_string_lossy();
61 !EXCLUDED_DIRS.contains(&name.as_ref())
62 });
63
64 for entry in walker.flatten() {
65 if entry.file_type().is_file() {
66 if let Ok(metadata) = entry.metadata() {
67 if let Ok(mtime) = metadata.modified() {
68 latest = Some(match latest {
69 Some(current) if mtime > current => mtime,
70 Some(current) => current,
71 None => mtime,
72 });
73 }
74 }
75 }
76 }
77
78 Ok(latest)
79}
80
81pub fn get_last_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
87 let commit_time = get_last_commit_time(repo_path)?;
88 let mtime = get_mtime_activity(repo_path)?;
89
90 match (commit_time, mtime) {
91 (Some(c), Some(m)) => Ok(Some(c.max(m))),
92 (Some(c), None) => Ok(Some(c)),
93 (None, Some(m)) => Ok(Some(m)),
94 (None, None) => Ok(None),
95 }
96}
97
98pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
106 match last_activity {
107 Some(activity_time) => {
108 let idle_duration = Duration::from_secs(idle_days.saturating_mul(24 * 60 * 60));
112 let Some(threshold) = SystemTime::now().checked_sub(idle_duration) else {
113 return false;
114 };
115 activity_time < threshold
116 }
117 None => true,
119 }
120}
121
122pub fn is_repo_idle(repo_path: &Path, idle_days: u64) -> Result<bool> {
124 Ok(is_idle_at(get_last_activity(repo_path)?, idle_days))
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use std::fs;
131 use tempfile::TempDir;
132
133 fn git(path: &Path) -> Command {
141 let mut cmd = Command::new("git");
142 cmd.current_dir(path)
143 .env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
144 .env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
145 cmd
146 }
147
148 fn create_git_repo(path: &Path) {
150 fs::create_dir_all(path).unwrap();
151 git(path).args(["init"]).output().unwrap();
152 }
153
154 fn create_git_repo_with_commit(path: &Path) {
156 create_git_repo(path);
157 fs::write(path.join("README.md"), "# Test").unwrap();
158 git(path).args(["add", "."]).output().unwrap();
159 git(path)
160 .args([
161 "-c",
162 "user.name=Test",
163 "-c",
164 "user.email=test@test.com",
165 "commit",
166 "-m",
167 "initial",
168 ])
169 .output()
170 .unwrap();
171 }
172
173 #[test]
174 fn test_get_last_commit_time_with_commits() {
175 let tmp = TempDir::new().unwrap();
176 let repo = tmp.path().join("repo");
177 create_git_repo_with_commit(&repo);
178 let time = get_last_commit_time(&repo).unwrap();
179 assert!(time.is_some());
180 }
181
182 #[test]
183 fn test_get_last_commit_time_empty_repo() {
184 let tmp = TempDir::new().unwrap();
185 let repo = tmp.path().join("repo");
186 create_git_repo(&repo);
187 let time = get_last_commit_time(&repo).unwrap();
188 assert!(time.is_none());
189 }
190
191 #[test]
192 fn test_get_mtime_activity() {
193 let tmp = TempDir::new().unwrap();
194 fs::write(tmp.path().join("file.txt"), "hello").unwrap();
195 let activity = get_mtime_activity(tmp.path()).unwrap();
196 assert!(activity.is_some());
197 }
198
199 #[test]
200 fn test_get_mtime_activity_excludes_git() {
201 let tmp = TempDir::new().unwrap();
202 let git_dir = tmp.path().join(".git");
203 fs::create_dir(&git_dir).unwrap();
204 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
205 let activity = get_mtime_activity(tmp.path()).unwrap();
207 assert!(activity.is_some() || activity.is_none());
210 }
211
212 #[test]
213 fn test_get_last_activity_with_commits() {
214 let tmp = TempDir::new().unwrap();
215 let repo = tmp.path().join("repo");
216 create_git_repo_with_commit(&repo);
217 let activity = get_last_activity(&repo).unwrap();
218 assert!(activity.is_some());
219 }
220
221 #[test]
222 fn test_get_last_activity_empty_repo_with_files() {
223 let tmp = TempDir::new().unwrap();
224 let repo = tmp.path().join("repo");
225 create_git_repo(&repo);
226 fs::write(repo.join("main.py"), "print('hello')").unwrap();
227 let activity = get_last_activity(&repo).unwrap();
228 assert!(activity.is_some());
229 }
230
231 #[test]
232 fn test_is_repo_idle_recent() {
233 let tmp = TempDir::new().unwrap();
234 let repo = tmp.path().join("repo");
235 create_git_repo_with_commit(&repo);
236 assert!(!is_repo_idle(&repo, 15).unwrap());
238 }
239
240 #[test]
241 fn is_idle_at_agrees_with_the_repo_level_check() {
242 let tmp = TempDir::new().unwrap();
245 let repo = tmp.path().join("repo");
246 create_git_repo_with_commit(&repo);
247
248 let activity = get_last_activity(&repo).unwrap();
249 assert_eq!(is_idle_at(activity, 15), is_repo_idle(&repo, 15).unwrap());
250 assert!(!is_idle_at(activity, 15));
251 assert!(is_idle_at(activity, 0));
252 }
253
254 #[test]
255 fn a_repo_with_no_activity_at_all_is_idle() {
256 assert!(is_idle_at(None, 15));
257 }
258
259 #[test]
260 fn an_absurd_idle_threshold_means_never_idle_rather_than_a_panic() {
261 assert!(!is_idle_at(Some(SystemTime::UNIX_EPOCH), u64::MAX));
263 }
264
265 #[test]
266 fn test_is_repo_idle_no_activity() {
267 let tmp = TempDir::new().unwrap();
268 let repo = tmp.path().join("repo");
269 create_git_repo(&repo);
270 let result = is_repo_idle(&repo, 0);
274 assert!(result.is_ok());
275 }
276}