Skip to main content

dev_prune/scanner/
mod.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Git repository scanner and activity detection.
5//
6// This module provides functions to:
7// - Detect valid Git repositories
8// - Recursively scan directories for Git repos
9// - Determine last activity time for a repository
10
11pub mod git;
12
13use std::path::{Path, PathBuf};
14
15use anyhow::Result;
16use walkdir::WalkDir;
17
18/// Maximum directory depth `scan_for_repos` will descend.
19///
20/// Without a bound, pointing `devp init` at a home directory or a drive root walks the
21/// entire filesystem. 8 levels comfortably covers `~/code/org/project/...` layouts.
22const MAX_SCAN_DEPTH: usize = 8;
23
24/// Check if a path is a valid Git repository.
25///
26/// `.git` is a directory in a normal clone but a *file* containing a `gitdir:` pointer
27/// in linked worktrees and submodules. Accepting both means dev-prune stops silently
28/// ignoring every worktree and submodule on the machine.
29pub fn is_git_repo(path: &Path) -> bool {
30    let dot_git = path.join(".git");
31    dot_git.is_dir() || dot_git.is_file()
32}
33
34/// Recursively scan a root directory for Git repositories.
35///
36/// Returns a list of absolute paths to directories containing `.git`.
37/// Automatically skips dot-directories (`.cache`, `.claude`, `.gemini`),
38/// hidden system folders, and build bloat directories.
39pub fn scan_for_repos(root: &Path) -> Result<Vec<PathBuf>> {
40    let mut repos = Vec::new();
41
42    let walker = WalkDir::new(root)
43        .follow_links(false)
44        .max_depth(MAX_SCAN_DEPTH)
45        .into_iter()
46        .filter_entry(|entry| {
47            // Always allow root
48            if entry.path() == root {
49                return true;
50            }
51            let name = entry.file_name().to_string_lossy();
52            // Skip hidden dot-directories (e.g. .cache, .gemini, .claude, .cargo) and common bloat folders
53            if name.starts_with('.')
54                || matches!(
55                    name.as_ref(),
56                    "node_modules" | "venv" | "target" | "AppData"
57                )
58            {
59                return false;
60            }
61            true
62        });
63
64    for entry in walker {
65        let entry = entry?;
66        if entry.file_type().is_dir() && is_git_repo(entry.path()) {
67            repos.push(entry.path().to_path_buf());
68        }
69    }
70
71    Ok(repos)
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use std::fs;
78    use tempfile::TempDir;
79
80    #[test]
81    fn test_is_git_repo_true() {
82        let tmp = TempDir::new().unwrap();
83        fs::create_dir(tmp.path().join(".git")).unwrap();
84        assert!(is_git_repo(tmp.path()));
85    }
86
87    #[test]
88    fn test_is_git_repo_false() {
89        let tmp = TempDir::new().unwrap();
90        assert!(!is_git_repo(tmp.path()));
91    }
92
93    /// Linked worktrees and submodules have `.git` as a file holding a `gitdir:`
94    /// pointer, not a directory. They are real repositories and must be detected.
95    #[test]
96    fn test_is_git_repo_worktree_gitfile() {
97        let tmp = TempDir::new().unwrap();
98        fs::write(tmp.path().join(".git"), "gitdir: /repo/.git/worktrees/wt").unwrap();
99        assert!(is_git_repo(tmp.path()));
100    }
101
102    #[test]
103    fn test_scan_for_repos_finds_repos() {
104        let tmp = TempDir::new().unwrap();
105        // Create two git repos
106        let repo1 = tmp.path().join("project1");
107        let repo2 = tmp.path().join("project2");
108        fs::create_dir_all(repo1.join(".git")).unwrap();
109        fs::create_dir_all(repo2.join(".git")).unwrap();
110        // Create a non-repo directory
111        fs::create_dir_all(tmp.path().join("not_a_repo")).unwrap();
112
113        let repos = scan_for_repos(tmp.path()).unwrap();
114        assert_eq!(repos.len(), 2);
115    }
116
117    #[test]
118    fn test_scan_for_repos_skips_node_modules() {
119        let tmp = TempDir::new().unwrap();
120        // A real repo
121        fs::create_dir_all(tmp.path().join("real_repo").join(".git")).unwrap();
122        // A git repo inside node_modules (should be skipped)
123        fs::create_dir_all(
124            tmp.path()
125                .join("real_repo")
126                .join("node_modules")
127                .join("some_pkg")
128                .join(".git"),
129        )
130        .unwrap();
131
132        let repos = scan_for_repos(tmp.path()).unwrap();
133        assert_eq!(repos.len(), 1);
134    }
135
136    #[test]
137    fn test_scan_empty_directory() {
138        let tmp = TempDir::new().unwrap();
139        let repos = scan_for_repos(tmp.path()).unwrap();
140        assert!(repos.is_empty());
141    }
142}