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