1use std::env;
8use std::fs::{File, create_dir_all, remove_file};
9use std::io::{BufRead, BufReader, Write};
10use std::path::{Path, PathBuf};
11
12use directories::{ProjectDirs, UserDirs};
13use walkdir::{DirEntry, WalkDir};
14
15use crate::repo::Repo;
16
17const QUALIFIER: &str = "";
18const ORGANIZATION: &str = "peap";
19const APPLICATION: &str = "git-global";
20const CACHE_FILE: &str = "repos.txt";
21
22const DEFAULT_CMD: &str = "status";
23const DEFAULT_FOLLOW_SYMLINKS: bool = true;
24const DEFAULT_SAME_FILESYSTEM: bool = cfg!(any(unix, windows));
25const DEFAULT_VERBOSE: bool = false;
26const DEFAULT_SHOW_UNTRACKED: bool = true;
27
28const SETTING_BASEDIR: &str = "global.basedir";
29const SETTING_FOLLOW_SYMLINKS: &str = "global.follow-symlinks";
30const SETTING_SAME_FILESYSTEM: &str = "global.same-filesystem";
31const SETTING_IGNORE: &str = "global.ignore";
32const SETTING_DEFAULT_CMD: &str = "global.default-cmd";
33const SETTING_SHOW_UNTRACKED: &str = "global.show-untracked";
34const SETTING_VERBOSE: &str = "global.verbose";
35
36#[derive(Clone, Debug)]
38pub struct Config {
39 pub basedir: PathBuf,
43
44 pub follow_symlinks: bool,
48
49 pub same_filesystem: bool,
54
55 pub ignored_patterns: Vec<String>,
59
60 pub default_cmd: String,
64
65 pub verbose: bool,
69
70 pub show_untracked: bool,
74
75 pub cache_file: Option<PathBuf>,
80
81 pub manpage_file: Option<PathBuf>,
86
87 git_config_path: Option<PathBuf>,
91}
92
93impl Default for Config {
94 fn default() -> Self {
95 Config::new()
96 }
97}
98
99impl Config {
100 pub fn new() -> Self {
103 Self::build(None)
104 }
105
106 pub fn from_gitconfig(path: impl Into<PathBuf>) -> Self {
113 Self::build(Some(path.into()))
114 }
115
116 fn build(git_config_path: Option<PathBuf>) -> Self {
119 let homedir = UserDirs::new()
121 .expect("Could not determine home directory.")
122 .home_dir()
123 .to_path_buf();
124 let cache_file =
126 ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
127 .map(|project_dirs| project_dirs.cache_dir().join(CACHE_FILE));
128 let manpage_file = match env::consts::OS {
129 "linux" => Some(PathBuf::from("/usr/share/man/man1/git-global.1")),
131 "macos" => Some(PathBuf::from("/usr/share/man/man1/git-global.1")),
132 "windows" => env::var("MSYSTEM").ok().and_then(|val| {
133 (val == "MINGW64").then(|| {
134 PathBuf::from("/mingw64/share/doc/git-doc/git-global.html")
135 })
136 }),
137 _ => None,
138 };
139 let git_cfg = match &git_config_path {
141 Some(path) => git2::Config::open(path).ok(),
142 None => git2::Config::open_default().ok(),
143 };
144 match git_cfg {
145 Some(cfg) => Config {
146 basedir: cfg.get_path(SETTING_BASEDIR).unwrap_or(homedir),
147 follow_symlinks: cfg
148 .get_bool(SETTING_FOLLOW_SYMLINKS)
149 .unwrap_or(DEFAULT_FOLLOW_SYMLINKS),
150 same_filesystem: cfg
151 .get_bool(SETTING_SAME_FILESYSTEM)
152 .unwrap_or(DEFAULT_SAME_FILESYSTEM),
153 ignored_patterns: cfg
154 .get_string(SETTING_IGNORE)
155 .unwrap_or_default()
156 .split(',')
157 .map(|p| p.trim().to_string())
158 .filter(|p| !p.is_empty())
159 .collect(),
160 default_cmd: cfg
161 .get_string(SETTING_DEFAULT_CMD)
162 .unwrap_or_else(|_| String::from(DEFAULT_CMD)),
163 verbose: cfg
164 .get_bool(SETTING_VERBOSE)
165 .unwrap_or(DEFAULT_VERBOSE),
166 show_untracked: cfg
167 .get_bool(SETTING_SHOW_UNTRACKED)
168 .unwrap_or(DEFAULT_SHOW_UNTRACKED),
169 cache_file,
170 manpage_file,
171 git_config_path,
172 },
173 None => {
174 Config {
176 basedir: homedir,
177 follow_symlinks: DEFAULT_FOLLOW_SYMLINKS,
178 same_filesystem: DEFAULT_SAME_FILESYSTEM,
179 ignored_patterns: vec![],
180 default_cmd: String::from(DEFAULT_CMD),
181 verbose: DEFAULT_VERBOSE,
182 show_untracked: DEFAULT_SHOW_UNTRACKED,
183 cache_file,
184 manpage_file,
185 git_config_path,
186 }
187 }
188 }
189 }
190
191 pub fn get_repos(&mut self) -> Vec<Repo> {
193 if !self.has_cache() {
194 let repos = self.find_repos(&[]);
195 self.cache_repos(&repos);
196 }
197 self.get_cached_repos()
198 }
199
200 pub fn scan_with_extra_paths(
203 &mut self,
204 extra_paths: &[PathBuf],
205 ) -> Vec<Repo> {
206 self.clear_cache();
207 let repos = self.find_repos(extra_paths);
208 self.cache_repos(&repos);
209 repos
210 }
211
212 pub fn clear_cache(&mut self) {
215 if self.has_cache()
216 && let Some(file) = &self.cache_file
217 {
218 remove_file(file).expect("Failed to delete cache file.");
219 }
220 }
221
222 fn filter(&self, entry: &DirEntry) -> bool {
224 if let Some(entry_path) = entry.path().to_str() {
225 self.ignored_patterns
226 .iter()
227 .filter(|p| !p.is_empty())
228 .all(|pattern| !entry_path.contains(pattern))
229 } else {
230 false
232 }
233 }
234
235 fn find_repos(&self, extra_roots: &[PathBuf]) -> Vec<Repo> {
238 let mut repos = Vec::new();
239 self.scan_root(&self.basedir.clone(), &mut repos);
240 for root in extra_roots {
241 self.scan_root(root, &mut repos);
242 }
243 repos.sort_by_key(|r| r.path());
244 repos.dedup_by_key(|r| r.path());
245 repos
246 }
247
248 fn scan_root(&self, root: &Path, repos: &mut Vec<Repo>) {
250 println!(
251 "Scanning for git repos under {}; this may take a while...",
252 root.display()
253 );
254 let mut n_dirs = 0;
255 let walker = WalkDir::new(root)
256 .follow_links(self.follow_symlinks)
257 .same_file_system(self.same_filesystem);
258 for entry in walker
259 .into_iter()
260 .filter_entry(|e| self.filter(e))
261 .flatten()
262 {
263 if entry.file_type().is_dir() {
264 n_dirs += 1;
265 if entry.file_name() == ".git" {
266 let parent_path = entry
267 .path()
268 .parent()
269 .expect("Could not determine parent.");
270 if git2::Repository::open(parent_path).is_ok() {
272 repos.push(Repo::new(parent_path));
273 }
274 }
275 if self.verbose
276 && let Some(size) = termsize::get()
277 {
278 let prefix = format!(
279 "\r... found {} repos; scanning directory #{}: ",
280 repos.len(),
281 n_dirs
282 );
283 let width = size.cols as usize - prefix.len() - 1;
284 let mut cur_path =
285 String::from(entry.path().to_str().unwrap());
286 let byte_width = match cur_path.char_indices().nth(width) {
287 None => &cur_path,
288 Some((idx, _)) => &cur_path[..idx],
289 }
290 .len();
291 cur_path.truncate(byte_width);
292 print!("{}{:<width$}", prefix, cur_path);
293 }
294 }
295 }
296 if self.verbose {
297 println!();
298 }
299 }
300
301 fn has_cache(&self) -> bool {
303 self.cache_file.as_ref().is_some_and(|f| f.exists())
304 }
305
306 fn cache_repos(&self, repos: &[Repo]) {
308 if let Some(file) = &self.cache_file {
309 if !file.exists()
310 && let Some(parent) = &file.parent()
311 {
312 create_dir_all(parent)
313 .expect("Could not create cache directory.")
314 }
315 let mut f =
316 File::create(file).expect("Could not create cache file.");
317 for repo in repos.iter() {
318 match writeln!(f, "{}", repo.path()) {
319 Ok(_) => (),
320 Err(e) => panic!("Problem writing cache file: {}", e),
321 }
322 }
323 }
324 }
325
326 fn get_cached_repos(&self) -> Vec<Repo> {
328 let mut repos = Vec::new();
329 if let Some(file) = &self.cache_file
330 && file.exists()
331 {
332 let f = File::open(file).expect("Could not open cache file.");
333 let reader = BufReader::new(f);
334 for repo_path in reader.lines().map_while(Result::ok) {
335 if !Path::new(&repo_path).exists() {
336 continue;
337 }
338 repos.push(Repo::new(repo_path))
339 }
340 }
341 repos
342 }
343
344 pub fn add_ignore_pattern(&self, pattern: &str) -> Result<(), String> {
349 let mut cfg = match &self.git_config_path {
350 Some(path) => git2::Config::open(path),
351 None => git2::Config::open_default(),
352 }
353 .map_err(|e| format!("Could not open git config: {}", e))?;
354
355 let current = cfg.get_string(SETTING_IGNORE).unwrap_or_default();
357 let patterns: Vec<&str> = current
358 .split(',')
359 .map(|p| p.trim())
360 .filter(|p| !p.is_empty())
361 .collect();
362
363 if patterns.contains(&pattern) {
365 return Err(format!("'{}' is already in global.ignore", pattern));
366 }
367
368 let new_value = if current.is_empty() {
370 pattern.to_string()
371 } else {
372 format!("{},{}", current, pattern)
373 };
374
375 cfg.set_str(SETTING_IGNORE, &new_value)
376 .map_err(|e| format!("Could not update git config: {}", e))?;
377
378 Ok(())
379 }
380}