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}