Skip to main content

git_worktree_manager/
registry.rs

1/// Global repository registry for cross-repo worktree management.
2///
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::constants::home_dir_or_fallback;
9use crate::error::Result;
10use crate::git;
11
12const REGISTRY_VERSION: u32 = 1;
13
14/// Directories to skip during filesystem scan.
15const SCAN_SKIP_DIRS: &[&str] = &[
16    "node_modules",
17    ".cache",
18    ".npm",
19    ".yarn",
20    "__pycache__",
21    ".venv",
22    "venv",
23    ".tox",
24    ".nox",
25    ".eggs",
26    "dist",
27    "build",
28    ".git",
29    "Library",
30    ".Trash",
31    ".local",
32    "Applications",
33    ".cargo",
34    ".rustup",
35    ".pyenv",
36    ".nvm",
37    ".rbenv",
38    ".goenv",
39    ".volta",
40    "site-packages",
41    ".mypy_cache",
42    ".ruff_cache",
43    ".pytest_cache",
44    "coverage",
45    ".next",
46    ".nuxt",
47    ".output",
48    ".turbo",
49];
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RepoEntry {
53    pub name: String,
54    pub registered_at: String,
55    pub last_seen: String,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Registry {
60    pub version: u32,
61    pub repositories: HashMap<String, RepoEntry>,
62}
63
64impl Default for Registry {
65    fn default() -> Self {
66        Self {
67            version: REGISTRY_VERSION,
68            repositories: HashMap::new(),
69        }
70    }
71}
72
73/// Get the path to the global registry file.
74pub fn get_registry_path() -> PathBuf {
75    home_dir_or_fallback()
76        .join(".config")
77        .join("git-worktree-manager")
78        .join("registry.json")
79}
80
81/// Load the global registry from disk.
82pub fn load_registry() -> Registry {
83    let path = get_registry_path();
84    if !path.exists() {
85        return Registry::default();
86    }
87
88    match std::fs::read_to_string(&path) {
89        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
90        Err(_) => Registry::default(),
91    }
92}
93
94/// Save the global registry to disk.
95pub fn save_registry(registry: &Registry) -> Result<()> {
96    let path = get_registry_path();
97    if let Some(parent) = path.parent() {
98        std::fs::create_dir_all(parent)?;
99    }
100    let content = serde_json::to_string_pretty(registry)?;
101    std::fs::write(&path, content)?;
102    Ok(())
103}
104
105/// Register a repository in the global registry.
106pub fn register_repo(repo_path: &Path) -> Result<()> {
107    let mut registry = load_registry();
108    let key = repo_path
109        .canonicalize()
110        .unwrap_or_else(|_| repo_path.to_path_buf())
111        .to_string_lossy()
112        .to_string();
113
114    let now = crate::session::chrono_now_iso_pub();
115
116    if let Some(entry) = registry.repositories.get_mut(&key) {
117        entry.last_seen = now;
118    } else {
119        let name = repo_path
120            .file_name()
121            .map(|n| n.to_string_lossy().to_string())
122            .unwrap_or_else(|| "unknown".to_string());
123
124        registry.repositories.insert(
125            key,
126            RepoEntry {
127                name,
128                registered_at: now.clone(),
129                last_seen: now,
130            },
131        );
132    }
133
134    save_registry(&registry)
135}
136
137/// Update the last_seen timestamp.
138pub fn update_last_seen(repo_path: &Path) -> Result<()> {
139    let mut registry = load_registry();
140    let key = repo_path
141        .canonicalize()
142        .unwrap_or_else(|_| repo_path.to_path_buf())
143        .to_string_lossy()
144        .to_string();
145
146    if let Some(entry) = registry.repositories.get_mut(&key) {
147        entry.last_seen = crate::session::chrono_now_iso_pub();
148        save_registry(&registry)?;
149    }
150    Ok(())
151}
152
153/// Prune registry entries for non-existent repositories.
154pub fn prune_registry() -> Result<Vec<String>> {
155    let mut registry = load_registry();
156    let mut removed = Vec::new();
157
158    let keys: Vec<String> = registry.repositories.keys().cloned().collect();
159    for key in keys {
160        let path = PathBuf::from(&key);
161        if !path.exists() || !path.join(".git").exists() {
162            registry.repositories.remove(&key);
163            removed.push(key);
164        }
165    }
166
167    if !removed.is_empty() {
168        save_registry(&registry)?;
169    }
170    Ok(removed)
171}
172
173/// Get all registered repositories.
174pub fn get_all_registered_repos() -> Vec<(String, PathBuf)> {
175    let registry = load_registry();
176    registry
177        .repositories
178        .iter()
179        .map(|(path, entry)| (entry.name.clone(), PathBuf::from(path)))
180        .collect()
181}
182
183/// Check if a path is a main git repository root (not a worktree).
184fn is_git_repo(path: &Path) -> bool {
185    path.join(".git").is_dir()
186}
187
188/// Check if a git repository has worktrees beyond the main one.
189fn has_worktrees(repo_path: &Path) -> bool {
190    git::git_command(
191        &["worktree", "list", "--porcelain"],
192        Some(repo_path),
193        false,
194        true,
195    )
196    .map(|r| {
197        r.stdout
198            .lines()
199            .filter(|l| l.starts_with("worktree "))
200            .count()
201            > 1
202    })
203    .unwrap_or(false)
204}
205
206/// Scan filesystem for git repositories with worktrees.
207pub fn scan_for_repos(base_dir: Option<&Path>, max_depth: usize) -> Vec<PathBuf> {
208    let base = base_dir
209        .map(|p| p.to_path_buf())
210        .unwrap_or_else(home_dir_or_fallback);
211
212    let base = crate::git::canonicalize_or(&base);
213
214    let mut found = Vec::new();
215
216    fn scan_recursive(current: &Path, depth: usize, max_depth: usize, found: &mut Vec<PathBuf>) {
217        if depth > max_depth {
218            return;
219        }
220
221        let entries = match std::fs::read_dir(current) {
222            Ok(e) => e,
223            Err(_) => return,
224        };
225
226        let mut sorted: Vec<_> = entries.flatten().collect();
227        sorted.sort_by_key(|e| e.file_name());
228
229        for entry in sorted {
230            let path = entry.path();
231            if !path.is_dir() {
232                continue;
233            }
234
235            let name = entry.file_name();
236            let name_str = name.to_string_lossy();
237
238            if name_str.starts_with('.') || SCAN_SKIP_DIRS.contains(&name_str.as_ref()) {
239                continue;
240            }
241
242            if is_git_repo(&path) && has_worktrees(&path) {
243                found.push(path);
244                continue; // Don't recurse into git repos
245            }
246
247            scan_recursive(&path, depth + 1, max_depth, found);
248        }
249    }
250
251    scan_recursive(&base, 0, max_depth, &mut found);
252    found
253}