grit_lib/
init_filesystem.rs1use std::fs;
8use std::path::Path;
9
10use crate::config::{parse_config_parameters, ConfigFile, ConfigScope};
11use crate::environment::Environment;
12use crate::error::Result;
13use crate::unicode_normalization::probe_filesystem_normalizes_nfd_to_nfc;
14
15#[derive(Debug, Clone, Copy, Default)]
17pub struct InitFilesystemConfigOptions {
18 pub is_reinit: bool,
20 pub force_precompose_probe: bool,
22}
23
24fn config_bool_from_parameters(env: &Environment, key: &str) -> Option<bool> {
25 let raw = env.git_config_parameters.as_deref()?;
26 let key_lower = key.to_ascii_lowercase();
27 let mut last: Option<bool> = None;
28 for entry in parse_config_parameters(raw) {
29 let Some((k, v)) = entry.split_once('\u{1}') else {
30 continue;
31 };
32 if !k.trim().eq_ignore_ascii_case(&key_lower) {
33 continue;
34 }
35 let v = v.trim();
36 last = Some(matches!(
37 v.to_ascii_lowercase().as_str(),
38 "true" | "yes" | "on" | "1"
39 ));
40 }
41 last
42}
43
44fn precompose_from_git_config_parameters(env: &Environment) -> Option<bool> {
45 config_bool_from_parameters(env, "core.precomposeunicode")
46}
47
48fn ignorecase_from_git_config_parameters(env: &Environment) -> Option<bool> {
49 config_bool_from_parameters(env, "core.ignorecase")
50}
51
52fn filemode_from_git_config_parameters(env: &Environment) -> Option<bool> {
53 config_bool_from_parameters(env, "core.filemode")
54}
55
56fn symlinks_from_git_config_parameters(env: &Environment) -> Option<bool> {
57 config_bool_from_parameters(env, "core.symlinks")
58}
59
60pub fn probe_filesystem_case_insensitive(git_dir: &Path) -> std::io::Result<bool> {
64 let config_path = git_dir.join("config");
65 if !config_path.is_file() {
66 return Ok(false);
67 }
68 Ok(git_dir.join("CoNfIg").is_file())
69}
70
71pub fn probe_trust_filemode(git_dir: &Path) -> std::io::Result<bool> {
75 let config_path = git_dir.join("config");
76 if !config_path.is_file() {
77 return Ok(true);
78 }
79
80 #[cfg(unix)]
81 {
82 use std::os::unix::fs::PermissionsExt;
83 let meta = fs::metadata(&config_path)?;
84 let mode = meta.permissions().mode();
85 let toggled = mode ^ 0o100;
86 fs::set_permissions(&config_path, fs::Permissions::from_mode(toggled))?;
87 let meta2 = fs::metadata(&config_path)?;
88 let mut filemode = mode != meta2.permissions().mode();
89 fs::set_permissions(&config_path, fs::Permissions::from_mode(mode))?;
90 if filemode && mode & 0o100 != 0 {
91 filemode = false;
92 }
93 Ok(filemode)
94 }
95
96 #[cfg(not(unix))]
97 {
98 let _ = git_dir;
99 Ok(false)
100 }
101}
102
103pub fn probe_symlinks_supported(git_dir: &Path) -> std::io::Result<bool> {
107 for attempt in 0..128_u32 {
108 let probe_path = git_dir.join(format!(".grit-symlink-probe-{attempt:05}"));
109 if fs::symlink_metadata(&probe_path).is_ok() {
110 continue;
111 }
112
113 #[cfg(unix)]
114 {
115 use std::os::unix::fs::symlink;
116 return Ok(if symlink("testing", &probe_path).is_err() {
117 false
118 } else {
119 let ok = fs::symlink_metadata(&probe_path)
120 .map(|m| m.file_type().is_symlink())
121 .unwrap_or(false);
122 let _ = fs::remove_file(&probe_path);
123 ok
124 });
125 }
126
127 #[cfg(windows)]
128 {
129 use std::os::windows::fs::symlink_file;
130 return Ok(if symlink_file("testing", &probe_path).is_err() {
131 false
132 } else {
133 let ok = fs::symlink_metadata(&probe_path)
134 .map(|m| m.file_type().is_symlink())
135 .unwrap_or(false);
136 let _ = fs::remove_file(&probe_path);
137 ok
138 });
139 }
140
141 #[cfg(not(any(unix, windows)))]
142 {
143 let _ = git_dir;
144 return Ok(false);
145 }
146 }
147 Ok(false)
148}
149
150pub fn apply_init_filesystem_config(
162 git_dir: &Path,
163 opts: InitFilesystemConfigOptions,
164 environment: &Environment,
165) -> Result<()> {
166 if opts.is_reinit {
167 return Ok(());
168 }
169
170 let precompose_from_cmdline = precompose_from_git_config_parameters(environment).is_some();
171 let ignorecase_from_cmdline = ignorecase_from_git_config_parameters(environment).is_some();
172 let force_precompose_probe = opts.force_precompose_probe;
173
174 let filemode = probe_trust_filemode(git_dir).unwrap_or(true);
175 let symlinks = probe_symlinks_supported(git_dir).unwrap_or(false);
176
177 let mut changed = false;
178 let config_path = git_dir.join("config");
179 let content = fs::read_to_string(&config_path).unwrap_or_default();
180 let mut cfg = ConfigFile::parse(&config_path, &content, ConfigScope::Local)?;
181 let precompose_locally_set = cfg.get("core.precomposeunicode").is_some();
182 let ignorecase_locally_set = cfg.get("core.ignorecase").is_some();
183
184 let filemode_value = filemode_from_git_config_parameters(environment)
185 .map(|v| if v { "true" } else { "false" })
186 .unwrap_or_else(|| if filemode { "true" } else { "false" });
187 cfg.set("core.filemode", filemode_value)?;
188 changed |= true;
189
190 if let Some(from_cmdline) = symlinks_from_git_config_parameters(environment) {
191 cfg.set("core.symlinks", if from_cmdline { "true" } else { "false" })?;
192 changed |= true;
193 } else if !symlinks {
194 cfg.set("core.symlinks", "false")?;
195 changed |= true;
196 }
197
198 if !precompose_from_cmdline && !precompose_locally_set {
199 let probe_ok = force_precompose_probe
200 || probe_filesystem_normalizes_nfd_to_nfc(git_dir).unwrap_or(false);
201 if probe_ok {
202 cfg.set("core.precomposeunicode", "true")?;
203 changed |= true;
204 }
205 }
206
207 if !ignorecase_from_cmdline
208 && !ignorecase_locally_set
209 && probe_filesystem_case_insensitive(git_dir).unwrap_or(false)
210 {
211 cfg.set("core.ignorecase", "true")?;
212 changed |= true;
213 }
214
215 if changed {
216 cfg.write()?;
217 }
218 Ok(())
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use std::fs;
225 use tempfile::TempDir;
226
227 #[test]
228 fn case_insensitive_probe_false_on_normal_fs() {
229 let td = TempDir::new().expect("tempdir");
230 let git_dir = td.path().join(".git");
231 fs::create_dir_all(&git_dir).expect("mkdir");
232 fs::write(git_dir.join("config"), "[core]\n").expect("config");
233 assert!(!probe_filesystem_case_insensitive(&git_dir).expect("probe"));
234 }
235
236 #[test]
237 fn apply_honors_git_config_parameters_filemode() {
238 use crate::config::environment::ConfigOverride;
239
240 let td = TempDir::new().expect("tempdir");
241 let git_dir = td.path().join(".git");
242 fs::create_dir_all(&git_dir).expect("mkdir");
243 fs::write(
244 git_dir.join("config"),
245 "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n",
246 )
247 .expect("config");
248 let mut env = Environment::empty();
249 env.push_config_override(
250 &"core.filemode=false"
251 .parse::<ConfigOverride>()
252 .expect("override"),
253 );
254 apply_init_filesystem_config(&git_dir, InitFilesystemConfigOptions::default(), &env)
255 .expect("apply");
256 let text = fs::read_to_string(git_dir.join("config")).expect("read");
257 assert!(text.contains("filemode = false"), "{text}");
258 }
259
260 #[test]
261 fn apply_sets_filemode_from_probe() {
262 let td = TempDir::new().expect("tempdir");
263 let git_dir = td.path().join(".git");
264 fs::create_dir_all(&git_dir).expect("mkdir");
265 fs::write(
266 git_dir.join("config"),
267 "[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n",
268 )
269 .expect("config");
270 apply_init_filesystem_config(
271 &git_dir,
272 InitFilesystemConfigOptions::default(),
273 &crate::environment::Environment::empty(),
274 )
275 .expect("apply");
276 let probed = probe_trust_filemode(&git_dir).expect("probe");
277 let text = fs::read_to_string(git_dir.join("config")).expect("read");
278 let expected = if probed {
279 "filemode = true"
280 } else {
281 "filemode = false"
282 };
283 assert!(text.contains(expected), "{text}");
284 }
285
286 #[test]
287 fn apply_respects_git_test_utf8_nfd_to_nfc() {
288 let td = TempDir::new().expect("tempdir");
289 let git_dir = td.path().join(".git");
290 fs::create_dir_all(&git_dir).expect("mkdir");
291 fs::write(
292 git_dir.join("config"),
293 "[core]\n\trepositoryformatversion = 0\n",
294 )
295 .expect("config");
296 apply_init_filesystem_config(
297 &git_dir,
298 InitFilesystemConfigOptions {
299 force_precompose_probe: true,
300 ..InitFilesystemConfigOptions::default()
301 },
302 &Environment::empty(),
303 )
304 .expect("apply");
305 let text = fs::read_to_string(git_dir.join("config")).expect("read");
306 assert!(text.contains("precomposeunicode = true"));
307 }
308
309 #[test]
310 fn apply_does_not_override_template_precomposeunicode_false() {
311 let tmpl = TempDir::new().expect("tempdir");
312 fs::write(
313 tmpl.path().join("config"),
314 "[core]\n\tprecomposeunicode = false\n",
315 )
316 .expect("template config");
317
318 let root = TempDir::new().expect("worktree");
319 crate::repo::init_repository(
320 root.path(),
321 false,
322 "main",
323 Some(tmpl.path()),
324 crate::RefStorageFormat::Files,
325 )
326 .expect("init");
327
328 let text = fs::read_to_string(root.path().join(".git/config")).expect("read");
329 assert!(
330 text.contains("precomposeunicode = false"),
331 "template local value must be preserved:\n{text}"
332 );
333 }
334
335 #[test]
336 fn symlink_probe_does_not_delete_template_file() {
337 let td = TempDir::new().expect("tempdir");
338 let git_dir = td.path().join(".git");
339 fs::create_dir_all(&git_dir).expect("mkdir");
340 fs::write(git_dir.join("config"), "[core]\n").expect("config");
341 let sentinel = git_dir.join(".grit-symlink-probe-00000");
342 fs::write(&sentinel, "keep me").expect("sentinel");
343 let _ = probe_symlinks_supported(&git_dir);
344 assert!(
345 sentinel.is_file(),
346 "probe must not remove an existing template file"
347 );
348 assert_eq!(fs::read_to_string(&sentinel).expect("read"), "keep me");
349 }
350}