Skip to main content

grit_lib/
init_filesystem.rs

1//! Filesystem capability probes during repository initialization.
2//!
3//! Git sets `core.filemode`, `core.symlinks`, `core.ignorecase`, and
4//! `core.precomposeunicode` in the new local config when initialization detects
5//! platform limits.
6
7use 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/// Controls when init-time filesystem probes may write local config.
16#[derive(Debug, Clone, Copy, Default)]
17pub struct InitFilesystemConfigOptions {
18    /// Skip all probes (Git skips on re-init).
19    pub is_reinit: bool,
20    /// Force `core.precomposeunicode` probe (embedder/test knob, not from process env).
21    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
60/// True when `config` in `git_dir` is visible as `CoNfIg` (case-insensitive git dir).
61///
62/// Matches Git `init_db` / `setup.c` case-insensitivity detection.
63pub 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
71/// Probe whether the executable bit is meaningful for `git_dir/config`.
72///
73/// Matches Git `init_db` filemode trustability check in `setup.c`.
74pub 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
103/// Probe whether symbolic links can be created under `git_dir`.
104///
105/// Uses a unique probe path and never deletes pre-existing files (e.g. from init templates).
106pub 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
150/// After a fresh `.git` layout is written, probe the filesystem and set local
151/// `core.*` keys when appropriate.
152///
153/// Honors higher-priority config (system, global, `GIT_CONFIG_PARAMETERS`), values already
154/// present in the local config (including from an init template), and does not override them.
155/// When [`InitFilesystemConfigOptions::force_precompose_probe`] is set, the precompose probe runs
156/// even on filesystems that would not normally normalize NFD to NFC (Linux CI).
157///
158/// # Errors
159///
160/// Returns [`Error::Io`](crate::error::Error::Io) or config parse/write failures.
161pub 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}