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] = &[
26 ".git",
27 "node_modules",
28 ".venv",
29 "venv",
30 "target",
31 "vendor",
32 "__pypackages__",
33 "Pods",
34 "deps",
35 "_build",
36 ".build",
37 ".gradle",
38];
39
40const EXCLUDED_FILES: &[&str] = &[
49 crate::constants::PER_REPO_CONFIG_FILE,
50 crate::constants::PROJECT_REPO_CONFIG_FILE,
51 crate::constants::DEVPRUNE_IGNORE_FILE,
52];
53
54const MAX_MTIME_SCAN_DEPTH: usize = 8;
61
62pub fn git_in(repo_path: &Path) -> Command {
70 let mut cmd = crate::spawn::command("git");
71 cmd.env_remove("GIT_DIR")
72 .env_remove("GIT_WORK_TREE")
73 .env_remove("GIT_INDEX_FILE")
74 .env_remove("GIT_COMMON_DIR")
75 .env_remove("GIT_OBJECT_DIRECTORY")
76 .current_dir(repo_path);
77 cmd
78}
79
80pub fn repo_identity(repo_path: &Path) -> Option<String> {
92 let output = git_in(repo_path)
93 .args(["rev-list", "--max-parents=0", "HEAD"])
94 .output()
95 .ok()?;
96 if !output.status.success() {
97 return None;
98 }
99 let text = String::from_utf8_lossy(&output.stdout);
100 let hash = text.split_whitespace().next_back()?;
104 (hash.len() >= 7 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
105}
106
107pub fn get_last_commit_time(repo_path: &Path) -> Result<Option<SystemTime>> {
115 let output = git_in(repo_path)
116 .args(["log", "-1", "--format=%ct"])
117 .output()
118 .context("Failed to execute git log")?;
119
120 if !output.status.success() {
121 let probe = git_in(repo_path)
122 .args(["rev-parse", "--git-dir"])
123 .output()
124 .context("Failed to execute git rev-parse")?;
125 if probe.status.success() {
126 return Ok(None);
127 }
128 anyhow::bail!(
129 "git could not read `{}`: {}",
130 repo_path.display(),
131 String::from_utf8_lossy(&probe.stderr).trim()
132 );
133 }
134
135 let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
136 if timestamp_str.is_empty() {
137 return Ok(None);
138 }
139
140 let timestamp: u64 = timestamp_str
141 .parse()
142 .with_context(|| format!("Failed to parse git timestamp: {timestamp_str}"))?;
143
144 Ok(Some(
145 SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp),
146 ))
147}
148
149pub fn get_mtime_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
155 let mut latest: Option<SystemTime> = None;
156
157 let now = SystemTime::now();
158 let walker = WalkDir::new(repo_path)
159 .follow_links(false)
160 .max_depth(MAX_MTIME_SCAN_DEPTH)
161 .into_iter()
162 .filter_entry(|entry| {
163 let name = entry.file_name().to_string_lossy();
164 !EXCLUDED_DIRS.contains(&name.as_ref()) && !EXCLUDED_FILES.contains(&name.as_ref())
165 });
166
167 for entry in walker.flatten() {
168 if entry.file_type().is_file()
169 && let Ok(metadata) = entry.metadata()
170 && let Ok(mtime) = metadata.modified()
171 {
172 let mtime = mtime.min(now);
176 latest = Some(match latest {
177 Some(current) if mtime > current => mtime,
178 Some(current) => current,
179 None => mtime,
180 });
181 }
182 }
183
184 Ok(latest)
185}
186
187pub fn get_last_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
193 let commit_time = get_last_commit_time(repo_path)?;
194 let mtime = get_mtime_activity(repo_path)?;
195
196 match (commit_time, mtime) {
197 (Some(c), Some(m)) => Ok(Some(c.max(m))),
198 (Some(c), None) => Ok(Some(c)),
199 (None, Some(m)) => Ok(Some(m)),
200 (None, None) => Ok(None),
201 }
202}
203
204pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
212 match last_activity {
213 Some(activity_time) => {
214 let idle_duration = Duration::from_secs(idle_days.saturating_mul(24 * 60 * 60));
218 let Some(threshold) = SystemTime::now().checked_sub(idle_duration) else {
219 return false;
220 };
221 activity_time < threshold
222 }
223 None => true,
225 }
226}
227
228pub fn is_repo_idle(repo_path: &Path, idle_days: u64) -> Result<bool> {
230 Ok(is_idle_at(get_last_activity(repo_path)?, idle_days))
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::fs;
237 use tempfile::TempDir;
238
239 fn git(path: &Path) -> Command {
247 let mut cmd = Command::new("git");
248 cmd.current_dir(path)
249 .env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
250 .env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
251 cmd
252 }
253
254 fn create_git_repo(path: &Path) {
256 fs::create_dir_all(path).unwrap();
257 git(path).args(["init"]).output().unwrap();
258 }
259
260 fn create_git_repo_with_commit(path: &Path) {
262 create_git_repo(path);
263 fs::write(path.join("README.md"), "# Test").unwrap();
264 git(path).args(["add", "."]).output().unwrap();
265 git(path)
266 .args([
267 "-c",
268 "user.name=Test",
269 "-c",
270 "user.email=test@test.com",
271 "commit",
272 "-m",
273 "initial",
274 ])
275 .output()
276 .unwrap();
277 }
278
279 #[test]
280 fn test_get_last_commit_time_with_commits() {
281 let tmp = TempDir::new().unwrap();
282 let repo = tmp.path().join("repo");
283 create_git_repo_with_commit(&repo);
284 let time = get_last_commit_time(&repo).unwrap();
285 assert!(time.is_some());
286 }
287
288 #[test]
289 fn test_get_last_commit_time_empty_repo() {
290 let tmp = TempDir::new().unwrap();
291 let repo = tmp.path().join("repo");
292 create_git_repo(&repo);
293 let time = get_last_commit_time(&repo).unwrap();
294 assert!(time.is_none());
295 }
296
297 #[test]
298 fn test_get_mtime_activity() {
299 let tmp = TempDir::new().unwrap();
300 fs::write(tmp.path().join("file.txt"), "hello").unwrap();
301 let activity = get_mtime_activity(tmp.path()).unwrap();
302 assert!(activity.is_some());
303 }
304
305 #[test]
306 fn test_get_mtime_activity_excludes_git() {
307 let tmp = TempDir::new().unwrap();
308 let git_dir = tmp.path().join(".git");
309 fs::create_dir(&git_dir).unwrap();
310 fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
311 let activity = get_mtime_activity(tmp.path()).unwrap();
313 assert!(activity.is_some() || activity.is_none());
316 }
317
318 #[test]
319 fn a_repo_whose_only_new_file_is_dev_prunes_own_config_is_not_active() {
320 let tmp = TempDir::new().unwrap();
323 fs::write(
324 tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
325 "{}",
326 )
327 .unwrap();
328 assert!(
329 get_mtime_activity(tmp.path()).unwrap().is_none(),
330 "`.devprune.json` must not count as user activity"
331 );
332
333 fs::write(tmp.path().join(crate::constants::DEVPRUNE_IGNORE_FILE), "").unwrap();
334 assert!(
335 get_mtime_activity(tmp.path()).unwrap().is_none(),
336 "`ignore.devprune.json` must not count as user activity"
337 );
338
339 fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
340 assert!(
341 get_mtime_activity(tmp.path()).unwrap().is_some(),
342 "a real source file still counts"
343 );
344 }
345
346 #[test]
347 fn test_get_last_activity_with_commits() {
348 let tmp = TempDir::new().unwrap();
349 let repo = tmp.path().join("repo");
350 create_git_repo_with_commit(&repo);
351 let activity = get_last_activity(&repo).unwrap();
352 assert!(activity.is_some());
353 }
354
355 #[test]
356 fn test_get_last_activity_empty_repo_with_files() {
357 let tmp = TempDir::new().unwrap();
358 let repo = tmp.path().join("repo");
359 create_git_repo(&repo);
360 fs::write(repo.join("main.py"), "print('hello')").unwrap();
361 let activity = get_last_activity(&repo).unwrap();
362 assert!(activity.is_some());
363 }
364
365 #[test]
366 fn test_is_repo_idle_recent() {
367 let tmp = TempDir::new().unwrap();
368 let repo = tmp.path().join("repo");
369 create_git_repo_with_commit(&repo);
370 assert!(!is_repo_idle(&repo, 15).unwrap());
372 }
373
374 #[test]
375 fn is_idle_at_agrees_with_the_repo_level_check() {
376 let tmp = TempDir::new().unwrap();
379 let repo = tmp.path().join("repo");
380 create_git_repo_with_commit(&repo);
381
382 let activity = get_last_activity(&repo).unwrap();
383 assert_eq!(is_idle_at(activity, 15), is_repo_idle(&repo, 15).unwrap());
384 assert!(!is_idle_at(activity, 15));
385 assert!(is_idle_at(activity, 0));
386 }
387
388 #[test]
389 fn a_repo_with_no_activity_at_all_is_idle() {
390 assert!(is_idle_at(None, 15));
391 }
392
393 #[test]
394 fn an_absurd_idle_threshold_means_never_idle_rather_than_a_panic() {
395 assert!(!is_idle_at(Some(SystemTime::UNIX_EPOCH), u64::MAX));
397 }
398
399 #[test]
400 fn test_is_repo_idle_no_activity() {
401 let tmp = TempDir::new().unwrap();
402 let repo = tmp.path().join("repo");
403 create_git_repo(&repo);
404 let result = is_repo_idle(&repo, 0);
408 assert!(result.is_ok());
409 }
410}