Skip to main content

grit_lib/
worktree_rules.rs

1//! Per-operation worktree attribute and ignore context.
2//!
3//! Hot porcelain paths (`status`, staging, index/worktree diffs, checkout stat checks)
4//! previously loaded `.gitattributes` and exclude sources independently, often repeating
5//! the same disk reads within one operation. [`WorktreeRules`] is built once from a
6//! [`Repository`] config snapshot and index view, then threaded through those paths.
7//! Attribute files along parent directories and per-directory `.gitignore` files are
8//! loaded at most once per [`WorktreeRules`] instance (memoized inside the value, not
9//! in process globals).
10
11use std::cell::RefCell;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use crate::attributes::{
17    parse_gitattributes_file_content, parse_gitattributes_file_content_with_base,
18    ParsedGitAttributes, MAX_ATTR_FILE_BYTES,
19};
20use crate::config::parse_path;
21use crate::config::ConfigSet;
22use crate::crlf::{self, AttrRule, ConversionConfig, FileAttrs};
23use crate::environment::Environment;
24use crate::error::{Error, Result};
25use crate::filter_process::FilterProcessState;
26use crate::ignore::IgnoreMatcher;
27use crate::index::Index;
28use crate::objects::ObjectId;
29use crate::odb::Odb;
30use crate::repo::Repository;
31use crate::repo_caches::RepoCaches;
32
33/// Counts disk reads of attribute/ignore pattern files (tests only).
34///
35/// Counts are per thread so tests running concurrently in one process do not see each other's
36/// reads. Attribute files are always read on the calling thread ([`WorktreeRules`] is not
37/// `Sync`), so an operation's reads land in its caller's counts.
38#[cfg(test)]
39pub mod file_load_counters {
40    use std::cell::RefCell;
41    use std::collections::HashMap;
42    use std::path::{Path, PathBuf};
43
44    thread_local! {
45        static MAP: RefCell<HashMap<PathBuf, u32>> = RefCell::new(HashMap::new());
46    }
47
48    /// Reset this thread's counters before an operation under test.
49    pub fn reset() {
50        MAP.with(|m| m.borrow_mut().clear());
51    }
52
53    /// Record one read of `path` (canonicalized when possible).
54    pub fn record(path: &Path) {
55        let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
56        MAP.with(|m| *m.borrow_mut().entry(key).or_insert(0) += 1);
57    }
58
59    /// Maximum read count for any single file since the last [`reset`].
60    #[must_use]
61    pub fn max_reads_per_file() -> u32 {
62        MAP.with(|m| m.borrow().values().copied().max().unwrap_or(0))
63    }
64
65    /// Read count for `path` since the last [`reset`] (canonicalized when possible).
66    #[must_use]
67    pub fn reads_for(path: &Path) -> u32 {
68        let key = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
69        MAP.with(|m| m.borrow().get(&key).copied().unwrap_or(0))
70    }
71}
72
73fn record_attr_read(path: &Path) {
74    #[cfg(test)]
75    file_load_counters::record(path);
76    let _ = path;
77}
78
79fn record_ignore_read(path: &Path) {
80    #[cfg(test)]
81    file_load_counters::record(path);
82    let _ = path;
83}
84
85/// Attribute/ignore/conversion state shared across one porcelain operation.
86pub struct WorktreeRules {
87    config: Arc<ConfigSet>,
88    conversion: ConversionConfig,
89    ignore: RefCell<IgnoreMatcher>,
90    attrs: AttributeState,
91    caches: Arc<RepoCaches>,
92}
93
94struct AttributeState {
95    work_tree: PathBuf,
96    _git_dir: PathBuf,
97    odb: Odb,
98    /// Stage-0 `.gitattributes` paths recorded in the index (path → blob oid).
99    gitattributes_index: HashMap<Vec<u8>, ObjectId>,
100    /// When true, nested and root `.gitattributes` come only from [`Index`] blobs (checkout smudge).
101    gitattributes_index_only: bool,
102    /// Global + root `.gitattributes` (lowest precedence among file sources).
103    stack_prefix: ParsedGitAttributes,
104    /// `.git/info/attributes` — applied after all worktree `.gitattributes` along the path.
105    info_rules: ParsedGitAttributes,
106    dir_fragment: RefCell<HashMap<String, ParsedGitAttributes>>,
107    stack_without_info_by_dir: RefCell<HashMap<String, ParsedGitAttributes>>,
108}
109
110impl WorktreeRules {
111    /// Build rules context from `repo`, using `index` for in-index `.gitattributes` fallbacks.
112    ///
113    /// # Errors
114    ///
115    /// Returns I/O or config errors from loading global exclude/attribute sources.
116    pub fn from_repository(repo: &Repository, index: &Index) -> Result<Self> {
117        let config = repo.config()?.clone();
118        Self::from_parts(repo, index, config)
119    }
120
121    /// Build rules when the caller already holds a config snapshot.
122    ///
123    /// # Errors
124    ///
125    /// Returns I/O errors from loading exclude/attribute files.
126    pub fn from_parts(repo: &Repository, index: &Index, config: Arc<ConfigSet>) -> Result<Self> {
127        Self::from_parts_with_attribute_source(repo, index, config, false)
128    }
129
130    /// Like [`Self::from_parts`], but reads nested `.gitattributes` only from the index snapshot.
131    pub(crate) fn from_parts_index_attributes_only(
132        repo: &Repository,
133        index: &Index,
134        config: Arc<ConfigSet>,
135    ) -> Result<Self> {
136        Self::from_parts_with_attribute_source(repo, index, config, true)
137    }
138
139    fn from_parts_with_attribute_source(
140        repo: &Repository,
141        index: &Index,
142        config: Arc<ConfigSet>,
143        gitattributes_index_only: bool,
144    ) -> Result<Self> {
145        let work_tree = repo
146            .work_tree()
147            .ok_or(Error::RequiresWorkTree)?
148            .to_path_buf();
149        let conversion = ConversionConfig::from_config_with_runner(&config, repo.command_runner());
150        let ignore = RefCell::new(IgnoreMatcher::from_repository(repo)?);
151        let (stack_prefix, info_rules) = load_attribute_stack_prefix_and_info(
152            repo,
153            &work_tree,
154            &config,
155            index,
156            repo.odb(),
157            gitattributes_index_only,
158        )?;
159        let attrs = AttributeState {
160            work_tree,
161            _git_dir: repo.git_dir().to_path_buf(),
162            odb: repo.odb().clone(),
163            gitattributes_index: gitattributes_index_map(index),
164            gitattributes_index_only,
165            stack_prefix,
166            info_rules,
167            dir_fragment: RefCell::new(HashMap::new()),
168            stack_without_info_by_dir: RefCell::new(HashMap::new()),
169        };
170        Ok(Self {
171            config,
172            conversion,
173            ignore,
174            attrs,
175            caches: Arc::clone(repo.caches()),
176        })
177    }
178
179    /// Build attribute/conversion context for tree checkout using the **destination** index view.
180    ///
181    /// `.gitattributes` along each path are read from `prospective_index` (and global/info sources),
182    /// not from the working tree, so stale on-disk attribute files cannot override the tree being
183    /// checked out.
184    ///
185    /// # Errors
186    ///
187    /// Returns I/O errors from loading global attribute sources.
188    pub fn from_checkout_prospective_index(
189        repo: &Repository,
190        prospective_index: &Index,
191        config: Arc<ConfigSet>,
192    ) -> Result<Self> {
193        Self::from_parts_index_attributes_only(repo, prospective_index, config)
194    }
195
196    /// Long-running filter-process registry for this repository handle.
197    #[must_use]
198    pub fn filter_process(&self) -> &FilterProcessState {
199        self.caches.filters()
200    }
201
202    /// Repository config snapshot used to build this context.
203    #[must_use]
204    pub fn config(&self) -> &ConfigSet {
205        &self.config
206    }
207
208    /// Config snapshot as `Arc` (for diff options that clone options structs).
209    #[must_use]
210    pub fn config_arc(&self) -> Arc<ConfigSet> {
211        Arc::clone(&self.config)
212    }
213
214    /// Line-ending / filter conversion derived from config.
215    #[must_use]
216    pub fn conversion(&self) -> &ConversionConfig {
217        &self.conversion
218    }
219
220    /// Ignore engine for untracked/ignored walks (`check-ignore` semantics).
221    pub fn ignore(&self) -> std::cell::Ref<'_, IgnoreMatcher> {
222        self.ignore.borrow()
223    }
224
225    /// Mutable ignore engine (per-directory `.gitignore` loads are memoized inside).
226    pub fn ignore_mut(&self) -> std::cell::RefMut<'_, IgnoreMatcher> {
227        self.ignore.borrow_mut()
228    }
229
230    /// Merged `.gitattributes` rules applying to `rel_path` (parent directories included).
231    #[must_use]
232    pub fn attribute_rules_for_path(&self, rel_path: &str) -> Vec<AttrRule> {
233        let parent = parent_dir_key(rel_path);
234        self.attrs.merged_for_dir(&parent).rules
235    }
236
237    /// Resolved attributes for `rel_path` using this context's config snapshot.
238    #[must_use]
239    pub fn file_attrs(&self, rel_path: &str, is_dir: bool) -> FileAttrs {
240        let parent = parent_dir_key(rel_path);
241        let parsed = self.attrs.merged_for_dir(&parent);
242        crlf::file_attrs_from_gitattributes(
243            &parsed.rules,
244            &parsed.macros,
245            rel_path,
246            is_dir,
247            &self.config,
248        )
249    }
250}
251
252impl AttributeState {
253    fn merged_for_dir(&self, dir: &str) -> ParsedGitAttributes {
254        let mut merged = self.stack_without_info_for_dir(dir);
255        append_parsed(&mut merged, self.info_rules.clone(), true);
256        merged
257    }
258
259    fn stack_without_info_for_dir(&self, dir: &str) -> ParsedGitAttributes {
260        if let Some(hit) = self.stack_without_info_by_dir.borrow().get(dir).cloned() {
261            return hit;
262        }
263        let stack = if dir.is_empty() {
264            self.stack_prefix.clone()
265        } else {
266            let parent = parent_of_dir(dir);
267            let mut parsed = self.stack_without_info_for_dir(&parent);
268            append_parsed(&mut parsed, self.load_dir_fragment(dir), false);
269            parsed
270        };
271        self.stack_without_info_by_dir
272            .borrow_mut()
273            .insert(dir.to_owned(), stack.clone());
274        stack
275    }
276
277    fn load_dir_fragment(&self, dir: &str) -> ParsedGitAttributes {
278        if let Some(hit) = self.dir_fragment.borrow().get(dir).cloned() {
279            return hit;
280        }
281        let mut parsed = ParsedGitAttributes::default();
282        let ga_stored = if dir.is_empty() {
283            ".gitattributes".to_owned()
284        } else {
285            format!("{dir}/.gitattributes")
286        };
287        let key = crate::repo_relpath::storage_bytes(&ga_stored).into_owned();
288        if !self.gitattributes_index_only {
289            let wt_ga = if dir.is_empty() {
290                self.work_tree.join(".gitattributes")
291            } else {
292                self.work_tree.join(dir).join(".gitattributes")
293            };
294            if let Some(content) = read_worktree_gitattributes(&wt_ga) {
295                if content.len() <= MAX_ATTR_FILE_BYTES {
296                    let mut p =
297                        parse_gitattributes_file_content_with_base(&content, &ga_stored, dir);
298                    parsed.rules.append(&mut p.rules);
299                    parsed.warnings.append(&mut p.warnings);
300                }
301                self.dir_fragment
302                    .borrow_mut()
303                    .insert(dir.to_owned(), parsed.clone());
304                return parsed;
305            }
306        }
307        if let Some(oid) = self.gitattributes_index.get(&key) {
308            if let Ok(obj) = self.odb.read(oid) {
309                if let Ok(content) = String::from_utf8(obj.data) {
310                    if content.len() <= MAX_ATTR_FILE_BYTES {
311                        let mut p =
312                            parse_gitattributes_file_content_with_base(&content, &ga_stored, dir);
313                        parsed.rules.append(&mut p.rules);
314                        parsed.warnings.append(&mut p.warnings);
315                    }
316                }
317            }
318        }
319        self.dir_fragment
320            .borrow_mut()
321            .insert(dir.to_owned(), parsed.clone());
322        parsed
323    }
324}
325
326fn append_parsed(
327    merged: &mut ParsedGitAttributes,
328    mut parsed: ParsedGitAttributes,
329    take_macros: bool,
330) {
331    merged.rules.append(&mut parsed.rules);
332    if take_macros {
333        merged.macros.defs.extend(parsed.macros.defs.drain());
334    }
335    merged.warnings.append(&mut parsed.warnings);
336}
337
338fn parent_dir_key(rel_path: &str) -> String {
339    Path::new(rel_path)
340        .parent()
341        .map(|p| p.to_string_lossy().replace('\\', "/"))
342        .filter(|s| !s.is_empty())
343        .unwrap_or_default()
344}
345
346fn parent_of_dir(dir: &str) -> String {
347    Path::new(dir)
348        .parent()
349        .map(|p| p.to_string_lossy().replace('\\', "/"))
350        .filter(|s| !s.is_empty())
351        .unwrap_or_default()
352}
353
354fn load_attribute_stack_prefix_and_info(
355    repo: &Repository,
356    work_tree: &Path,
357    config: &ConfigSet,
358    index: &Index,
359    odb: &Odb,
360    gitattributes_index_only: bool,
361) -> Result<(ParsedGitAttributes, ParsedGitAttributes)> {
362    let mut stack_prefix = ParsedGitAttributes::default();
363    if let Some(g) = global_attributes_path(repo.environment(), config)? {
364        if let Some(content) = read_worktree_gitattributes(&g) {
365            if content.len() <= MAX_ATTR_FILE_BYTES {
366                let p = parse_gitattributes_file_content(&content, g.to_string_lossy().as_ref());
367                append_parsed(&mut stack_prefix, p, true);
368            }
369        }
370    }
371    if gitattributes_index_only {
372        if let Some(entry) = index.get(b".gitattributes", 0) {
373            if let Ok(obj) = odb.read(&entry.oid) {
374                if let Ok(content) = String::from_utf8(obj.data) {
375                    if content.len() <= MAX_ATTR_FILE_BYTES {
376                        let p = parse_gitattributes_file_content(&content, ".gitattributes");
377                        append_parsed(&mut stack_prefix, p, true);
378                    }
379                }
380            }
381        }
382    } else {
383        let root_ga = work_tree.join(".gitattributes");
384        if let Some(content) = read_worktree_gitattributes(&root_ga) {
385            if content.len() <= MAX_ATTR_FILE_BYTES {
386                let p = parse_gitattributes_file_content(&content, ".gitattributes");
387                append_parsed(&mut stack_prefix, p, true);
388            }
389        } else if let Some(entry) = index.get(b".gitattributes", 0) {
390            if let Ok(obj) = odb.read(&entry.oid) {
391                if let Ok(content) = String::from_utf8(obj.data) {
392                    if content.len() <= MAX_ATTR_FILE_BYTES {
393                        let p = parse_gitattributes_file_content(&content, ".gitattributes");
394                        append_parsed(&mut stack_prefix, p, true);
395                    }
396                }
397            }
398        }
399    }
400    let mut info_rules = ParsedGitAttributes::default();
401    let info = repo.git_dir().join("info/attributes");
402    if let Some(content) = read_worktree_gitattributes(&info) {
403        if content.len() <= MAX_ATTR_FILE_BYTES {
404            let p = parse_gitattributes_file_content(&content, "info/attributes");
405            append_parsed(&mut info_rules, p, true);
406        }
407    }
408    Ok((stack_prefix, info_rules))
409}
410
411fn global_attributes_path(env: &Environment, config: &ConfigSet) -> Result<Option<PathBuf>> {
412    if let Some(path) = config.get("core.attributesfile") {
413        return Ok(Some(PathBuf::from(parse_path(env, &path))));
414    }
415    Ok(env.default_xdg_config_git_file("attributes"))
416}
417
418fn read_worktree_gitattributes(path: &Path) -> Option<String> {
419    let meta = std::fs::symlink_metadata(path).ok()?;
420    if meta.file_type().is_symlink() {
421        return None;
422    }
423    record_attr_read(path);
424    std::fs::read_to_string(path).ok()
425}
426
427fn gitattributes_index_map(index: &Index) -> HashMap<Vec<u8>, ObjectId> {
428    index
429        .entries()
430        .iter()
431        .filter(|e| e.stage() == 0 && is_gitattributes_index_path(&e.path))
432        .map(|e| (e.path.clone(), e.oid))
433        .collect()
434}
435
436fn is_gitattributes_index_path(path: &[u8]) -> bool {
437    path == b".gitattributes" || path.ends_with(b"/.gitattributes")
438}
439
440/// Hook for [`crate::ignore`] to record pattern-file reads (memoization tests).
441pub(crate) fn record_ignore_pattern_file_read(path: &Path) {
442    record_ignore_read(path);
443}