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.
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            && let Some(file) = &self.cache_file
175        {
176            remove_file(file).expect("Failed to delete cache file.");
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.is_empty())
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                    // Validate it's actually a valid git repo before adding
217                    if git2::Repository::open(parent_path).is_ok()
218                        && let Some(path) = parent_path.to_str()
219                    {
220                        repos.push(Repo::new(path.to_string()));
221                    }
222                }
223                if self.verbose
224                    && let Some(size) = termsize::get()
225                {
226                    let prefix = format!(
227                        "\r... found {} repos; scanning directory #{}: ",
228                        repos.len(),
229                        n_dirs
230                    );
231                    let width = size.cols as usize - prefix.len() - 1;
232                    let mut cur_path =
233                        String::from(entry.path().to_str().unwrap());
234                    let byte_width = match cur_path.char_indices().nth(width) {
235                        None => &cur_path,
236                        Some((idx, _)) => &cur_path[..idx],
237                    }
238                    .len();
239                    cur_path.truncate(byte_width);
240                    print!("{}{:<width$}", prefix, cur_path);
241                }
242            }
243        }
244        if self.verbose {
245            println!();
246        }
247        repos.sort_by_key(|r| r.path());
248        repos
249    }
250
251    /// Returns boolean indicating if the cache file exists.
252    fn has_cache(&self) -> bool {
253        self.cache_file.as_ref().is_some_and(|f| f.exists())
254    }
255
256    /// Writes the given repo paths to the cache file.
257    fn cache_repos(&self, repos: &[Repo]) {
258        if let Some(file) = &self.cache_file {
259            if !file.exists()
260                && let Some(parent) = &file.parent()
261            {
262                create_dir_all(parent)
263                    .expect("Could not create cache directory.")
264            }
265            let mut f =
266                File::create(file).expect("Could not create cache file.");
267            for repo in repos.iter() {
268                match writeln!(f, "{}", repo.path()) {
269                    Ok(_) => (),
270                    Err(e) => panic!("Problem writing cache file: {}", e),
271                }
272            }
273        }
274    }
275
276    /// Returns the list of repos found in the cache file.
277    fn get_cached_repos(&self) -> Vec<Repo> {
278        let mut repos = Vec::new();
279        if let Some(file) = &self.cache_file
280            && file.exists()
281        {
282            let f = File::open(file).expect("Could not open cache file.");
283            let reader = BufReader::new(f);
284            for repo_path in reader.lines().map_while(Result::ok) {
285                if !Path::new(&repo_path).exists() {
286                    continue;
287                }
288                repos.push(Repo::new(repo_path))
289            }
290        }
291        repos
292    }
293
294    /// Adds a pattern to the global.ignore setting in gitconfig.
295    pub fn add_ignore_pattern(pattern: &str) -> Result<(), String> {
296        let mut cfg = git2::Config::open_default()
297            .map_err(|e| format!("Could not open git config: {}", e))?;
298
299        // Get current patterns
300        let current = cfg.get_string(SETTING_IGNORE).unwrap_or_default();
301        let patterns: Vec<&str> = current
302            .split(',')
303            .map(|p| p.trim())
304            .filter(|p| !p.is_empty())
305            .collect();
306
307        // Check if already present
308        if patterns.contains(&pattern) {
309            return Err(format!("'{}' is already in global.ignore", pattern));
310        }
311
312        // Append new pattern
313        let new_value = if current.is_empty() {
314            pattern.to_string()
315        } else {
316            format!("{},{}", current, pattern)
317        };
318
319        cfg.set_str(SETTING_IGNORE, &new_value)
320            .map_err(|e| format!("Could not update git config: {}", e))?;
321
322        Ok(())
323    }
324}