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+}