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/// A `git` command aimed at `repo_path` and nothing else.
29///
30/// `current_dir` alone does not win against an inherited absolute `GIT_DIR`: a user
31/// invoking dev-prune from inside a git hook, a `git rebase -x` step, or any wrapper
32/// that exports repository state would have every repository's history read from that
33/// one repo. Cleared, the question is always answered by the repository being asked
34/// about.
35pub 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
46/// Get the timestamp of the most recent commit in a repository.
47///
48/// Returns `None` if the repo has no commits. `git log` on an unborn HEAD exits
49/// non-zero — but so does git refusing the repository outright (dubious ownership,
50/// corruption), and "could not read the history" must not feed the idle check the
51/// same answer as "there is no history": the first is an error, the second makes an
52/// empty repo eligible. A `rev-parse` probe tells the two apart.
53pub 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
88/// Scan all source files in a repo and return the latest `mtime`.
89///
90/// Used as a fallback for empty repos (no commits). Excludes bloat directories
91/// and the `.git` folder itself.
92pub 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            // A future mtime — a skewed clock, an extracted archive — would make
111            // the repository read as active forever. Clamped, it reads as
112            // touched just now and ages out normally.
113            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
125/// Get the last activity time for a repository.
126///
127/// Strategy:
128/// Checks BOTH commit-based timestamp AND source file mtimes (excluding bloat dirs and .git).
129/// Returns `max(commit_time, latest_mtime)` so uncommitted local edits delay pruning.
130pub 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
142/// Whether an already-known activity time counts as idle.
143///
144/// Split out from [`is_repo_idle`] so a caller that has just computed the activity time
145/// for display can decide idleness from the same value instead of recomputing it. The
146/// dashboard used to do exactly that — a second `git log` plus a second full tree walk
147/// per repository — and, worse, showed a "last activity" that the idle decision had not
148/// actually used.
149pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
150    match last_activity {
151        Some(activity_time) => {
152            // Saturating throughout: `idle_days * 86400` overflows u64 past ~2.1e14
153            // days, and `SystemTime::now() - duration` panics if the result would be
154            // before the epoch. A huge idle_days should mean "never idle", not a crash.
155            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        // No activity detected at all → consider it idle
162        None => true,
163    }
164}
165
166/// Check if a repository is considered "idle" (inactive for `idle_days`).
167pub 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    /// Helper: a `git` that cannot see the developer's own configuration.
178    ///
179    /// dev-prune installs a *global* `core.hooksPath`. Without this, the commit below
180    /// fires the real `post-commit` hook, which registers this temporary directory in the
181    /// developer's real registry and leaves a dead entry behind once the fixture is
182    /// deleted. Pointing `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` at files that do not
183    /// exist is how git is told to read neither.
184    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    /// Helper: create a real git repo with `git init`
193    fn create_git_repo(path: &Path) {
194        fs::create_dir_all(path).unwrap();
195        git(path).args(["init"]).output().unwrap();
196    }
197
198    /// Helper: create a git repo with at least one commit
199    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        // Only .git files, no source files
250        let activity = get_mtime_activity(tmp.path()).unwrap();
251        // The root dir itself might return something, but no source files
252        // This just verifies it doesn't crash
253        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        // A repo committed just now should NOT be idle
281        assert!(!is_repo_idle(&repo, 15).unwrap());
282    }
283
284    #[test]
285    fn is_idle_at_agrees_with_the_repo_level_check() {
286        // The two must not drift: the dashboard decides with `is_idle_at` on an activity
287        // time it already has, the prune pass decides with `is_repo_idle`.
288        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        // `now - u64::MAX days` is before the epoch; subtracting it must not panic.
306        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        // Empty repo with no files → idle
315        // Note: might have mtime from git init, but that's recent
316        // so let's test with 0 idle days
317        let result = is_repo_idle(&repo, 0);
318        assert!(result.is_ok());
319    }
320}