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