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
105pub 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}