Skip to main content

git_global/
config.rs

1//! Configuration of git-global.
2//!
3//! Exports the `Config` struct, which defines the base path for finding git
4//! repos on the machine, path patterns to ignore when scanning for repos, the
5//! location of a cache file, and other config options for running git-global.
6
7use 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/// A container for git-global configuration options.
37#[derive(Clone, Debug)]
38pub struct Config {
39    /// The base directory to walk when searching for git repositories.
40    ///
41    /// Default: $HOME.
42    pub basedir: PathBuf,
43
44    /// Whether to follow symbolic links when searching for git repos.
45    ///
46    /// Default: true
47    pub follow_symlinks: bool,
48
49    /// Whether to stay on the same filesystem (as `basedir`) when searching
50    /// for git repos on Unix or Windows.
51    ///
52    /// Default: true [on supported platforms]
53    pub same_filesystem: bool,
54
55    /// Path patterns to ignore when searching for git repositories.
56    ///
57    /// Default: none
58    pub ignored_patterns: Vec<String>,
59
60    /// The git-global subcommand to run when unspecified.
61    ///
62    /// Default: `status`
63    pub default_cmd: String,
64
65    /// Whether to enable verbose mode.
66    ///
67    /// Default: false
68    pub verbose: bool,
69
70    /// Whether to show untracked files in output.
71    ///
72    /// Default: true
73    pub show_untracked: bool,
74
75    /// Optional path to a cache file for git-global's usage.
76    ///
77    /// Default: `repos.txt` in the user's XDG cache directory, if we understand
78    /// XDG for the host system.
79    pub cache_file: Option<PathBuf>,
80
81    /// Optional path to our manpage, regardless of whether it's installed.
82    ///
83    /// Default: `git-global.1` in the relevant manpages directory, if we
84    /// understand where that should be for the host system.
85    pub manpage_file: Option<PathBuf>,
86
87    /// Path to the gitconfig file to use for reading/writing settings.
88    ///
89    /// `None` means use the default global gitconfig.
90    git_config_path: Option<PathBuf>,
91}
92
93impl Default for Config {
94    fn default() -> Self {
95        Config::new()
96    }
97}
98
99impl Config {
100    /// Create a new `Config` with the default behavior, first checking global
101    /// git config options in ~/.gitconfig, then using defaults:
102    pub fn new() -> Self {
103        Self::build(None)
104    }
105
106    /// Create a new `Config` reading settings from the given gitconfig file.
107    ///
108    /// Settings like `global.basedir`, `global.ignore`, etc. are read from
109    /// the specified file instead of the system default `~/.gitconfig`.
110    /// The default basedir still falls back to `$HOME` if `global.basedir`
111    /// is not set in the file.
112    pub fn from_gitconfig(path: impl Into<PathBuf>) -> Self {
113        Self::build(Some(path.into()))
114    }
115
116    /// Shared constructor: builds a `Config`, optionally reading settings
117    /// from an explicit gitconfig path instead of the system default.
118    fn build(git_config_path: Option<PathBuf>) -> Self {
119        // Find the user's home directory (used as the default basedir).
120        let homedir = UserDirs::new()
121            .expect("Could not determine home directory.")
122            .home_dir()
123            .to_path_buf();
124        // Set the options that aren't user-configurable.
125        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            // Consider ~/.local/share/man/man1/, too.
130            "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        // Open the gitconfig: either from the explicit path or the system default.
140        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                // Build the default configuration.
175                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    /// Returns all known git repos, populating the cache first, if necessary.
192    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    /// Clears the cache, scans basedir and any extra paths, caches the
201    /// results, and returns the combined list of repos.
202    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    /// Clears the cache of known git repos, forcing a re-scan on the next
213    /// `get_repos()` call.
214    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    /// Returns `true` if this directory entry should be included in scans.
223    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            // Skip invalid file name
231            false
232        }
233    }
234
235    /// Walks the configured base directory (and any extra roots), looking for
236    /// git repos.
237    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    /// Walks a single root directory, appending discovered repos to `repos`.
249    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                    // Validate it's actually a valid git repo before adding.
271                    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    /// Returns boolean indicating if the cache file exists.
302    fn has_cache(&self) -> bool {
303        self.cache_file.as_ref().is_some_and(|f| f.exists())
304    }
305
306    /// Writes the given repo paths to the cache file.
307    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    /// Returns the list of repos found in the cache file.
327    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    /// Adds a pattern to the global.ignore setting in gitconfig.
345    ///
346    /// Uses the gitconfig path that was determined when this `Config` was
347    /// created (explicit path for `from_homedir`, system default for `new`).
348    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        // Get current patterns
356        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        // Check if already present
364        if patterns.contains(&pattern) {
365            return Err(format!("'{}' is already in global.ignore", pattern));
366        }
367
368        // Append new pattern
369        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}