Skip to main content

dev_prune/scanner/
git.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Git-specific activity detection.
5//
6// Determines when a repository was last active by:
7// 1. Checking the latest commit timestamp via `git log`
8// 2. Falling back to file `mtime` scanning for empty repos (0 commits)
9
10use std::path::Path;
11use std::process::Command;
12use std::time::{Duration, SystemTime};
13
14use anyhow::{Context, Result};
15use walkdir::WalkDir;
16
17/// Directories to exclude when scanning file mtimes.
18const EXCLUDED_DIRS: &[&str] = &[".git", "node_modules", ".venv", "venv", "target"];
19
20/// Get the timestamp of the most recent commit in a repository.
21///
22/// Returns `None` if the repo has no commits — `git log` on an unborn HEAD exits
23/// non-zero, so no separate `git rev-parse HEAD` probe is needed to find that out.
24pub 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
49/// Scan all source files in a repo and return the latest `mtime`.
50///
51/// Used as a fallback for empty repos (no commits). Excludes bloat directories
52/// and the `.git` folder itself.
53pub 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
81/// Get the last activity time for a repository.
82///
83/// Strategy:
84/// Checks BOTH commit-based timestamp AND source file mtimes (excluding bloat dirs and .git).
85/// Returns `max(commit_time, latest_mtime)` so uncommitted local edits delay pruning.
86pub 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
98/// Whether an already-known activity time counts as idle.
99///
100/// Split out from [`is_repo_idle`] so a caller that has just computed the activity time
101/// for display can decide idleness from the same value instead of recomputing it. The
102/// dashboard used to do exactly that — a second `git log` plus a second full tree walk
103/// per repository — and, worse, showed a "last activity" that the idle decision had not
104/// actually used.
105pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
106    match last_activity {
107        Some(activity_time) => {
108            // Saturating throughout: `idle_days * 86400` overflows u64 past ~2.1e14
109            // days, and `SystemTime::now() - duration` panics if the result would be
110            // before the epoch. A huge idle_days should mean "never idle", not a crash.
111            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        // No activity detected at all → consider it idle
118        None => true,
119    }
120}
121
122/// Check if a repository is considered "idle" (inactive for `idle_days`).
123pub 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    /// Helper: a `git` that cannot see the developer's own configuration.
134    ///
135    /// dev-prune installs a *global* `core.hooksPath`. Without this, the commit below
136    /// fires the real `post-commit` hook, which registers this temporary directory in the
137    /// developer's real registry and leaves a dead entry behind once the fixture is
138    /// deleted. Pointing `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` at files that do not
139    /// exist is how git is told to read neither.
140    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    /// Helper: create a real git repo with `git init`
149    fn create_git_repo(path: &Path) {
150        fs::create_dir_all(path).unwrap();
151        git(path).args(["init"]).output().unwrap();
152    }
153
154    /// Helper: create a git repo with at least one commit
155    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        // Only .git files, no source files
206        let activity = get_mtime_activity(tmp.path()).unwrap();
207        // The root dir itself might return something, but no source files
208        // This just verifies it doesn't crash
209        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        // A repo committed just now should NOT be idle
237        assert!(!is_repo_idle(&repo, 15).unwrap());
238    }
239
240    #[test]
241    fn is_idle_at_agrees_with_the_repo_level_check() {
242        // The two must not drift: the dashboard decides with `is_idle_at` on an activity
243        // time it already has, the prune pass decides with `is_repo_idle`.
244        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        // `now - u64::MAX days` is before the epoch; subtracting it must not panic.
262        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        // Empty repo with no files → idle
271        // Note: might have mtime from git init, but that's recent
272        // so let's test with 0 idle days
273        let result = is_repo_idle(&repo, 0);
274        assert!(result.is_ok());
275    }
276}