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 the
53            // same bloat folders `workspace` skips inside a repository — a repo nested in
54            // one of those is a dependency's repo, not the user's.
55            if name.starts_with('.')
56                || matches!(
57                    name.as_ref(),
58                    "node_modules" | "venv" | "target" | "vendor" | "bower_components" | "AppData"
59                )
60            {
61                return false;
62            }
63            true
64        });
65
66    for entry in walker {
67        // One unreadable directory — a permissions boundary, a cloud placeholder — must
68        // not abort the whole scan and hide every repository found after it.
69        let Ok(entry) = entry else {
70            continue;
71        };
72        if entry.file_type().is_dir() && is_git_repo(entry.path()) {
73            repos.push(entry.path().to_path_buf());
74        }
75    }
76
77    Ok(repos)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::fs;
84    use tempfile::TempDir;
85
86    #[test]
87    fn test_is_git_repo_true() {
88        let tmp = TempDir::new().unwrap();
89        fs::create_dir(tmp.path().join(".git")).unwrap();
90        assert!(is_git_repo(tmp.path()));
91    }
92
93    #[test]
94    fn test_is_git_repo_false() {
95        let tmp = TempDir::new().unwrap();
96        assert!(!is_git_repo(tmp.path()));
97    }
98
99    /// Linked worktrees and submodules have `.git` as a file holding a `gitdir:`
100    /// pointer, not a directory. They are real repositories and must be detected.
101    #[test]
102    fn test_is_git_repo_worktree_gitfile() {
103        let tmp = TempDir::new().unwrap();
104        fs::write(tmp.path().join(".git"), "gitdir: /repo/.git/worktrees/wt").unwrap();
105        assert!(is_git_repo(tmp.path()));
106    }
107
108    #[test]
109    fn test_scan_for_repos_finds_repos() {
110        let tmp = TempDir::new().unwrap();
111        // Create two git repos
112        let repo1 = tmp.path().join("project1");
113        let repo2 = tmp.path().join("project2");
114        fs::create_dir_all(repo1.join(".git")).unwrap();
115        fs::create_dir_all(repo2.join(".git")).unwrap();
116        // Create a non-repo directory
117        fs::create_dir_all(tmp.path().join("not_a_repo")).unwrap();
118
119        let repos = scan_for_repos(tmp.path()).unwrap();
120        assert_eq!(repos.len(), 2);
121    }
122
123    #[test]
124    fn test_scan_for_repos_skips_node_modules() {
125        let tmp = TempDir::new().unwrap();
126        // A real repo
127        fs::create_dir_all(tmp.path().join("real_repo").join(".git")).unwrap();
128        // A git repo inside node_modules (should be skipped)
129        fs::create_dir_all(
130            tmp.path()
131                .join("real_repo")
132                .join("node_modules")
133                .join("some_pkg")
134                .join(".git"),
135        )
136        .unwrap();
137
138        let repos = scan_for_repos(tmp.path()).unwrap();
139        assert_eq!(repos.len(), 1);
140    }
141
142    #[test]
143    fn test_scan_empty_directory() {
144        let tmp = TempDir::new().unwrap();
145        let repos = scan_for_repos(tmp.path()).unwrap();
146        assert!(repos.is_empty());
147    }
148}