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