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", "vendor"];
19
20/// Depth ceiling for the mtime fallback walk, matching the repo-discovery scan.
21///
22/// The fallback only exists for repositories with no commits; walking an arbitrarily
23/// deep tree to answer "has anyone touched this lately" costs more than the answer is
24/// worth, and a pathological layout (a recursive junction, a vendored monorepo) could
25/// stall every status refresh.
26const MAX_MTIME_SCAN_DEPTH: usize = 8;
27
28/// Get the timestamp of the most recent commit in a repository.
29///
30/// Returns `None` if the repo has no commits — `git log` on an unborn HEAD exits
31/// non-zero, so no separate `git rev-parse HEAD` probe is needed to find that out.
32pub 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
57/// Scan all source files in a repo and return the latest `mtime`.
58///
59/// Used as a fallback for empty repos (no commits). Excludes bloat directories
60/// and the `.git` folder itself.
61pub 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                    // A future mtime — a skewed clock, an extracted archive — would make
79                    // the repository read as active forever. Clamped, it reads as
80                    // touched just now and ages out normally.
81                    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
95/// Get the last activity time for a repository.
96///
97/// Strategy:
98/// Checks BOTH commit-based timestamp AND source file mtimes (excluding bloat dirs and .git).
99/// Returns `max(commit_time, latest_mtime)` so uncommitted local edits delay pruning.
100pub 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
112/// Whether an already-known activity time counts as idle.
113///
114/// Split out from [`is_repo_idle`] so a caller that has just computed the activity time
115/// for display can decide idleness from the same value instead of recomputing it. The
116/// dashboard used to do exactly that — a second `git log` plus a second full tree walk
117/// per repository — and, worse, showed a "last activity" that the idle decision had not
118/// actually used.
119pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
120    match last_activity {
121        Some(activity_time) => {
122            // Saturating throughout: `idle_days * 86400` overflows u64 past ~2.1e14
123            // days, and `SystemTime::now() - duration` panics if the result would be
124            // before the epoch. A huge idle_days should mean "never idle", not a crash.
125            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        // No activity detected at all → consider it idle
132        None => true,
133    }
134}
135
136/// Check if a repository is considered "idle" (inactive for `idle_days`).
137pub 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    /// Helper: a `git` that cannot see the developer's own configuration.
148    ///
149    /// dev-prune installs a *global* `core.hooksPath`. Without this, the commit below
150    /// fires the real `post-commit` hook, which registers this temporary directory in the
151    /// developer's real registry and leaves a dead entry behind once the fixture is
152    /// deleted. Pointing `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` at files that do not
153    /// exist is how git is told to read neither.
154    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    /// Helper: create a real git repo with `git init`
163    fn create_git_repo(path: &Path) {
164        fs::create_dir_all(path).unwrap();
165        git(path).args(["init"]).output().unwrap();
166    }
167
168    /// Helper: create a git repo with at least one commit
169    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        // Only .git files, no source files
220        let activity = get_mtime_activity(tmp.path()).unwrap();
221        // The root dir itself might return something, but no source files
222        // This just verifies it doesn't crash
223        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        // A repo committed just now should NOT be idle
251        assert!(!is_repo_idle(&repo, 15).unwrap());
252    }
253
254    #[test]
255    fn is_idle_at_agrees_with_the_repo_level_check() {
256        // The two must not drift: the dashboard decides with `is_idle_at` on an activity
257        // time it already has, the prune pass decides with `is_repo_idle`.
258        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        // `now - u64::MAX days` is before the epoch; subtracting it must not panic.
276        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        // Empty repo with no files → idle
285        // Note: might have mtime from git init, but that's recent
286        // so let's test with 0 idle days
287        let result = is_repo_idle(&repo, 0);
288        assert!(result.is_ok());
289    }
290}