Skip to main content

gity_git/
lib.rs

1use git2::{Config, Repository, Status, StatusOptions};
2use std::{
3    collections::BTreeSet,
4    path::{Path, PathBuf},
5};
6use thiserror::Error;
7
8const PARTIAL_CLONE_FILTER: &str = "blob:none";
9
10#[derive(Debug, Error)]
11pub enum GitError {
12    #[error("failed to open git repository at {path:?}: {source}")]
13    OpenRepo {
14        path: PathBuf,
15        #[source]
16        source: git2::Error,
17    },
18    #[error("git config error: {0}")]
19    Config(String),
20    #[error("git status error: {source}")]
21    Status {
22        #[from]
23        source: git2::Error,
24    },
25}
26
27pub struct RepoConfigurator {
28    repo: Repository,
29}
30
31impl RepoConfigurator {
32    pub fn open(path: impl AsRef<Path>) -> Result<Self, GitError> {
33        let repo_path = path.as_ref().to_path_buf();
34        let repo = Repository::open(&repo_path).map_err(|source| GitError::OpenRepo {
35            path: repo_path.clone(),
36            source,
37        })?;
38        Ok(Self { repo })
39    }
40
41    pub fn apply_performance_settings(
42        &self,
43        fsmonitor_helper: Option<&str>,
44    ) -> Result<(), GitError> {
45        let mut config = self.repository_config()?;
46        if let Some(helper) = fsmonitor_helper {
47            config
48                .set_str("core.fsmonitor", helper)
49                .map_err(map_config_err)?;
50        } else {
51            remove_entry(&mut config, "core.fsmonitor")?;
52        }
53        config
54            .set_bool("core.untrackedCache", true)
55            .map_err(map_config_err)?;
56        config
57            .set_bool("feature.manyFiles", true)
58            .map_err(map_config_err)?;
59        config
60            .set_bool("fetch.writeCommitGraph", true)
61            .map_err(map_config_err)?;
62        config
63            .set_bool("gc.writeCommitGraph", true)
64            .map_err(map_config_err)?;
65        config
66            .set_bool("remote.origin.promisor", true)
67            .map_err(map_config_err)?;
68        config
69            .set_str("remote.origin.partialclonefilter", PARTIAL_CLONE_FILTER)
70            .map_err(map_config_err)?;
71        Ok(())
72    }
73
74    pub fn clear_performance_settings(&self) -> Result<(), GitError> {
75        let mut config = self.repository_config()?;
76        remove_entry(&mut config, "core.fsmonitor")?;
77        remove_entry(&mut config, "core.untrackedCache")?;
78        remove_entry(&mut config, "feature.manyFiles")?;
79        remove_entry(&mut config, "fetch.writeCommitGraph")?;
80        remove_entry(&mut config, "gc.writeCommitGraph")?;
81        remove_entry(&mut config, "remote.origin.promisor")?;
82        remove_entry(&mut config, "remote.origin.partialclonefilter")?;
83        Ok(())
84    }
85
86    fn repository_config(&self) -> Result<Config, GitError> {
87        self.repo
88            .config()
89            .map_err(|err| GitError::Config(err.to_string()))
90    }
91}
92
93fn remove_entry(config: &mut Config, key: &str) -> Result<(), GitError> {
94    match config.remove(key) {
95        Ok(()) => Ok(()),
96        Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(()),
97        Err(err) => Err(GitError::Config(err.to_string())),
98    }
99}
100
101fn map_config_err(err: git2::Error) -> GitError {
102    GitError::Config(err.to_string())
103}
104
105/// Returns the set of paths that Git currently considers dirty.
106pub fn working_tree_status(
107    repo_path: &Path,
108    focus_paths: &[PathBuf],
109) -> Result<Vec<PathBuf>, GitError> {
110    let repo = Repository::open(repo_path).map_err(|source| GitError::OpenRepo {
111        path: repo_path.to_path_buf(),
112        source,
113    })?;
114
115    let mut options = StatusOptions::new();
116    options
117        .include_untracked(true)
118        .recurse_untracked_dirs(true)
119        .renames_head_to_index(true)
120        .exclude_submodules(true);
121
122    if !focus_paths.is_empty() {
123        for path in focus_paths {
124            if let Some(spec) = path.to_str() {
125                options.pathspec(spec);
126            }
127        }
128    }
129
130    let statuses = repo.statuses(Some(&mut options))?;
131    let mut dirty = BTreeSet::new();
132    for entry in statuses.iter() {
133        let status = entry.status();
134        if status == Status::CURRENT || status.contains(Status::IGNORED) {
135            continue;
136        }
137        if let Some(path) = entry.path() {
138            dirty.insert(PathBuf::from(path));
139        }
140    }
141
142    Ok(dirty.into_iter().collect())
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use git2::Signature;
149    use std::fs;
150
151    #[test]
152    fn applies_and_clears_settings() {
153        let dir = tempfile::tempdir().unwrap();
154        Repository::init(dir.path()).expect("init repo");
155        let configurator = RepoConfigurator::open(dir.path()).expect("open repo");
156        let helper_cmd = "gity fsmonitor-helper";
157        configurator
158            .apply_performance_settings(Some(helper_cmd))
159            .expect("apply settings");
160        let config = read_config(dir.path());
161        assert!(config.contains(helper_cmd));
162        assert!(config.contains(PARTIAL_CLONE_FILTER));
163
164        configurator
165            .clear_performance_settings()
166            .expect("clear settings");
167        let config_after = read_config(dir.path());
168        assert!(!config_after.contains(helper_cmd));
169    }
170
171    #[test]
172    fn working_tree_status_detects_dirty_paths() {
173        let dir = tempfile::tempdir().unwrap();
174        let repo = Repository::init(dir.path()).expect("init repo");
175        let tracked = dir.path().join("tracked.txt");
176        fs::write(&tracked, "hello").unwrap();
177
178        let mut index = repo.index().expect("index");
179        index.add_path(Path::new("tracked.txt")).expect("add path");
180        index.write().expect("write index");
181        let tree_id = index.write_tree().expect("write tree");
182        let tree = repo.find_tree(tree_id).expect("tree");
183        let sig = Signature::now("Gity", "gity@example.com").expect("signature");
184        repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
185            .expect("commit");
186
187        fs::write(&tracked, "changed").unwrap();
188        let status = working_tree_status(dir.path(), &[]).expect("status");
189        assert_eq!(status, vec![PathBuf::from("tracked.txt")]);
190
191        let filtered =
192            working_tree_status(dir.path(), &[PathBuf::from("tracked.txt")]).expect("filtered");
193        assert_eq!(filtered, vec![PathBuf::from("tracked.txt")]);
194    }
195
196    fn read_config(path: &Path) -> String {
197        fs::read_to_string(path.join(".git/config")).expect("read config")
198    }
199}