git_worktree_manager/
registry.rs1use 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
14const 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
73pub 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
82pub 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
95pub 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
106pub 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(®istry)
136}
137
138pub 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(®istry)?;
150 }
151 Ok(())
152}
153
154pub 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(®istry)?;
170 }
171 Ok(removed)
172}
173
174pub 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
184fn is_git_repo(path: &Path) -> bool {
186 path.join(".git").is_dir()
187}
188
189fn 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
207pub 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; }
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}