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