Skip to main content

git_worktree_manager/
registry.rs

1/// Global repository registry for cross-repo worktree management.
2///
3/// Mirrors src/git_worktree_manager/registry.py (267 lines).
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
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    dirs::home_dir()
76        .unwrap_or_else(|| PathBuf::from("."))
77        .join(".config")
78        .join("git-worktree-manager")
79        .join("registry.json")
80}
81
82/// Load the global registry from disk.
83pub fn load_registry() -> Registry {
84    let path = get_registry_path();
85    if !path.exists() {
86        return Registry::default();
87    }
88
89    match std::fs::read_to_string(&path) {
90        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
91        Err(_) => Registry::default(),
92    }
93}
94
95/// Save the global registry to disk.
96pub fn save_registry(registry: &Registry) -> Result<()> {
97    let path = get_registry_path();
98    if let Some(parent) = path.parent() {
99        std::fs::create_dir_all(parent)?;
100    }
101    let content = serde_json::to_string_pretty(registry)?;
102    std::fs::write(&path, content)?;
103    Ok(())
104}
105
106/// Register a repository in the global registry.
107pub fn register_repo(repo_path: &Path) -> Result<()> {
108    let mut registry = load_registry();
109    let key = repo_path
110        .canonicalize()
111        .unwrap_or_else(|_| repo_path.to_path_buf())
112        .to_string_lossy()
113        .to_string();
114
115    let now = crate::session::chrono_now_iso_pub();
116
117    if let Some(entry) = registry.repositories.get_mut(&key) {
118        entry.last_seen = now;
119    } else {
120        let name = repo_path
121            .file_name()
122            .map(|n| n.to_string_lossy().to_string())
123            .unwrap_or_else(|| "unknown".to_string());
124
125        registry.repositories.insert(
126            key,
127            RepoEntry {
128                name,
129                registered_at: now.clone(),
130                last_seen: now,
131            },
132        );
133    }
134
135    save_registry(&registry)
136}
137
138/// Update the last_seen timestamp.
139pub fn update_last_seen(repo_path: &Path) -> Result<()> {
140    let mut registry = load_registry();
141    let key = repo_path
142        .canonicalize()
143        .unwrap_or_else(|_| repo_path.to_path_buf())
144        .to_string_lossy()
145        .to_string();
146
147    if let Some(entry) = registry.repositories.get_mut(&key) {
148        entry.last_seen = crate::session::chrono_now_iso_pub();
149        save_registry(&registry)?;
150    }
151    Ok(())
152}
153
154/// Prune registry entries for non-existent repositories.
155pub fn prune_registry() -> Result<Vec<String>> {
156    let mut registry = load_registry();
157    let mut removed = Vec::new();
158
159    let keys: Vec<String> = registry.repositories.keys().cloned().collect();
160    for key in keys {
161        let path = PathBuf::from(&key);
162        if !path.exists() || !path.join(".git").exists() {
163            registry.repositories.remove(&key);
164            removed.push(key);
165        }
166    }
167
168    if !removed.is_empty() {
169        save_registry(&registry)?;
170    }
171    Ok(removed)
172}
173
174/// Get all registered repositories.
175pub fn get_all_registered_repos() -> Vec<(String, PathBuf)> {
176    let registry = load_registry();
177    registry
178        .repositories
179        .iter()
180        .map(|(path, entry)| (entry.name.clone(), PathBuf::from(path)))
181        .collect()
182}
183
184/// Check if a path is a main git repository root (not a worktree).
185fn is_git_repo(path: &Path) -> bool {
186    path.join(".git").is_dir()
187}
188
189/// Check if a git repository has worktrees beyond the main one.
190fn has_worktrees(repo_path: &Path) -> bool {
191    git::git_command(
192        &["worktree", "list", "--porcelain"],
193        Some(repo_path),
194        false,
195        true,
196    )
197    .map(|r| {
198        r.stdout
199            .lines()
200            .filter(|l| l.starts_with("worktree "))
201            .count()
202            > 1
203    })
204    .unwrap_or(false)
205}
206
207/// Scan filesystem for git repositories with worktrees.
208pub fn scan_for_repos(base_dir: Option<&Path>, max_depth: usize) -> Vec<PathBuf> {
209    let base = base_dir
210        .map(|p| p.to_path_buf())
211        .unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")));
212
213    let base = base.canonicalize().unwrap_or_else(|_| base.clone());
214
215    let mut found = Vec::new();
216
217    fn scan_recursive(current: &Path, depth: usize, max_depth: usize, found: &mut Vec<PathBuf>) {
218        if depth > max_depth {
219            return;
220        }
221
222        let entries = match std::fs::read_dir(current) {
223            Ok(e) => e,
224            Err(_) => return,
225        };
226
227        let mut sorted: Vec<_> = entries.flatten().collect();
228        sorted.sort_by_key(|e| e.file_name());
229
230        for entry in sorted {
231            let path = entry.path();
232            if !path.is_dir() {
233                continue;
234            }
235
236            let name = entry.file_name();
237            let name_str = name.to_string_lossy();
238
239            if name_str.starts_with('.') || SCAN_SKIP_DIRS.contains(&name_str.as_ref()) {
240                continue;
241            }
242
243            if is_git_repo(&path) && has_worktrees(&path) {
244                found.push(path);
245                continue; // Don't recurse into git repos
246            }
247
248            scan_recursive(&path, depth + 1, max_depth, found);
249        }
250    }
251
252    scan_recursive(&base, 0, max_depth, &mut found);
253    found
254}