main
1diff --git a/CHANGELOG.md b/CHANGELOG.md
2index 5e3d8d9..430ad41 100644
3--- a/CHANGELOG.md
4+++ b/CHANGELOG.md
5@@ -246,6 +246,8 @@ None
6 directory or loaded via `--config-file`). This allows precise file targeting
7 and avoids interactive prompts when multiple config files exist.
8
9+* Added `git.ignore-filters` setting to specify what filtered files in
10+ `.gitattributes` are ignored by `jj`. Defaults to `["lfs"]`.
11 ### Fixed bugs
12
13 * [The default `immutable_heads()` set](docs/config.md#set-of-immutable-commits)
14diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs
15index e7c410a..7157756 100644
16--- a/cli/src/command_error.rs
17+++ b/cli/src/command_error.rs
18@@ -35,6 +35,8 @@ use jj_lib::fileset::FilePatternParseError;
19 use jj_lib::fileset::FilesetParseError;
20 use jj_lib::fileset::FilesetParseErrorKind;
21 use jj_lib::fix::FixError;
22+#[cfg(feature = "git")]
23+use jj_lib::gitattributes::GitAttributesError;
24 use jj_lib::gitignore::GitIgnoreError;
25 use jj_lib::index::IndexError;
26 use jj_lib::op_heads_store::OpHeadsStoreError;
27@@ -742,6 +744,13 @@ impl From<GitIgnoreError> for CommandError {
28 }
29 }
30
31+#[cfg(feature = "git")]
32+impl From<GitAttributesError> for CommandError {
33+ fn from(err: GitAttributesError) -> Self {
34+ user_error_with_message("Failed to process .gitattributes.", err)
35+ }
36+}
37+
38 impl From<ParseBulkEditMessageError> for CommandError {
39 fn from(err: ParseBulkEditMessageError) -> Self {
40 user_error(err)
41diff --git a/cli/src/commands/file/edit.rs b/cli/src/commands/file/edit.rs
42index f1d7154..a1329a3 100644
43--- a/cli/src/commands/file/edit.rs
44+++ b/cli/src/commands/file/edit.rs
45@@ -12,6 +12,7 @@
46 // See the License for the specific language governing permissions and
47 // limitations under the License.
48
49+use std::collections::HashSet;
50 use std::io::Write as _;
51
52 use clap_complete::ArgValueCompleter;
53@@ -121,6 +122,8 @@ pub(crate) async fn cmd_file_edit(
54 eol_conversion_mode: EolConversionMode::None,
55 exec_change_setting: ExecChangeSetting::Auto,
56 fsmonitor_settings: FsmonitorSettings::None,
57+ // The user asked to edit this file explicitly, so never filter it out.
58+ ignore_filters: HashSet::new(),
59 };
60 let mut tree_state = TreeState::init(
61 repo.store().clone(),
62diff --git a/cli/src/commands/run.rs b/cli/src/commands/run.rs
63index 974e8c7..584453c 100644
64--- a/cli/src/commands/run.rs
65+++ b/cli/src/commands/run.rs
66@@ -16,6 +16,7 @@
67
68 use std::cmp::min;
69 use std::collections::HashMap;
70+use std::collections::HashSet;
71 use std::fmt;
72 use std::fs;
73 use std::io;
74@@ -113,12 +114,13 @@ impl From<RunError> for CommandError {
75 }
76 }
77
78-fn default_tree_state_settings() -> TreeStateSettings {
79+fn default_tree_state_settings(ignore_filters: &HashSet<String>) -> TreeStateSettings {
80 TreeStateSettings {
81 conflict_marker_style: ConflictMarkerStyle::Snapshot,
82 eol_conversion_mode: EolConversionMode::None,
83 exec_change_setting: ExecChangeSetting::Auto,
84 fsmonitor_settings: FsmonitorSettings::None,
85+ ignore_filters: ignore_filters.clone(),
86 }
87 }
88
89@@ -183,6 +185,7 @@ struct WorkspacePool {
90 /// Sparse patterns that influence what parts of the tree are materialized
91 /// when running the command. If None, whole tree is materialized.
92 sparsity: Option<Vec<RepoPathBuf>>,
93+ ignore_filters: HashSet<String>,
94 }
95
96 impl WorkspacePool {
97@@ -192,6 +195,7 @@ impl WorkspacePool {
98 auto_tracking_matcher: Box<dyn Matcher>,
99 clean: bool,
100 sparsity: Option<Vec<RepoPathBuf>>,
101+ ignore_filters: HashSet<String>,
102 ) -> Result<Self, RunError> {
103 // The parent() call is needed to not write under `.jj/repo/`.
104 let base_path = repo_path.parent().unwrap().join("run").join("default");
105@@ -202,6 +206,7 @@ impl WorkspacePool {
106 auto_tracking_matcher,
107 clean,
108 sparsity,
109+ ignore_filters,
110 })
111 }
112
113@@ -228,7 +233,7 @@ impl WorkspacePool {
114 let tree_state_path = state_dir.join("tree_state");
115
116 let is_reused_workspace = tree_state_path.exists();
117- let settings = default_tree_state_settings();
118+ let settings = default_tree_state_settings(&self.ignore_filters);
119 let mut tree_state = if !self.clean && is_reused_workspace {
120 // Load the persisted tree state so `check_out` below can diff
121 // against it, only touching files that changed and removing files
122@@ -829,6 +834,21 @@ pub async fn cmd_run(
123 }
124 };
125
126+ let ignore_filters = {
127+ #[cfg(feature = "git")]
128+ {
129+ use jj_lib::git::GitSettings;
130+ GitSettings::from_settings(workspace_command.settings())?
131+ .ignore_filters
132+ .into_iter()
133+ .collect()
134+ }
135+ #[cfg(not(feature = "git"))]
136+ {
137+ HashSet::new()
138+ }
139+ };
140+
141 let mut tx = workspace_command.start_transaction();
142
143 let rt = {
144@@ -845,6 +865,7 @@ pub async fn cmd_run(
145 auto_tracking_matcher,
146 args.clean,
147 sparsity,
148+ ignore_filters,
149 )?);
150
151 let spec = Arc::new(CommandSpec {
152diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json
153index ddf82bb..1a93cd7 100644
154--- a/cli/src/config-schema.json
155+++ b/cli/src/config-schema.json
156@@ -569,6 +569,14 @@
157 "enum": ["sha1", "sha256"],
158 "description": "Object hash algorithm used when initializing a new Git repository",
159 "default": "sha1"
160+ },
161+ "ignore-filters": {
162+ "type": "array",
163+ "items": {
164+ "type": "string"
165+ },
166+ "description": "Names of `.gitattributes` filter attributes whose matching files should be excluded from snapshots in Git-backed repositories",
167+ "default": ["lfs"]
168 }
169 }
170 },
171diff --git a/cli/src/config/misc.toml b/cli/src/config/misc.toml
172index 70ed210..cd5d01c 100644
173--- a/cli/src/config/misc.toml
174+++ b/cli/src/config/misc.toml
175@@ -26,6 +26,7 @@ disabled-branches = []
176
177 [git]
178 colocate = true
179+ignore-filters = ["lfs"]
180 object-hash = "sha1"
181 private-commits = "none()"
182 sign-on-push = false
183diff --git a/cli/src/merge_tools/diff_working_copies.rs b/cli/src/merge_tools/diff_working_copies.rs
184index 771cc58..c4171da 100644
185--- a/cli/src/merge_tools/diff_working_copies.rs
186+++ b/cli/src/merge_tools/diff_working_copies.rs
187@@ -1,4 +1,5 @@
188 use std::collections::HashMap;
189+use std::collections::HashSet;
190 use std::ffi::OsString;
191 use std::fs::File;
192 use std::io::Write as _;
193@@ -144,6 +145,7 @@ pub(crate) async fn check_out_trees(
194 matcher: &dyn Matcher,
195 diff_type: DiffType,
196 conflict_marker_style: ConflictMarkerStyle,
197+ ignore_filters: HashSet<String>,
198 ) -> Result<DiffWorkingCopies, DiffCheckoutError> {
199 let store = trees.before.store();
200 let changed_files: Vec<_> = trees
201@@ -170,6 +172,7 @@ pub(crate) async fn check_out_trees(
202 eol_conversion_mode: EolConversionMode::None,
203 exec_change_setting: ExecChangeSetting::Auto,
204 fsmonitor_settings: FsmonitorSettings::None,
205+ ignore_filters: ignore_filters.clone(),
206 };
207 let mut state = TreeState::init(store.clone(), wc_path, state_dir, &tree_state_settings)?;
208 state.set_sparse_patterns(changed_files.clone())?;
209@@ -205,9 +208,16 @@ impl DiffEditWorkingCopies {
210 diff_type: DiffType,
211 instructions: Option<&str>,
212 conflict_marker_style: ConflictMarkerStyle,
213+ ignore_filters: HashSet<String>,
214 ) -> Result<Self, DiffEditError> {
215- let working_copies =
216- check_out_trees(trees, matcher, diff_type, conflict_marker_style).await?;
217+ let working_copies = check_out_trees(
218+ trees,
219+ matcher,
220+ diff_type,
221+ conflict_marker_style,
222+ ignore_filters,
223+ )
224+ .await?;
225 working_copies.set_left_readonly()?;
226 if diff_type == DiffType::ThreeWay {
227 working_copies.set_right_readonly()?;
228diff --git a/cli/src/merge_tools/external.rs b/cli/src/merge_tools/external.rs
229index 1fcb437..34afb96 100644
230--- a/cli/src/merge_tools/external.rs
231+++ b/cli/src/merge_tools/external.rs
232@@ -1,4 +1,5 @@
233 use std::collections::HashMap;
234+use std::collections::HashSet;
235 use std::ffi::OsString;
236 use std::io;
237 use std::io::Write;
238@@ -386,6 +387,7 @@ pub async fn edit_diff_external(
239 instructions: Option<&str>,
240 base_ignores: Arc<GitIgnoreFile>,
241 default_conflict_marker_style: ConflictMarkerStyle,
242+ ignore_filters: HashSet<String>,
243 ) -> Result<MergedTree, DiffEditError> {
244 let conflict_marker_style = editor
245 .conflict_marker_style
246@@ -403,6 +405,7 @@ pub async fn edit_diff_external(
247 diff_type,
248 instructions,
249 conflict_marker_style,
250+ ignore_filters,
251 )
252 .await?;
253
254@@ -454,7 +457,14 @@ pub async fn generate_diff(
255 let conflict_marker_style = tool
256 .conflict_marker_style
257 .unwrap_or(default_conflict_marker_style);
258- let diff_wc = check_out_trees(trees, matcher, DiffType::TwoWay, conflict_marker_style).await?;
259+ let diff_wc = check_out_trees(
260+ trees,
261+ matcher,
262+ DiffType::TwoWay,
263+ conflict_marker_style,
264+ HashSet::new(),
265+ )
266+ .await?;
267 diff_wc.set_left_readonly()?;
268 diff_wc.set_right_readonly()?;
269 let mut patterns = diff_wc.to_command_variables(true);
270diff --git a/cli/src/merge_tools/mod.rs b/cli/src/merge_tools/mod.rs
271index 2cfc12b..aaeb858 100644
272--- a/cli/src/merge_tools/mod.rs
273+++ b/cli/src/merge_tools/mod.rs
274@@ -16,6 +16,7 @@ mod builtin;
275 mod diff_working_copies;
276 mod external;
277
278+use std::collections::HashSet;
279 use std::sync::Arc;
280
281 use futures::future::try_join_all;
282@@ -234,6 +235,7 @@ pub fn get_external_tool_config(
283 pub struct DiffEditor {
284 tool: DiffEditTool,
285 base_ignores: Arc<GitIgnoreFile>,
286+ ignore_filters: HashSet<String>,
287 use_instructions: bool,
288 conflict_marker_style: ConflictMarkerStyle,
289 }
290@@ -283,9 +285,24 @@ impl DiffEditor {
291 tool_name: name.to_string(),
292 });
293 }
294+ let ignore_filters = {
295+ #[cfg(feature = "git")]
296+ {
297+ use jj_lib::git::GitSettings;
298+ GitSettings::from_settings(settings)?
299+ .ignore_filters
300+ .into_iter()
301+ .collect()
302+ }
303+ #[cfg(not(feature = "git"))]
304+ {
305+ HashSet::new()
306+ }
307+ };
308 Ok(Self {
309 tool,
310 base_ignores,
311+ ignore_filters,
312 use_instructions: settings.get_bool("ui.diff-instructions")?,
313 conflict_marker_style,
314 })
315@@ -315,6 +332,7 @@ impl DiffEditor {
316 instructions.as_deref(),
317 self.base_ignores.clone(),
318 self.conflict_marker_style,
319+ self.ignore_filters.clone(),
320 )
321 .await
322 }
323diff --git a/cli/tests/test_diffedit_command.rs b/cli/tests/test_diffedit_command.rs
324index e54c639..fe074bb 100644
325--- a/cli/tests/test_diffedit_command.rs
326+++ b/cli/tests/test_diffedit_command.rs
327@@ -18,6 +18,48 @@ use testutils::TestResult;
328
329 use crate::common::TestEnvironment;
330
331+#[test]
332+fn test_diffedit_gitattributes_filter_in_temp_snapshot() -> TestResult {
333+ let mut test_env = TestEnvironment::default();
334+ let edit_script = test_env.set_up_fake_diff_editor();
335+ test_env.run_jj_in(".", ["git", "init", "repo"]).success();
336+ let work_dir = test_env.work_dir("repo");
337+
338+ work_dir.write_file(".gitattributes", "*.bin filter=lfs\n");
339+ work_dir.write_file("file.bin", "original\n");
340+ work_dir
341+ .run_jj(["--config=git.ignore-filters=[]", "commit", "-m", "base"])
342+ .success();
343+ work_dir.write_file("file.bin", "after\n");
344+ work_dir
345+ .run_jj(["--config=git.ignore-filters=[]", "debug", "snapshot"])
346+ .success();
347+
348+ std::fs::write(
349+ &edit_script,
350+ "files-before file.bin\0files-after JJ-INSTRUCTIONS file.bin\0write file.bin\nedited\n",
351+ )?;
352+ work_dir.run_jj(["diffedit"]).success();
353+
354+ // Regression test for a bug found during development.
355+ //
356+ // This creates a tracked LFS-filtered modification by disabling filter
357+ // ignores only for setup. `diffedit` then operates with normal settings.
358+ // Its external editor writes into a temporary output working copy, and
359+ // snapshotting that output must use the configured .gitattributes filters.
360+ // Otherwise the editor can rewrite a tracked LFS-filtered file even though
361+ // normal snapshots would ignore it.
362+ insta::assert_snapshot!(
363+ work_dir.run_jj(["file", "show", "file.bin"]).success().stdout,
364+ @r"
365+ after
366+ [EOF]
367+ "
368+ );
369+
370+ Ok(())
371+}
372+
373 #[test]
374 fn test_diffedit() -> TestResult {
375 let mut test_env = TestEnvironment::default();
376diff --git a/cli/tests/test_run_command.rs b/cli/tests/test_run_command.rs
377index 8d3d990..f41a73d 100644
378--- a/cli/tests/test_run_command.rs
379+++ b/cli/tests/test_run_command.rs
380@@ -67,6 +67,48 @@ fn test_run_simple() {
381 insta::assert_snapshot!(stdout, @"xxxx[EOF]");
382 }
383
384+#[test]
385+fn test_run_gitattributes_filter_in_temp_snapshot() {
386+ let test_env = TestEnvironment::default();
387+ test_env.run_jj_in(".", ["git", "init", "repo"]).success();
388+ let fake_formatter = assert_cmd::cargo::cargo_bin("fake-formatter");
389+ assert!(fake_formatter.is_file());
390+ let fake_formatter_path = fake_formatter.to_string_lossy().into_owned();
391+ let work_dir = test_env.work_dir("repo");
392+
393+ work_dir.write_file(".gitattributes", "*.bin filter=lfs\n");
394+ work_dir.write_file("file.bin", "original");
395+ work_dir
396+ .run_jj(["--config=git.ignore-filters=[]", "commit", "-m", "base"])
397+ .success();
398+
399+ work_dir
400+ .run_jj([
401+ "run",
402+ "-r",
403+ "@-",
404+ "--",
405+ &fake_formatter_path,
406+ "--stdout",
407+ "modified",
408+ "--tee",
409+ "file.bin",
410+ ])
411+ .success();
412+
413+ // Regression test for a bug found during development.
414+ //
415+ // This creates a legitimately tracked file by disabling filter ignores only
416+ // for setup. `jj run` then operates with normal settings. Its temporary
417+ // working copy is snapshotted after the command runs, and that snapshot must
418+ // use the configured .gitattributes filters. Otherwise a command can rewrite
419+ // a tracked LFS-filtered file even though normal snapshots would ignore it.
420+ insta::assert_snapshot!(
421+ work_dir.run_jj(["file", "show", "-r", "@-", "file.bin"]).success().stdout,
422+ @"original[EOF]"
423+ );
424+}
425+
426 #[test]
427 fn test_run_on_immutable() {
428 let test_env = TestEnvironment::default();
429diff --git a/docs/config.md b/docs/config.md
430index dd95ea6..81856a7 100644
431--- a/docs/config.md
432+++ b/docs/config.md
433@@ -2065,6 +2065,23 @@ you can:
434 executable-path = "/path/to/git"
435 ```
436
437+### Ignoring files by `.gitattributes` filter
438+
439+In Git-backed repositories, `jj` can skip files during snapshot based on the
440+`filter` attribute in `.gitattributes`. The `git.ignore-filters` setting lists
441+the filter values to skip, and defaults to `["lfs"]`.
442+
443+```toml
444+[git]
445+ignore-filters = ["lfs", "git-crypt"]
446+```
447+
448+This only affects snapshotting from disk to the store. Checkout still writes
449+the files from the tree, so external tools such as `git lfs pull` or
450+`git lfs checkout` can hydrate them afterward. Files already tracked by `jj` may
451+need to be removed with `jj file untrack <path>` before this setting affects
452+future snapshots.
453+
454 ## Gerrit settings
455
456 ### Default remote
457diff --git a/docs/git-compatibility.md b/docs/git-compatibility.md
458index ab8f854..fa6e0c7 100644
459--- a/docs/git-compatibility.md
460+++ b/docs/git-compatibility.md
461@@ -35,8 +35,13 @@ a comparison with Git, including how workflows are different, see the
462 working-copy commit. It's recommended to set up the ignore patterns earlier.
463 The `.gitignore` support uses a native implementation, so please report a bug
464 if you notice any difference compared to `git`.
465-* **.gitattributes: No.** There's [#53](https://github.com/jj-vcs/jj/issues/53)
466- about adding support for at least the `eol` attribute.
467+* **.gitattributes: Partial.** In Git-backed repositories, `jj` reads
468+ `.gitattributes` `filter` attributes during snapshot and skips files whose
469+ filter is listed in `git.ignore-filters`, which defaults to `["lfs"]`. This
470+ is snapshot-only; checkout still writes files from the tree. Other attributes
471+ such as `eol`, `text`, `diff`, and `merge` are not otherwise supported.
472+ There's [#53](https://github.com/jj-vcs/jj/issues/53) about adding support for
473+ at least the `eol` attribute.
474 * **Hooks: No.** There's [#405](https://github.com/jj-vcs/jj/issues/405)
475 specifically for providing the checks from <https://pre-commit.com>.
476 * **Merge commits: Yes.** Octopus merges (i.e. with more than 2 parents) are
477@@ -69,7 +74,14 @@ a comparison with Git, including how workflows are different, see the
478 * **Signed commits: Yes.**
479 You can sign commits automatically [by configuration](config.md#commit-signing),
480 or use the `jj sign` command.
481-* **Git LFS: No.** ([#80](https://github.com/jj-vcs/jj/issues/80))
482+* **Git LFS: Partial.** In Git-backed repositories, files with `filter=lfs` are
483+ ignored during snapshot by default. `jj` does not fetch, clean, smudge, or
484+ hydrate LFS contents; use Git LFS commands such as `git lfs pull` or
485+ `git lfs checkout` for those steps. Existing tracked files may need
486+ `jj file untrack <path>` before the snapshot ignore takes effect
487+ ([#80](https://github.com/jj-vcs/jj/issues/80)). Note that `git` commands
488+ will not work in workspaces created by `jj workspace` as they do not have a
489+ `.git` directory (see also: [#4436](https://github.com/jj-vcs/jj/issues/4436)).
490
491 ## Creating an empty repo
492
493diff --git a/lib/src/git.rs b/lib/src/git.rs
494index 9429cd8..0dfd7b1 100644
495--- a/lib/src/git.rs
496+++ b/lib/src/git.rs
497@@ -43,6 +43,7 @@ use crate::backend::CommitId;
498 use crate::backend::TreeValue;
499 use crate::commit::Commit;
500 use crate::config::ConfigGetError;
501+use crate::config::ConfigGetResultExt as _;
502 use crate::file_util::IoResultExt as _;
503 use crate::file_util::PathError;
504 use crate::file_util::is_empty_dir;
505@@ -112,6 +113,7 @@ pub struct GitSettings {
506 pub executable_path: PathBuf,
507 pub record_synthetic_predecessors: bool,
508 pub write_change_id_header: bool,
509+ pub ignore_filters: Vec<String>,
510 }
511
512 impl GitSettings {
513@@ -122,6 +124,10 @@ impl GitSettings {
514 record_synthetic_predecessors: settings
515 .get_bool("git.record-synthetic-predecessors")?,
516 write_change_id_header: settings.get("git.write-change-id-header")?,
517+ ignore_filters: settings
518+ .get("git.ignore-filters")
519+ .optional()?
520+ .unwrap_or_else(|| vec!["lfs".to_string()]),
521 })
522 }
523
524diff --git a/lib/src/gitattributes.rs b/lib/src/gitattributes.rs
525new file mode 100644
526index 0000000..8a12782
527--- /dev/null
528+++ b/lib/src/gitattributes.rs
529@@ -0,0 +1,974 @@
530+// Copyright 2026 The Jujutsu Authors
531+//
532+// Licensed under the Apache License, Version 2.0 (the "License");
533+// you may not use this file except in compliance with the License.
534+// You may obtain a copy of the License at
535+//
536+// https://www.apache.org/licenses/LICENSE-2.0
537+//
538+// Unless required by applicable law or agreed to in writing, software
539+// distributed under the License is distributed on an "AS IS" BASIS,
540+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
541+// See the License for the specific language governing permissions and
542+// limitations under the License.
543+
544+//! Git attributes parsing and evaluation.
545+//!
546+//! This module provides the `GitAttributes` struct for parsing and evaluating
547+//! `.gitattributes` files. To support reading attributes both from the working
548+//! copy (disk) and from the repository's tree (store), the loading mechanism is
549+//! abstracted using the `FileLoader` trait.
550+//!
551+//! Depending on the context, we may choose which loader acts as the "primary"
552+//! source for the `.gitattributes` file via `SearchPriority`:
553+//!
554+//! - `SearchPriority::Disk`: Used when operating on the working copy. The
555+//! `DiskFileLoader` is checked first to ensure uncommitted changes to
556+//! `.gitattributes` are respected.
557+//! - `SearchPriority::Store`: Useful when the working copy is not a concern
558+//! (e.g., historical gitattributes). The `TreeFileLoader` is checked first.
559+//! Ignores any working-copy changes on the "primary" read.
560+//!
561+//! In either mode, if the `.gitattributes` file is not found by the "primary"
562+//! loader for a given directory, we fall back to checking the "secondary"
563+//! loader.
564+//!
565+//! Attribute rules are loaded lazily on a per-directory basis. When evaluating
566+//! a path like `a/b/file.txt`, the system recursively evaluates rules from the
567+//! repository root down to `a/b/`. Child directories inherit and can override
568+//! parent rules. Git macros are restricted to the root directory. Parsed rules
569+//! for each directory are cached. This implementation attempts to follow Git
570+//! handles attributes.
571+
572+use std::collections::HashMap;
573+use std::collections::HashSet;
574+use std::fs::File;
575+use std::io::ErrorKind;
576+use std::path::PathBuf;
577+use std::sync::Arc;
578+use std::sync::Mutex;
579+
580+use futures::AsyncRead;
581+use futures::AsyncReadExt as _;
582+use futures::io::AllowStdIo;
583+use gix::attrs::Search;
584+use gix::attrs::State;
585+use gix::attrs::glob::pattern::Case;
586+use gix::attrs::search::MetadataCollection;
587+use gix::attrs::search::Outcome;
588+
589+use crate::backend::MergedTreeValueExt as _;
590+use crate::backend::TreeValue;
591+use crate::merge::SameChange;
592+use crate::merged_tree::MergedTree;
593+use crate::repo_path::RepoPath;
594+use crate::repo_path::RepoPathBuf;
595+use crate::repo_path::RepoPathComponent;
596+
597+/// Git Attributes instance
598+///
599+/// This struct handles accessing .gitattributes files
600+/// and maintains a cache to prevent loading the same file twice.
601+///
602+/// It's lazy loaded so .gitattributes files are only accessed
603+/// if you search for attributes for a given path.
604+pub(crate) struct GitAttributes {
605+ disk_file_loader: Arc<dyn FileLoader>,
606+ store_file_loader: Arc<dyn FileLoader>,
607+ node_cache: Mutex<HashMap<RepoPathBuf, Arc<GitAttributesNode>>>,
608+}
609+
610+#[async_trait::async_trait]
611+pub(crate) trait FileLoader: Send + Sync {
612+ /// Loads a file in a given `path`
613+ ///
614+ /// Returns Some(..) if the file was found and None if not
615+ async fn load(
616+ &self,
617+ path: &RepoPath,
618+ ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError>;
619+}
620+
621+pub(crate) struct TreeFileLoader {
622+ tree: MergedTree,
623+}
624+impl TreeFileLoader {
625+ pub fn new(tree: MergedTree) -> Self {
626+ Self { tree }
627+ }
628+}
629+
630+#[async_trait::async_trait]
631+impl FileLoader for TreeFileLoader {
632+ async fn load(
633+ &self,
634+ path: &RepoPath,
635+ ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError> {
636+ let merged_tree_value =
637+ self.tree
638+ .path_value(path)
639+ .await
640+ .map_err(|err| GitAttributesError {
641+ message: "Could not retrieve the value from path".to_string(),
642+ source: err.into(),
643+ })?;
644+ let maybe_file_merge = merged_tree_value.to_file_merge();
645+ // try to resolve the file
646+ let id = match maybe_file_merge
647+ .as_ref()
648+ .and_then(|files| files.resolve_trivial(SameChange::Accept))
649+ {
650+ Some(Some(id)) => id,
651+ Some(None) => return Ok(None),
652+ None => {
653+ // conflict path
654+ let Some(id) = merged_tree_value.iter().find_map(|tree_value| {
655+ let Some(TreeValue::File { id, .. }) = tree_value else {
656+ return None;
657+ };
658+ Some(id)
659+ }) else {
660+ return Ok(None);
661+ };
662+ id
663+ }
664+ };
665+
666+ let result =
667+ self.tree
668+ .store()
669+ .read_file(path, id)
670+ .await
671+ .map_err(|err| GitAttributesError {
672+ message: "Could not retrieve the value from path".to_string(),
673+ source: err.into(),
674+ })?;
675+ Ok(Some(Box::new(result)))
676+ }
677+}
678+
679+pub(crate) struct DiskFileLoader {
680+ repo_root: PathBuf,
681+}
682+
683+impl DiskFileLoader {
684+ pub fn new(repo_root: PathBuf) -> Self {
685+ Self { repo_root }
686+ }
687+}
688+
689+#[async_trait::async_trait]
690+impl FileLoader for DiskFileLoader {
691+ async fn load(
692+ &self,
693+ path: &RepoPath,
694+ ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError> {
695+ let path = path
696+ .to_fs_path(&self.repo_root)
697+ .map_err(|err| GitAttributesError {
698+ message: "Could not convert path into fs path".to_string(),
699+ source: err.into(),
700+ })?;
701+
702+ // we use symlink_metadata to not follow symlinks to follow Git's behavior.
703+ let metadata = match path.symlink_metadata() {
704+ Ok(metadata) => metadata,
705+ Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
706+ return Ok(None);
707+ }
708+ Err(err) => {
709+ return Err(GitAttributesError {
710+ message: format!("Failed to obtain the file metadata of {}", path.display()),
711+ source: err.into(),
712+ });
713+ }
714+ };
715+ if !metadata.is_file() {
716+ return Ok(None);
717+ }
718+
719+ let file = match File::open(&path) {
720+ Ok(file) => file,
721+ Err(error)
722+ if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) =>
723+ {
724+ return Ok(None);
725+ }
726+ Err(err) => {
727+ return Err(GitAttributesError {
728+ message: format!("Failed to open the file at {}", path.display()),
729+ source: err.into(),
730+ });
731+ }
732+ };
733+ Ok(Some(Box::new(AllowStdIo::new(file))))
734+ }
735+}
736+
737+struct SearchAndCollection {
738+ search: Search,
739+ collection: MetadataCollection,
740+}
741+
742+struct GitAttributesNode {
743+ disk_first: futures::lock::Mutex<Option<Arc<SearchAndCollection>>>,
744+ store_first: futures::lock::Mutex<Option<Arc<SearchAndCollection>>>,
745+ disk_file_loader: Arc<dyn FileLoader>,
746+ store_file_loader: Arc<dyn FileLoader>,
747+ parent: Option<Arc<Self>>,
748+ path: RepoPathBuf,
749+}
750+
751+impl GitAttributesNode {
752+ async fn get_disk_first(&self) -> Result<Arc<SearchAndCollection>, GitAttributesError> {
753+ self.primary_then_secondary(SearchPriority::Disk).await
754+ }
755+ async fn get_store_first(&self) -> Result<Arc<SearchAndCollection>, GitAttributesError> {
756+ self.primary_then_secondary(SearchPriority::Store).await
757+ }
758+
759+ fn store(
760+ &self,
761+ priority: SearchPriority,
762+ ) -> &futures::lock::Mutex<Option<Arc<SearchAndCollection>>> {
763+ match priority {
764+ SearchPriority::Store => &self.store_first,
765+ SearchPriority::Disk => &self.disk_first,
766+ }
767+ }
768+
769+ fn primary_loader(&self, priority: SearchPriority) -> &Arc<dyn FileLoader> {
770+ match priority {
771+ SearchPriority::Store => &self.store_file_loader,
772+ SearchPriority::Disk => &self.disk_file_loader,
773+ }
774+ }
775+
776+ fn secondary_loader(&self, priority: SearchPriority) -> &Arc<dyn FileLoader> {
777+ match priority {
778+ SearchPriority::Store => &self.disk_file_loader,
779+ SearchPriority::Disk => &self.store_file_loader,
780+ }
781+ }
782+
783+ async fn primary_then_secondary(
784+ &self,
785+ priority: SearchPriority,
786+ ) -> Result<Arc<SearchAndCollection>, GitAttributesError> {
787+ let store = self.store(priority);
788+ let mut guard = store.lock().await;
789+ if let Some(res) = guard.as_ref() {
790+ return Ok(res.clone());
791+ }
792+
793+ let parent_metadata = match &self.parent {
794+ // we use pin because this is a recursive call
795+ Some(parent) => Box::pin(parent.primary_then_secondary(priority)).await?,
796+ None => {
797+ let mut search = Search::default();
798+ let mut collection = MetadataCollection::default();
799+ // initialize search
800+ search.add_patterns_buffer(
801+ b"[attr]binary -diff -merge -text",
802+ "[builtin]".into(),
803+ None,
804+ &mut collection,
805+ true, /* allow macros */
806+ );
807+ Arc::new(SearchAndCollection { search, collection })
808+ }
809+ };
810+ let git_attributes_path =
811+ self.path
812+ .join(RepoPathComponent::new(".gitattributes").map_err(|err| {
813+ GitAttributesError {
814+ message: "Could not join path with .gitattributes".to_string(),
815+ source: err.into(),
816+ }
817+ })?);
818+ let mut async_reader = match self
819+ .primary_loader(priority)
820+ .load(&git_attributes_path)
821+ .await?
822+ {
823+ Some(reader) => reader,
824+ None => {
825+ // fallback to the secondary loader
826+ match self
827+ .secondary_loader(priority)
828+ .load(&git_attributes_path)
829+ .await?
830+ {
831+ Some(reader) => reader,
832+ None => {
833+ *guard = Some(parent_metadata.clone());
834+ return Ok(parent_metadata);
835+ }
836+ }
837+ }
838+ };
839+ let mut bytes = Vec::new();
840+ async_reader
841+ .read_to_end(&mut bytes)
842+ .await
843+ .map_err(|err| GitAttributesError {
844+ source: err.into(),
845+ message: "Could not read .gitattributes file".into(),
846+ })?;
847+ let mut search = parent_metadata.search.clone();
848+ let mut collection = parent_metadata.collection.clone();
849+
850+ search.add_patterns_buffer(
851+ &bytes,
852+ git_attributes_path
853+ .to_fs_path(&PathBuf::new())
854+ .map_err(|err| GitAttributesError {
855+ message: "Could not convert gitattributes path into PathBuf".to_string(),
856+ source: err.into(),
857+ })?,
858+ Some(&PathBuf::new()),
859+ &mut collection,
860+ // Macros can only be defined in top-level gitattributes:
861+ // https://git-scm.com/docs/gitattributes#_defining_macro_attributes
862+ self.parent.is_none(), /* allow macros */
863+ );
864+
865+ let res = Arc::new(SearchAndCollection { search, collection });
866+ *guard = Some(res.clone());
867+ Ok(res)
868+ }
869+}
870+
871+#[derive(Clone, Copy)]
872+pub(crate) enum SearchPriority {
873+ Store,
874+ Disk,
875+}
876+
877+impl GitAttributes {
878+ /// Creates a new instance with store and disk file loaders.
879+ pub fn new(
880+ store_file_loader: impl FileLoader + 'static,
881+ disk_file_loader: impl FileLoader + 'static,
882+ ) -> Self {
883+ Self {
884+ store_file_loader: Arc::new(store_file_loader),
885+ disk_file_loader: Arc::new(disk_file_loader),
886+ node_cache: Default::default(),
887+ }
888+ }
889+
890+ pub(crate) async fn search(
891+ &self,
892+ path: &RepoPath,
893+ attribute_names: impl AsRef<[&str]>,
894+ priority: SearchPriority,
895+ ) -> Result<HashMap<String, State>, GitAttributesError> {
896+ let attributes = self.get_git_attributes_node(path);
897+ let store = match priority {
898+ SearchPriority::Store => attributes.get_store_first().await?,
899+ SearchPriority::Disk => attributes.get_disk_first().await?,
900+ };
901+ let SearchAndCollection { search, collection } = &*store;
902+
903+ let mut out = Outcome::default();
904+ out.initialize_with_selection(
905+ collection,
906+ // From<&str> is implemented for KStringRef
907+ attribute_names.as_ref().iter().copied(),
908+ );
909+ search.pattern_matching_relative_path(
910+ path.as_internal_file_string().into(),
911+ Case::Sensitive,
912+ None,
913+ &mut out,
914+ );
915+
916+ let mut map = out
917+ .iter_selected()
918+ .map(|attr| {
919+ let val = attr.assignment.to_owned();
920+ (val.name.as_str().to_string(), val.state)
921+ })
922+ .collect::<HashMap<String, State>>();
923+
924+ // go over attributes and mark as unspecified if not set
925+ for attribute_name in attribute_names.as_ref() {
926+ map.entry(attribute_name.to_string())
927+ .or_insert(State::Unspecified);
928+ }
929+
930+ Ok(map)
931+ }
932+
933+ fn get_git_attributes_node(&self, path: &RepoPath) -> Arc<GitAttributesNode> {
934+ let path = path.parent().unwrap_or(RepoPath::root());
935+ let mut val = self.node_cache.lock().expect("Not be poisoned");
936+ self.inner(&mut val, path)
937+ }
938+
939+ fn inner(
940+ &self,
941+ map: &mut HashMap<RepoPathBuf, Arc<GitAttributesNode>>,
942+ path: &RepoPath,
943+ ) -> Arc<GitAttributesNode> {
944+ if let Some(node) = map.get(path) {
945+ return node.clone();
946+ }
947+ let parent = path.parent().map(|parent| self.inner(map, parent));
948+ let new_node = Arc::new(GitAttributesNode {
949+ disk_first: futures::lock::Mutex::new(None),
950+ store_first: futures::lock::Mutex::new(None),
951+ disk_file_loader: self.disk_file_loader.clone(),
952+ store_file_loader: self.store_file_loader.clone(),
953+ parent,
954+ path: path.to_owned(),
955+ });
956+ map.insert(path.to_owned(), new_node.clone());
957+ new_node
958+ }
959+}
960+
961+impl GitAttributes {
962+ /// Returns whether the given `path` has a `filter` attribute in
963+ /// .gitattributes whose value is contained in `ignore_filters`.
964+ pub(crate) async fn filter_matches(
965+ &self,
966+ path: &RepoPath,
967+ ignore_filters: &HashSet<String>,
968+ priority: SearchPriority,
969+ ) -> Result<bool, GitAttributesError> {
970+ if ignore_filters.is_empty() {
971+ return Ok(false);
972+ }
973+ let result = self.search(path, ["filter"], priority).await?;
974+
975+ let Some(State::Value(value)) = result.get("filter") else {
976+ return Ok(false);
977+ };
978+ let value = value.as_ref().as_bstr();
979+ Ok(ignore_filters.iter().any(|state| value == state.as_str()))
980+ }
981+}
982+
983+/// Errors for GitAttributes
984+#[derive(Debug, thiserror::Error)]
985+#[error("{message}")]
986+pub struct GitAttributesError {
987+ message: String,
988+ #[source]
989+ source: Box<dyn std::error::Error + Send + Sync>,
990+}
991+
992+#[cfg(test)]
993+mod tests {
994+
995+ use futures::io::Cursor;
996+ use gix::attrs::state::Value;
997+ use indoc::indoc;
998+ use pollster::FutureExt as _;
999+
1000+ use super::*;
1001+ use crate::tests::new_temp_dir;
1002+
1003+ type TestStore = HashMap<RepoPathBuf, Result<String, String>>;
1004+
1005+ #[async_trait::async_trait]
1006+ impl FileLoader for TestStore {
1007+ async fn load(
1008+ &self,
1009+ path: &RepoPath,
1010+ ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError> {
1011+ let Some(mocked_result) = self.get(path) else {
1012+ return Ok(None);
1013+ };
1014+ let data = mocked_result.clone();
1015+ match data {
1016+ Ok(data) => Ok(Some(Box::new(Cursor::new(Vec::from(data))))),
1017+ Err(message) => Err(GitAttributesError {
1018+ message: message.clone(),
1019+ source: message.into(),
1020+ }),
1021+ }
1022+ }
1023+ }
1024+
1025+ fn repo_path(path: &str) -> &RepoPath {
1026+ RepoPath::from_internal_string(path).unwrap()
1027+ }
1028+
1029+ fn create_git_attributes(files: &[(&'static str, &'static str)]) -> GitAttributes {
1030+ let data: TestStore = files
1031+ .iter()
1032+ .map(|(key, value)| (repo_path(key).to_owned(), Ok(value.to_string())))
1033+ .collect();
1034+
1035+ GitAttributes::new(HashMap::new(), data)
1036+ }
1037+
1038+ fn assert_search_output(
1039+ git_attributes: &GitAttributes,
1040+ file: &str,
1041+ attribute: &str,
1042+ expected: State,
1043+ ) {
1044+ assert_search_output_with_priority(
1045+ git_attributes,
1046+ file,
1047+ attribute,
1048+ expected,
1049+ SearchPriority::Disk,
1050+ );
1051+ }
1052+
1053+ fn assert_search_output_with_priority(
1054+ git_attributes: &GitAttributes,
1055+ file: &str,
1056+ attribute: &str,
1057+ expected: State,
1058+ search: SearchPriority,
1059+ ) {
1060+ let map = git_attributes
1061+ .search(repo_path(file), &[attribute], search)
1062+ .block_on()
1063+ .unwrap();
1064+ assert_eq!(map.len(), 1);
1065+ assert_eq!(*map.get(attribute).unwrap(), expected);
1066+ }
1067+
1068+ fn assert_attribute_output(
1069+ git_attributes_content: &'static str,
1070+ file: &str,
1071+ attribute: &str,
1072+ expected: State,
1073+ ) {
1074+ let git_attributes = create_git_attributes(&[(".gitattributes", git_attributes_content)]);
1075+ assert_search_output(&git_attributes, file, attribute, expected);
1076+ }
1077+
1078+ #[test]
1079+ fn test_search_state_set() {
1080+ assert_attribute_output("abc foo", "abc", "foo", State::Set);
1081+ }
1082+
1083+ #[test]
1084+ fn test_search_state_eol_lf() {
1085+ assert_attribute_output("foo eol=lf", "foo", "eol", State::Value(Value::from("lf")));
1086+ }
1087+
1088+ #[test]
1089+ fn test_search_unset() {
1090+ assert_attribute_output("foo -text", "foo", "text", State::Unset);
1091+ }
1092+
1093+ #[test]
1094+ fn test_search_unspecified() {
1095+ assert_attribute_output("foo !text", "foo", "text", State::Unspecified);
1096+ }
1097+
1098+ #[test]
1099+ fn test_search_unspecified_no_pattern() {
1100+ assert_attribute_output("foo elo=lf", "bar", "text", State::Unspecified);
1101+ assert_attribute_output("foo elo=lf", "bar", "elo", State::Unspecified);
1102+ }
1103+
1104+ #[test]
1105+ fn test_path_and_pattern_matching() {
1106+ // Using https://git-scm.com/docs/gitattributes#_examples as example
1107+ // It's not 1:1 because we don't support $GIT_DIR/info/.gitattributes
1108+ // that would override in-tree settings.
1109+ let git_attributes = create_git_attributes(&[
1110+ (".gitattributes", "abc foo bar baz"),
1111+ (
1112+ "t/.gitattributes",
1113+ indoc! {"
1114+ ab* merge=filfre
1115+ abc -foo -bar
1116+ *.c frotz
1117+ "},
1118+ ),
1119+ ]);
1120+
1121+ assert_search_output(&git_attributes, "t/abc", "foo", State::Unset);
1122+ assert_search_output(&git_attributes, "t/abc", "bar", State::Unset);
1123+ assert_search_output(&git_attributes, "t/abc", "baz", State::Set);
1124+ assert_search_output(
1125+ &git_attributes,
1126+ "t/abc",
1127+ "merge",
1128+ State::Value(Value::from("filfre")),
1129+ );
1130+ assert_search_output(&git_attributes, "t/abc", "frotz", State::Unspecified);
1131+ }
1132+
1133+ #[test]
1134+ fn test_glob_matching() {
1135+ assert_attribute_output("*.txt text", "bar.txt", "text", State::Set);
1136+ assert_attribute_output("foo/ text", "foo/bar.txt", "text", State::Unspecified);
1137+ assert_attribute_output("**/bar.rs text", "baz/bar.rs", "text", State::Set);
1138+ }
1139+
1140+ #[test]
1141+ fn test_case_sensitive_attr() {
1142+ assert_attribute_output(
1143+ indoc! {"
1144+ foo text
1145+ FOO -text
1146+ "},
1147+ "foo",
1148+ "text",
1149+ State::Set,
1150+ );
1151+ assert_attribute_output(
1152+ indoc! {"
1153+ foo text
1154+ FOO -text
1155+ "},
1156+ "FOO",
1157+ "text",
1158+ State::Unset,
1159+ );
1160+ }
1161+
1162+ #[test]
1163+ fn test_subdirectory_override() {
1164+ let git_attributes = create_git_attributes(&[
1165+ (".gitattributes", "abc !baz"),
1166+ ("t/.gitattributes", "abc baz"),
1167+ ]);
1168+ assert_search_output(&git_attributes, "t/abc", "baz", State::Set);
1169+ }
1170+
1171+ #[test]
1172+ fn test_subdirectory_root_match() {
1173+ assert_attribute_output("baz/foo text", "baz/foo", "text", State::Set);
1174+ }
1175+
1176+ #[test]
1177+ fn test_directory_shouldnt_match() {
1178+ assert_attribute_output("foo text", "foo/bar.txt", "text", State::Unspecified);
1179+ }
1180+
1181+ #[test]
1182+ fn test_subdirectory_attribute() {
1183+ let git_attributes = create_git_attributes(&[("foo/.gitattributes", "bar.txt text")]);
1184+ assert_search_output(&git_attributes, "foo/bar.txt", "text", State::Set);
1185+ }
1186+
1187+ #[test]
1188+ fn test_macro_definition() {
1189+ let git_attributes = create_git_attributes(&[
1190+ (
1191+ ".gitattributes",
1192+ indoc! {"
1193+ [attr]base_macro a
1194+ [attr]override_macro b
1195+ "},
1196+ ),
1197+ (
1198+ "foo1/.gitattributes",
1199+ indoc! {"
1200+ # this macro definition shouldn't take effect.
1201+ [attr]override_macro c
1202+ # this macro definition shouldn't take effect.
1203+ [attr]new_macro e
1204+ bar base_macro override_macro new_macro
1205+ "},
1206+ ),
1207+ (
1208+ "foo2/.gitattributes",
1209+ indoc! {"
1210+ # this macro definition shouldn't take effect.
1211+ [attr]override_macro d
1212+ # this macro definition shouldn't take effect.
1213+ [attr]new_macro e
1214+ bar base_macro override_macro new_macro
1215+ "},
1216+ ),
1217+ ]);
1218+ assert_search_output(&git_attributes, "foo1/bar", "a", State::Set);
1219+ assert_search_output(&git_attributes, "foo1/bar", "b", State::Set);
1220+ assert_search_output(&git_attributes, "foo1/bar", "c", State::Unspecified);
1221+ assert_search_output(&git_attributes, "foo1/bar", "d", State::Unspecified);
1222+ assert_search_output(&git_attributes, "foo1/bar", "e", State::Unspecified);
1223+ assert_search_output(&git_attributes, "foo1/bar", "new_macro", State::Set);
1224+
1225+ assert_search_output(&git_attributes, "foo2/bar", "a", State::Set);
1226+ assert_search_output(&git_attributes, "foo2/bar", "b", State::Set);
1227+ assert_search_output(&git_attributes, "foo2/bar", "c", State::Unspecified);
1228+ assert_search_output(&git_attributes, "foo2/bar", "d", State::Unspecified);
1229+ assert_search_output(&git_attributes, "foo2/bar", "e", State::Unspecified);
1230+ assert_search_output(&git_attributes, "foo2/bar", "new_macro", State::Set);
1231+ }
1232+
1233+ #[test]
1234+ fn test_search_priority_and_fallback() {
1235+ let disk = TestStore::from([(
1236+ repo_path("foo/.gitattributes").to_owned(),
1237+ Ok("*.txt text".to_string()),
1238+ )]);
1239+
1240+ let store = TestStore::from([(
1241+ repo_path("bar/.gitattributes").to_owned(),
1242+ Ok("*.txt text".to_string()),
1243+ )]);
1244+
1245+ let git_attributes = GitAttributes::new(store, disk);
1246+ assert_search_output_with_priority(
1247+ &git_attributes,
1248+ "foo/bar.txt",
1249+ "text",
1250+ State::Set,
1251+ SearchPriority::Disk,
1252+ );
1253+ assert_search_output_with_priority(
1254+ &git_attributes,
1255+ "foo/bar.txt",
1256+ "text",
1257+ State::Set,
1258+ SearchPriority::Store,
1259+ );
1260+ assert_search_output_with_priority(
1261+ &git_attributes,
1262+ "bar/bar.txt",
1263+ "text",
1264+ State::Set,
1265+ SearchPriority::Store,
1266+ );
1267+ assert_search_output_with_priority(
1268+ &git_attributes,
1269+ "bar/bar.txt",
1270+ "text",
1271+ State::Set,
1272+ SearchPriority::Disk,
1273+ );
1274+ }
1275+
1276+ #[test]
1277+ fn test_file_loader_io_error() {
1278+ let store: TestStore = TestStore::from([(
1279+ repo_path(".gitattributes").to_owned(),
1280+ Err("There was an IO error".to_string()),
1281+ )]);
1282+
1283+ let git_attributes = GitAttributes::new(store, HashMap::new());
1284+ let result = &git_attributes
1285+ .search(repo_path("foo/bar.txt"), &["text"], SearchPriority::Disk)
1286+ .block_on();
1287+ assert!(result.is_err());
1288+ assert_eq!(
1289+ &result.as_ref().unwrap_err().message,
1290+ "There was an IO error",
1291+ );
1292+ }
1293+
1294+ fn matches(input: &str, path: &str) -> bool {
1295+ let data = TestStore::from([(
1296+ RepoPathBuf::from_internal_string(".gitattributes").unwrap(),
1297+ Ok(input.to_string()),
1298+ )]);
1299+ let attributes = GitAttributes::new(HashMap::new(), data);
1300+
1301+ attributes
1302+ .filter_matches(
1303+ repo_path(path),
1304+ &HashSet::from(["lfs".to_string()]),
1305+ SearchPriority::Disk,
1306+ )
1307+ .block_on()
1308+ .unwrap()
1309+ }
1310+
1311+ // Regression test for a bug found during development.
1312+ //
1313+ // `filter_matches()` is used by snapshot to decide whether a matching file
1314+ // should be omitted. If loading .gitattributes fails, returning false would
1315+ // silently snapshot a file that may be intentionally excluded. Preserve the
1316+ // error so snapshot fails visibly instead.
1317+ #[test]
1318+ fn test_filter_matches_propagates_error() {
1319+ let store: TestStore = TestStore::from([(
1320+ repo_path(".gitattributes").to_owned(),
1321+ Err("There was an IO error".to_string()),
1322+ )]);
1323+ let attributes = GitAttributes::new(store, HashMap::new());
1324+
1325+ let error = attributes
1326+ .filter_matches(
1327+ repo_path("file.bin"),
1328+ &HashSet::from(["lfs".to_string()]),
1329+ SearchPriority::Disk,
1330+ )
1331+ .block_on()
1332+ .unwrap_err();
1333+
1334+ assert_eq!(error.message, "There was an IO error");
1335+ }
1336+
1337+ // Regression test for a bug found during development.
1338+ //
1339+ // This is distinct from `test_filter_matches_propagates_error()`: probing
1340+ // for optional `.gitattributes` files below a path component that is
1341+ // actually a file means no attributes file exists at that path. Treating
1342+ // `NotADirectory` as fatal caused unrelated snapshot operations, such as
1343+ // rename detection, to fail when they evaluated attributes for paths like
1344+ // `file/.gitattributes`.
1345+ #[test]
1346+ fn test_disk_file_loader_ignores_file_in_attributes_path() {
1347+ let temp_dir = new_temp_dir();
1348+ std::fs::write(temp_dir.path().join("file"), "contents").unwrap();
1349+ let loader = DiskFileLoader::new(temp_dir.path().to_owned());
1350+
1351+ let loaded = loader
1352+ .load(repo_path("file/.gitattributes"))
1353+ .block_on()
1354+ .unwrap();
1355+
1356+ assert!(loaded.is_none());
1357+ }
1358+
1359+ #[test]
1360+ fn test_gitattributes_empty_file() {
1361+ assert!(!matches("", "foo"));
1362+ }
1363+
1364+ #[test]
1365+ fn test_gitattributes_simple_match() {
1366+ assert!(matches("*.bin filter=lfs\n", "file.bin"));
1367+ assert!(!matches("*.bin filter=lfs\n", "file.txt"));
1368+ assert!(!matches("*.bin filter=other\n", "file.bin"));
1369+ assert!(!matches("*.bin filter=other\n", "path/to/file.bin"));
1370+ }
1371+
1372+ #[test]
1373+ fn test_gitattributes_directory_match() {
1374+ // patterns that match a directory do not recursively match paths inside that
1375+ // directory (so using the trailing-slash path/ syntax is pointless in
1376+ // an attributes file; use path/** instead)
1377+ // https://git-scm.com/docs/gitattributes#_description
1378+ assert!(!matches("dir/ filter=lfs\n", "dir/file.txt"));
1379+ assert!(!matches("dir/ filter=lfs\n", "other/file.txt"));
1380+ assert!(!matches("dir/ filter=lfs\n", "dir"));
1381+ }
1382+
1383+ #[test]
1384+ fn test_gitattributes_path_match() {
1385+ assert!(matches("path/to/file.bin filter=lfs\n", "path/to/file.bin"));
1386+ assert!(!matches("path/to/file.bin filter=lfs\n", "path/file.bin"));
1387+ }
1388+
1389+ #[test]
1390+ fn test_gitattributes_wildcard_match() {
1391+ assert!(matches("*.bin filter=lfs\n", "file.bin"));
1392+ assert!(matches("file.* filter=lfs\n", "file.bin"));
1393+ assert!(matches("**/file.bin filter=lfs\n", "path/to/file.bin"));
1394+ }
1395+
1396+ #[test]
1397+ fn test_gitattributes_multiple_attributes() {
1398+ let input = "*.bin filter=lfs diff=binary\n";
1399+ assert!(matches(input, "file.bin"));
1400+ assert!(!matches("*.bin diff=binary\n", "file.bin")); // Only testing filter=lfs
1401+ }
1402+
1403+ #[test]
1404+ fn test_gitattributes_chained_files() {
1405+ let data = TestStore::from([
1406+ (
1407+ repo_path(".gitattributes").to_owned(),
1408+ Ok("*.bin filter=lfs\n".to_string()),
1409+ ),
1410+ (
1411+ repo_path("subdir/.gitattributes").to_owned(),
1412+ Ok("*.txt filter=text\n".to_string()),
1413+ ),
1414+ ]);
1415+ let attributes = GitAttributes::new(HashMap::new(), data);
1416+
1417+ let filters = &HashSet::from(["lfs".to_string(), "text".to_string()]);
1418+ assert!(
1419+ attributes
1420+ .filter_matches(repo_path("file.bin"), filters, SearchPriority::Disk)
1421+ .block_on()
1422+ .unwrap()
1423+ );
1424+ assert!(
1425+ attributes
1426+ .filter_matches(repo_path("subdir/file.txt"), filters, SearchPriority::Disk)
1427+ .block_on()
1428+ .unwrap()
1429+ );
1430+ assert!(
1431+ !attributes
1432+ .filter_matches(repo_path("file.txt"), filters, SearchPriority::Disk)
1433+ .block_on()
1434+ .unwrap()
1435+ ); // Not in subdir
1436+ }
1437+
1438+ #[test]
1439+ fn test_gitattributes_negated_pattern() {
1440+ let input = "*.bin filter=lfs\n!important.bin filter=lfs\n";
1441+ assert!(matches(input, "file.bin"));
1442+ // negative patterns are forbidden
1443+ // https://git-scm.com/docs/gitattributes#_description
1444+ assert!(matches(input, "important.bin"));
1445+ }
1446+
1447+ #[test]
1448+ fn test_gitattributes_multiple_filters() {
1449+ let data = TestStore::from([
1450+ (
1451+ repo_path(".gitattributes").to_owned(),
1452+ Ok(indoc! {"
1453+ *.bin filter=lfs
1454+ *.secret filter=git-crypt
1455+ *.txt filter=other
1456+ "}
1457+ .to_string()),
1458+ ),
1459+ (
1460+ repo_path("subdir/.gitattributes").to_owned(),
1461+ Ok("*.txt filter=text\n".to_string()),
1462+ ),
1463+ ]);
1464+
1465+ // Create a GitAttributesFile with both "lfs" and "git-crypt" as ignore filters
1466+ let attributes = GitAttributes::new(HashMap::new(), data);
1467+
1468+ let filters = &HashSet::from(["lfs".to_string(), "git-crypt".to_string()]);
1469+
1470+ // Test with lfs filter
1471+ assert!(
1472+ attributes
1473+ .filter_matches(repo_path("file.bin"), filters, SearchPriority::Disk)
1474+ .block_on()
1475+ .unwrap()
1476+ );
1477+ // Test with git-crypt filter
1478+ assert!(
1479+ attributes
1480+ .filter_matches(
1481+ repo_path("credentials.secret"),
1482+ filters,
1483+ SearchPriority::Disk
1484+ )
1485+ .block_on()
1486+ .unwrap()
1487+ );
1488+ // Not In the filter
1489+ assert!(
1490+ !attributes
1491+ .filter_matches(repo_path("file.bin2"), filters, SearchPriority::Disk)
1492+ .block_on()
1493+ .unwrap()
1494+ );
1495+ // Test that other filters don't match
1496+ assert!(
1497+ !attributes
1498+ .filter_matches(repo_path("file.txt"), filters, SearchPriority::Disk)
1499+ .block_on()
1500+ .unwrap()
1501+ );
1502+ }
1503+}
1504diff --git a/lib/src/lib.rs b/lib/src/lib.rs
1505index 130ca7a..d3a5ee1 100644
1506--- a/lib/src/lib.rs
1507+++ b/lib/src/lib.rs
1508@@ -55,6 +55,8 @@ pub mod git;
1509 pub mod git_backend;
1510 #[cfg(feature = "git")]
1511 mod git_subprocess;
1512+#[cfg(feature = "git")]
1513+pub mod gitattributes;
1514 pub mod gitignore;
1515 pub mod gpg_signing;
1516 pub use jj_core::graph;
1517diff --git a/lib/src/local_working_copy.rs b/lib/src/local_working_copy.rs
1518index a254834..0587023 100644
1519--- a/lib/src/local_working_copy.rs
1520+++ b/lib/src/local_working_copy.rs
1521@@ -93,6 +93,16 @@ use crate::fsmonitor::FsmonitorSettings;
1522 use crate::fsmonitor::WatchmanConfig;
1523 #[cfg(feature = "watchman")]
1524 use crate::fsmonitor::watchman;
1525+#[cfg(feature = "git")]
1526+use crate::git::GitSettings;
1527+#[cfg(feature = "git")]
1528+use crate::gitattributes::DiskFileLoader;
1529+#[cfg(feature = "git")]
1530+use crate::gitattributes::GitAttributes;
1531+#[cfg(feature = "git")]
1532+use crate::gitattributes::SearchPriority;
1533+#[cfg(feature = "git")]
1534+use crate::gitattributes::TreeFileLoader;
1535 use crate::gitignore::GitIgnoreFile;
1536 use crate::lock::FileLock;
1537 use crate::matchers::DifferenceMatcher;
1538@@ -977,6 +987,10 @@ pub struct TreeStateSettings {
1539 pub exec_change_setting: ExecChangeSetting,
1540 /// The fsmonitor (e.g. Watchman) to use, if any.
1541 pub fsmonitor_settings: FsmonitorSettings,
1542+
1543+ /// Names of .gitattributes filters whose matching files should be ignored
1544+ /// in the working copy.
1545+ pub ignore_filters: HashSet<String>,
1546 }
1547
1548 impl TreeStateSettings {
1549@@ -987,6 +1001,19 @@ impl TreeStateSettings {
1550 eol_conversion_mode: EolConversionMode::try_from_settings(user_settings)?,
1551 exec_change_setting: user_settings.get("working-copy.exec-bit-change")?,
1552 fsmonitor_settings: FsmonitorSettings::from_settings(user_settings)?,
1553+ ignore_filters: {
1554+ #[cfg(feature = "git")]
1555+ {
1556+ GitSettings::from_settings(user_settings)?
1557+ .ignore_filters
1558+ .into_iter()
1559+ .collect()
1560+ }
1561+ #[cfg(not(feature = "git"))]
1562+ {
1563+ HashSet::new()
1564+ }
1565+ },
1566 })
1567 }
1568 }
1569@@ -1011,6 +1038,9 @@ pub struct TreeState {
1570 exec_policy: ExecChangePolicy,
1571 fsmonitor_settings: FsmonitorSettings,
1572 target_eol_strategy: TargetEolStrategy,
1573+
1574+ #[cfg_attr(not(feature = "git"), allow(dead_code))]
1575+ ignore_filters: HashSet<String>,
1576 }
1577
1578 #[derive(Debug, Error)]
1579@@ -1084,9 +1114,18 @@ impl TreeState {
1580 eol_conversion_mode,
1581 exec_change_setting,
1582 fsmonitor_settings,
1583+ ignore_filters: settings_ignore_filters,
1584 }: &TreeStateSettings,
1585 ) -> Self {
1586 let exec_policy = ExecChangePolicy::new(*exec_change_setting, &state_path);
1587+
1588+ // Only enable ignore filters when using Git.
1589+ let ignore_filters = if store.backend().name() == "git" {
1590+ settings_ignore_filters.clone()
1591+ } else {
1592+ HashSet::new()
1593+ };
1594+
1595 Self {
1596 store: store.clone(),
1597 working_copy_path,
1598@@ -1101,6 +1140,7 @@ impl TreeState {
1599 exec_policy,
1600 fsmonitor_settings: fsmonitor_settings.clone(),
1601 target_eol_strategy: TargetEolStrategy::new(*eol_conversion_mode),
1602+ ignore_filters,
1603 }
1604 }
1605
1606@@ -1337,6 +1377,11 @@ impl TreeState {
1607 let (untracked_paths_tx, untracked_paths_rx) = channel();
1608 let (invalid_utf8_paths_tx, invalid_utf8_paths_rx) = channel();
1609 let (deleted_files_tx, deleted_files_rx) = channel();
1610+ #[cfg(feature = "git")]
1611+ let git_attributes = Arc::new(GitAttributes::new(
1612+ TreeFileLoader::new(self.tree.clone()),
1613+ DiskFileLoader::new(self.working_copy_path.clone()),
1614+ ));
1615
1616 trace_span!("traverse filesystem").in_scope(|| -> Result<(), SnapshotError> {
1617 let snapshotter = FileSnapshotter {
1618@@ -1355,6 +1400,10 @@ impl TreeState {
1619 error: OnceLock::new(),
1620 progress: *progress,
1621 max_new_file_size: *max_new_file_size,
1622+ #[cfg(feature = "git")]
1623+ git_attributes,
1624+ #[cfg(feature = "git")]
1625+ ignore_filters: self.ignore_filters.clone(),
1626 };
1627 let directory_to_visit = DirectoryToVisit {
1628 dir: RepoPathBuf::root(),
1629@@ -1544,6 +1593,11 @@ struct FileSnapshotter<'a> {
1630 error: OnceLock<SnapshotError>,
1631 progress: Option<&'a SnapshotProgress<'a>>,
1632 max_new_file_size: u64,
1633+
1634+ #[cfg(feature = "git")]
1635+ git_attributes: Arc<GitAttributes>,
1636+ #[cfg(feature = "git")]
1637+ ignore_filters: HashSet<String>,
1638 }
1639
1640 impl FileSnapshotter<'_> {
1641@@ -1616,7 +1670,7 @@ impl FileSnapshotter<'_> {
1642 })
1643 .collect::<Result<_, _>>()?;
1644 let present_entries = PresentDirEntries { dirs, files };
1645- self.emit_deleted_files(&dir, file_states, &present_entries);
1646+ self.emit_deleted_files(&dir, file_states, &present_entries)?;
1647 Ok(())
1648 }
1649
1650@@ -1785,8 +1839,20 @@ impl FileSnapshotter<'_> {
1651 if let Some(progress) = self.progress {
1652 progress(&path);
1653 }
1654+ #[cfg(feature = "git")]
1655+ if self
1656+ .git_attributes
1657+ .filter_matches(&path, &self.ignore_filters, SearchPriority::Disk)
1658+ .block_on()?
1659+ {
1660+ // Skip gitattributes files that we want to ignore - this
1661+ // would result in them showing up as deleted, but we also
1662+ // omit them in `emit_deleted_files` to avoid that.
1663+ return Ok(None);
1664+ }
1665 if maybe_current_file_state.is_none()
1666- && (git_ignore.matches_file(&path) && !self.force_tracking_matcher.matches(&path))
1667+ && (git_ignore.matches_file(path.as_ref())
1668+ && !self.force_tracking_matcher.matches(&path))
1669 {
1670 // If it wasn't already tracked and it matches
1671 // the ignored paths, then ignore it.
1672@@ -1845,6 +1911,14 @@ impl FileSnapshotter<'_> {
1673 if !self.matcher.matches(tracked_path) {
1674 continue;
1675 }
1676+ #[cfg(feature = "git")]
1677+ if self
1678+ .git_attributes
1679+ .filter_matches(tracked_path, &self.ignore_filters, SearchPriority::Disk)
1680+ .await?
1681+ {
1682+ continue;
1683+ }
1684 let disk_path = tracked_path.to_fs_path(&self.tree_state.working_copy_path)?;
1685 let metadata = match disk_path.symlink_metadata() {
1686 Ok(metadata) => Some(metadata),
1687@@ -1906,7 +1980,7 @@ impl FileSnapshotter<'_> {
1688 dir: &RepoPath,
1689 file_states: FileStates<'_>,
1690 present_entries: &PresentDirEntries,
1691- ) {
1692+ ) -> Result<(), SnapshotError> {
1693 let file_state_chunks = file_states.iter().chunk_by(|(path, _state)| {
1694 // Extract <name> from <dir>, <dir>/<name>, or <dir>/<name>/**.
1695 // (file_states may contain <dir> file on file->dir transition.)
1696@@ -1919,18 +1993,34 @@ impl FileSnapshotter<'_> {
1697 None => (PresentDirEntryKind::File, tail),
1698 }
1699 });
1700- file_state_chunks
1701+ for (_, chunk) in file_state_chunks
1702 .into_iter()
1703 .filter(|&((kind, name), _)| match kind {
1704 PresentDirEntryKind::Dir => !present_entries.dirs.contains(name),
1705 PresentDirEntryKind::File => !present_entries.files.contains(name),
1706 })
1707- .flat_map(|(_, chunk)| chunk)
1708- // Whether or not the entry exists, submodule should be ignored
1709- .filter(|(_, state)| state.file_type != FileType::GitSubmodule)
1710- .filter(|(path, _)| self.matcher.matches(path))
1711- .try_for_each(|(path, _)| self.deleted_files_tx.send(path.to_owned()))
1712- .ok();
1713+ {
1714+ for (path, state) in chunk {
1715+ // Whether or not the entry exists, submodule should be ignored.
1716+ if state.file_type == FileType::GitSubmodule {
1717+ continue;
1718+ }
1719+ #[cfg(feature = "git")]
1720+ if self
1721+ .git_attributes
1722+ .filter_matches(path, &self.ignore_filters, SearchPriority::Disk)
1723+ .block_on()?
1724+ {
1725+ // Whether or not the entry exists, ignored gitattributes
1726+ // files should be omitted.
1727+ continue;
1728+ }
1729+ if self.matcher.matches(path) {
1730+ self.deleted_files_tx.send(path.to_owned()).ok();
1731+ }
1732+ }
1733+ }
1734+ Ok(())
1735 }
1736
1737 async fn get_updated_tree_value(
1738diff --git a/lib/src/working_copy.rs b/lib/src/working_copy.rs
1739index 0d4317c..6b3950a 100644
1740--- a/lib/src/working_copy.rs
1741+++ b/lib/src/working_copy.rs
1742@@ -28,6 +28,8 @@ use tracing::instrument;
1743
1744 use crate::backend::BackendError;
1745 use crate::commit::Commit;
1746+#[cfg(feature = "git")]
1747+use crate::gitattributes::GitAttributesError;
1748 use crate::gitignore::GitIgnoreError;
1749 use crate::gitignore::GitIgnoreFile;
1750 use crate::matchers::Matcher;
1751@@ -186,6 +188,10 @@ pub enum SnapshotError {
1752 /// Checking path with ignore patterns failed.
1753 #[error(transparent)]
1754 GitIgnoreError(#[from] GitIgnoreError),
1755+ /// Checking path with gitattributes patterns failed.
1756+ #[cfg(feature = "git")]
1757+ #[error(transparent)]
1758+ GitAttributesError(#[from] GitAttributesError),
1759 /// Failed to load the working copy state.
1760 #[error(transparent)]
1761 WorkingCopyStateError(#[from] WorkingCopyStateError),
1762diff --git a/lib/tests/test_local_working_copy.rs b/lib/tests/test_local_working_copy.rs
1763index 509a20b..85c4708 100644
1764--- a/lib/tests/test_local_working_copy.rs
1765+++ b/lib/tests/test_local_working_copy.rs
1766@@ -3192,3 +3192,181 @@ fn test_always_store_empty_tree() -> TestResult {
1767 assert!(empty_tree.data.is_empty());
1768 Ok(())
1769 }
1770+
1771+#[test_case(TestRepoBackend::Simple ; "simple backend")]
1772+#[test_case(TestRepoBackend::Git ; "git backend")]
1773+fn test_gitattributes_ignore_only_on_git_backend(backend: TestRepoBackend) -> TestResult {
1774+ let mut test_workspace = TestWorkspace::init_with_backend(backend);
1775+ let workspace_root = test_workspace.workspace.workspace_root().to_owned();
1776+
1777+ // Write .gitattributes with LFS filter.
1778+ std::fs::write(workspace_root.join(".gitattributes"), "*.txt filter=lfs\n").unwrap();
1779+ // Write a matching file.
1780+ std::fs::write(workspace_root.join("file.txt"), "some content").unwrap();
1781+ // Write a non-matching file.
1782+ std::fs::write(workspace_root.join("file.dat"), "some other content").unwrap();
1783+
1784+ let new_tree = test_workspace.snapshot()?;
1785+
1786+ // "file.dat" should always be present
1787+ assert!(
1788+ new_tree
1789+ .path_value(repo_path("file.dat"))
1790+ .block_on()?
1791+ .is_present()
1792+ );
1793+
1794+ // ".gitattributes" should always be present
1795+ assert!(
1796+ new_tree
1797+ .path_value(repo_path(".gitattributes"))
1798+ .block_on()?
1799+ .is_present()
1800+ );
1801+
1802+ match backend {
1803+ TestRepoBackend::Git => {
1804+ // Under Git backend, "file.txt" should be ignored
1805+ assert!(
1806+ new_tree
1807+ .path_value(repo_path("file.txt"))
1808+ .block_on()?
1809+ .is_absent()
1810+ );
1811+ }
1812+ TestRepoBackend::Simple | TestRepoBackend::Test => {
1813+ // Under non-Git backends, "file.txt" should NOT be ignored
1814+ assert!(
1815+ new_tree
1816+ .path_value(repo_path("file.txt"))
1817+ .block_on()?
1818+ .is_present()
1819+ );
1820+ }
1821+ }
1822+
1823+ Ok(())
1824+}
1825+
1826+// Regression test for a bug found during development.
1827+//
1828+// Snapshot prefers .gitattributes from disk but falls back to the current tree
1829+// when the disk file is missing. TreeState starts with an empty tree before
1830+// checkout, so caching GitAttributes there can leave the fallback pointed at
1831+// the empty tree even after checkout updates TreeState::tree. Removing the disk
1832+// .gitattributes file exercises that fallback; the tracked LFS file must keep
1833+// the tree contents instead of snapshotting the modified disk contents.
1834+#[test]
1835+fn test_gitattributes_use_store_fallback_after_checkout() -> TestResult {
1836+ let mut test_workspace = TestWorkspace::init_with_backend(TestRepoBackend::Git);
1837+ let repo = &test_workspace.repo;
1838+ let workspace_root = test_workspace.workspace.workspace_root().to_owned();
1839+
1840+ let tree = create_tree(
1841+ repo,
1842+ &[
1843+ (repo_path(".gitattributes"), "*.txt filter=lfs\n"),
1844+ (repo_path("file.txt"), "original\n"),
1845+ ],
1846+ );
1847+ let commit = commit_with_tree(repo.store(), tree.clone());
1848+ test_workspace
1849+ .workspace
1850+ .check_out(repo.op_id().clone(), None, &commit)
1851+ .block_on()?;
1852+
1853+ std::fs::remove_file(workspace_root.join(".gitattributes"))?;
1854+ std::fs::write(workspace_root.join("file.txt"), "modified\n")?;
1855+
1856+ let new_tree = test_workspace.snapshot()?;
1857+ assert_eq!(
1858+ new_tree.path_value(repo_path("file.txt")).block_on()?,
1859+ tree.path_value(repo_path("file.txt")).block_on()?,
1860+ );
1861+
1862+ Ok(())
1863+}
1864+
1865+// Regression test for a bug found during development.
1866+//
1867+// Snapshot handles files that are present on disk and files that were deleted
1868+// from disk through separate paths. If deletion emission does not apply the
1869+// .gitattributes filter check, removing a tracked LFS-filtered file from disk
1870+// records a deletion even though normal snapshots would ignore changes to that
1871+// file. The tree should keep the tracked contents instead.
1872+#[test]
1873+fn test_gitattributes_ignore_deleted_tracked_file() -> TestResult {
1874+ let mut test_workspace = TestWorkspace::init_with_backend(TestRepoBackend::Git);
1875+ let repo = test_workspace.repo.clone();
1876+ let workspace_root = test_workspace.workspace.workspace_root().to_owned();
1877+
1878+ let tracked_path = repo_path("file.bin");
1879+ let tree = create_tree(
1880+ &repo,
1881+ &[
1882+ (repo_path(".gitattributes"), "*.bin filter=lfs\n"),
1883+ (tracked_path, "original\n"),
1884+ ],
1885+ );
1886+ let commit = commit_with_tree(repo.store(), tree.clone());
1887+ test_workspace
1888+ .workspace
1889+ .check_out(repo.op_id().clone(), None, &commit)
1890+ .block_on()?;
1891+
1892+ std::fs::remove_file(tracked_path.to_fs_path_unchecked(&workspace_root))?;
1893+
1894+ let new_tree = test_workspace.snapshot()?;
1895+ assert_eq!(
1896+ new_tree.path_value(tracked_path).block_on()?,
1897+ tree.path_value(tracked_path).block_on()?,
1898+ );
1899+
1900+ Ok(())
1901+}
1902+
1903+// Regression test for a bug found during development.
1904+//
1905+// The normal snapshot path checks .gitattributes before updating or deleting a
1906+// file. When .gitignore ignores an entire directory, snapshot uses a shortcut
1907+// that scans only already-tracked files in that directory. This test puts a
1908+// tracked file under that shortcut and marks it with filter=lfs. Modifying the
1909+// disk file must not update the tree: the .gitattributes filter should still
1910+// apply even though traversal used the ignored-directory path.
1911+#[test]
1912+fn test_gitattributes_ignore_tracked_file_in_gitignored_dir() -> TestResult {
1913+ let mut test_workspace = TestWorkspace::init_with_backend(TestRepoBackend::Git);
1914+ let repo = test_workspace.repo.clone();
1915+ let workspace_root = test_workspace.workspace.workspace_root().to_owned();
1916+
1917+ let tracked_path = repo_path("ignored/file.bin");
1918+ let tree = create_tree(
1919+ &repo,
1920+ &[
1921+ (repo_path(".gitignore"), "/ignored/\n"),
1922+ (repo_path(".gitattributes"), "ignored/file.bin filter=lfs\n"),
1923+ (tracked_path, "original\n"),
1924+ ],
1925+ );
1926+ let commit = commit_with_tree(repo.store(), tree.clone());
1927+ test_workspace
1928+ .workspace
1929+ .check_out(repo.op_id().clone(), None, &commit)
1930+ .block_on()?;
1931+
1932+ std::fs::write(
1933+ tracked_path.to_fs_path_unchecked(&workspace_root),
1934+ "modified\n",
1935+ )?;
1936+
1937+ let new_tree = test_workspace.snapshot()?;
1938+
1939+ // The .gitattributes filter should still apply when .gitignore sends
1940+ // tracked files through the ignored-directory shortcut.
1941+ assert_eq!(
1942+ new_tree.path_value(tracked_path).block_on()?,
1943+ tree.path_value(tracked_path).block_on()?,
1944+ );
1945+
1946+ Ok(())
1947+}