Skip to main content

grit_lib/
precompose_config.rs

1//! Read `core.precomposeunicode` without opening a full [`Repository`].
2//!
3//! Used for pathspec matching when argv may use NFD spellings while the index stores NFC.
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::config::{parse_config_parameters, ConfigSet};
9use crate::unicode_normalization::probe_filesystem_normalizes_nfd_to_nfc;
10
11fn parse_ceiling_directories_paths() -> Vec<PathBuf> {
12    let raw = match std::env::var("GIT_CEILING_DIRECTORIES") {
13        Ok(val) => val,
14        Err(_) => return Vec::new(),
15    };
16    if raw.is_empty() {
17        return Vec::new();
18    }
19    raw.split(':')
20        .filter(|s| !s.is_empty())
21        .filter_map(|s| {
22            let p = PathBuf::from(s);
23            if !p.is_absolute() {
24                return None;
25            }
26            Some(
27                p.canonicalize()
28                    .unwrap_or_else(|_| PathBuf::from(s.trim_end_matches('/'))),
29            )
30        })
31        .collect()
32}
33
34fn path_for_ceiling_compare(path: &Path) -> String {
35    path.to_string_lossy().replace('\\', "/")
36}
37
38fn offset_1st_component(path: &str) -> usize {
39    if path.starts_with('/') {
40        1
41    } else {
42        0
43    }
44}
45
46fn longest_ancestor_length(path: &str, ceilings: &[String]) -> Option<usize> {
47    if path == "/" {
48        return None;
49    }
50    let mut max_len: Option<usize> = None;
51    for ceil in ceilings {
52        let mut len = ceil.len();
53        while len > 0 && ceil.as_bytes().get(len - 1) == Some(&b'/') {
54            len -= 1;
55        }
56        if len == 0 {
57            continue;
58        }
59        if path.len() <= len + 1 {
60            continue;
61        }
62        if !path.starts_with(&ceil[..len]) {
63            continue;
64        }
65        if path.as_bytes().get(len) != Some(&b'/') {
66            continue;
67        }
68        if path.as_bytes().get(len + 1).is_none() {
69            continue;
70        }
71        max_len = Some(max_len.map_or(len, |m| m.max(len)));
72    }
73    max_len
74}
75
76fn probe_git_dir_at(dir: &Path) -> Option<PathBuf> {
77    let dot_git = dir.join(".git");
78    if dot_git.is_dir() {
79        return Some(dot_git.canonicalize().unwrap_or(dot_git));
80    }
81    if dot_git.is_file() {
82        let content = fs::read_to_string(&dot_git).ok()?;
83        for line in content.lines() {
84            let line = line.trim();
85            if let Some(rest) = line.strip_prefix("gitdir:") {
86                let p = Path::new(rest.trim());
87                let resolved = if p.is_absolute() {
88                    p.to_path_buf()
89                } else {
90                    dir.join(p)
91                };
92                return Some(resolved.canonicalize().unwrap_or(resolved));
93            }
94        }
95    }
96    None
97}
98
99/// Walk parents from `cwd` for `.git`, honouring `GIT_CEILING_DIRECTORIES` like Git discovery.
100pub fn locate_git_dir_from_cwd(cwd: PathBuf) -> Option<PathBuf> {
101    let start_canon = cwd.canonicalize().unwrap_or(cwd);
102    let ceilings: Vec<String> = parse_ceiling_directories_paths()
103        .into_iter()
104        .map(|p| path_for_ceiling_compare(&p))
105        .collect();
106    let mut dir_buf = path_for_ceiling_compare(&start_canon);
107    let min_offset = offset_1st_component(&dir_buf);
108    let mut ceil_offset: isize = longest_ancestor_length(&dir_buf, &ceilings)
109        .map(|n| n as isize)
110        .unwrap_or(-1);
111    if ceil_offset < 0 {
112        ceil_offset = min_offset as isize - 2;
113    }
114
115    loop {
116        if let Some(gd) = probe_git_dir_at(Path::new(&dir_buf)) {
117            return Some(gd);
118        }
119
120        let mut offset: isize = dir_buf.len() as isize;
121        if offset <= min_offset as isize {
122            break;
123        }
124        loop {
125            offset -= 1;
126            if offset <= ceil_offset {
127                break;
128            }
129            if dir_buf
130                .as_bytes()
131                .get(offset as usize)
132                .is_some_and(|b| *b == b'/')
133            {
134                break;
135            }
136        }
137        if offset <= ceil_offset {
138            break;
139        }
140        let off_u = offset as usize;
141        let new_len = if off_u > min_offset {
142            off_u
143        } else {
144            min_offset
145        };
146        dir_buf.truncate(new_len);
147    }
148    None
149}
150
151/// Parse `.git/config` only (no global/system cascade). `None` when the key is absent.
152#[must_use]
153pub fn read_core_precomposeunicode(git_dir: &Path) -> Option<bool> {
154    let path = git_dir.join("config");
155    let Ok(text) = fs::read_to_string(&path) else {
156        return None;
157    };
158    let mut in_core = false;
159    let mut last: Option<bool> = None;
160    for line in text.lines() {
161        let t = line.trim();
162        if t.starts_with('[') {
163            in_core = t.eq_ignore_ascii_case("[core]");
164            continue;
165        }
166        if !in_core {
167            continue;
168        }
169        let Some((k, v)) = t.split_once('=') else {
170            continue;
171        };
172        if !k.trim().eq_ignore_ascii_case("precomposeunicode") {
173            continue;
174        }
175        let v = v.trim();
176        last = Some(matches!(
177            v.to_ascii_lowercase().as_str(),
178            "true" | "yes" | "on" | "1"
179        ));
180    }
181    last
182}
183
184/// `git -c core.precomposeunicode=…` overrides local config (last token wins).
185fn precompose_from_git_config_parameters() -> Option<bool> {
186    let Ok(raw) = std::env::var("GIT_CONFIG_PARAMETERS") else {
187        return None;
188    };
189    let mut last: Option<bool> = None;
190    for entry in parse_config_parameters(&raw) {
191        let Some((k, v)) = entry.split_once('=') else {
192            continue;
193        };
194        if !k.trim().eq_ignore_ascii_case("core.precomposeunicode") {
195            continue;
196        }
197        let v = v.trim();
198        last = Some(matches!(
199            v.to_ascii_lowercase().as_str(),
200            "true" | "yes" | "on" | "1"
201        ));
202    }
203    last
204}
205
206/// Effective `core.precomposeunicode` after the normal config cascade (system, global, local,
207/// `GIT_CONFIG_PARAMETERS`), matching [`ConfigSet::load`].
208///
209/// Does not imply argv should be rewritten: Git only runs `precompose_argv_prefix` when the
210/// filesystem aliases NFD/NFC (or the test harness forces that probe for `git init`).
211#[must_use]
212pub fn effective_core_precomposeunicode(git_dir: Option<&Path>) -> bool {
213    if let Some(v) = precompose_from_git_config_parameters() {
214        return v;
215    }
216    let Some(gd) = git_dir else {
217        return false;
218    };
219    ConfigSet::load(Some(gd), true)
220        .ok()
221        .and_then(|cfg| cfg.get_bool("core.precomposeunicode").and_then(|r| r.ok()))
222        .unwrap_or(false)
223}
224
225/// True when the filesystem aliases NFD and NFC spellings for the same path (macOS / HFS+ style).
226///
227/// `GIT_TEST_UTF8_NFD_TO_NFC` does **not** count here: it only makes `git init` write
228/// `core.precomposeunicode` on Linux; argv and directory walks must still use the bytes the shell
229/// passed when the FS does not alias.
230#[must_use]
231pub fn filesystem_nfd_nfc_aliases(git_dir: &Path) -> bool {
232    probe_filesystem_normalizes_nfd_to_nfc(git_dir).unwrap_or(false)
233}
234
235/// NFC-normalize command-line path arguments (Git's `precompose_argv_prefix`).
236#[must_use]
237pub fn argv_precompose_enabled(git_dir: Option<&Path>) -> bool {
238    if !effective_core_precomposeunicode(git_dir) {
239        return false;
240    }
241    let Some(gd) = git_dir else {
242        return false;
243    };
244    filesystem_nfd_nfc_aliases(gd)
245}
246
247/// NFC-normalize for pathspec comparisons when the repo opts into precomposed Unicode storage.
248///
249/// Memoized for the process lifetime: cwd and the config cascade are fixed before any pathspec
250/// matching runs, and this is called from per-entry hot loops (`status`/`add` pathspec matching)
251/// where re-walking to the git dir and re-parsing config files per call dominated the profile.
252#[must_use]
253pub fn pathspec_precompose_enabled() -> bool {
254    static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
255    *CACHE.get_or_init(|| {
256        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
257        let gd = locate_git_dir_from_cwd(cwd);
258        effective_core_precomposeunicode(gd.as_deref())
259    })
260}