dev-prune 1.11.0

Universal, lockfile-safe workspace pruner and background dependency cleaner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
// Copyright 2026 VKrishna04
// SPDX-License-Identifier: Apache-2.0

// Git-specific activity detection.
//
// Determines when a repository was last active by:
// 1. Checking the latest commit timestamp via `git log`
// 2. Falling back to file `mtime` scanning for empty repos (0 commits)

use std::path::Path;
use std::process::Command;
use std::time::{Duration, SystemTime};

use anyhow::{Context, Result};
use walkdir::WalkDir;

/// Directories to exclude when scanning file mtimes.
///
/// Every one of these is a directory some adapter deletes and some package manager
/// refills. Counting what a manager wrote there as the user's activity is how a
/// repository that was pruned and restored last week reads as touched today: the
/// restore stamps every file in the tree with the moment it ran. Plain `build/` is
/// deliberately not here — outside a Gradle project that name usually holds build
/// scripts people edit, and suppressing those would hide real work.
const EXCLUDED_DIRS: &[&str] = &[
    ".git",
    "node_modules",
    ".venv",
    "venv",
    "target",
    "vendor",
    "__pypackages__",
    "Pods",
    "deps",
    "_build",
    ".build",
    ".gradle",
];

/// Files whose mtime is dev-prune's own bookkeeping rather than the user's work.
///
/// `.devprune.json` is written by `auto_config` and by `devp init`, so on a machine where
/// every repository was linked in one afternoon every repository also has a file modified
/// that afternoon — and `get_last_activity` returns the newest mtime in the tree. The
/// effect was that linking a workspace reset every repository's activity clock to *now*
/// and no repository could go idle again until the user next edited it. Eighty tracked
/// repositories, zero candidates, and nothing anywhere reporting a fault.
const EXCLUDED_FILES: &[&str] = &[
    crate::constants::PER_REPO_CONFIG_FILE,
    crate::constants::PROJECT_REPO_CONFIG_FILE,
];

/// Depth ceiling for the mtime fallback walk, matching the repo-discovery scan.
///
/// The fallback only exists for repositories with no commits; walking an arbitrarily
/// deep tree to answer "has anyone touched this lately" costs more than the answer is
/// worth, and a pathological layout (a recursive junction, a vendored monorepo) could
/// stall every status refresh.
const MAX_MTIME_SCAN_DEPTH: usize = 8;

/// A `git` command aimed at `repo_path` and nothing else.
///
/// `current_dir` alone does not win against an inherited absolute `GIT_DIR`: a user
/// invoking dev-prune from inside a git hook, a `git rebase -x` step, or any wrapper
/// that exports repository state would have every repository's history read from that
/// one repo. Cleared, the question is always answered by the repository being asked
/// about.
pub fn git_in(repo_path: &Path) -> Command {
    let mut cmd = crate::spawn::command("git");
    cmd.env_remove("GIT_DIR")
        .env_remove("GIT_WORK_TREE")
        .env_remove("GIT_INDEX_FILE")
        .env_remove("GIT_COMMON_DIR")
        .env_remove("GIT_OBJECT_DIRECTORY")
        .current_dir(repo_path);
    cmd
}

/// A repository's root commit — the one identifier that survives being moved.
///
/// The registry is keyed by path, so a workspace that is moved or renamed looks like a
/// repository that vanished and a different one that appeared: the prune history is
/// stranded on a path that will never exist again, and the same project registers a
/// second time from scratch. The root commit is identical on both sides of that move,
/// which is what lets `link` and `init` join them back up.
///
/// `None` when the repository has no commits yet, or when git refuses to answer. There
/// is nothing to identify an empty repository by, and a guess would be worse than the
/// dead entry it replaced.
pub fn repo_identity(repo_path: &Path) -> Option<String> {
    let output = git_in(repo_path)
        .args(["rev-list", "--max-parents=0", "HEAD"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&output.stdout);
    // A history with more than one root — a subtree merge, a graft — lists them newest
    // first. The last is the oldest, and it is the one that does not change when
    // another root is merged in later.
    let hash = text.split_whitespace().next_back()?;
    (hash.len() >= 7 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string())
}

/// Get the timestamp of the most recent commit in a repository.
///
/// Returns `None` if the repo has no commits. `git log` on an unborn HEAD exits
/// non-zero — but so does git refusing the repository outright (dubious ownership,
/// corruption), and "could not read the history" must not feed the idle check the
/// same answer as "there is no history": the first is an error, the second makes an
/// empty repo eligible. A `rev-parse` probe tells the two apart.
pub fn get_last_commit_time(repo_path: &Path) -> Result<Option<SystemTime>> {
    let output = git_in(repo_path)
        .args(["log", "-1", "--format=%ct"])
        .output()
        .context("Failed to execute git log")?;

    if !output.status.success() {
        let probe = git_in(repo_path)
            .args(["rev-parse", "--git-dir"])
            .output()
            .context("Failed to execute git rev-parse")?;
        if probe.status.success() {
            return Ok(None);
        }
        anyhow::bail!(
            "git could not read `{}`: {}",
            repo_path.display(),
            String::from_utf8_lossy(&probe.stderr).trim()
        );
    }

    let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if timestamp_str.is_empty() {
        return Ok(None);
    }

    let timestamp: u64 = timestamp_str
        .parse()
        .with_context(|| format!("Failed to parse git timestamp: {timestamp_str}"))?;

    Ok(Some(
        SystemTime::UNIX_EPOCH + Duration::from_secs(timestamp),
    ))
}

/// Scan all source files in a repo and return the latest `mtime`.
///
/// Used as a fallback for empty repos (no commits). Excludes bloat directories, the
/// `.git` folder itself, and every file dev-prune writes into a repository — see
/// [`EXCLUDED_FILES`].
pub fn get_mtime_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
    let mut latest: Option<SystemTime> = None;

    let now = SystemTime::now();
    let walker = WalkDir::new(repo_path)
        .follow_links(false)
        .max_depth(MAX_MTIME_SCAN_DEPTH)
        .into_iter()
        .filter_entry(|entry| {
            let name = entry.file_name().to_string_lossy();
            !EXCLUDED_DIRS.contains(&name.as_ref()) && !EXCLUDED_FILES.contains(&name.as_ref())
        });

    for entry in walker.flatten() {
        if entry.file_type().is_file()
            && let Ok(metadata) = entry.metadata()
            && let Ok(mtime) = metadata.modified()
        {
            // A future mtime — a skewed clock, an extracted archive — would make
            // the repository read as active forever. Clamped, it reads as
            // touched just now and ages out normally.
            let mtime = mtime.min(now);
            latest = Some(match latest {
                Some(current) if mtime > current => mtime,
                Some(current) => current,
                None => mtime,
            });
        }
    }

    Ok(latest)
}

/// Get the last activity time for a repository.
///
/// Strategy:
/// Checks BOTH commit-based timestamp AND source file mtimes (excluding bloat dirs and .git).
/// Returns `max(commit_time, latest_mtime)` so uncommitted local edits delay pruning.
pub fn get_last_activity(repo_path: &Path) -> Result<Option<SystemTime>> {
    let commit_time = get_last_commit_time(repo_path)?;
    let mtime = get_mtime_activity(repo_path)?;

    match (commit_time, mtime) {
        (Some(c), Some(m)) => Ok(Some(c.max(m))),
        (Some(c), None) => Ok(Some(c)),
        (None, Some(m)) => Ok(Some(m)),
        (None, None) => Ok(None),
    }
}

/// Whether an already-known activity time counts as idle.
///
/// Split out from [`is_repo_idle`] so a caller that has just computed the activity time
/// for display can decide idleness from the same value instead of recomputing it. The
/// dashboard used to do exactly that — a second `git log` plus a second full tree walk
/// per repository — and, worse, showed a "last activity" that the idle decision had not
/// actually used.
pub fn is_idle_at(last_activity: Option<SystemTime>, idle_days: u64) -> bool {
    match last_activity {
        Some(activity_time) => {
            // Saturating throughout: `idle_days * 86400` overflows u64 past ~2.1e14
            // days, and `SystemTime::now() - duration` panics if the result would be
            // before the epoch. A huge idle_days should mean "never idle", not a crash.
            let idle_duration = Duration::from_secs(idle_days.saturating_mul(24 * 60 * 60));
            let Some(threshold) = SystemTime::now().checked_sub(idle_duration) else {
                return false;
            };
            activity_time < threshold
        }
        // No activity detected at all → consider it idle
        None => true,
    }
}

/// Check if a repository is considered "idle" (inactive for `idle_days`).
pub fn is_repo_idle(repo_path: &Path, idle_days: u64) -> Result<bool> {
    Ok(is_idle_at(get_last_activity(repo_path)?, idle_days))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    /// Helper: a `git` that cannot see the developer's own configuration.
    ///
    /// dev-prune installs a *global* `core.hooksPath`. Without this, the commit below
    /// fires the real `post-commit` hook, which registers this temporary directory in the
    /// developer's real registry and leaves a dead entry behind once the fixture is
    /// deleted. Pointing `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` at files that do not
    /// exist is how git is told to read neither.
    fn git(path: &Path) -> Command {
        let mut cmd = Command::new("git");
        cmd.current_dir(path)
            .env("GIT_CONFIG_GLOBAL", path.join("no-such-gitconfig"))
            .env("GIT_CONFIG_SYSTEM", path.join("no-such-gitconfig"));
        cmd
    }

    /// Helper: create a real git repo with `git init`
    fn create_git_repo(path: &Path) {
        fs::create_dir_all(path).unwrap();
        git(path).args(["init"]).output().unwrap();
    }

    /// Helper: create a git repo with at least one commit
    fn create_git_repo_with_commit(path: &Path) {
        create_git_repo(path);
        fs::write(path.join("README.md"), "# Test").unwrap();
        git(path).args(["add", "."]).output().unwrap();
        git(path)
            .args([
                "-c",
                "user.name=Test",
                "-c",
                "user.email=test@test.com",
                "commit",
                "-m",
                "initial",
            ])
            .output()
            .unwrap();
    }

    #[test]
    fn test_get_last_commit_time_with_commits() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("repo");
        create_git_repo_with_commit(&repo);
        let time = get_last_commit_time(&repo).unwrap();
        assert!(time.is_some());
    }

    #[test]
    fn test_get_last_commit_time_empty_repo() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("repo");
        create_git_repo(&repo);
        let time = get_last_commit_time(&repo).unwrap();
        assert!(time.is_none());
    }

    #[test]
    fn test_get_mtime_activity() {
        let tmp = TempDir::new().unwrap();
        fs::write(tmp.path().join("file.txt"), "hello").unwrap();
        let activity = get_mtime_activity(tmp.path()).unwrap();
        assert!(activity.is_some());
    }

    #[test]
    fn test_get_mtime_activity_excludes_git() {
        let tmp = TempDir::new().unwrap();
        let git_dir = tmp.path().join(".git");
        fs::create_dir(&git_dir).unwrap();
        fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
        // Only .git files, no source files
        let activity = get_mtime_activity(tmp.path()).unwrap();
        // The root dir itself might return something, but no source files
        // This just verifies it doesn't crash
        assert!(activity.is_some() || activity.is_none());
    }

    #[test]
    fn a_repo_whose_only_new_file_is_dev_prunes_own_config_is_not_active() {
        // The bug this pins: `devp link` writes `.devprune.json`, the activity scan saw
        // it, and every linked repository read as "worked in today" forever.
        let tmp = TempDir::new().unwrap();
        fs::write(
            tmp.path().join(crate::constants::PER_REPO_CONFIG_FILE),
            "{}",
        )
        .unwrap();
        assert!(
            get_mtime_activity(tmp.path()).unwrap().is_none(),
            "`.devprune.json` must not count as user activity"
        );

        fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
        assert!(
            get_mtime_activity(tmp.path()).unwrap().is_some(),
            "a real source file still counts"
        );
    }

    #[test]
    fn test_get_last_activity_with_commits() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("repo");
        create_git_repo_with_commit(&repo);
        let activity = get_last_activity(&repo).unwrap();
        assert!(activity.is_some());
    }

    #[test]
    fn test_get_last_activity_empty_repo_with_files() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("repo");
        create_git_repo(&repo);
        fs::write(repo.join("main.py"), "print('hello')").unwrap();
        let activity = get_last_activity(&repo).unwrap();
        assert!(activity.is_some());
    }

    #[test]
    fn test_is_repo_idle_recent() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("repo");
        create_git_repo_with_commit(&repo);
        // A repo committed just now should NOT be idle
        assert!(!is_repo_idle(&repo, 15).unwrap());
    }

    #[test]
    fn is_idle_at_agrees_with_the_repo_level_check() {
        // The two must not drift: the dashboard decides with `is_idle_at` on an activity
        // time it already has, the prune pass decides with `is_repo_idle`.
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("repo");
        create_git_repo_with_commit(&repo);

        let activity = get_last_activity(&repo).unwrap();
        assert_eq!(is_idle_at(activity, 15), is_repo_idle(&repo, 15).unwrap());
        assert!(!is_idle_at(activity, 15));
        assert!(is_idle_at(activity, 0));
    }

    #[test]
    fn a_repo_with_no_activity_at_all_is_idle() {
        assert!(is_idle_at(None, 15));
    }

    #[test]
    fn an_absurd_idle_threshold_means_never_idle_rather_than_a_panic() {
        // `now - u64::MAX days` is before the epoch; subtracting it must not panic.
        assert!(!is_idle_at(Some(SystemTime::UNIX_EPOCH), u64::MAX));
    }

    #[test]
    fn test_is_repo_idle_no_activity() {
        let tmp = TempDir::new().unwrap();
        let repo = tmp.path().join("repo");
        create_git_repo(&repo);
        // Empty repo with no files → idle
        // Note: might have mtime from git init, but that's recent
        // so let's test with 0 idle days
        let result = is_repo_idle(&repo, 0);
        assert!(result.is_ok());
    }
}