1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
//! Configuration of git-global.
//!
//! Exports the `Config` struct, which defines the base path for finding git
//! repos on the machine, path patterns to ignore when scanning for repos, the
//! location of a cache file, and other config options for running git-global.

use std::env;
use std::fs::{create_dir_all, remove_file, File};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};

use directories::{ProjectDirs, UserDirs};
use walkdir::{DirEntry, WalkDir};

use crate::repo::Repo;

const QUALIFIER: &str = "";
const ORGANIZATION: &str = "peap";
const APPLICATION: &str = "git-global";
const CACHE_FILE: &str = "repos.txt";

const DEFAULT_CMD: &str = "status";
const DEFAULT_FOLLOW_SYMLINKS: bool = true;
const DEFAULT_SAME_FILESYSTEM: bool = cfg!(any(unix, windows));
const DEFAULT_SHOW_UNTRACKED: bool = true;

const SETTING_BASEDIR: &str = "global.basedir";
const SETTING_FOLLOW_SYMLINKS: &str = "global.follow-symlinks";
const SETTING_SAME_FILESYSTEM: &str = "global.same-filesystem";
const SETTING_IGNORE: &str = "global.ignore";
const SETTING_DEFAULT_CMD: &str = "global.default-cmd";
const SETTING_SHOW_UNTRACKED: &str = "global.show-untracked";

/// A container for git-global configuration options.
pub struct Config {
    /// The base directory to walk when searching for git repositories.
    ///
    /// Default: $HOME.
    pub basedir: PathBuf,

    /// Whether to follow symbolic links when searching for git repos.
    ///
    /// Default: true
    pub follow_symlinks: bool,

    /// Whether to stay on the same filesystem (as `basedir`) when searching
    /// for git repos on Unix or Windows.
    ///
    /// Default: true [on supported platforms]
    pub same_filesystem: bool,

    /// Path patterns to ignore when searching for git repositories.
    ///
    /// Default: none
    pub ignored_patterns: Vec<String>,

    /// The git-global subcommand to run when unspecified.
    ///
    /// Default: `status`
    pub default_cmd: String,

    /// Whether to show untracked files in output.
    ///
    /// Default: true
    pub show_untracked: bool,

    /// Optional path to a cache file for git-global's usage.
    ///
    /// Default: `repos.txt` in the user's XDG cache directory, if we understand
    /// XDG for the host system.
    pub cache_file: Option<PathBuf>,

    /// Optional path to our manpage, regardless of whether it's installed.
    ///
    /// Default: `git-global.1` in the relevant manpages directory, if we
    /// understand where that should be for the host system.
    pub manpage_file: Option<PathBuf>,
}

impl Default for Config {
    fn default() -> Self {
        Config::new()
    }
}

impl Config {
    /// Create a new `Config` with the default behavior, first checking global
    /// git config options in ~/.gitconfig, then using defaults:
    pub fn new() -> Self {
        // Find the user's home directory.
        let homedir = UserDirs::new()
            .expect("Could not determine home directory.")
            .home_dir()
            .to_path_buf();
        // Set the options that aren't user-configurable.
        let cache_file =
            ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
                .map(|project_dirs| project_dirs.cache_dir().join(CACHE_FILE));
        let manpage_file = match env::consts::OS {
            // Consider ~/.local/share/man/man1/, too.
            "linux" => Some(PathBuf::from("/usr/share/man/man1/git-global.1")),
            "macos" => Some(PathBuf::from("/usr/share/man/man1/git-global.1")),
            "windows" => env::var("MSYSTEM").ok().and_then(|val| {
                (val == "MINGW64").then(|| {
                    PathBuf::from("/mingw64/share/doc/git-doc/git-global.html")
                })
            }),
            _ => None,
        };
        match ::git2::Config::open_default() {
            Ok(cfg) => Config {
                basedir: cfg.get_path(SETTING_BASEDIR).unwrap_or(homedir),
                follow_symlinks: cfg
                    .get_bool(SETTING_FOLLOW_SYMLINKS)
                    .unwrap_or(DEFAULT_FOLLOW_SYMLINKS),
                same_filesystem: cfg
                    .get_bool(SETTING_SAME_FILESYSTEM)
                    .unwrap_or(DEFAULT_SAME_FILESYSTEM),
                ignored_patterns: cfg
                    .get_string(SETTING_IGNORE)
                    .unwrap_or_default()
                    .split(',')
                    .map(|p| p.trim().to_string())
                    .collect(),
                default_cmd: cfg
                    .get_string(SETTING_DEFAULT_CMD)
                    .unwrap_or_else(|_| String::from(DEFAULT_CMD)),
                show_untracked: cfg
                    .get_bool(SETTING_SHOW_UNTRACKED)
                    .unwrap_or(DEFAULT_SHOW_UNTRACKED),
                cache_file,
                manpage_file,
            },
            Err(_) => {
                // Build the default configuration.
                Config {
                    basedir: homedir,
                    follow_symlinks: DEFAULT_FOLLOW_SYMLINKS,
                    same_filesystem: DEFAULT_SAME_FILESYSTEM,
                    ignored_patterns: vec![],
                    default_cmd: String::from(DEFAULT_CMD),
                    show_untracked: DEFAULT_SHOW_UNTRACKED,
                    cache_file,
                    manpage_file,
                }
            }
        }
    }

    /// Returns all known git repos, populating the cache first, if necessary.
    pub fn get_repos(&mut self) -> Vec<Repo> {
        if !self.has_cache() {
            let repos = self.find_repos();
            self.cache_repos(&repos);
        }
        self.get_cached_repos()
    }

    /// Clears the cache of known git repos, forcing a re-scan on the next
    /// `get_repos()` call.
    pub fn clear_cache(&mut self) {
        if self.has_cache() {
            if let Some(file) = &self.cache_file {
                remove_file(file).expect("Failed to delete cache file.");
            }
        }
    }

    /// Returns `true` if this directory entry should be included in scans.
    fn filter(&self, entry: &DirEntry) -> bool {
        if let Some(entry_path) = entry.path().to_str() {
            self.ignored_patterns
                .iter()
                .filter(|p| p != &"")
                .all(|pattern| !entry_path.contains(pattern))
        } else {
            // Skip invalid file name
            false
        }
    }

    /// Walks the configured base directory, looking for git repos.
    fn find_repos(&self) -> Vec<Repo> {
        let mut repos = Vec::new();
        println!(
            "Scanning for git repos under {}; this may take a while...",
            self.basedir.display()
        );
        let walker = WalkDir::new(&self.basedir)
            .follow_links(self.follow_symlinks)
            .same_file_system(self.same_filesystem);
        for entry in walker
            .into_iter()
            .filter_entry(|e| self.filter(e))
            .flatten()
        {
            if entry.file_type().is_dir() && entry.file_name() == ".git" {
                let parent_path =
                    entry.path().parent().expect("Could not determine parent.");
                if let Some(path) = parent_path.to_str() {
                    repos.push(Repo::new(path.to_string()));
                }
            }
        }
        repos.sort_by_key(|r| r.path());
        repos
    }

    /// Returns boolean indicating if the cache file exists.
    fn has_cache(&self) -> bool {
        self.cache_file.as_ref().map_or(false, |f| f.exists())
    }

    /// Writes the given repo paths to the cache file.
    fn cache_repos(&self, repos: &[Repo]) {
        if let Some(file) = &self.cache_file {
            if !file.exists() {
                if let Some(parent) = &file.parent() {
                    create_dir_all(parent)
                        .expect("Could not create cache directory.")
                }
            }
            let mut f =
                File::create(file).expect("Could not create cache file.");
            for repo in repos.iter() {
                match writeln!(f, "{}", repo.path()) {
                    Ok(_) => (),
                    Err(e) => panic!("Problem writing cache file: {}", e),
                }
            }
        }
    }

    /// Returns the list of repos found in the cache file.
    fn get_cached_repos(&self) -> Vec<Repo> {
        let mut repos = Vec::new();
        if let Some(file) = &self.cache_file {
            if file.exists() {
                let f = File::open(file).expect("Could not open cache file.");
                let reader = BufReader::new(f);
                for repo_path in reader.lines().map_while(Result::ok) {
                    if !Path::new(&repo_path).exists() {
                        continue;
                    }
                    repos.push(Repo::new(repo_path))
                }
            }
        }
        repos
    }
}