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::{create_dir_all, remove_file, 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.
37pub struct Config {
38    /// The base directory to walk when searching for git repositories.
39    ///
40    /// Default: $HOME.
41    pub basedir: PathBuf,
42
43    /// Whether to follow symbolic links when searching for git repos.
44    ///
45    /// Default: true
46    pub follow_symlinks: bool,
47
48    /// Whether to stay on the same filesystem (as `basedir`) when searching
49    /// for git repos on Unix or Windows.
50    ///
51    /// Default: true [on supported platforms]
52    pub same_filesystem: bool,
53
54    /// Path patterns to ignore when searching for git repositories.
55    ///
56    /// Default: none
57    pub ignored_patterns: Vec<String>,
58
59    /// The git-global subcommand to run when unspecified.
60    ///
61    /// Default: `status`
62    pub default_cmd: String,
63
64    /// Whether to enable verbose mode.
65    ///
66    /// Default: false
67    pub verbose: bool,
68
69    /// Whether to show untracked files in output.
70    ///
71    /// Default: true
72    pub show_untracked: bool,
73
74    /// Optional path to a cache file for git-global's usage.
75    ///
76    /// Default: `repos.txt` in the user's XDG cache directory, if we understand
77    /// XDG for the host system.
78    pub cache_file: Option<PathBuf>,
79
80    /// Optional path to our manpage, regardless of whether it's installed.
81    ///
82    /// Default: `git-global.1` in the relevant manpages directory, if we
83    /// understand where that should be for the host system.
84    pub manpage_file: Option<PathBuf>,
85}
86
87impl Default for Config {
88    fn default() -> Self {
89        Config::new()
90    }
91}
92
93impl Config {
94    /// Create a new `Config` with the default behavior, first checking global
95    /// git config options in ~/.gitconfig, then using defaults:
96    pub fn new() -> Self {
97        // Find the user's home directory.
98        let homedir = UserDirs::new()
99            .expect("Could not determine home directory.")
100            .home_dir()
101            .to_path_buf();
102        // Set the options that aren't user-configurable.
103        let cache_file =
104            ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
105                .map(|project_dirs| project_dirs.cache_dir().join(CACHE_FILE));
106        let manpage_file = match env::consts::OS {
107            // Consider ~/.local/share/man/man1/, too.
108            "linux" => Some(PathBuf::from("/usr/share/man/man1/git-global.1")),
109            "macos" => Some(PathBuf::from("/usr/share/man/man1/git-global.1")),
110            "windows" => env::var("MSYSTEM").ok().and_then(|val| {
111                (val == "MINGW64").then(|| {
112                    PathBuf::from("/mingw64/share/doc/git-doc/git-global.html")
113                })
114            }),
115            _ => None,
116        };
117        match ::git2::Config::open_default() {
118            Ok(cfg) => Config {
119                basedir: cfg.get_path(SETTING_BASEDIR).unwrap_or(homedir),
120                follow_symlinks: cfg
121                    .get_bool(SETTING_FOLLOW_SYMLINKS)
122                    .unwrap_or(DEFAULT_FOLLOW_SYMLINKS),
123                same_filesystem: cfg
124                    .get_bool(SETTING_SAME_FILESYSTEM)
125                    .unwrap_or(DEFAULT_SAME_FILESYSTEM),
126                ignored_patterns: cfg
127                    .get_string(SETTING_IGNORE)
128                    .unwrap_or_default()
129                    .split(',')
130                    .map(|p| p.trim().to_string())
131                    .collect(),
132                default_cmd: cfg
133                    .get_string(SETTING_DEFAULT_CMD)
134                    .unwrap_or_else(|_| String::from(DEFAULT_CMD)),
135                verbose: cfg
136                    .get_bool(SETTING_VERBOSE)
137                    .unwrap_or(DEFAULT_VERBOSE),
138                show_untracked: cfg
139                    .get_bool(SETTING_SHOW_UNTRACKED)
140                    .unwrap_or(DEFAULT_SHOW_UNTRACKED),
141                cache_file,
142                manpage_file,
143            },
144            Err(_) => {
145                // Build the default configuration.
146                Config {
147                    basedir: homedir,
148                    follow_symlinks: DEFAULT_FOLLOW_SYMLINKS,
149                    same_filesystem: DEFAULT_SAME_FILESYSTEM,
150                    ignored_patterns: vec![],
151                    default_cmd: String::from(DEFAULT_CMD),
152                    verbose: DEFAULT_VERBOSE,
153                    show_untracked: DEFAULT_SHOW_UNTRACKED,
154                    cache_file,
155                    manpage_file,
156                }
157            }
158        }
159    }
160
161    /// Returns all known git repos, populating the cache first, if necessary.
162    pub fn get_repos(&mut self) -> Vec<Repo> {
163        if !self.has_cache() {
164            let repos = self.find_repos();
165            self.cache_repos(&repos);
166        }
167        self.get_cached_repos()
168    }
169
170    /// Clears the cache of known git repos, forcing a re-scan on the next
171    /// `get_repos()` call.
172    pub fn clear_cache(&mut self) {
173        if self.has_cache() {
174            if let Some(file) = &self.cache_file {
175                remove_file(file).expect("Failed to delete cache file.");
176            }
177        }
178    }
179
180    /// Returns `true` if this directory entry should be included in scans.
181    fn filter(&self, entry: &DirEntry) -> bool {
182        if let Some(entry_path) = entry.path().to_str() {
183            self.ignored_patterns
184                .iter()
185                .filter(|p| p != &"")
186                .all(|pattern| !entry_path.contains(pattern))
187        } else {
188            // Skip invalid file name
189            false
190        }
191    }
192
193    /// Walks the configured base directory, looking for git repos.
194    fn find_repos(&self) -> Vec<Repo> {
195        let mut repos = Vec::new();
196        println!(
197            "Scanning for git repos under {}; this may take a while...",
198            self.basedir.display()
199        );
200        let mut n_dirs = 0;
201        let walker = WalkDir::new(&self.basedir)
202            .follow_links(self.follow_symlinks)
203            .same_file_system(self.same_filesystem);
204        for entry in walker
205            .into_iter()
206            .filter_entry(|e| self.filter(e))
207            .flatten()
208        {
209            if entry.file_type().is_dir() {
210                n_dirs += 1;
211                if entry.file_name() == ".git" {
212                    let parent_path = entry
213                        .path()
214                        .parent()
215                        .expect("Could not determine parent.");
216                    if let Some(path) = parent_path.to_str() {
217                        repos.push(Repo::new(path.to_string()));
218                    }
219                }
220                if self.verbose {
221                    if let Some(size) = termsize::get() {
222                        let prefix = format!(
223                            "\r... found {} repos; scanning directory #{}: ",
224                            repos.len(),
225                            n_dirs
226                        );
227                        let width = size.cols as usize - prefix.len() - 1;
228                        let mut cur_path =
229                            String::from(entry.path().to_str().unwrap());
230                        let byte_width =
231                            match cur_path.char_indices().nth(width) {
232                                None => &cur_path,
233                                Some((idx, _)) => &cur_path[..idx],
234                            }
235                            .len();
236                        cur_path.truncate(byte_width);
237                        print!("{}{:<width$}", prefix, cur_path);
238                    };
239                }
240            }
241        }
242        if self.verbose {
243            println!();
244        }
245        repos.sort_by_key(|r| r.path());
246        repos
247    }
248
249    /// Returns boolean indicating if the cache file exists.
250    fn has_cache(&self) -> bool {
251        self.cache_file.as_ref().is_some_and(|f| f.exists())
252    }
253
254    /// Writes the given repo paths to the cache file.
255    fn cache_repos(&self, repos: &[Repo]) {
256        if let Some(file) = &self.cache_file {
257            if !file.exists() {
258                if let Some(parent) = &file.parent() {
259                    create_dir_all(parent)
260                        .expect("Could not create cache directory.")
261                }
262            }
263            let mut f =
264                File::create(file).expect("Could not create cache file.");
265            for repo in repos.iter() {
266                match writeln!(f, "{}", repo.path()) {
267                    Ok(_) => (),
268                    Err(e) => panic!("Problem writing cache file: {}", e),
269                }
270            }
271        }
272    }
273
274    /// Returns the list of repos found in the cache file.
275    fn get_cached_repos(&self) -> Vec<Repo> {
276        let mut repos = Vec::new();
277        if let Some(file) = &self.cache_file {
278            if file.exists() {
279                let f = File::open(file).expect("Could not open cache file.");
280                let reader = BufReader::new(f);
281                for repo_path in reader.lines().map_while(Result::ok) {
282                    if !Path::new(&repo_path).exists() {
283                        continue;
284                    }
285                    repos.push(Repo::new(repo_path))
286                }
287            }
288        }
289        repos
290    }
291}