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.
18///
19/// Every one of these is a directory some adapter deletes and some package manager
20/// refills. Counting what a manager wrote there as the user's activity is how a
21/// repository that was pruned and restored last week reads as touched today: the
22/// restore stamps every file in the tree with the moment it ran. Plain `build/` is
23/// deliberately not here — outside a Gradle project that name usually holds build
24/// scripts people edit, and suppressing those would hide real work.
25const 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
40/// Files whose mtime is dev-prune's own bookkeeping rather than the user's work.
41///
42/// `.devprune.json` is written by `auto_config` and by `devp init`, so on a machine where
43/// every repository was linked in one afternoon every repository also has a file modified
44/// that afternoon — and `get_last_activity` returns the newest mtime in the tree. The
45/// effect was that linking a workspace reset every repository's activity clock to *now*
46/// and no repository could go idle again until the user next edited it. Eighty tracked
47/// repositories, zero candidates, and nothing anywhere reporting a fault.
48const EXCLUDED_FILES: &[&str] = &[crate::constants::PER_REPO_CONFIG_FILE];
49
50/// Depth ceiling for the mtime fallback walk, matching the repo-discovery scan.
51///
52/// The fallback only exists for repositories with no commits; walking an arbitrarily
53/// deep tree to answer "has anyone touched this lately" costs more than the answer is
54/// worth, and a pathological layout (a recursive junction, a vendored monorepo) could
55/// stall every status refresh.
56const MAX_MTIME_SCAN_DEPTH: usize = 8;
57
58/// A `git` command aimed at `repo_path` and nothing else.
59///
60/// `current_dir` alone does not win against an inherited absolute `GIT_DIR`: a user
61/// invoking dev-prune from inside a git hook, a `git rebase -x` step, or any wrapper
62/// that exports repository state would have every repository's history read from that
63/// one repo. Cleared, the question is always answered by the repository being asked
64/// about.
65pub fn git_in(repo_path: &Path) -> Command {
66    let mut cmd = crate::spawn::command("git");
67    cmd.env_remove("GIT_DIR")
68        .env_remove("GIT_WORK_TREE")
69        .env_remove("GIT_INDEX_FILE")
70        .env_remove("GIT_COMMON_DIR")
71        .env_remove("GIT_OBJECT_DIRECTORY")
72        .current_dir(repo_path);
73    cmd
74}
75
76/// A repository's root commit — the one identifier that survives being moved.
77///
78/// The registry is keyed by path, so a workspace that is moved or renamed looks like a
79/// repository that vanished and a different one that appeared: the prune history is
80/// stranded on a path that will never exist again, and the same project registers a
81/// second time from scratch. The root commit is identical on both sides of that move,
82/// which is what lets `link` and `init` join them back up.
83///
84/// `None` when the repository has no commits yet, or when git refuses to answer. There
85/// is nothing to identify an empty repository by, and a guess would be worse than the
86/// dead entry it replaced.
87pub fn repo_identity(repo_path: &Path) -> Option<String> {
88    let output = git_in(repo_path)
89        .args(["rev-list", "--max-parents=0", "HEAD"])
90        .output()
91        .ok()?;
92    if !output.status.success() {
93        return None;
94    }
95    let text = String::from_utf8_lossy(&output.stdout);
96    // A history with more than one root — a subtree merge, a graft — lists them newest
97    // first. The last is the oldest, and it is the one that does not change when
98    // another root is merged in later.
99    let hash = text.split_whitespace().next_back()?;
100    (hash.len() >= 7 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
101}
102
103/// Get the timestamp of the most recent commit in a repository.
104///
105/// Returns `None` if the repo has no commits. `git log` on an unborn HEAD exits
106/// non-zero — but so does git refusing the repository outright (dubious ownership,
107/// corruption), and "could not read the history" must not feed the idle check the
108/// same answer as "there is no history": the first is an error, the second makes an
109/// empty repo eligible. A `rev-parse` probe tells the two apart.
110pub fn get_last_commit_time(repo_path: &Path) -> Result<Option<SystemTime>> {
111    let output = git_in(repo_path)
112        .args(["log", "-1", "--format=%ct"])
113        .output()
114        .context("Failed to execute git log")?;
115
116    if !output.status.success() {
117        let probe = git_in(repo_path)
118            .args(["rev-parse", "--git-dir"])
119            .output()
120            .context("Failed to execute git rev-parse")?;
121        if probe.status.success() {
122            return Ok(None);
123        }
124        anyhow::bail!(
125            "git could not read `{}`: {}",
126            repo_path.display(),
127            String::from_utf8_lossy(&probe.stderr).trim()
128        );
129    }
130
131    let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
132    if timestamp_str.is_empty() {
133        return Ok(None);
134    }
135
136    let timestamp: u64 = timestamp_str
137        .parse()
138        .with_context(|| format!("Failed to parse git timestamp: {timestamp_str}"))?;
139
140    Ok(Some(
141        SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp),
142    ))
143}
144
145/// Scan all source files in a repo and return the latest `mtime`.
146///
147/// Used as a fallback for empty repos (no commits). Excludes bloat directories, the
148/// `.git` folder itself, and every file dev-prune writes into a repository — see
149/// [`EXCLUDED_FILES`].
150pub fn get_mtime_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
151    let mut latest: Option<SystemTime> = None;
152
153    let now = SystemTime::now();
154    let walker = WalkDir::new(repo_path)
155        .follow_links(false)
156        .max_depth(MAX_MTIME_SCAN_DEPTH)
157        .into_iter()
158        .filter_entry(|entry| {
159            let name = entry.file_name().to_string_lossy();
160            !EXCLUDED_DIRS.contains(&name.as_ref()) && !EXCLUDED_FILES.contains(&name.as_ref())
161        });
162
163    for entry in walker.flatten() {
164        if entry.file_type().is_file()
165            && let Ok(metadata) = entry.metadata()
166            && let Ok(mtime) = metadata.modified()
167        {
168            // A future mtime — a skewed clock, an extracted archive — would make
169            // the repository read as active forever. Clamped, it reads as
170            // touched just now and ages out normally.
171            let mtime = mtime.min(now);
172            latest = Some(match latest {
173                Some(current) if mtime > current => mtime,
174                Some(current) => current,
175                None => mtime,
176            });
177        }
178    }
179
180    Ok(latest)
181}
182
183/// Get the last activity time for a repository.
184///
185/// Strategy:
186/// Checks BOTH commit-based timestamp AND source file mtimes (excluding bloat dirs and .git).
187/// Returns `max(commit_time, latest_mtime)` so uncommitted local edits delay pruning.
188pub fn get_last_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
189    let commit_time = get_last_commit_time(repo_path)?;
190    let mtime = get_mtime_activity(repo_path)?;
191
192    match (commit_time, mtime) {
193        (Some(c), Some(m)) => Ok(Some(c.max(m))),
194        (Some(c), None) => Ok(Some(c)),
195        (None, Some(m)) => Ok(Some(m)),
196        (None, None) => Ok(None),
197    }
198}
199
200/// Whether an already-known activity time counts as idle.
201///
202/// Split out from [`is_repo_idle`] so a caller that has just computed the activity time
203/// for display can decide idleness from the same value instead of recomputing it. The
204/// dashboard used to do exactly that — a second `git log` plus a second full tree walk
205/// per repository — and, worse, showed a "last activity" that the idle decision had not
206/// actually used.
207pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
208    match last_activity {
209        Some(activity_time) => {
210            // Saturating throughout: `idle_days * 86400` overflows u64 past ~2.1e14
211            // days, and `SystemTime::now() - duration` panics if the result would be
212            // before the epoch. A huge idle_days should mean "never idle", not a crash.
213            let idle_duration = Duration::from_secs(idle_days.saturating_mul(24 * 60 * 60));
214            let Some(threshold) = SystemTime::now().checked_sub(idle_duration) else {
215                return false;
216            };
217            activity_time < threshold
218        }
219        // No activity detected at all → consider it idle
220        None => true,
221    }
222}
223
224/// Check if a repository is considered "idle" (inactive for `idle_days`).
225pub fn is_repo_idle(repo_path: &Path, idle_days: u64) -> Result<bool> {
226    Ok(is_idle_at(get_last_activity(repo_path)?, idle_days))
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::fs;
233    use tempfile::TempDir;
234
235    /// Helper: a `git` that cannot see the developer's own configuration.
236    ///
237    /// dev-prune installs a *global* `core.hooksPath`. Without this, the commit below
238    /// fires the real `post-commit` hook, which registers this temporary directory in the
239    /// developer's real registry and leaves a dead entry behind once the fixture is
240    /// deleted. Pointing `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` at files that do not
241    /// exist is how git is told to read neither.
242    fn git(path: &Path) -> Command {
243        let mut cmd = Command::new("git");
244        cmd.current_dir(path)
245            .env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
246            .env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
247        cmd
248    }
249
250    /// Helper: create a real git repo with `git init`
251    fn create_git_repo(path: &Path) {
252        fs::create_dir_all(path).unwrap();
253        git(path).args(["init"]).output().unwrap();
254    }
255
256    /// Helper: create a git repo with at least one commit
257    fn create_git_repo_with_commit(path: &Path) {
258        create_git_repo(path);
259        fs::write(path.join("README.md"), "# Test").unwrap();
260        git(path).args(["add", "."]).output().unwrap();
261        git(path)
262            .args([
263                "-c",
264                "user.name=Test",
265                "-c",
266                "user.email=test@test.com",
267                "commit",
268                "-m",
269                "initial",
270            ])
271            .output()
272            .unwrap();
273    }
274
275    #[test]
276    fn test_get_last_commit_time_with_commits() {
277        let tmp = TempDir::new().unwrap();
278        let repo = tmp.path().join("repo");
279        create_git_repo_with_commit(&repo);
280        let time = get_last_commit_time(&repo).unwrap();
281        assert!(time.is_some());
282    }
283
284    #[test]
285    fn test_get_last_commit_time_empty_repo() {
286        let tmp = TempDir::new().unwrap();
287        let repo = tmp.path().join("repo");
288        create_git_repo(&repo);
289        let time = get_last_commit_time(&repo).unwrap();
290        assert!(time.is_none());
291    }
292
293    #[test]
294    fn test_get_mtime_activity() {
295        let tmp = TempDir::new().unwrap();
296        fs::write(tmp.path().join("file.txt"), "hello").unwrap();
297        let activity = get_mtime_activity(tmp.path()).unwrap();
298        assert!(activity.is_some());
299    }
300
301    #[test]
302    fn test_get_mtime_activity_excludes_git() {
303        let tmp = TempDir::new().unwrap();
304        let git_dir = tmp.path().join(".git");
305        fs::create_dir(&git_dir).unwrap();
306        fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
307        // Only .git files, no source files
308        let activity = get_mtime_activity(tmp.path()).unwrap();
309        // The root dir itself might return something, but no source files
310        // This just verifies it doesn't crash
311        assert!(activity.is_some() || activity.is_none());
312    }
313
314    #[test]
315    fn a_repo_whose_only_new_file_is_dev_prunes_own_config_is_not_active() {
316        // The bug this pins: `devp link` writes `.devprune.json`, the activity scan saw
317        // it, and every linked repository read as "worked in today" forever.
318        let tmp = TempDir::new().unwrap();
319        fs::write(
320            tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
321            "{}",
322        )
323        .unwrap();
324        assert!(
325            get_mtime_activity(tmp.path()).unwrap().is_none(),
326            "`.devprune.json` must not count as user activity"
327        );
328
329        fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
330        assert!(
331            get_mtime_activity(tmp.path()).unwrap().is_some(),
332            "a real source file still counts"
333        );
334    }
335
336    #[test]
337    fn test_get_last_activity_with_commits() {
338        let tmp = TempDir::new().unwrap();
339        let repo = tmp.path().join("repo");
340        create_git_repo_with_commit(&repo);
341        let activity = get_last_activity(&repo).unwrap();
342        assert!(activity.is_some());
343    }
344
345    #[test]
346    fn test_get_last_activity_empty_repo_with_files() {
347        let tmp = TempDir::new().unwrap();
348        let repo = tmp.path().join("repo");
349        create_git_repo(&repo);
350        fs::write(repo.join("main.py"), "print('hello')").unwrap();
351        let activity = get_last_activity(&repo).unwrap();
352        assert!(activity.is_some());
353    }
354
355    #[test]
356    fn test_is_repo_idle_recent() {
357        let tmp = TempDir::new().unwrap();
358        let repo = tmp.path().join("repo");
359        create_git_repo_with_commit(&repo);
360        // A repo committed just now should NOT be idle
361        assert!(!is_repo_idle(&repo, 15).unwrap());
362    }
363
364    #[test]
365    fn is_idle_at_agrees_with_the_repo_level_check() {
366        // The two must not drift: the dashboard decides with `is_idle_at` on an activity
367        // time it already has, the prune pass decides with `is_repo_idle`.
368        let tmp = TempDir::new().unwrap();
369        let repo = tmp.path().join("repo");
370        create_git_repo_with_commit(&repo);
371
372        let activity = get_last_activity(&repo).unwrap();
373        assert_eq!(is_idle_at(activity, 15), is_repo_idle(&repo, 15).unwrap());
374        assert!(!is_idle_at(activity, 15));
375        assert!(is_idle_at(activity, 0));
376    }
377
378    #[test]
379    fn a_repo_with_no_activity_at_all_is_idle() {
380        assert!(is_idle_at(None, 15));
381    }
382
383    #[test]
384    fn an_absurd_idle_threshold_means_never_idle_rather_than_a_panic() {
385        // `now - u64::MAX days` is before the epoch; subtracting it must not panic.
386        assert!(!is_idle_at(Some(SystemTime::UNIX_EPOCH), u64::MAX));
387    }
388
389    #[test]
390    fn test_is_repo_idle_no_activity() {
391        let tmp = TempDir::new().unwrap();
392        let repo = tmp.path().join("repo");
393        create_git_repo(&repo);
394        // Empty repo with no files → idle
395        // Note: might have mtime from git init, but that's recent
396        // so let's test with 0 idle days
397        let result = is_repo_idle(&repo, 0);
398        assert!(result.is_ok());
399    }
400}