Commit f621e52

HPCesia <me@hpcesia.com>
2026-09-17 08:09:54
jujutsu: add lfs patch
Assisted-by: crush:deepseek-v4.1-flash
1 parent e6986eb
Changed files (2)
modules/dev/jujutsu-lfs.patch
@@ -0,0 +1,1925 @@
+diff --git a/CHANGELOG.md b/CHANGELOG.md
+index 19ffa10..60eae12 100644
+--- a/CHANGELOG.md
++++ b/CHANGELOG.md
+@@ -75,6 +75,8 @@ None
+   directory or loaded via `--config-file`). This allows precise file targeting
+   and avoids interactive prompts when multiple config files exist.
+ 
++* Added `git.ignore-filters` setting to specify what filtered files in
++  `.gitattributes` are ignored by `jj`. Defaults to `["lfs"]`.
+ ### Fixed bugs
+ 
+ * [The default `immutable_heads()` set](docs/config.md#set-of-immutable-commits)
+diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs
+index 3b6ea7b..30f4f3d 100644
+--- a/cli/src/command_error.rs
++++ b/cli/src/command_error.rs
+@@ -35,6 +35,8 @@ use jj_lib::fileset::FilePatternParseError;
+ use jj_lib::fileset::FilesetParseError;
+ use jj_lib::fileset::FilesetParseErrorKind;
+ use jj_lib::fix::FixError;
++#[cfg(feature = "git")]
++use jj_lib::gitattributes::GitAttributesError;
+ use jj_lib::gitignore::GitIgnoreError;
+ use jj_lib::index::IndexError;
+ use jj_lib::op_heads_store::OpHeadsStoreError;
+@@ -733,6 +735,13 @@ impl From<GitIgnoreError> for CommandError {
+     }
+ }
+ 
++#[cfg(feature = "git")]
++impl From<GitAttributesError> for CommandError {
++    fn from(err: GitAttributesError) -> Self {
++        user_error_with_message("Failed to process .gitattributes.", err)
++    }
++}
++
+ impl From<ParseBulkEditMessageError> for CommandError {
+     fn from(err: ParseBulkEditMessageError) -> Self {
+         user_error(err)
+diff --git a/cli/src/commands/run.rs b/cli/src/commands/run.rs
+index 3e386a5..e839790 100644
+--- a/cli/src/commands/run.rs
++++ b/cli/src/commands/run.rs
+@@ -16,6 +16,7 @@
+ 
+ use std::cmp::min;
+ use std::collections::HashMap;
++use std::collections::HashSet;
+ use std::fmt;
+ use std::fs;
+ use std::io;
+@@ -99,12 +100,13 @@ impl From<RunError> for CommandError {
+     }
+ }
+ 
+-fn default_tree_state_settings() -> TreeStateSettings {
++fn default_tree_state_settings(ignore_filters: &HashSet<String>) -> TreeStateSettings {
+     TreeStateSettings {
+         conflict_marker_style: ConflictMarkerStyle::Snapshot,
+         eol_conversion_mode: EolConversionMode::None,
+         exec_change_setting: ExecChangeSetting::Auto,
+         fsmonitor_settings: FsmonitorSettings::None,
++        ignore_filters: ignore_filters.clone(),
+     }
+ }
+ 
+@@ -166,6 +168,7 @@ struct WorkspacePool {
+     /// When true, wipe each slot's working copy on acquisition so every commit
+     /// starts from a freshly checked-out tree (no artifact reuse).
+     clean: bool,
++    ignore_filters: HashSet<String>,
+ }
+ 
+ impl WorkspacePool {
+@@ -174,6 +177,7 @@ impl WorkspacePool {
+         size: NonZeroUsize,
+         auto_tracking_matcher: Box<dyn Matcher>,
+         clean: bool,
++        ignore_filters: HashSet<String>,
+     ) -> Result<Self, RunError> {
+         // The parent() call is needed to not write under `.jj/repo/`.
+         let base_path = repo_path.parent().unwrap().join("run").join("default");
+@@ -183,6 +187,7 @@ impl WorkspacePool {
+             size,
+             auto_tracking_matcher,
+             clean,
++            ignore_filters,
+         })
+     }
+ 
+@@ -209,7 +214,7 @@ impl WorkspacePool {
+         let tree_state_path = state_dir.join("tree_state");
+ 
+         let is_reused_workspace = tree_state_path.exists();
+-        let settings = default_tree_state_settings();
++        let settings = default_tree_state_settings(&self.ignore_filters);
+         let mut tree_state = if !self.clean && is_reused_workspace {
+             // Load the persisted tree state so `check_out` below can diff
+             // against it, only touching files that changed and removing files
+@@ -754,6 +759,20 @@ pub async fn cmd_run(
+     let store = workspace_command.repo().store().clone();
+     let auto_tracking_matcher = workspace_command.auto_tracking_matcher(ui)?;
+ 
++    let ignore_filters = {
++        #[cfg(feature = "git")]
++        {
++            use jj_lib::git::GitSettings;
++            GitSettings::from_settings(workspace_command.settings())?
++                .ignore_filters
++                .into_iter()
++                .collect()
++        }
++        #[cfg(not(feature = "git"))]
++        {
++            HashSet::new()
++        }
++    };
+     let mut tx = workspace_command.start_transaction();
+ 
+     let rt = {
+@@ -769,6 +788,7 @@ pub async fn cmd_run(
+         jobs,
+         auto_tracking_matcher,
+         args.clean,
++        ignore_filters,
+     )?);
+ 
+     let spec = Arc::new(CommandSpec {
+diff --git a/cli/src/config/misc.toml b/cli/src/config/misc.toml
+index 70ed210..cd5d01c 100644
+--- a/cli/src/config/misc.toml
++++ b/cli/src/config/misc.toml
+@@ -26,6 +26,7 @@ disabled-branches = []
+ 
+ [git]
+ colocate = true
++ignore-filters = ["lfs"]
+ object-hash = "sha1"
+ private-commits = "none()"
+ sign-on-push = false
+diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json
+index aa84124..9b7d768 100644
+--- a/cli/src/config-schema.json
++++ b/cli/src/config-schema.json
+@@ -559,6 +559,14 @@
+                     "enum": ["sha1", "sha256"],
+                     "description": "Object hash algorithm used when initializing a new Git repository",
+                     "default": "sha1"
++                },
++                "ignore-filters": {
++                    "type": "array",
++                    "items": {
++                        "type": "string"
++                    },
++                    "description": "Names of `.gitattributes` filter attributes whose matching files should be excluded from snapshots in Git-backed repositories",
++                    "default": ["lfs"]
+                 }
+             }
+         },
+diff --git a/cli/src/merge_tools/diff_working_copies.rs b/cli/src/merge_tools/diff_working_copies.rs
+index 7b5cb00..5113cd8 100644
+--- a/cli/src/merge_tools/diff_working_copies.rs
++++ b/cli/src/merge_tools/diff_working_copies.rs
+@@ -1,4 +1,5 @@
+ use std::collections::HashMap;
++use std::collections::HashSet;
+ use std::fs::File;
+ use std::io;
+ use std::io::Write as _;
+@@ -156,6 +157,7 @@ pub(crate) async fn check_out_trees(
+     matcher: &dyn Matcher,
+     diff_type: DiffType,
+     conflict_marker_style: ConflictMarkerStyle,
++    ignore_filters: HashSet<String>,
+ ) -> Result<DiffWorkingCopies, DiffCheckoutError> {
+     let store = trees.before.store();
+     let changed_files: Vec<_> = trees
+@@ -179,6 +181,7 @@ pub(crate) async fn check_out_trees(
+             eol_conversion_mode: EolConversionMode::None,
+             exec_change_setting: ExecChangeSetting::Auto,
+             fsmonitor_settings: FsmonitorSettings::None,
++            ignore_filters: ignore_filters.clone(),
+         };
+         let mut state = TreeState::init(store.clone(), wc_path, state_dir, &tree_state_settings)?;
+         state.set_sparse_patterns(changed_files.clone())?;
+@@ -214,9 +217,16 @@ impl DiffEditWorkingCopies {
+         diff_type: DiffType,
+         instructions: Option<&str>,
+         conflict_marker_style: ConflictMarkerStyle,
++        ignore_filters: HashSet<String>,
+     ) -> Result<Self, DiffEditError> {
+-        let working_copies =
+-            check_out_trees(trees, matcher, diff_type, conflict_marker_style).await?;
++        let working_copies = check_out_trees(
++            trees,
++            matcher,
++            diff_type,
++            conflict_marker_style,
++            ignore_filters,
++        )
++        .await?;
+         working_copies.set_left_readonly()?;
+         if diff_type == DiffType::ThreeWay {
+             working_copies.set_right_readonly()?;
+diff --git a/cli/src/merge_tools/external.rs b/cli/src/merge_tools/external.rs
+index 9465915..994ed83 100644
+--- a/cli/src/merge_tools/external.rs
++++ b/cli/src/merge_tools/external.rs
+@@ -1,4 +1,5 @@
+ use std::collections::HashMap;
++use std::collections::HashSet;
+ use std::io;
+ use std::io::Write;
+ use std::path::Path;
+@@ -388,6 +389,7 @@ pub async fn edit_diff_external(
+     instructions: Option<&str>,
+     base_ignores: Arc<GitIgnoreFile>,
+     default_conflict_marker_style: ConflictMarkerStyle,
++    ignore_filters: HashSet<String>,
+ ) -> Result<MergedTree, DiffEditError> {
+     let conflict_marker_style = editor
+         .conflict_marker_style
+@@ -405,6 +407,7 @@ pub async fn edit_diff_external(
+         diff_type,
+         instructions,
+         conflict_marker_style,
++        ignore_filters,
+     )
+     .await?;
+ 
+@@ -456,7 +459,14 @@ pub async fn generate_diff(
+     let conflict_marker_style = tool
+         .conflict_marker_style
+         .unwrap_or(default_conflict_marker_style);
+-    let diff_wc = check_out_trees(trees, matcher, DiffType::TwoWay, conflict_marker_style).await?;
++    let diff_wc = check_out_trees(
++        trees,
++        matcher,
++        DiffType::TwoWay,
++        conflict_marker_style,
++        HashSet::new(),
++    )
++    .await?;
+     diff_wc.set_left_readonly()?;
+     diff_wc.set_right_readonly()?;
+     let mut patterns = diff_wc.to_command_variables(true);
+diff --git a/cli/src/merge_tools/mod.rs b/cli/src/merge_tools/mod.rs
+index 17f9521..f7a8958 100644
+--- a/cli/src/merge_tools/mod.rs
++++ b/cli/src/merge_tools/mod.rs
+@@ -16,6 +16,7 @@ mod builtin;
+ mod diff_working_copies;
+ mod external;
+ 
++use std::collections::HashSet;
+ use std::sync::Arc;
+ 
+ use futures::future::try_join_all;
+@@ -235,6 +236,7 @@ pub fn get_external_tool_config(
+ pub struct DiffEditor {
+     tool: DiffEditTool,
+     base_ignores: Arc<GitIgnoreFile>,
++    ignore_filters: HashSet<String>,
+     use_instructions: bool,
+     conflict_marker_style: ConflictMarkerStyle,
+ }
+@@ -284,9 +286,24 @@ impl DiffEditor {
+                 tool_name: name.to_string(),
+             });
+         }
++        let ignore_filters = {
++            #[cfg(feature = "git")]
++            {
++                use jj_lib::git::GitSettings;
++                GitSettings::from_settings(settings)?
++                    .ignore_filters
++                    .into_iter()
++                    .collect()
++            }
++            #[cfg(not(feature = "git"))]
++            {
++                HashSet::new()
++            }
++        };
+         Ok(Self {
+             tool,
+             base_ignores,
++            ignore_filters,
+             use_instructions: settings.get_bool("ui.diff-instructions")?,
+             conflict_marker_style,
+         })
+@@ -316,6 +333,7 @@ impl DiffEditor {
+                     instructions.as_deref(),
+                     self.base_ignores.clone(),
+                     self.conflict_marker_style,
++                    self.ignore_filters.clone(),
+                 )
+                 .await
+             }
+diff --git a/cli/tests/test_diffedit_command.rs b/cli/tests/test_diffedit_command.rs
+index 675c66e..2adff9c 100644
+--- a/cli/tests/test_diffedit_command.rs
++++ b/cli/tests/test_diffedit_command.rs
+@@ -18,6 +18,48 @@ use testutils::TestResult;
+ 
+ use crate::common::TestEnvironment;
+ 
++#[test]
++fn test_diffedit_gitattributes_filter_in_temp_snapshot() -> TestResult {
++    let mut test_env = TestEnvironment::default();
++    let edit_script = test_env.set_up_fake_diff_editor();
++    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
++    let work_dir = test_env.work_dir("repo");
++
++    work_dir.write_file(".gitattributes", "*.bin filter=lfs\n");
++    work_dir.write_file("file.bin", "original\n");
++    work_dir
++        .run_jj(["--config=git.ignore-filters=[]", "commit", "-m", "base"])
++        .success();
++    work_dir.write_file("file.bin", "after\n");
++    work_dir
++        .run_jj(["--config=git.ignore-filters=[]", "debug", "snapshot"])
++        .success();
++
++    std::fs::write(
++        &edit_script,
++        "files-before file.bin\0files-after JJ-INSTRUCTIONS file.bin\0write file.bin\nedited\n",
++    )?;
++    work_dir.run_jj(["diffedit"]).success();
++
++    // Regression test for a bug found during development.
++    //
++    // This creates a tracked LFS-filtered modification by disabling filter
++    // ignores only for setup. `diffedit` then operates with normal settings.
++    // Its external editor writes into a temporary output working copy, and
++    // snapshotting that output must use the configured .gitattributes filters.
++    // Otherwise the editor can rewrite a tracked LFS-filtered file even though
++    // normal snapshots would ignore it.
++    insta::assert_snapshot!(
++        work_dir.run_jj(["file", "show", "file.bin"]).success().stdout,
++        @r"
++    after
++    [EOF]
++    "
++    );
++
++    Ok(())
++}
++
+ #[test]
+ fn test_diffedit() -> TestResult {
+     let mut test_env = TestEnvironment::default();
+diff --git a/cli/tests/test_run_command.rs b/cli/tests/test_run_command.rs
+index 2cb2a48..6fc07c3 100644
+--- a/cli/tests/test_run_command.rs
++++ b/cli/tests/test_run_command.rs
+@@ -67,6 +67,48 @@ fn test_run_simple() {
+     insta::assert_snapshot!(stdout, @"xxxx[EOF]");
+ }
+ 
++#[test]
++fn test_run_gitattributes_filter_in_temp_snapshot() {
++    let test_env = TestEnvironment::default();
++    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
++    let fake_formatter = assert_cmd::cargo::cargo_bin("fake-formatter");
++    assert!(fake_formatter.is_file());
++    let fake_formatter_path = fake_formatter.to_string_lossy().into_owned();
++    let work_dir = test_env.work_dir("repo");
++
++    work_dir.write_file(".gitattributes", "*.bin filter=lfs\n");
++    work_dir.write_file("file.bin", "original");
++    work_dir
++        .run_jj(["--config=git.ignore-filters=[]", "commit", "-m", "base"])
++        .success();
++
++    work_dir
++        .run_jj([
++            "run",
++            "-r",
++            "@-",
++            "--",
++            &fake_formatter_path,
++            "--stdout",
++            "modified",
++            "--tee",
++            "file.bin",
++        ])
++        .success();
++
++    // Regression test for a bug found during development.
++    //
++    // This creates a legitimately tracked file by disabling filter ignores only
++    // for setup. `jj run` then operates with normal settings. Its temporary
++    // working copy is snapshotted after the command runs, and that snapshot must
++    // use the configured .gitattributes filters. Otherwise a command can rewrite
++    // a tracked LFS-filtered file even though normal snapshots would ignore it.
++    insta::assert_snapshot!(
++        work_dir.run_jj(["file", "show", "-r", "@-", "file.bin"]).success().stdout,
++        @"original[EOF]"
++    );
++}
++
+ #[test]
+ fn test_run_on_immutable() {
+     let test_env = TestEnvironment::default();
+diff --git a/docs/config.md b/docs/config.md
+index 5d4bb11..3c6b04d 100644
+--- a/docs/config.md
++++ b/docs/config.md
+@@ -2000,6 +2000,23 @@ you can:
+ executable-path = "/path/to/git"
+ ```
+ 
++### Ignoring files by `.gitattributes` filter
++
++In Git-backed repositories, `jj` can skip files during snapshot based on the
++`filter` attribute in `.gitattributes`. The `git.ignore-filters` setting lists
++the filter values to skip, and defaults to `["lfs"]`.
++
++```toml
++[git]
++ignore-filters = ["lfs", "git-crypt"]
++```
++
++This only affects snapshotting from disk to the store. Checkout still writes
++the files from the tree, so external tools such as `git lfs pull` or
++`git lfs checkout` can hydrate them afterward. Files already tracked by `jj` may
++need to be removed with `jj file untrack <path>` before this setting affects
++future snapshots.
++
+ ## Gerrit settings
+ 
+ ### Default remote
+diff --git a/docs/git-compatibility.md b/docs/git-compatibility.md
+index c9726fe..430acc3 100644
+--- a/docs/git-compatibility.md
++++ b/docs/git-compatibility.md
+@@ -35,8 +35,13 @@ a comparison with Git, including how workflows are different, see the
+   working-copy commit. It's recommended to set up the ignore patterns earlier.
+   The `.gitignore` support uses a native implementation, so please report a bug
+   if you notice any difference compared to `git`.
+-* **.gitattributes: No.** There's [#53](https://github.com/jj-vcs/jj/issues/53)
+-  about adding support for at least the `eol` attribute.
++* **.gitattributes: Partial.** In Git-backed repositories, `jj` reads
++  `.gitattributes` `filter` attributes during snapshot and skips files whose
++  filter is listed in `git.ignore-filters`, which defaults to `["lfs"]`. This
++  is snapshot-only; checkout still writes files from the tree. Other attributes
++  such as `eol`, `text`, `diff`, and `merge` are not otherwise supported.
++  There's [#53](https://github.com/jj-vcs/jj/issues/53) about adding support for
++  at least the `eol` attribute.
+ * **Hooks: No.** There's [#405](https://github.com/jj-vcs/jj/issues/405)
+   specifically for providing the checks from <https://pre-commit.com>.
+ * **Merge commits: Yes.** Octopus merges (i.e. with more than 2 parents) are
+@@ -69,7 +74,14 @@ a comparison with Git, including how workflows are different, see the
+ * **Signed commits: Yes.**
+   You can sign commits automatically [by configuration](config.md#commit-signing),
+   or use the `jj sign` command.
+-* **Git LFS: No.** ([#80](https://github.com/jj-vcs/jj/issues/80))
++* **Git LFS: Partial.** In Git-backed repositories, files with `filter=lfs` are
++  ignored during snapshot by default. `jj` does not fetch, clean, smudge, or
++  hydrate LFS contents; use Git LFS commands such as `git lfs pull` or
++  `git lfs checkout` for those steps. Existing tracked files may need
++  `jj file untrack <path>` before the snapshot ignore takes effect
++  ([#80](https://github.com/jj-vcs/jj/issues/80)). Note that `git` commands
++  will not work in workspaces created by `jj workspace` as they do not have a
++  `.git` directory (see also: [#4436](https://github.com/jj-vcs/jj/issues/4436)).
+ 
+ ## Creating an empty repo
+ 
+diff --git a/lib/src/git.rs b/lib/src/git.rs
+index 5c3c178..a84179a 100644
+--- a/lib/src/git.rs
++++ b/lib/src/git.rs
+@@ -42,6 +42,7 @@ use crate::backend::CommitId;
+ use crate::backend::TreeValue;
+ use crate::commit::Commit;
+ use crate::config::ConfigGetError;
++use crate::config::ConfigGetResultExt as _;
+ use crate::file_util::IoResultExt as _;
+ use crate::file_util::PathError;
+ use crate::git_backend::GitBackend;
+@@ -104,6 +105,7 @@ pub struct GitSettings {
+     pub executable_path: PathBuf,
+     pub record_synthetic_predecessors: bool,
+     pub write_change_id_header: bool,
++    pub ignore_filters: Vec<String>,
+ }
+ 
+ impl GitSettings {
+@@ -114,6 +116,10 @@ impl GitSettings {
+             record_synthetic_predecessors: settings
+                 .get_bool("git.record-synthetic-predecessors")?,
+             write_change_id_header: settings.get("git.write-change-id-header")?,
++            ignore_filters: settings
++                .get("git.ignore-filters")
++                .optional()?
++                .unwrap_or_else(|| vec!["lfs".to_string()]),
+         })
+     }
+ 
+diff --git b/lib/src/gitattributes.rs b/lib/src/gitattributes.rs
+new file mode 100644
+index 0000000..8a12782
+--- /dev/null
++++ b/lib/src/gitattributes.rs
+@@ -0,0 +1,974 @@
++// Copyright 2026 The Jujutsu Authors
++//
++// Licensed under the Apache License, Version 2.0 (the "License");
++// you may not use this file except in compliance with the License.
++// You may obtain a copy of the License at
++//
++// https://www.apache.org/licenses/LICENSE-2.0
++//
++// Unless required by applicable law or agreed to in writing, software
++// distributed under the License is distributed on an "AS IS" BASIS,
++// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
++// See the License for the specific language governing permissions and
++// limitations under the License.
++
++//! Git attributes parsing and evaluation.
++//!
++//! This module provides the `GitAttributes` struct for parsing and evaluating
++//! `.gitattributes` files. To support reading attributes both from the working
++//! copy (disk) and from the repository's tree (store), the loading mechanism is
++//! abstracted using the `FileLoader` trait.
++//!
++//! Depending on the context, we may choose which loader acts as the "primary"
++//! source for the `.gitattributes` file via `SearchPriority`:
++//!
++//! - `SearchPriority::Disk`: Used when operating on the working copy. The
++//!   `DiskFileLoader` is checked first to ensure uncommitted changes to
++//!   `.gitattributes` are respected.
++//! - `SearchPriority::Store`: Useful when the working copy is not a concern
++//!   (e.g., historical gitattributes). The `TreeFileLoader` is checked first.
++//!   Ignores any working-copy changes on the "primary" read.
++//!
++//! In either mode, if the `.gitattributes` file is not found by the "primary"
++//! loader for a given directory, we fall back to checking the "secondary"
++//! loader.
++//!
++//! Attribute rules are loaded lazily on a per-directory basis. When evaluating
++//! a path like `a/b/file.txt`, the system recursively evaluates rules from the
++//! repository root down to `a/b/`. Child directories inherit and can override
++//! parent rules. Git macros are restricted to the root directory. Parsed rules
++//! for each directory are cached. This implementation attempts to follow Git
++//! handles attributes.
++
++use std::collections::HashMap;
++use std::collections::HashSet;
++use std::fs::File;
++use std::io::ErrorKind;
++use std::path::PathBuf;
++use std::sync::Arc;
++use std::sync::Mutex;
++
++use futures::AsyncRead;
++use futures::AsyncReadExt as _;
++use futures::io::AllowStdIo;
++use gix::attrs::Search;
++use gix::attrs::State;
++use gix::attrs::glob::pattern::Case;
++use gix::attrs::search::MetadataCollection;
++use gix::attrs::search::Outcome;
++
++use crate::backend::MergedTreeValueExt as _;
++use crate::backend::TreeValue;
++use crate::merge::SameChange;
++use crate::merged_tree::MergedTree;
++use crate::repo_path::RepoPath;
++use crate::repo_path::RepoPathBuf;
++use crate::repo_path::RepoPathComponent;
++
++/// Git Attributes instance
++///
++/// This struct handles accessing .gitattributes files
++/// and maintains a cache to prevent loading the same file twice.
++///
++/// It's lazy loaded so .gitattributes files are only accessed
++/// if you search for attributes for a given path.
++pub(crate) struct GitAttributes {
++    disk_file_loader: Arc<dyn FileLoader>,
++    store_file_loader: Arc<dyn FileLoader>,
++    node_cache: Mutex<HashMap<RepoPathBuf, Arc<GitAttributesNode>>>,
++}
++
++#[async_trait::async_trait]
++pub(crate) trait FileLoader: Send + Sync {
++    /// Loads a file in a given `path`
++    ///
++    /// Returns Some(..) if the file was found and None if not
++    async fn load(
++        &self,
++        path: &RepoPath,
++    ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError>;
++}
++
++pub(crate) struct TreeFileLoader {
++    tree: MergedTree,
++}
++impl TreeFileLoader {
++    pub fn new(tree: MergedTree) -> Self {
++        Self { tree }
++    }
++}
++
++#[async_trait::async_trait]
++impl FileLoader for TreeFileLoader {
++    async fn load(
++        &self,
++        path: &RepoPath,
++    ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError> {
++        let merged_tree_value =
++            self.tree
++                .path_value(path)
++                .await
++                .map_err(|err| GitAttributesError {
++                    message: "Could not retrieve the value from path".to_string(),
++                    source: err.into(),
++                })?;
++        let maybe_file_merge = merged_tree_value.to_file_merge();
++        // try to resolve the file
++        let id = match maybe_file_merge
++            .as_ref()
++            .and_then(|files| files.resolve_trivial(SameChange::Accept))
++        {
++            Some(Some(id)) => id,
++            Some(None) => return Ok(None),
++            None => {
++                // conflict path
++                let Some(id) = merged_tree_value.iter().find_map(|tree_value| {
++                    let Some(TreeValue::File { id, .. }) = tree_value else {
++                        return None;
++                    };
++                    Some(id)
++                }) else {
++                    return Ok(None);
++                };
++                id
++            }
++        };
++
++        let result =
++            self.tree
++                .store()
++                .read_file(path, id)
++                .await
++                .map_err(|err| GitAttributesError {
++                    message: "Could not retrieve the value from path".to_string(),
++                    source: err.into(),
++                })?;
++        Ok(Some(Box::new(result)))
++    }
++}
++
++pub(crate) struct DiskFileLoader {
++    repo_root: PathBuf,
++}
++
++impl DiskFileLoader {
++    pub fn new(repo_root: PathBuf) -> Self {
++        Self { repo_root }
++    }
++}
++
++#[async_trait::async_trait]
++impl FileLoader for DiskFileLoader {
++    async fn load(
++        &self,
++        path: &RepoPath,
++    ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError> {
++        let path = path
++            .to_fs_path(&self.repo_root)
++            .map_err(|err| GitAttributesError {
++                message: "Could not convert path into fs path".to_string(),
++                source: err.into(),
++            })?;
++
++        // we use symlink_metadata to not follow symlinks to follow Git's behavior.
++        let metadata = match path.symlink_metadata() {
++            Ok(metadata) => metadata,
++            Err(err) if matches!(err.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => {
++                return Ok(None);
++            }
++            Err(err) => {
++                return Err(GitAttributesError {
++                    message: format!("Failed to obtain the file metadata of {}", path.display()),
++                    source: err.into(),
++                });
++            }
++        };
++        if !metadata.is_file() {
++            return Ok(None);
++        }
++
++        let file = match File::open(&path) {
++            Ok(file) => file,
++            Err(error)
++                if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) =>
++            {
++                return Ok(None);
++            }
++            Err(err) => {
++                return Err(GitAttributesError {
++                    message: format!("Failed to open the file at {}", path.display()),
++                    source: err.into(),
++                });
++            }
++        };
++        Ok(Some(Box::new(AllowStdIo::new(file))))
++    }
++}
++
++struct SearchAndCollection {
++    search: Search,
++    collection: MetadataCollection,
++}
++
++struct GitAttributesNode {
++    disk_first: futures::lock::Mutex<Option<Arc<SearchAndCollection>>>,
++    store_first: futures::lock::Mutex<Option<Arc<SearchAndCollection>>>,
++    disk_file_loader: Arc<dyn FileLoader>,
++    store_file_loader: Arc<dyn FileLoader>,
++    parent: Option<Arc<Self>>,
++    path: RepoPathBuf,
++}
++
++impl GitAttributesNode {
++    async fn get_disk_first(&self) -> Result<Arc<SearchAndCollection>, GitAttributesError> {
++        self.primary_then_secondary(SearchPriority::Disk).await
++    }
++    async fn get_store_first(&self) -> Result<Arc<SearchAndCollection>, GitAttributesError> {
++        self.primary_then_secondary(SearchPriority::Store).await
++    }
++
++    fn store(
++        &self,
++        priority: SearchPriority,
++    ) -> &futures::lock::Mutex<Option<Arc<SearchAndCollection>>> {
++        match priority {
++            SearchPriority::Store => &self.store_first,
++            SearchPriority::Disk => &self.disk_first,
++        }
++    }
++
++    fn primary_loader(&self, priority: SearchPriority) -> &Arc<dyn FileLoader> {
++        match priority {
++            SearchPriority::Store => &self.store_file_loader,
++            SearchPriority::Disk => &self.disk_file_loader,
++        }
++    }
++
++    fn secondary_loader(&self, priority: SearchPriority) -> &Arc<dyn FileLoader> {
++        match priority {
++            SearchPriority::Store => &self.disk_file_loader,
++            SearchPriority::Disk => &self.store_file_loader,
++        }
++    }
++
++    async fn primary_then_secondary(
++        &self,
++        priority: SearchPriority,
++    ) -> Result<Arc<SearchAndCollection>, GitAttributesError> {
++        let store = self.store(priority);
++        let mut guard = store.lock().await;
++        if let Some(res) = guard.as_ref() {
++            return Ok(res.clone());
++        }
++
++        let parent_metadata = match &self.parent {
++            // we use pin because this is a recursive call
++            Some(parent) => Box::pin(parent.primary_then_secondary(priority)).await?,
++            None => {
++                let mut search = Search::default();
++                let mut collection = MetadataCollection::default();
++                // initialize search
++                search.add_patterns_buffer(
++                    b"[attr]binary -diff -merge -text",
++                    "[builtin]".into(),
++                    None,
++                    &mut collection,
++                    true, /* allow macros */
++                );
++                Arc::new(SearchAndCollection { search, collection })
++            }
++        };
++        let git_attributes_path =
++            self.path
++                .join(RepoPathComponent::new(".gitattributes").map_err(|err| {
++                    GitAttributesError {
++                        message: "Could not join path with .gitattributes".to_string(),
++                        source: err.into(),
++                    }
++                })?);
++        let mut async_reader = match self
++            .primary_loader(priority)
++            .load(&git_attributes_path)
++            .await?
++        {
++            Some(reader) => reader,
++            None => {
++                // fallback to the secondary loader
++                match self
++                    .secondary_loader(priority)
++                    .load(&git_attributes_path)
++                    .await?
++                {
++                    Some(reader) => reader,
++                    None => {
++                        *guard = Some(parent_metadata.clone());
++                        return Ok(parent_metadata);
++                    }
++                }
++            }
++        };
++        let mut bytes = Vec::new();
++        async_reader
++            .read_to_end(&mut bytes)
++            .await
++            .map_err(|err| GitAttributesError {
++                source: err.into(),
++                message: "Could not read .gitattributes file".into(),
++            })?;
++        let mut search = parent_metadata.search.clone();
++        let mut collection = parent_metadata.collection.clone();
++
++        search.add_patterns_buffer(
++            &bytes,
++            git_attributes_path
++                .to_fs_path(&PathBuf::new())
++                .map_err(|err| GitAttributesError {
++                    message: "Could not convert gitattributes path into PathBuf".to_string(),
++                    source: err.into(),
++                })?,
++            Some(&PathBuf::new()),
++            &mut collection,
++            // Macros can only be defined in top-level gitattributes:
++            // https://git-scm.com/docs/gitattributes#_defining_macro_attributes
++            self.parent.is_none(), /* allow macros */
++        );
++
++        let res = Arc::new(SearchAndCollection { search, collection });
++        *guard = Some(res.clone());
++        Ok(res)
++    }
++}
++
++#[derive(Clone, Copy)]
++pub(crate) enum SearchPriority {
++    Store,
++    Disk,
++}
++
++impl GitAttributes {
++    /// Creates a new instance with store and disk file loaders.
++    pub fn new(
++        store_file_loader: impl FileLoader + 'static,
++        disk_file_loader: impl FileLoader + 'static,
++    ) -> Self {
++        Self {
++            store_file_loader: Arc::new(store_file_loader),
++            disk_file_loader: Arc::new(disk_file_loader),
++            node_cache: Default::default(),
++        }
++    }
++
++    pub(crate) async fn search(
++        &self,
++        path: &RepoPath,
++        attribute_names: impl AsRef<[&str]>,
++        priority: SearchPriority,
++    ) -> Result<HashMap<String, State>, GitAttributesError> {
++        let attributes = self.get_git_attributes_node(path);
++        let store = match priority {
++            SearchPriority::Store => attributes.get_store_first().await?,
++            SearchPriority::Disk => attributes.get_disk_first().await?,
++        };
++        let SearchAndCollection { search, collection } = &*store;
++
++        let mut out = Outcome::default();
++        out.initialize_with_selection(
++            collection,
++            // From<&str> is implemented for KStringRef
++            attribute_names.as_ref().iter().copied(),
++        );
++        search.pattern_matching_relative_path(
++            path.as_internal_file_string().into(),
++            Case::Sensitive,
++            None,
++            &mut out,
++        );
++
++        let mut map = out
++            .iter_selected()
++            .map(|attr| {
++                let val = attr.assignment.to_owned();
++                (val.name.as_str().to_string(), val.state)
++            })
++            .collect::<HashMap<String, State>>();
++
++        // go over attributes and mark as unspecified if not set
++        for attribute_name in attribute_names.as_ref() {
++            map.entry(attribute_name.to_string())
++                .or_insert(State::Unspecified);
++        }
++
++        Ok(map)
++    }
++
++    fn get_git_attributes_node(&self, path: &RepoPath) -> Arc<GitAttributesNode> {
++        let path = path.parent().unwrap_or(RepoPath::root());
++        let mut val = self.node_cache.lock().expect("Not be poisoned");
++        self.inner(&mut val, path)
++    }
++
++    fn inner(
++        &self,
++        map: &mut HashMap<RepoPathBuf, Arc<GitAttributesNode>>,
++        path: &RepoPath,
++    ) -> Arc<GitAttributesNode> {
++        if let Some(node) = map.get(path) {
++            return node.clone();
++        }
++        let parent = path.parent().map(|parent| self.inner(map, parent));
++        let new_node = Arc::new(GitAttributesNode {
++            disk_first: futures::lock::Mutex::new(None),
++            store_first: futures::lock::Mutex::new(None),
++            disk_file_loader: self.disk_file_loader.clone(),
++            store_file_loader: self.store_file_loader.clone(),
++            parent,
++            path: path.to_owned(),
++        });
++        map.insert(path.to_owned(), new_node.clone());
++        new_node
++    }
++}
++
++impl GitAttributes {
++    /// Returns whether the given `path` has a `filter` attribute in
++    /// .gitattributes whose value is contained in `ignore_filters`.
++    pub(crate) async fn filter_matches(
++        &self,
++        path: &RepoPath,
++        ignore_filters: &HashSet<String>,
++        priority: SearchPriority,
++    ) -> Result<bool, GitAttributesError> {
++        if ignore_filters.is_empty() {
++            return Ok(false);
++        }
++        let result = self.search(path, ["filter"], priority).await?;
++
++        let Some(State::Value(value)) = result.get("filter") else {
++            return Ok(false);
++        };
++        let value = value.as_ref().as_bstr();
++        Ok(ignore_filters.iter().any(|state| value == state.as_str()))
++    }
++}
++
++/// Errors for GitAttributes
++#[derive(Debug, thiserror::Error)]
++#[error("{message}")]
++pub struct GitAttributesError {
++    message: String,
++    #[source]
++    source: Box<dyn std::error::Error + Send + Sync>,
++}
++
++#[cfg(test)]
++mod tests {
++
++    use futures::io::Cursor;
++    use gix::attrs::state::Value;
++    use indoc::indoc;
++    use pollster::FutureExt as _;
++
++    use super::*;
++    use crate::tests::new_temp_dir;
++
++    type TestStore = HashMap<RepoPathBuf, Result<String, String>>;
++
++    #[async_trait::async_trait]
++    impl FileLoader for TestStore {
++        async fn load(
++            &self,
++            path: &RepoPath,
++        ) -> Result<Option<Box<dyn AsyncRead + Send + Unpin>>, GitAttributesError> {
++            let Some(mocked_result) = self.get(path) else {
++                return Ok(None);
++            };
++            let data = mocked_result.clone();
++            match data {
++                Ok(data) => Ok(Some(Box::new(Cursor::new(Vec::from(data))))),
++                Err(message) => Err(GitAttributesError {
++                    message: message.clone(),
++                    source: message.into(),
++                }),
++            }
++        }
++    }
++
++    fn repo_path(path: &str) -> &RepoPath {
++        RepoPath::from_internal_string(path).unwrap()
++    }
++
++    fn create_git_attributes(files: &[(&'static str, &'static str)]) -> GitAttributes {
++        let data: TestStore = files
++            .iter()
++            .map(|(key, value)| (repo_path(key).to_owned(), Ok(value.to_string())))
++            .collect();
++
++        GitAttributes::new(HashMap::new(), data)
++    }
++
++    fn assert_search_output(
++        git_attributes: &GitAttributes,
++        file: &str,
++        attribute: &str,
++        expected: State,
++    ) {
++        assert_search_output_with_priority(
++            git_attributes,
++            file,
++            attribute,
++            expected,
++            SearchPriority::Disk,
++        );
++    }
++
++    fn assert_search_output_with_priority(
++        git_attributes: &GitAttributes,
++        file: &str,
++        attribute: &str,
++        expected: State,
++        search: SearchPriority,
++    ) {
++        let map = git_attributes
++            .search(repo_path(file), &[attribute], search)
++            .block_on()
++            .unwrap();
++        assert_eq!(map.len(), 1);
++        assert_eq!(*map.get(attribute).unwrap(), expected);
++    }
++
++    fn assert_attribute_output(
++        git_attributes_content: &'static str,
++        file: &str,
++        attribute: &str,
++        expected: State,
++    ) {
++        let git_attributes = create_git_attributes(&[(".gitattributes", git_attributes_content)]);
++        assert_search_output(&git_attributes, file, attribute, expected);
++    }
++
++    #[test]
++    fn test_search_state_set() {
++        assert_attribute_output("abc foo", "abc", "foo", State::Set);
++    }
++
++    #[test]
++    fn test_search_state_eol_lf() {
++        assert_attribute_output("foo eol=lf", "foo", "eol", State::Value(Value::from("lf")));
++    }
++
++    #[test]
++    fn test_search_unset() {
++        assert_attribute_output("foo -text", "foo", "text", State::Unset);
++    }
++
++    #[test]
++    fn test_search_unspecified() {
++        assert_attribute_output("foo !text", "foo", "text", State::Unspecified);
++    }
++
++    #[test]
++    fn test_search_unspecified_no_pattern() {
++        assert_attribute_output("foo elo=lf", "bar", "text", State::Unspecified);
++        assert_attribute_output("foo elo=lf", "bar", "elo", State::Unspecified);
++    }
++
++    #[test]
++    fn test_path_and_pattern_matching() {
++        // Using https://git-scm.com/docs/gitattributes#_examples as example
++        // It's not 1:1 because we don't support $GIT_DIR/info/.gitattributes
++        // that would override in-tree settings.
++        let git_attributes = create_git_attributes(&[
++            (".gitattributes", "abc	foo bar baz"),
++            (
++                "t/.gitattributes",
++                indoc! {"
++                    ab*	merge=filfre
++                    abc	-foo -bar
++                    *.c	frotz
++                "},
++            ),
++        ]);
++
++        assert_search_output(&git_attributes, "t/abc", "foo", State::Unset);
++        assert_search_output(&git_attributes, "t/abc", "bar", State::Unset);
++        assert_search_output(&git_attributes, "t/abc", "baz", State::Set);
++        assert_search_output(
++            &git_attributes,
++            "t/abc",
++            "merge",
++            State::Value(Value::from("filfre")),
++        );
++        assert_search_output(&git_attributes, "t/abc", "frotz", State::Unspecified);
++    }
++
++    #[test]
++    fn test_glob_matching() {
++        assert_attribute_output("*.txt text", "bar.txt", "text", State::Set);
++        assert_attribute_output("foo/ text", "foo/bar.txt", "text", State::Unspecified);
++        assert_attribute_output("**/bar.rs text", "baz/bar.rs", "text", State::Set);
++    }
++
++    #[test]
++    fn test_case_sensitive_attr() {
++        assert_attribute_output(
++            indoc! {"
++                foo text
++                FOO -text
++            "},
++            "foo",
++            "text",
++            State::Set,
++        );
++        assert_attribute_output(
++            indoc! {"
++                foo text
++                FOO -text
++            "},
++            "FOO",
++            "text",
++            State::Unset,
++        );
++    }
++
++    #[test]
++    fn test_subdirectory_override() {
++        let git_attributes = create_git_attributes(&[
++            (".gitattributes", "abc	!baz"),
++            ("t/.gitattributes", "abc baz"),
++        ]);
++        assert_search_output(&git_attributes, "t/abc", "baz", State::Set);
++    }
++
++    #[test]
++    fn test_subdirectory_root_match() {
++        assert_attribute_output("baz/foo text", "baz/foo", "text", State::Set);
++    }
++
++    #[test]
++    fn test_directory_shouldnt_match() {
++        assert_attribute_output("foo text", "foo/bar.txt", "text", State::Unspecified);
++    }
++
++    #[test]
++    fn test_subdirectory_attribute() {
++        let git_attributes = create_git_attributes(&[("foo/.gitattributes", "bar.txt text")]);
++        assert_search_output(&git_attributes, "foo/bar.txt", "text", State::Set);
++    }
++
++    #[test]
++    fn test_macro_definition() {
++        let git_attributes = create_git_attributes(&[
++            (
++                ".gitattributes",
++                indoc! {"
++                    [attr]base_macro a
++                    [attr]override_macro b
++                "},
++            ),
++            (
++                "foo1/.gitattributes",
++                indoc! {"
++                    # this macro definition shouldn't take effect.
++                    [attr]override_macro c
++                    # this macro definition shouldn't take effect.
++                    [attr]new_macro e
++                    bar base_macro override_macro new_macro
++                "},
++            ),
++            (
++                "foo2/.gitattributes",
++                indoc! {"
++                    # this macro definition shouldn't take effect.
++                    [attr]override_macro d
++                    # this macro definition shouldn't take effect.
++                    [attr]new_macro e
++                    bar base_macro override_macro new_macro
++                "},
++            ),
++        ]);
++        assert_search_output(&git_attributes, "foo1/bar", "a", State::Set);
++        assert_search_output(&git_attributes, "foo1/bar", "b", State::Set);
++        assert_search_output(&git_attributes, "foo1/bar", "c", State::Unspecified);
++        assert_search_output(&git_attributes, "foo1/bar", "d", State::Unspecified);
++        assert_search_output(&git_attributes, "foo1/bar", "e", State::Unspecified);
++        assert_search_output(&git_attributes, "foo1/bar", "new_macro", State::Set);
++
++        assert_search_output(&git_attributes, "foo2/bar", "a", State::Set);
++        assert_search_output(&git_attributes, "foo2/bar", "b", State::Set);
++        assert_search_output(&git_attributes, "foo2/bar", "c", State::Unspecified);
++        assert_search_output(&git_attributes, "foo2/bar", "d", State::Unspecified);
++        assert_search_output(&git_attributes, "foo2/bar", "e", State::Unspecified);
++        assert_search_output(&git_attributes, "foo2/bar", "new_macro", State::Set);
++    }
++
++    #[test]
++    fn test_search_priority_and_fallback() {
++        let disk = TestStore::from([(
++            repo_path("foo/.gitattributes").to_owned(),
++            Ok("*.txt text".to_string()),
++        )]);
++
++        let store = TestStore::from([(
++            repo_path("bar/.gitattributes").to_owned(),
++            Ok("*.txt text".to_string()),
++        )]);
++
++        let git_attributes = GitAttributes::new(store, disk);
++        assert_search_output_with_priority(
++            &git_attributes,
++            "foo/bar.txt",
++            "text",
++            State::Set,
++            SearchPriority::Disk,
++        );
++        assert_search_output_with_priority(
++            &git_attributes,
++            "foo/bar.txt",
++            "text",
++            State::Set,
++            SearchPriority::Store,
++        );
++        assert_search_output_with_priority(
++            &git_attributes,
++            "bar/bar.txt",
++            "text",
++            State::Set,
++            SearchPriority::Store,
++        );
++        assert_search_output_with_priority(
++            &git_attributes,
++            "bar/bar.txt",
++            "text",
++            State::Set,
++            SearchPriority::Disk,
++        );
++    }
++
++    #[test]
++    fn test_file_loader_io_error() {
++        let store: TestStore = TestStore::from([(
++            repo_path(".gitattributes").to_owned(),
++            Err("There was an IO error".to_string()),
++        )]);
++
++        let git_attributes = GitAttributes::new(store, HashMap::new());
++        let result = &git_attributes
++            .search(repo_path("foo/bar.txt"), &["text"], SearchPriority::Disk)
++            .block_on();
++        assert!(result.is_err());
++        assert_eq!(
++            &result.as_ref().unwrap_err().message,
++            "There was an IO error",
++        );
++    }
++
++    fn matches(input: &str, path: &str) -> bool {
++        let data = TestStore::from([(
++            RepoPathBuf::from_internal_string(".gitattributes").unwrap(),
++            Ok(input.to_string()),
++        )]);
++        let attributes = GitAttributes::new(HashMap::new(), data);
++
++        attributes
++            .filter_matches(
++                repo_path(path),
++                &HashSet::from(["lfs".to_string()]),
++                SearchPriority::Disk,
++            )
++            .block_on()
++            .unwrap()
++    }
++
++    // Regression test for a bug found during development.
++    //
++    // `filter_matches()` is used by snapshot to decide whether a matching file
++    // should be omitted. If loading .gitattributes fails, returning false would
++    // silently snapshot a file that may be intentionally excluded. Preserve the
++    // error so snapshot fails visibly instead.
++    #[test]
++    fn test_filter_matches_propagates_error() {
++        let store: TestStore = TestStore::from([(
++            repo_path(".gitattributes").to_owned(),
++            Err("There was an IO error".to_string()),
++        )]);
++        let attributes = GitAttributes::new(store, HashMap::new());
++
++        let error = attributes
++            .filter_matches(
++                repo_path("file.bin"),
++                &HashSet::from(["lfs".to_string()]),
++                SearchPriority::Disk,
++            )
++            .block_on()
++            .unwrap_err();
++
++        assert_eq!(error.message, "There was an IO error");
++    }
++
++    // Regression test for a bug found during development.
++    //
++    // This is distinct from `test_filter_matches_propagates_error()`: probing
++    // for optional `.gitattributes` files below a path component that is
++    // actually a file means no attributes file exists at that path. Treating
++    // `NotADirectory` as fatal caused unrelated snapshot operations, such as
++    // rename detection, to fail when they evaluated attributes for paths like
++    // `file/.gitattributes`.
++    #[test]
++    fn test_disk_file_loader_ignores_file_in_attributes_path() {
++        let temp_dir = new_temp_dir();
++        std::fs::write(temp_dir.path().join("file"), "contents").unwrap();
++        let loader = DiskFileLoader::new(temp_dir.path().to_owned());
++
++        let loaded = loader
++            .load(repo_path("file/.gitattributes"))
++            .block_on()
++            .unwrap();
++
++        assert!(loaded.is_none());
++    }
++
++    #[test]
++    fn test_gitattributes_empty_file() {
++        assert!(!matches("", "foo"));
++    }
++
++    #[test]
++    fn test_gitattributes_simple_match() {
++        assert!(matches("*.bin filter=lfs\n", "file.bin"));
++        assert!(!matches("*.bin filter=lfs\n", "file.txt"));
++        assert!(!matches("*.bin filter=other\n", "file.bin"));
++        assert!(!matches("*.bin filter=other\n", "path/to/file.bin"));
++    }
++
++    #[test]
++    fn test_gitattributes_directory_match() {
++        // patterns that match a directory do not recursively match paths inside that
++        // directory (so using the trailing-slash path/ syntax is pointless in
++        // an attributes file; use path/** instead)
++        // https://git-scm.com/docs/gitattributes#_description
++        assert!(!matches("dir/ filter=lfs\n", "dir/file.txt"));
++        assert!(!matches("dir/ filter=lfs\n", "other/file.txt"));
++        assert!(!matches("dir/ filter=lfs\n", "dir"));
++    }
++
++    #[test]
++    fn test_gitattributes_path_match() {
++        assert!(matches("path/to/file.bin filter=lfs\n", "path/to/file.bin"));
++        assert!(!matches("path/to/file.bin filter=lfs\n", "path/file.bin"));
++    }
++
++    #[test]
++    fn test_gitattributes_wildcard_match() {
++        assert!(matches("*.bin filter=lfs\n", "file.bin"));
++        assert!(matches("file.* filter=lfs\n", "file.bin"));
++        assert!(matches("**/file.bin filter=lfs\n", "path/to/file.bin"));
++    }
++
++    #[test]
++    fn test_gitattributes_multiple_attributes() {
++        let input = "*.bin filter=lfs diff=binary\n";
++        assert!(matches(input, "file.bin"));
++        assert!(!matches("*.bin diff=binary\n", "file.bin")); // Only testing filter=lfs
++    }
++
++    #[test]
++    fn test_gitattributes_chained_files() {
++        let data = TestStore::from([
++            (
++                repo_path(".gitattributes").to_owned(),
++                Ok("*.bin filter=lfs\n".to_string()),
++            ),
++            (
++                repo_path("subdir/.gitattributes").to_owned(),
++                Ok("*.txt filter=text\n".to_string()),
++            ),
++        ]);
++        let attributes = GitAttributes::new(HashMap::new(), data);
++
++        let filters = &HashSet::from(["lfs".to_string(), "text".to_string()]);
++        assert!(
++            attributes
++                .filter_matches(repo_path("file.bin"), filters, SearchPriority::Disk)
++                .block_on()
++                .unwrap()
++        );
++        assert!(
++            attributes
++                .filter_matches(repo_path("subdir/file.txt"), filters, SearchPriority::Disk)
++                .block_on()
++                .unwrap()
++        );
++        assert!(
++            !attributes
++                .filter_matches(repo_path("file.txt"), filters, SearchPriority::Disk)
++                .block_on()
++                .unwrap()
++        ); // Not in subdir
++    }
++
++    #[test]
++    fn test_gitattributes_negated_pattern() {
++        let input = "*.bin filter=lfs\n!important.bin filter=lfs\n";
++        assert!(matches(input, "file.bin"));
++        // negative patterns are forbidden
++        // https://git-scm.com/docs/gitattributes#_description
++        assert!(matches(input, "important.bin"));
++    }
++
++    #[test]
++    fn test_gitattributes_multiple_filters() {
++        let data = TestStore::from([
++            (
++                repo_path(".gitattributes").to_owned(),
++                Ok(indoc! {"
++                    *.bin filter=lfs
++                    *.secret filter=git-crypt
++                    *.txt filter=other
++                "}
++                .to_string()),
++            ),
++            (
++                repo_path("subdir/.gitattributes").to_owned(),
++                Ok("*.txt filter=text\n".to_string()),
++            ),
++        ]);
++
++        // Create a GitAttributesFile with both "lfs" and "git-crypt" as ignore filters
++        let attributes = GitAttributes::new(HashMap::new(), data);
++
++        let filters = &HashSet::from(["lfs".to_string(), "git-crypt".to_string()]);
++
++        // Test with lfs filter
++        assert!(
++            attributes
++                .filter_matches(repo_path("file.bin"), filters, SearchPriority::Disk)
++                .block_on()
++                .unwrap()
++        );
++        // Test with git-crypt filter
++        assert!(
++            attributes
++                .filter_matches(
++                    repo_path("credentials.secret"),
++                    filters,
++                    SearchPriority::Disk
++                )
++                .block_on()
++                .unwrap()
++        );
++        // Not In the filter
++        assert!(
++            !attributes
++                .filter_matches(repo_path("file.bin2"), filters, SearchPriority::Disk)
++                .block_on()
++                .unwrap()
++        );
++        // Test that other filters don't match
++        assert!(
++            !attributes
++                .filter_matches(repo_path("file.txt"), filters, SearchPriority::Disk)
++                .block_on()
++                .unwrap()
++        );
++    }
++}
+diff --git a/lib/src/lib.rs b/lib/src/lib.rs
+index 7cb8160..c990b62 100644
+--- a/lib/src/lib.rs
++++ b/lib/src/lib.rs
+@@ -55,6 +55,8 @@ pub mod git;
+ pub mod git_backend;
+ #[cfg(feature = "git")]
+ mod git_subprocess;
++#[cfg(feature = "git")]
++pub mod gitattributes;
+ pub mod gitignore;
+ pub mod gpg_signing;
+ pub use jj_core::graph;
+diff --git a/lib/src/local_working_copy.rs b/lib/src/local_working_copy.rs
+index 87da9f5..c3547b3 100644
+--- a/lib/src/local_working_copy.rs
++++ b/lib/src/local_working_copy.rs
+@@ -93,6 +93,16 @@ use crate::fsmonitor::FsmonitorSettings;
+ use crate::fsmonitor::WatchmanConfig;
+ #[cfg(feature = "watchman")]
+ use crate::fsmonitor::watchman;
++#[cfg(feature = "git")]
++use crate::git::GitSettings;
++#[cfg(feature = "git")]
++use crate::gitattributes::DiskFileLoader;
++#[cfg(feature = "git")]
++use crate::gitattributes::GitAttributes;
++#[cfg(feature = "git")]
++use crate::gitattributes::SearchPriority;
++#[cfg(feature = "git")]
++use crate::gitattributes::TreeFileLoader;
+ use crate::gitignore::GitIgnoreFile;
+ use crate::lock::FileLock;
+ use crate::matchers::DifferenceMatcher;
+@@ -975,6 +985,10 @@ pub struct TreeStateSettings {
+     pub exec_change_setting: ExecChangeSetting,
+     /// The fsmonitor (e.g. Watchman) to use, if any.
+     pub fsmonitor_settings: FsmonitorSettings,
++
++    /// Names of .gitattributes filters whose matching files should be ignored
++    /// in the working copy.
++    pub ignore_filters: HashSet<String>,
+ }
+ 
+ impl TreeStateSettings {
+@@ -985,6 +999,19 @@ impl TreeStateSettings {
+             eol_conversion_mode: EolConversionMode::try_from_settings(user_settings)?,
+             exec_change_setting: user_settings.get("working-copy.exec-bit-change")?,
+             fsmonitor_settings: FsmonitorSettings::from_settings(user_settings)?,
++            ignore_filters: {
++                #[cfg(feature = "git")]
++                {
++                    GitSettings::from_settings(user_settings)?
++                        .ignore_filters
++                        .into_iter()
++                        .collect()
++                }
++                #[cfg(not(feature = "git"))]
++                {
++                    HashSet::new()
++                }
++            },
+         })
+     }
+ }
+@@ -1009,6 +1036,9 @@ pub struct TreeState {
+     exec_policy: ExecChangePolicy,
+     fsmonitor_settings: FsmonitorSettings,
+     target_eol_strategy: TargetEolStrategy,
++
++    #[cfg_attr(not(feature = "git"), allow(dead_code))]
++    ignore_filters: HashSet<String>,
+ }
+ 
+ #[derive(Debug, Error)]
+@@ -1082,9 +1112,18 @@ impl TreeState {
+             eol_conversion_mode,
+             exec_change_setting,
+             fsmonitor_settings,
++            ignore_filters: settings_ignore_filters,
+         }: &TreeStateSettings,
+     ) -> Self {
+         let exec_policy = ExecChangePolicy::new(*exec_change_setting, &state_path);
++
++        // Only enable ignore filters when using Git.
++        let ignore_filters = if store.backend().name() == "git" {
++            settings_ignore_filters.clone()
++        } else {
++            HashSet::new()
++        };
++
+         Self {
+             store: store.clone(),
+             working_copy_path,
+@@ -1099,6 +1138,7 @@ impl TreeState {
+             exec_policy,
+             fsmonitor_settings: fsmonitor_settings.clone(),
+             target_eol_strategy: TargetEolStrategy::new(*eol_conversion_mode),
++            ignore_filters,
+         }
+     }
+ 
+@@ -1332,6 +1372,11 @@ impl TreeState {
+         let (untracked_paths_tx, untracked_paths_rx) = channel();
+         let (invalid_utf8_paths_tx, invalid_utf8_paths_rx) = channel();
+         let (deleted_files_tx, deleted_files_rx) = channel();
++        #[cfg(feature = "git")]
++        let git_attributes = Arc::new(GitAttributes::new(
++            TreeFileLoader::new(self.tree.clone()),
++            DiskFileLoader::new(self.working_copy_path.clone()),
++        ));
+ 
+         trace_span!("traverse filesystem").in_scope(|| -> Result<(), SnapshotError> {
+             let snapshotter = FileSnapshotter {
+@@ -1349,6 +1394,10 @@ impl TreeState {
+                 error: OnceLock::new(),
+                 progress: *progress,
+                 max_new_file_size: *max_new_file_size,
++                #[cfg(feature = "git")]
++                git_attributes,
++                #[cfg(feature = "git")]
++                ignore_filters: self.ignore_filters.clone(),
+             };
+             let directory_to_visit = DirectoryToVisit {
+                 dir: RepoPathBuf::root(),
+@@ -1524,6 +1573,11 @@ struct FileSnapshotter<'a> {
+     error: OnceLock<SnapshotError>,
+     progress: Option<&'a SnapshotProgress<'a>>,
+     max_new_file_size: u64,
++
++    #[cfg(feature = "git")]
++    git_attributes: Arc<GitAttributes>,
++    #[cfg(feature = "git")]
++    ignore_filters: HashSet<String>,
+ }
+ 
+ impl FileSnapshotter<'_> {
+@@ -1589,7 +1643,7 @@ impl FileSnapshotter<'_> {
+             })
+             .collect::<Result<_, _>>()?;
+         let present_entries = PresentDirEntries { dirs, files };
+-        self.emit_deleted_files(&dir, file_states, &present_entries);
++        self.emit_deleted_files(&dir, file_states, &present_entries)?;
+         Ok(())
+     }
+ 
+@@ -1671,8 +1725,20 @@ impl FileSnapshotter<'_> {
+             if let Some(progress) = self.progress {
+                 progress(&path);
+             }
++            #[cfg(feature = "git")]
++            if self
++                .git_attributes
++                .filter_matches(&path, &self.ignore_filters, SearchPriority::Disk)
++                .block_on()?
++            {
++                // Skip gitattributes files that we want to ignore - this
++                // would result in them showing up as deleted, but we also
++                // omit them in `emit_deleted_files` to avoid that.
++                return Ok(None);
++            }
+             if maybe_current_file_state.is_none()
+-                && (git_ignore.matches_file(&path) && !self.force_tracking_matcher.matches(&path))
++                && (git_ignore.matches_file(path.as_ref())
++                    && !self.force_tracking_matcher.matches(&path))
+             {
+                 // If it wasn't already tracked and it matches
+                 // the ignored paths, then ignore it.
+@@ -1731,6 +1797,14 @@ impl FileSnapshotter<'_> {
+             if !self.matcher.matches(tracked_path) {
+                 continue;
+             }
++            #[cfg(feature = "git")]
++            if self
++                .git_attributes
++                .filter_matches(tracked_path, &self.ignore_filters, SearchPriority::Disk)
++                .await?
++            {
++                continue;
++            }
+             let disk_path = tracked_path.to_fs_path(&self.tree_state.working_copy_path)?;
+             let metadata = match disk_path.symlink_metadata() {
+                 Ok(metadata) => Some(metadata),
+@@ -1792,7 +1866,7 @@ impl FileSnapshotter<'_> {
+         dir: &RepoPath,
+         file_states: FileStates<'_>,
+         present_entries: &PresentDirEntries,
+-    ) {
++    ) -> Result<(), SnapshotError> {
+         let file_state_chunks = file_states.iter().chunk_by(|(path, _state)| {
+             // Extract <name> from <dir>, <dir>/<name>, or <dir>/<name>/**.
+             // (file_states may contain <dir> file on file->dir transition.)
+@@ -1805,18 +1879,34 @@ impl FileSnapshotter<'_> {
+                 None => (PresentDirEntryKind::File, tail),
+             }
+         });
+-        file_state_chunks
++        for (_, chunk) in file_state_chunks
+             .into_iter()
+             .filter(|&((kind, name), _)| match kind {
+                 PresentDirEntryKind::Dir => !present_entries.dirs.contains(name),
+                 PresentDirEntryKind::File => !present_entries.files.contains(name),
+             })
+-            .flat_map(|(_, chunk)| chunk)
+-            // Whether or not the entry exists, submodule should be ignored
+-            .filter(|(_, state)| state.file_type != FileType::GitSubmodule)
+-            .filter(|(path, _)| self.matcher.matches(path))
+-            .try_for_each(|(path, _)| self.deleted_files_tx.send(path.to_owned()))
+-            .ok();
++        {
++            for (path, state) in chunk {
++                // Whether or not the entry exists, submodule should be ignored.
++                if state.file_type == FileType::GitSubmodule {
++                    continue;
++                }
++                #[cfg(feature = "git")]
++                if self
++                    .git_attributes
++                    .filter_matches(path, &self.ignore_filters, SearchPriority::Disk)
++                    .block_on()?
++                {
++                    // Whether or not the entry exists, ignored gitattributes
++                    // files should be omitted.
++                    continue;
++                }
++                if self.matcher.matches(path) {
++                    self.deleted_files_tx.send(path.to_owned()).ok();
++                }
++            }
++        }
++        Ok(())
+     }
+ 
+     async fn get_updated_tree_value(
+diff --git a/lib/src/working_copy.rs b/lib/src/working_copy.rs
+index 0d4317c..6b3950a 100644
+--- a/lib/src/working_copy.rs
++++ b/lib/src/working_copy.rs
+@@ -28,6 +28,8 @@ use tracing::instrument;
+ 
+ use crate::backend::BackendError;
+ use crate::commit::Commit;
++#[cfg(feature = "git")]
++use crate::gitattributes::GitAttributesError;
+ use crate::gitignore::GitIgnoreError;
+ use crate::gitignore::GitIgnoreFile;
+ use crate::matchers::Matcher;
+@@ -186,6 +188,10 @@ pub enum SnapshotError {
+     /// Checking path with ignore patterns failed.
+     #[error(transparent)]
+     GitIgnoreError(#[from] GitIgnoreError),
++    /// Checking path with gitattributes patterns failed.
++    #[cfg(feature = "git")]
++    #[error(transparent)]
++    GitAttributesError(#[from] GitAttributesError),
+     /// Failed to load the working copy state.
+     #[error(transparent)]
+     WorkingCopyStateError(#[from] WorkingCopyStateError),
+diff --git a/lib/tests/test_local_working_copy.rs b/lib/tests/test_local_working_copy.rs
+index a689d6e..12d290d 100644
+--- a/lib/tests/test_local_working_copy.rs
++++ b/lib/tests/test_local_working_copy.rs
+@@ -3173,3 +3173,181 @@ fn test_always_store_empty_tree() -> TestResult {
+     assert!(empty_tree.data.is_empty());
+     Ok(())
+ }
++
++#[test_case(TestRepoBackend::Simple ; "simple backend")]
++#[test_case(TestRepoBackend::Git ; "git backend")]
++fn test_gitattributes_ignore_only_on_git_backend(backend: TestRepoBackend) -> TestResult {
++    let mut test_workspace = TestWorkspace::init_with_backend(backend);
++    let workspace_root = test_workspace.workspace.workspace_root().to_owned();
++
++    // Write .gitattributes with LFS filter.
++    std::fs::write(workspace_root.join(".gitattributes"), "*.txt filter=lfs\n").unwrap();
++    // Write a matching file.
++    std::fs::write(workspace_root.join("file.txt"), "some content").unwrap();
++    // Write a non-matching file.
++    std::fs::write(workspace_root.join("file.dat"), "some other content").unwrap();
++
++    let new_tree = test_workspace.snapshot()?;
++
++    // "file.dat" should always be present
++    assert!(
++        new_tree
++            .path_value(repo_path("file.dat"))
++            .block_on()?
++            .is_present()
++    );
++
++    // ".gitattributes" should always be present
++    assert!(
++        new_tree
++            .path_value(repo_path(".gitattributes"))
++            .block_on()?
++            .is_present()
++    );
++
++    match backend {
++        TestRepoBackend::Git => {
++            // Under Git backend, "file.txt" should be ignored
++            assert!(
++                new_tree
++                    .path_value(repo_path("file.txt"))
++                    .block_on()?
++                    .is_absent()
++            );
++        }
++        TestRepoBackend::Simple | TestRepoBackend::Test => {
++            // Under non-Git backends, "file.txt" should NOT be ignored
++            assert!(
++                new_tree
++                    .path_value(repo_path("file.txt"))
++                    .block_on()?
++                    .is_present()
++            );
++        }
++    }
++
++    Ok(())
++}
++
++// Regression test for a bug found during development.
++//
++// Snapshot prefers .gitattributes from disk but falls back to the current tree
++// when the disk file is missing. TreeState starts with an empty tree before
++// checkout, so caching GitAttributes there can leave the fallback pointed at
++// the empty tree even after checkout updates TreeState::tree. Removing the disk
++// .gitattributes file exercises that fallback; the tracked LFS file must keep
++// the tree contents instead of snapshotting the modified disk contents.
++#[test]
++fn test_gitattributes_use_store_fallback_after_checkout() -> TestResult {
++    let mut test_workspace = TestWorkspace::init_with_backend(TestRepoBackend::Git);
++    let repo = &test_workspace.repo;
++    let workspace_root = test_workspace.workspace.workspace_root().to_owned();
++
++    let tree = create_tree(
++        repo,
++        &[
++            (repo_path(".gitattributes"), "*.txt filter=lfs\n"),
++            (repo_path("file.txt"), "original\n"),
++        ],
++    );
++    let commit = commit_with_tree(repo.store(), tree.clone());
++    test_workspace
++        .workspace
++        .check_out(repo.op_id().clone(), None, &commit)
++        .block_on()?;
++
++    std::fs::remove_file(workspace_root.join(".gitattributes"))?;
++    std::fs::write(workspace_root.join("file.txt"), "modified\n")?;
++
++    let new_tree = test_workspace.snapshot()?;
++    assert_eq!(
++        new_tree.path_value(repo_path("file.txt")).block_on()?,
++        tree.path_value(repo_path("file.txt")).block_on()?,
++    );
++
++    Ok(())
++}
++
++// Regression test for a bug found during development.
++//
++// Snapshot handles files that are present on disk and files that were deleted
++// from disk through separate paths. If deletion emission does not apply the
++// .gitattributes filter check, removing a tracked LFS-filtered file from disk
++// records a deletion even though normal snapshots would ignore changes to that
++// file. The tree should keep the tracked contents instead.
++#[test]
++fn test_gitattributes_ignore_deleted_tracked_file() -> TestResult {
++    let mut test_workspace = TestWorkspace::init_with_backend(TestRepoBackend::Git);
++    let repo = test_workspace.repo.clone();
++    let workspace_root = test_workspace.workspace.workspace_root().to_owned();
++
++    let tracked_path = repo_path("file.bin");
++    let tree = create_tree(
++        &repo,
++        &[
++            (repo_path(".gitattributes"), "*.bin filter=lfs\n"),
++            (tracked_path, "original\n"),
++        ],
++    );
++    let commit = commit_with_tree(repo.store(), tree.clone());
++    test_workspace
++        .workspace
++        .check_out(repo.op_id().clone(), None, &commit)
++        .block_on()?;
++
++    std::fs::remove_file(tracked_path.to_fs_path_unchecked(&workspace_root))?;
++
++    let new_tree = test_workspace.snapshot()?;
++    assert_eq!(
++        new_tree.path_value(tracked_path).block_on()?,
++        tree.path_value(tracked_path).block_on()?,
++    );
++
++    Ok(())
++}
++
++// Regression test for a bug found during development.
++//
++// The normal snapshot path checks .gitattributes before updating or deleting a
++// file. When .gitignore ignores an entire directory, snapshot uses a shortcut
++// that scans only already-tracked files in that directory. This test puts a
++// tracked file under that shortcut and marks it with filter=lfs. Modifying the
++// disk file must not update the tree: the .gitattributes filter should still
++// apply even though traversal used the ignored-directory path.
++#[test]
++fn test_gitattributes_ignore_tracked_file_in_gitignored_dir() -> TestResult {
++    let mut test_workspace = TestWorkspace::init_with_backend(TestRepoBackend::Git);
++    let repo = test_workspace.repo.clone();
++    let workspace_root = test_workspace.workspace.workspace_root().to_owned();
++
++    let tracked_path = repo_path("ignored/file.bin");
++    let tree = create_tree(
++        &repo,
++        &[
++            (repo_path(".gitignore"), "/ignored/\n"),
++            (repo_path(".gitattributes"), "ignored/file.bin filter=lfs\n"),
++            (tracked_path, "original\n"),
++        ],
++    );
++    let commit = commit_with_tree(repo.store(), tree.clone());
++    test_workspace
++        .workspace
++        .check_out(repo.op_id().clone(), None, &commit)
++        .block_on()?;
++
++    std::fs::write(
++        tracked_path.to_fs_path_unchecked(&workspace_root),
++        "modified\n",
++    )?;
++
++    let new_tree = test_workspace.snapshot()?;
++
++    // The .gitattributes filter should still apply when .gitignore sends
++    // tracked files through the ignored-directory shortcut.
++    assert_eq!(
++        new_tree.path_value(tracked_path).block_on()?,
++        tree.path_value(tracked_path).block_on()?,
++    );
++
++    Ok(())
++}
modules/dev/jujutsu.nix
@@ -11,9 +11,15 @@
         pkgs,
         lib,
         ...
-      }: {
+      }: let
+        # git.ignore-filters support, from https://github.com/jj-vcs/jj/pull/9635.
+        # TODO: re-check this patch on every nixpkgs bump, drop it once upstreamed.
+        jujutsu = pkgs.jujutsu.overrideAttrs {
+          patches = [./jujutsu-lfs.patch];
+        };
+      in {
         packages = [
-          pkgs.jujutsu
+          jujutsu
           pkgs.jjui
         ];