Repository: jj-vcs/jj (rust)

Issue:
FR: git core.autocrlf config support
Let's use this issue to discuss the design decisions on git `core.autocrlf` support and how that support can integrate with EOL conversion in other backends, especially the fig/mercurial backend, instead of discussing design decision in an ad-hoc way through the review comments on https://github.com/jj-vcs/jj/pull/6728.

* Should jj support the `core.autocrlf` flavor of EOL conversion for git backend or should it be available as a jj config, and available for all backends?
    * If the `core.autocrlf` flavor support is only for git backend, are we in favor of introducing an abstraction layer like the `TargetEolStrategy` trait in https://github.com/jj-vcs/jj/commit/e2293f6f994e29735a0b0f8df597c0bb00f0a5bb to hide the git related code as implementation details. If not, what's the way to hide the implementation details to avoid `#[cfg(feature = "git")]` from scattering everywhere?
    * If the `core.autocrlf` git config flavor support is not only for git backend,
        * Are we going to introduce a new jj setting to override the git config? If so, what are the key and valid values?
        * How should this support interact with other potential backends, especially the fig/mercurial backend that uses `.hgeol`?
* Should the `core.autocrlf` config be part of the `SnapshotOptions` and the `CheckoutOptions` API or should be initialized locally in `local_working_copy.rs`?
    * If we are supporting the `core.autocrlf` flavor config for all backends, changing `SnapshotOptions` and `CheckoutOptions` seem to be the best choice, since we don’t have access to `UserSettings` in `local_working_copy.rs`.
    * `SnapshotOptions` and `CheckoutOptions` are public APIs, and adding fields to them may break downstream projects.
* Handling binary files: we shouldn’t perform EOL conversion for binary files.
    * before we have `.gitattributes`
        * We probe the first N bytes(now 8KB hard coded in the source) from the content, and use the existing logic of [`gix_filter::eol::Stats::is_binary`](https://docs.rs/gix-filter/0.19.2/gix_filter/eol/struct.Stats.html#method.is_binary) to decide whether the file is binary
        * For performance, we only probe the file if the configuration requires EOL conversion, i.e., `core.autocrlf = true` when snapshotting and updating or `core.autocrlf=input` when snapshotting. We don’t probe the file otherwise, i.e., `core.autocrlf=false` when snapshotting and updating or `core.autocrlf=input` when updating.
    * after we have `.gitattributes`, we follow the `text` attribute.
        * If the `text` attribute is unset, we won’t perform the EOL conversion regardless of the `core.autocrlf` config.
        * If the `text` attribute is set, when snapshotting, we always convert the EOL to LF, when updating we perform the EOL conversion according to the `eol` attribute or the `core.eol` git config.
        * If the `text` attribute is set to a string value `“auto”`, we perform the binary file probing described previously in the “before we have `.gitattributes`” section.
        * If the `text` attribute is unspecified, we perform the EOL conversion based on the `core.autocrlf` git config without probing whether the file is binary, which will change the behavior described in the “before we have `.gitattributes`” section.
* Implement the EOL conversion in `local_working_copy.rs`
    * store -> disk EOL conversion
        * We only need LF to CRLF conversion, even if we take `.gitattributes` into consideration, the eol=lf attribute just means “uses the same line endings in the working directory as in the index when the file is checked out” in the gitattributes documentation.
        * In `TreeState::update`, where we handle the `MaterializedTreeValue::File` case, we query the decision on EOL conversion and pass that to `TreeState::write_file` as a new parameter.
        * In the `MaterializedTreeValue::Symlink` case in `TreeState::update`, we pass `TargetEol::PassThrough` to `TreeState::write_file` if the symbol link is not supported. This is the precise reason why we don’t move the logic to query the EOL conversion decision in `TreeState::write_file`, because in this case, we need to write without EOL conversion regardless of any configurations.
        * To implement EOL conversion in `TreeState::write_file`, we need to read the contents from the `AsyncRead`, and conduct the EOL conversion based on the content. We have several options:
            * A separate type, `EolWriter`, that implements the `Write` treat that converts the EOL on the fly. When the caller calls `<EolWriter as std::io::Write>::write`, it converts the EOL of the passed in data before writing to the inner writer, which in our case, the `File` to write. We still use the existing `copy_async_to_sync` function. To report the correct `FileState::size`, we need another utility type to count the actual bytes written to the file instead of the bytes consumed. The numbers can be different due to EOL conversion. This design allows the least memory footprint, especially if we are going to support other features that change the bytes when snapshotting or updating, e.g., the `working-tree-encoding` git attributes. However, the benefit is questionable given that we are supposed to only convert EOL for text files, which should never be too large. Therefore, we won’t adopt it based on the complexity.
            * Read all the contents asynchronous to a `Vec<u8>`. Use a function to convert the EOL for a `Vec<u8>`. Write the converted bytes back to the file system. With this implementation, we will change `TreeState::write_file` to an async function and get rid of `copy_async_to_sync`. Note that all the caller of `TreeState::write_file` is in `TreeState::update` which is already an async function. To report the correct `FileState::size`, we use the number of bytes after the EOL conversion is applied. We tend to adopt this design unless we don’t like changing `TreeState::write_file` to async.
            * Similar to the above, but use `copy_async_to_sync` to read the content of the file to a `Vec<u8>` synchronously. This allows us to keep `TreeState::write_file` away from async fn. The current implementation of `copy_async_to_sync` allocates another internal 16KB buffer. This implementation has the largest memory footprint. We tend not to adopt this design unless we want to keep `async` away from `TreeState::write_file`.
        * Should the EOL conversion function handle the logic for no-EOL conversion, and read from the store? Moving both logic into the EOL conversion function allows us to reduce the complexity of the `TreeState::write_file`, and unit test more paths. `TreeState::write_file` is almost not unit testable: one needs to set up `Store` to create `TreeState`. Testing a function whose parameters are `impl AsyncRead`, `TargetEol` is simpler.
    * disk -> store EOL conversion
        * We only need CRLF to LF conversion. No git configuration needs a LF to CRLF conversion when checking in code.
        * In `FileSnapshotter::write_path_to_store`, where we handle the merge conflicts, in addition to just reading the content of the file from the file system, we need to apply line conversion according to the configuration.
        * In `FileSnapshotter::write_file_to_store`, where we handle files that don’t have merge conflicts, we should pass a `std::io::Read` that reads the file content with EOL converted according to the configuration.
        * Should we implement another type that implements `std::io::Read` with EOL conversion from a `File`, or should we create a function that reads the content as a whole and do the EOL conversion in place?
            * A separate type is more complicated.
            * A separate type can implement the read with less memory footprint, especially if we are going to support other features that change the bytes when snapshotting or updating, e.g., the `working-tree-encoding` git attributes. However we are assumed to only read text files now, so the memory saved is questionable.
            * A separate type can delay the read to happen when the read is necessary. This is especially true of `FileSnapshotter::write_file_to_store`, which passes an `AsyncRead` to `Backend::write_file`, and may be able to yield the thread when waiting for IO. This benefit is also questionable, because currently we are using `BlockingAsyncReader`, which blocks the current thread when waiting for IO.
            * Therefore, we decide to go with a function that reads all the content and does the EOL conversion.
        * Should the EOL conversion function handle the logic for no-EOL conversion, and read from the files?
            * https://github.com/jj-vcs/jj/pull/6728#discussion_r2139307343 suggests that this function should handle the file read, and should only handle the case where an EOL conversion is needed.
            * Letting the function handle the case where no EOL conversion is needed can save an if at the caller site, `FileSnapshotter::write_file_to_store` or `FileSnapshotter::write_path_to_store`, which are complicated and difficult to unit test. In addition, it allows us to save 1 if, because we need to repeat the “check whether we need EOL conversion” logic in 2 places.
    * How to pass the EOL config to the function that does the EOL conversion?
        * According to different design decision to the “Should the `core.autocrlf` config be part of the `SnapshotOptions` and the `CheckoutOptions` API or should be initialized locally in `local_working_copy.rs`” question, the `core.autocrlf` config can be read at different places:
            * right at where the EOL conversion happens(e.g. `FileSnapshotter::write_path_to_store`);
            * when `TreeState` is initialized(i.e., `TreeState::empty`);
            * or when  `SnapshotOptions` or the `CheckoutOptions` type is constructed(e.g., in `WorkspaceCommandHelper`).
        * Right at where the EOL conversion happens(e.g. `FileSnapshotter::write_path_to_store`) should not be used, because this triggers a git config read on every file to snapshot or update, and according to my performance test, can make snapshotting 30% slower. See details at https://github.com/jj-vcs/jj/pull/6728/commits/cf28eb289f51c9fa047082b1edfaeec511eed729.
        *  If the git config is read when `TreeState` is initialized(i.e., `TreeState::empty`), we can put the type that carries the git config information as a field of `TreeState`, and `FileSnapshotter`. A new argument will be added to `TreeState::write_file`, because when we use this function to write a symbol link in update and symbol link is not supported, we need to ignore the EOL conversion and just write as is.
        * If the git config is read when `SnapshotOptions` or `CheckoutOptions` is constructed, we can pass the git config information through the function parameters, and as a field of `FileSnapshotter`. We will add a new parameter to `TreeState::update`, `TreeState::write_file`.
        * For the latter 2 designs, the git config read happens at a different place where the EOL conversion happens, so we need a data type that passes the config to functions that perform the EOL conversion, and we have different designs:
            * directly pass the value of the `core.autocrlf` config around;
            * pass a concrete type similar to `GitIgnoreFiles` that provides functions to get the git config;
            * or pass a trait object similar to the `matcher` parameter in `TreeState::update`.
        * Directly pass the `core.autocrlf` config value around. This design is suggested by https://github.com/jj-vcs/jj/pull/6728#discussion_r2137552726. This design should be the simplest design, however it could bring duplicate code. The logic to probe whether a file is binary may be repeated at various call sites. The future complicated `.gitattributes` logic that decides whether a file needs an EOL conversion may be also duplicated. It can be argued that we can wrap those logic in a function. But in that case, we lose the simplicity of the solution by introducing another layer of abstraction. Moreover, if we decide to only support the `core.autocrlf` git config for the git backend, this design leaks the implementation details of the git backend, resulting in a bad encapsulation. If we are going to support `.hgeol` from fig/mercurial, we will end up with another type and parameters that carries the `.hgeol` configuration.
        * Pass a concrete type similar to `GitIgnoreFiles` that provides functions to query the final decision of EOL conversion for a file. This design allows us to put common logic for binary probing and future `.gitattributes` interaction into methods. However, if we decide to only support the git autocrlf config on git backend only, the implementation details may be leaked, and can also cause `#[cfg(feature = “git”)]` to scatter everywhere. If we adopt this path, the type name will be `TargetEolStrategy`. We need 2 different functions to decide the target EOL: `fn get_target_eol_from_disk(&self, file_path: &Path) -> TargetEol` which probes the file based on the file on the disk, and `fn get_target_eol_from_store(&self, repo_path: &RepoPath, file_id: &FileId) -> TargetEol` which probes the file based on the file read from `Store`. To integrate with the future `.gitattributes` feature, we either add a `GitAttributesFile` parameter to those functions or we pass this `TargetEolStrategy` to an implicit backend function to decide the final EOL. The exact design decision is based on our answer to the “do we want to support `.gitattributes` for all backends” question.
        * Pass a trait object. This design has the highest complexity. It requires us to introduce a trait and a type that implements the trait. To be dyn-compatible, this trait can’t use async fn, and can only use fn that returns a boxed future. This can happen if we want to hide the binary file probe logic in the interfaces: to read a file from the store, we can only use the async `Store::read_file` function. However, it allows us to hide the implementation details. If we only want to support the git backend for the git `core.autocrlf` config, and want to provide a completely different implementation for `.hgeol`, this way provides the best abstraction. It can also minimize the use of `#[cfg(feature = “git”)]`, because we only need that when implementing the trait. We don’t need that when we are calling methods from the trait. Similar to the concrete type design, `fn get_target_eol_from_disk(&self, file_path: &Path) -> TargetEol` and `fn get_target_eol_from_store(&self, repo_path: &RepoPath, file_id: &FileId) -> TargetEol` will be the required methods. To integrate with the future `.gitattributes` feature, we either add a `GitAttributesFile` parameter to those functions or we let the implementation keep an access to the underlying git `.gitattributes` implementation and query on demand. The exact design decision is based on our answer to the “do we want to support `.gitattributes` for all backends” question.
* Implement the EOL conversion function
    * There will be 2 versions of the function: the async version, `async fn convert_eol_async(content: impl AsyncRead, target_eol: TargetEol) ->Box<dyn AsyncRead>`, and the non-async version `fn convert_eol(content: impl Read, target_eol: TargetEol) -> Box<dyn Read>`. The return value is the bytes with EOL converted as expected.
    * If `target_eol` indicates that no EOL conversion is needed, the passed in value will be wrapped in `Box` and returned.
    * If `target_eol` indicates that EOL conversion is needed, we call `read_to_end` to read all the bytes from the `content` parameter into a `buf` of the `Vec<u8>` type. If we encounter an error, return a `Read`/`AsyncRead` trait object that returns the error on the first call to read. This error handling design makes it easier for the caller to handle the error.
    * Create `res` which is another `Vec<u8>` with capacity set to the length of `buf`. We will fill `res` with EOL converted data later.
    * Iterating lines from `buf` through `ByteSlice::lines` seems ideal, but the last line becomes tricky: just from the iterator we can’t tell if the last line ends without LF, with CRLF, or with just LF. Therefore we have a design decision to make on whether to use `ByteSlice::lines`.
        * if we are using `ByteSlice::lines`, we use `peekable` to check if the current line is the last line. If it’s not the last line, we append the current line to `res` and append a target EOL to `res` by `Vec::extend_from_slice`. If it’s the last line, we append the current line to `res`, and check if the last byte of `buf` is `\n` or not. If so, we append the target EOL. If not, we do nothing.
        * if we are not using `ByteSlice::lines`, we use [`split`](https://doc.rust-lang.org/std/primitive.slice.html#method.split) with `|c| c == b‘\n’` as the predicate to iterate over lines. We also use `peekable` to check if the current line is the last line. If it’s not the last line, we strip the last possible `\r` before we push the line to `res`, and we append the expected EOL. If it’s the last line, we push the line as is to `res`.
    * Wrap `res` in a `Box<Cursor<Vec<u8>>`, and coerce it to `Box<dyn AsyncRead>` or `Box<dyn Read>` as needed before we return.
* File structure. We will add a `lib/src/eol.rs` file to store all the new types and functions added. I don’t want to call it `eol_util` suggested in https://github.com/jj-vcs/jj/pull/6728#discussion_r2137566057, because the `_util` suffix seems meaningless unless we have another naming convention we need to follow. A new `lib/tests/test_eol.rs` file will be added for integration tests. And if we introduce code in `jj-cli`(e.g., we construct `SnapshotOptions` or `CheckoutOptions`), a new `cli/tests/test_eol.rs` file will be added.
* Tests: the goal is to have 100% line coverage for the newly added code, and we use unit tests as much as possible for high coverage.
    * To unit test `convert_eol` and `convert_eol_async`, we can implement `Read` or `AsyncRead` for a struct that returns error or simply use `&[u8]` if we need `Read` to return contents we need. We should at least cover the following cases:
        * convert the EOL to LF, texts have CRLF line ending, verify that CR is correctly removed.
        * convert the EOL to CRLF, texts have LF line ending, verify that CR is correctly added.
        * convert the EOL to LF or CRLF, the text doesn’t have any EOL, verify that the text is not changed
        * convert the EOL to LF or CRLF, the text has only one line which ends with some EOL, verify that the returned bytes also have only one line, and the EOL is correctly converted
        * if the input text contains empty lines(i.e., continuous LF or CRLF), those empty lines should be preserved with EOL correctly converted
        * if the `Read` parameter or the `AsyncRead` parameter returns an error when read is called, the returned value function should return the same error on read
        * if the `target_eol` parameter suggests that no EOL conversion should happen, the output bytes should be the same as the input bytes
        * if the `target_eol` parameter suggests that no EOL conversion should happen, and the `Read` parameter or the `AsyncRead` parameter returns an error when read is called. The returned value should result in an error.
    * Testing the implementation of `TargetEolStrategy` requires creating `Store`, and so will be tested via integration tests in `lib/tests/test_eol.rs`.
        * To set up the test, we need to create a git repo either through `TestRepo::init_with_backend`.
        * We modify the repo git `core.autocrlf` config to `false`, so that we can check in the file to test as is. 
        * We then need to recreate the `Store` to pick up the git `core.autocrlf` config by implementing a new function. The implementation is similar to `TestRepo::init_with_backend`, but without creating a new `TestEnvironment`, and using `GitBackend::init_external` to replace the `GitBackend::init_internal`.
        * We use `Store::write_file` to create the file to test `get_target_eol_from_store`, one binary file(whose content should be the same as `docs/images/favicon-96x96.png`), one text file.
        * We modify the repo git `core.autocrlf` config to `true`, `input`, and `false` for different test cases and write back to the filesystem
        * We recreate the `Store` again.
        * We write a text file or a binary file to the filesystem to test `get_target_eol_from_disk`.
        * We use the `Store` to initialize the `TargetEolStrategy`, pass in the file to test, and verify the return value of `get_target_eol_from_store` and `get_target_eol_from_disk`.
        * In the future, with the `.gitattributes` feature’s implementation, this test will change accordingly.
    * `TreeState` is too complicated for unit tests. Modification to `TreeState` will be tested through integration tests. The test is set up similar to the `TargetEolStrategy` test.
        * The happy path test for snapshot: similar to `TargetEolStrategy` tests, but with actually snapshotting:
            * Initialize a test repo, set the `autocrlf=true` git config, recreate `Store` and create a `Workspace`. We can’t use the existing `TestWorkspace::init_backend` because currently it doesn’t support creating `Store` to pick up git config changes.
            * Create files with CRLF EOL, LF EOL, mixed EOL and a binary file for different test cases.
            * Create a snapshot. And create a snapshot again to verify that the working copy is clean.
            * Set the `core.autocrlf=false` git config, and recreate `Store` and `Workspace` to pick up the git config change, so that the later checkout won’t perform any EOL conversion.
            * Checkout an empty change and checkout the snapshot again to recreate the files.
            * Verify the file contents are expected: for text files, EOL should be converted to LF, for the binary file, no change should happen.
        * The happy path test for update: similar to snapshot, but we change the `core.autocrlf` git config to the value under test when check in, and change it to `core.autocrlf=false` when check out. And we don’t verify if the working copy is clean.
        * Test the case where we don’t perform EOL conversion for symbol link if symbol link is not enabled.
            * We need to introduce a new interface to override the `TreeState::symlink_support` field. We then have a design decision to make. I propose the 2 following approaches:
                * We pass that override information as a `UserSettings`, e.g. `test.symlink-support-override`, valid values are strings: “true”, “false”, or “auto”. If we have reserved config keys, we can use that key instead of `test`. The default is “auto”, which is the current behavior. “true” and “false” will override the `TreeState::symlink_support` value when creating. To pass that value to `TreeState::empty`, we store the test settings in `Store`, and add relevant interfaces to initialize and query the test settings in `Store`. We add a new `pub(crate) get_test_settings(&’a self) -> &’a HashMap<String, String>` method to `Store` to query the test settings, and a new `pub(crate) new_with_test_settings(backend: Box<dyn Backend>, signer: Signer, test_settings: HashMap<String, String>) -> Arc<Self>` method to `Store`. In `lib/src/repo.rs`, we introduce a new function that uses `UserSettings::get_string` with explicit keys(e.g., `test.symlink-support-override`) to create the test settings `HashMap`. And change both `ReadonlyRepo::init` and `RepoLoader::init_from_file_system` to use the new `Store::new_with_test_settings` function. When testing, we create a `ConfigLayer` with a toml string where the `test.symlink-support-override` setting set to the value we need, we add that layer to the `base_user_config`, and we use `UserSettings::from_config` to create the needed `UserSettings` that we use to create the `Workspace`. This solution is complicated, but doesn’t change any existing interfaces, and the public interfaces are mostly backward compatible.
                * Add a `pub symlink_support_override: Option<bool>` field to `SnapshotOptions` and `CheckoutOptions`, and pass that the override value through `TreeState::update` and another new field in `FileSnapshotter`. When passing the value, `TreeState::symlink_support` will be used if the override is `None`. This solution is simple, but it introduces a breaking change to the public API, and forces the users of `SnapshotOptions` and `CheckoutOptions` to fill a test field that they won’t ever care about.
            * In the test, we skip the test if the symbol link is not supported. Set the git config `core.autocrlf` to `true`. Override symbol link support to enabled. Create a file with a symbol link to the file. The name of the file will include LF without CR preceding. Create the snapshot. Checkout to an empty commit. Override symbol link support to disable. Set the git config `core.autocrlf` to `true`. Recreate the `Store` and `Workspace` to pick up the change. Checkout the commit with the symbol link. Read the content of the symbol link file, and verify that the content doesn’t change.
        * Test the case for merge conflicts.
            * Create 4 commits with the `core.autocrlf` git config set to `false`: A, the empty commit; B, and C, children of A, but add a new test-file with the content `B\n`, `C\n` respectively; D, child of B and C. A is created by `create_tree(repo, &[])`; B, and C are created by `TestTreeBuilder::file` and `commit_with_tree`; D is created by `merge_commit_trees` and `commit_with_tree`.
            * Set the `core.autocrlf` git config to `true`. Recreate `Store` and `Workspace`. Checkout D. Append `E\r\n` to `test-file`. Create a snapshot. Create a snapshot again to verify the working copy is clean. Create commit E based on the return value of the snapshot.
            * Set the `core.autocrlf` git config to `false`. Recreate `Store` and `Workspace`. Checkout the commit E. Verify that `test-file` doesn’t contain CRLF, ends with `E\n`, contains `B\n`, and contains `C\n`.
        * If we adopt the solution to add a `target_eol: TargetEol` field to `SnapshotOptions` and `CheckoutOptions`, and we construct this field in the `jj-cli` crate, like `WorkspaceCommandHelper::base_ignores`. We should add tests to the new `cli/tests/test_eol.rs` file to verify if it works correct for different git configs. The technique will be similar to `TreeState` tests, but will use `run_jj_in` and snapshot testing to verify if the correct EOL conversion is applied.
* Performance consideration for binary file probing
    * Probing whether a file is binary will read the first N bytes from the file. Later check in or checkout may read the same N bytes again. One would suspect that we could have a performance optimization chance here by using `BufReader` like https://github.com/jj-vcs/jj/pull/6728#discussion_r2139307343. However, the reality isn’t that simple.
    * `BufReader` can discard the cache, if the file size is smaller than the number of bytes we probe. This [Rust playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=57174b8917c0f61eb3e3baecff103576) demonstrates this caveat. In fact, when the `BufReader` would discard the inner cache is implementation details that we can’t rely on. To guarantee that we don’t read twice, we should use a combination of `Read::chain` and `VecDeque` as https://github.com/jj-vcs/jj/commit/bfd71ae67ed8a8cb90d2b687c03b3764f47f087e.
    * Testing on the nix-pkgs repo doesn’t indicate any meaningful performance improvements. We should benchmark before introducing this complexity, so we should at least put the performance optimization to a different PR.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b16ddd7e62d..9f98f82cb66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   It still fully traverses them while snapshotting but they won't clutter up
   the output with all of their contents.
 
+* Add the `working-copy.eol-conversion` config which is similar to the git
+  `core.autocrlf` config. A heuristics is used to detect if a file is a binary
+  file to prevent the EOL conversion from changing binary files unexpectedly.
+
 ### Fixed bugs
 
 ### Packaging changes
diff --git a/cli/examples/custom-working-copy/main.rs b/cli/examples/custom-working-copy/main.rs
index 9a0cf45b9d6..6ec35e0bd1e 100644
--- a/cli/examples/custom-working-copy/main.rs
+++ b/cli/examples/custom-working-copy/main.rs
@@ -123,6 +123,7 @@ impl ConflictsWorkingCopy {
         state_path: PathBuf,
         operation_id: OperationId,
         workspace_name: WorkspaceNameBuf,
+        user_settings: &UserSettings,
     ) -> Result<Self, WorkingCopyStateError> {
         let inner = LocalWorkingCopy::init(
             store,
@@ -130,6 +131,7 @@ impl ConflictsWorkingCopy {
             state_path,
             operation_id,
             workspace_name,
+            user_settings,
         )?;
         Ok(ConflictsWorkingCopy {
             inner: Box::new(inner),
@@ -137,12 +139,18 @@ impl ConflictsWorkingCopy {
         })
     }
 
-    fn load(store: Arc<Store>, working_copy_path: PathBuf, state_path: PathBuf) -> Self {
-        let inner = LocalWorkingCopy::load(store, working_copy_path.clone(), state_path);
-        ConflictsWorkingCopy {
+    fn load(
+        store: Arc<Store>,
+        working_copy_path: PathBuf,
+        state_path: PathBuf,
+        user_settings: &UserSettings,
+    ) -> Result<Self, WorkingCopyStateError> {
+        let inner =
+            LocalWorkingCopy::load(store, working_copy_path.clone(), state_path, user_settings)?;
+        Ok(ConflictsWorkingCopy {
             inner: Box::new(inner),
             working_copy_path,
-        }
+        })
     }
 }
 
@@ -190,6 +198,7 @@ impl WorkingCopyFactory for ConflictsWorkingCopyFactory {
         state_path: PathBuf,
         operation_id: OperationId,
         workspace_name: WorkspaceNameBuf,
+        settings: &UserSettings,
     ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError> {
         Ok(Box::new(ConflictsWorkingCopy::init(
             store,
@@ -197,6 +206,7 @@ impl WorkingCopyFactory for ConflictsWorkingCopyFactory {
             state_path,
             operation_id,
             workspace_name,
+            settings,
         )?))
     }
 
@@ -205,12 +215,14 @@ impl WorkingCopyFactory for ConflictsWorkingCopyFactory {
         store: Arc<Store>,
         working_copy_path: PathBuf,
         state_path: PathBuf,
+        settings: &UserSettings,
     ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError> {
         Ok(Box::new(ConflictsWorkingCopy::load(
             store,
             working_copy_path,
             state_path,
-        )))
+            settings,
+        )?))
     }
 }
 
diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json
index 1a3ba0582e1..ba4317a341c 100644
--- a/cli/src/config-schema.json
+++ b/cli/src/config-schema.json
@@ -866,6 +866,22 @@
             "additionalProperties": {
                 "type": "string"
             }
+        },
+        "working-copy": {
+            "type": "object",
+            "description": "Working copy settings",
+            "properties": {
+                "eol-conversion": {
+                    "type": "string",
+                    "description": "Configuring auto-converting CRLF line endings into LF when you add a file to the backend, and vice versa when it checks out code onto your filesystem.",
+                    "enum": [
+                        "input",
+                        "input-output",
+                        "none"
+                    ],
+                    "default": "none"
+                }
+            }
         }
     }
 }
diff --git a/cli/src/merge_tools/diff_working_copies.rs b/cli/src/merge_tools/diff_working_copies.rs
index fd9ada37a8d..64a44fa8c00 100644
--- a/cli/src/merge_tools/diff_working_copies.rs
+++ b/cli/src/merge_tools/diff_working_copies.rs
@@ -13,6 +13,7 @@ use jj_lib::fsmonitor::FsmonitorSettings;
 use jj_lib::gitignore::GitIgnoreFile;
 use jj_lib::local_working_copy::TreeState;
 use jj_lib::local_working_copy::TreeStateError;
+use jj_lib::local_working_copy::TreeStateSettings;
 use jj_lib::matchers::EverythingMatcher;
 use jj_lib::matchers::Matcher;
 use jj_lib::merged_tree::MergedTree;
@@ -102,7 +103,7 @@ fn check_out(
 ) -> Result<TreeState, DiffCheckoutError> {
     std::fs::create_dir(&wc_dir).map_err(DiffCheckoutError::SetUpDir)?;
     std::fs::create_dir(&state_dir).map_err(DiffCheckoutError::SetUpDir)?;
-    let mut tree_state = TreeState::init(store, wc_dir, state_dir)?;
+    let mut tree_state = TreeState::init(store, wc_dir, state_dir, &TreeStateSettings::default())?;
     tree_state.set_sparse_patterns(sparse_patterns, options)?;
     tree_state.check_out(tree, options)?;
     Ok(tree_state)
diff --git a/docs/config.md b/docs/config.md
index ccaecb2a550..9bf4a3b2f61 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1589,6 +1589,39 @@ Files that already exist in the working copy are not subject to this limit.
 
 Setting this value to zero will disable the limit entirely.
 
+## Working copy settings
+
+### EOL conversion settings
+
+This settings serves the same purpose as the [`core.autocrlf`][git-autocrlf] git
+config.
+
+The line endings conversion won't be applied to files detected as binary files
+via a heuristics[^1] regardless of the settings. This is similar to git.
+
+```toml
+[working-copy]
+# No EOL conversion. Similar to core.autocrlf = false.
+eol-conversion = "none"
+# Apply CRLF to LF EOL conversion when we check files in the backend store from
+# the local file system but not apply EOL conversion when we check out the code
+# from the backend store to the local file system. Similar to core.autocrlf =
+# input.
+eol-conversion = "input"
+# Setting this to "input-output" if you want to have CRLF line endings in your
+# working directory and the repository has LF line endings. Similar to
+# core.autocrlf = true.
+eol-conversion = "input-output"
+```
+
+[git-autocrlf]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration#_core_autocrlf
+[^1]: To detect if a file is binary, Jujutsu currently checks if there is NULL
+      byte in the file which is different from the algorithm of
+      [`gitoxide`][gitoxide-is-binary] or [`git`][git-is-binary]. Jujutsu
+      doesn't plan to align the binary detection logic with git.
+[gitoxide-is-binary]: https://github.com/GitoxideLabs/gitoxide/blob/073487b38ed40bcd7eb45dc110ae1ce84f9275a9/gix-filter/src/eol/utils.rs#L98-L100
+[git-is-binary]: https://github.com/git/git/blob/f1ca98f609f9a730b9accf24e5558a10a0b41b6c/convert.c#L94-L103
+
 ## Ways to specify `jj` config: details
 
 ### User config files
diff --git a/docs/windows.md b/docs/windows.md
index b423719be72..24f7137e5b9 100644
--- a/docs/windows.md
+++ b/docs/windows.md
@@ -3,35 +3,67 @@
 Jujutsu works the same on all platforms, but there are some caveats that Windows
 users should be aware of.
 
-## Line endings are not converted
+## Line endings conversion
 
-Jujutsu does not currently honor `.gitattributes` and does not have a setting
-like Git's [`core.autocrlf`][git-autocrlf]. This means that line endings will be checked
-out exactly as they are committed and committed exactly as authored. This is true on all
-platforms, but Windows users are most likely to miss CRLF conversion.
+Jujutsu currently has a setting,
+[`working-copy.eol-conversion`](config.md#eol-conversion-settings), similar to
+Git's [`core.autocrlf`][git-autocrlf][^1], but does not currently honor
+`.gitattributes` and the `core.autocrlf` git config, so it is recommended to
+keep the `working-copy.eol-conversion` setting and the `core.autocrlf` git
+config in sync[^1].
 
-Your Git repository may expect Windows users to have `core.autocrlf` set to
-`true`, so that files are checked out with line endings converted from LF to CRLF
-but committed with line endings converted from CRLF back to LF. Jujutsu doesn't
-understand this and preserves CRLF line endings in files when committing.
+!!! note
 
-After creating a colocated repository on Windows, you most likely want to set
-`core.autocrlf` to `input`, then `jj abandon` to convert all files on disk to LF
-line endings:
+    If you created a colocated git repo, forget to keep these 2 settings in
+    sync, and result in a dirty working copy with only EOL diffs, you can set
+    the `working-copy.eol-conversion` setting correctly and run `jj abandon` to
+    fix it.
+
+The line endings conversion won't be applied to files detected as a binary files
+via a heuristics[^2] regardless of the settings. This behavior is subject to
+change when we support the text git attribute.
+
+Jujutsu may make incorrect decision on whether a file is a binary file and apply
+line conversion incorrectly, but currently, Jujutsu doesn't support configuring
+line endings conversion for particular files. If this issue is hit, one should
+not enable the line conversion setting.
+
+!!! note
+
+    If Jujutsu applies line endings conversion on incorrect files, you should
+    not enable the line conversion setting and the git `core.autocrlf` setting.
+    See below.
+
+To disable line conversion, set the `core.autocrlf` setting to `none` or just
+remove the setting.
 
 ```powershell
 PS> git config core.autocrlf input
-
+# We use none instead of input to avoid applying EOL conversion.
+PS> jj config set --repo working-copy.eol-conversion none
 # Abandoning the working copy will cause Jujutsu to overwrite all files with
 # CRLF line endings with the line endings they are committed with, probably LF
 PS> jj abandon
 ```
 
+This means that line endings will be checked out exactly as they are committed
+and committed exactly as authored.
+
 This setting ensures Git will check out files with LF line endings without
 converting them to CRLF. You'll want to make sure any tooling you use,
 especially IDEs, preserve LF line endings.
 
+[^1]: This poses the question if we should support reading the `core.autocrlf`
+      setting in colocated repos. See details at the
+      [issue][read-git-config-issue].
+[^2]: To detect if a file is binary, Jujutsu currently checks if there is 0 byte
+      in the file which is different from the algorithm of
+      [`gitoxide`][gitoxide-is-binary] or [`git`][git-is-binary]. Jujutsu
+      doesn't plan to align the binary detection logic with git.
 [git-autocrlf]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration#_core_autocrlf
+[read-git-config-issue]: https://github.com/jj-vcs/jj/issues/4048
+[gitoxide-is-binary]: https://github.com/GitoxideLabs/gitoxide/blob/073487b38ed40bcd7eb45dc110ae1ce84f9275a9/gix-filter/src/eol/utils.rs#L98-L100
+[git-is-binary]: https://github.com/git/git/blob/f1ca98f609f9a730b9accf24e5558a10a0b41b6c/convert.c#L94-L103
 
 ## Pagination
 
diff --git a/lib/src/config/misc.toml b/lib/src/config/misc.toml
index 619c4b125bb..e99fd6a6d1d 100644
--- a/lib/src/config/misc.toml
+++ b/lib/src/config/misc.toml
@@ -39,3 +39,6 @@ program = "ssh-keygen"
 [user]
 email = ""
 name = ""
+
+[working-copy]
+eol-conversion = "none"
diff --git a/lib/src/conflicts.rs b/lib/src/conflicts.rs
index bc0cce614e0..6949cb9c168 100644
--- a/lib/src/conflicts.rs
+++ b/lib/src/conflicts.rs
@@ -156,7 +156,7 @@ pub struct MaterializedFileValue {
     pub id: FileId,
     pub executable: bool,
     pub copy_id: CopyId,
-    pub reader: Pin<Box<dyn AsyncRead>>,
+    pub reader: Pin<Box<dyn AsyncRead + Send>>,
 }
 
 impl MaterializedFileValue {
diff --git a/lib/src/eol.rs b/lib/src/eol.rs
new file mode 100644
index 00000000000..fd87237fd0b
--- /dev/null
+++ b/lib/src/eol.rs
@@ -0,0 +1,316 @@
+// Copyright 2025 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.
+
+use std::io::Cursor;
+
+use bstr::ByteSlice as _;
+use tokio::io::AsyncRead;
+use tokio::io::AsyncReadExt as _;
+
+use crate::config::ConfigGetError;
+use crate::local_working_copy::TreeStateSettings;
+use crate::settings::UserSettings;
+
+pub(crate) fn create_target_eol_strategy(
+    tree_state_settings: &TreeStateSettings,
+) -> TargetEolStrategy {
+    TargetEolStrategy {
+        eol_conversion_mode: tree_state_settings.eol_conversion_mode,
+    }
+}
+
+fn is_binary(bytes: &[u8]) -> bool {
+    // TODO(06393993): align the algorithm with git so that the git config autocrlf
+    // users won't see different decisions on whether a file is binary and needs to
+    // perform EOL conversion.
+    bytes.contains(&b'\0')
+}
+
+#[derive(Clone)]
+pub(crate) struct TargetEolStrategy {
+    eol_conversion_mode: EolConversionMode,
+}
+
+impl TargetEolStrategy {
+    /// The limit is to probe whether the file is binary is 8KB.
+    const PROBE_LIMIT: u64 = 8 << 10;
+
+    pub(crate) async fn convert_eol_for_snapshot<'a>(
+        &self,
+        mut contents: impl AsyncRead + Send + Unpin + 'a,
+    ) -> Result<Box<dyn AsyncRead + Send + Unpin + 'a>, std::io::Error> {
+        match self.eol_conversion_mode {
+            EolConversionMode::None => Ok(Box::new(contents)),
+            EolConversionMode::Input | EolConversionMode::InputOutput => {
+                let mut peek = vec![];
+                (&mut contents)
+                    .take(Self::PROBE_LIMIT)
+                    .read_to_end(&mut peek)
+                    .await?;
+                let target_eol = if is_binary(&peek) {
+                    TargetEol::PassThrough
+                } else {
+                    TargetEol::Lf
+                };
+                let peek = Cursor::new(peek);
+                let contents = peek.chain(contents);
+                convert_eol(contents, target_eol).await
+            }
+        }
+    }
+
+    pub(crate) async fn convert_eol_for_update<'a>(
+        &self,
+        mut contents: impl AsyncRead + Send + Unpin + 'a,
+    ) -> Result<Box<dyn AsyncRead + Send + Unpin + 'a>, std::io::Error> {
+        match self.eol_conversion_mode {
+            EolConversionMode::None | EolConversionMode::Input => Ok(Box::new(contents)),
+            EolConversionMode::InputOutput => {
+                let mut peek = vec![];
+                (&mut contents)
+                    .take(Self::PROBE_LIMIT)
+                    .read_to_end(&mut peek)
+                    .await?;
+                let target_eol = if is_binary(&peek) {
+                    TargetEol::PassThrough
+                } else {
+                    TargetEol::Crlf
+                };
+                let peek = Cursor::new(peek);
+                let contents = peek.chain(contents);
+                convert_eol(contents, target_eol).await
+            }
+        }
+    }
+}
+
+/// Configuring auto-converting CRLF line endings into LF when you add a file to
+/// the backend, and vice versa when it checks out code onto your filesystem.
+#[derive(Debug, PartialEq, Eq, Copy, Clone, serde::Deserialize, Default)]
+#[serde(rename_all(deserialize = "kebab-case"))]
+pub enum EolConversionMode {
+    /// Do not perform EOL conversion.
+    #[default]
+    None,
+    /// Only perform the CRLF to LF EOL conversion when writing to the backend
+    /// store from the file system.
+    Input,
+    /// Perform CRLF to LF EOL conversion when writing to the backend store from
+    /// the file system and LF to CRLF EOL conversion when writing to the file
+    /// system from the backend store.
+    InputOutput,
+}
+
+impl EolConversionMode {
+    /// Try to create the [`EolConversionMode`] based on the
+    /// `working-copy.eol-conversion` setting in the [`UserSettings`].
+    pub fn try_from_settings(user_settings: &UserSettings) -> Result<Self, ConfigGetError> {
+        user_settings.get("working-copy.eol-conversion")
+    }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum TargetEol {
+    Lf,
+    Crlf,
+    PassThrough,
+}
+
+async fn convert_eol<'a>(
+    mut input: impl AsyncRead + Send + Unpin + 'a,
+    target_eol: TargetEol,
+) -> Result<Box<dyn AsyncRead + Send + Unpin + 'a>, std::io::Error> {
+    let eol = match target_eol {
+        TargetEol::PassThrough => {
+            return Ok(Box::new(input));
+        }
+        TargetEol::Lf => b"\n".as_slice(),
+        TargetEol::Crlf => b"\r\n".as_slice(),
+    };
+
+    let mut contents = vec![];
+    input.read_to_end(&mut contents).await?;
+    let lines = contents.lines_with_terminator();
+    let mut res = Vec::<u8>::with_capacity(contents.len());
+    fn trim_last_eol(input: &[u8]) -> Option<&[u8]> {
+        input
+            .strip_suffix(b"\r\n")
+            .or_else(|| input.strip_suffix(b"\n"))
+    }
+    for line in lines {
+        if let Some(line) = trim_last_eol(line) {
+            res.extend_from_slice(line);
+            // If the line ends with an EOL, we should append the target EOL.
+            res.extend_from_slice(eol);
+        } else {
+            // If the line doesn't end with an EOL, we don't append the EOL. This can happen
+            // on the last line.
+            res.extend_from_slice(line);
+        }
+    }
+    Ok(Box::new(Cursor::new(res)))
+}
+
+#[cfg(test)]
+mod tests {
+    use std::error::Error;
+    use std::pin::Pin;
+    use std::task::Poll;
+
+    use test_case::test_case;
+
+    use super::*;
+
+    #[tokio::main(flavor = "current_thread")]
+    #[test_case(b"a\n", TargetEol::PassThrough, b"a\n"; "LF text with no EOL conversion")]
+    #[test_case(b"a\r\n", TargetEol::PassThrough, b"a\r\n"; "CRLF text with no EOL conversion")]
+    #[test_case(b"a", TargetEol::PassThrough, b"a"; "no EOL text with no EOL conversion")]
+    #[test_case(b"a\n", TargetEol::Crlf, b"a\r\n"; "LF text with CRLF EOL conversion")]
+    #[test_case(b"a\r\n", TargetEol::Crlf, b"a\r\n"; "CRLF text with CRLF EOL conversion")]
+    #[test_case(b"a", TargetEol::Crlf, b"a"; "no EOL text with CRLF conversion")]
+    #[test_case(b"", TargetEol::Crlf, b""; "empty text with CRLF EOL conversion")]
+    #[test_case(b"a\nb", TargetEol::Crlf, b"a\r\nb"; "text ends without EOL with CRLF EOL conversion")]
+    #[test_case(b"a\n", TargetEol::Lf, b"a\n"; "LF text with LF EOL conversion")]
+    #[test_case(b"a\r\n", TargetEol::Lf, b"a\n"; "CRLF text with LF EOL conversion")]
+    #[test_case(b"a", TargetEol::Lf, b"a"; "no EOL text with LF conversion")]
+    #[test_case(b"", TargetEol::Lf, b""; "empty text with LF EOL conversion")]
+    #[test_case(b"a\r\nb", TargetEol::Lf, b"a\nb"; "text ends without EOL with LF EOL conversion")]
+    async fn test_eol_conversion(input: &[u8], target_eol: TargetEol, expected_output: &[u8]) {
+        let mut input = input;
+        let mut output = vec![];
+        convert_eol(&mut input, target_eol)
+            .await
+            .expect("Failed to call convert_eol")
+            .read_to_end(&mut output)
+            .await
+            .expect("Failed to read from the result");
+        assert_eq!(output, expected_output);
+    }
+
+    struct ErrorReader(Option<std::io::Error>);
+
+    impl ErrorReader {
+        fn new(error: std::io::Error) -> Self {
+            Self(Some(error))
+        }
+    }
+
+    impl AsyncRead for ErrorReader {
+        fn poll_read(
+            mut self: Pin<&mut Self>,
+            _cx: &mut std::task::Context<'_>,
+            _buf: &mut tokio::io::ReadBuf<'_>,
+        ) -> Poll<std::io::Result<()>> {
+            if let Some(e) = self.0.take() {
+                return Poll::Ready(Err(e));
+            }
+            Poll::Ready(Ok(()))
+        }
+    }
+
+    #[tokio::main(flavor = "current_thread")]
+    #[test_case(TargetEol::PassThrough; "no EOL conversion")]
+    #[test_case(TargetEol::Lf; "LF EOL conversion")]
+    #[test_case(TargetEol::Crlf; "CRLF EOL conversion")]
+    async fn test_eol_convert_eol_read_error(target_eol: TargetEol) {
+        let message = "test error";
+        let error_reader = ErrorReader::new(std::io::Error::other(message));
+        let mut output = vec![];
+        // TODO: use TryFutureExt::and_then and async closure after we upgrade to 1.85.0
+        // or later.
+        let err = match convert_eol(error_reader, target_eol).await {
+            Ok(mut reader) => reader.read_to_end(&mut output).await,
+            Err(e) => Err(e),
+        }
+        .expect_err("should fail");
+        let has_expected_error_message = (0..)
+            .scan(Some(&err as &(dyn Error + 'static)), |err, _| {
+                let current_err = err.take()?;
+                *err = current_err.source();
+                Some(current_err)
+            })
+            .any(|e| e.to_string() == message);
+        assert!(
+            has_expected_error_message,
+            "should have expected error message: {message}"
+        );
+    }
+
+    #[tokio::main(flavor = "current_thread")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::None,
+      }, b"\r\n", b"\r\n"; "none settings")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::Input,
+      }, b"\r\n", b"\n"; "input settings text input")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::InputOutput,
+      }, b"\r\n", b"\n"; "input output settings text input")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::Input,
+      }, b"\0\r\n", b"\0\r\n"; "input settings binary input")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::InputOutput,
+      }, b"\0\r\n", b"\0\r\n"; "input output settings binary input")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::Input,
+      }, &[0; 20 << 10], &[0; 20 << 10]; "input settings long binary input")]
+    async fn test_eol_strategy_convert_eol_for_snapshot(
+        strategy: TargetEolStrategy,
+        contents: &[u8],
+        expected_output: &[u8],
+    ) {
+        let mut actual_output = vec![];
+        strategy
+            .convert_eol_for_snapshot(contents)
+            .await
+            .unwrap()
+            .read_to_end(&mut actual_output)
+            .await
+            .unwrap();
+        assert_eq!(actual_output, expected_output);
+    }
+
+    #[tokio::main(flavor = "current_thread")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::None,
+      }, b"\n", b"\n"; "none settings")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::Input,
+      }, b"\n", b"\n"; "input settings")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::InputOutput,
+      }, b"\n", b"\r\n"; "input output settings text input")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::InputOutput,
+      }, b"\0\n", b"\0\n"; "input output settings binary input")]
+    #[test_case(TargetEolStrategy {
+          eol_conversion_mode: EolConversionMode::Input,
+      }, &[0; 20 << 10], &[0; 20 << 10]; "input output settings long binary input")]
+    async fn test_eol_strategy_convert_eol_for_update(
+        strategy: TargetEolStrategy,
+        contents: &[u8],
+        expected_output: &[u8],
+    ) {
+        let mut actual_output = vec![];
+        strategy
+            .convert_eol_for_update(contents)
+            .await
+            .unwrap()
+            .read_to_end(&mut actual_output)
+            .await
+            .unwrap();
+        assert_eq!(actual_output, expected_output);
+    }
+}
diff --git a/lib/src/lib.rs b/lib/src/lib.rs
index f63ab1316f5..1c4b83cb59d 100644
--- a/lib/src/lib.rs
+++ b/lib/src/lib.rs
@@ -42,6 +42,7 @@ pub mod default_index;
 pub mod default_submodule_store;
 pub mod diff;
 pub mod dsl_util;
+pub(crate) mod eol;
 pub mod evolution;
 pub mod extensions_map;
 pub mod file_util;
diff --git a/lib/src/local_working_copy.rs b/lib/src/local_working_copy.rs
index eeed1e0b657..e28b9c3d662 100644
--- a/lib/src/local_working_copy.rs
+++ b/lib/src/local_working_copy.rs
@@ -54,6 +54,7 @@ use rayon::prelude::ParallelIterator as _;
 use tempfile::NamedTempFile;
 use thiserror::Error;
 use tokio::io::AsyncRead;
+use tokio::io::AsyncReadExt as _;
 use tracing::instrument;
 use tracing::trace_span;
 
@@ -67,6 +68,7 @@ use crate::backend::SymlinkId;
 use crate::backend::TreeId;
 use crate::backend::TreeValue;
 use crate::commit::Commit;
+use crate::config::ConfigGetError;
 use crate::conflicts;
 use crate::conflicts::choose_materialized_conflict_marker_len;
 use crate::conflicts::materialize_merge_result_to_bytes_with_marker_len;
@@ -74,6 +76,9 @@ use crate::conflicts::materialize_tree_value;
 use crate::conflicts::ConflictMarkerStyle;
 use crate::conflicts::MaterializedTreeValue;
 use crate::conflicts::MIN_CONFLICT_MARKER_LEN;
+use crate::eol::create_target_eol_strategy;
+pub use crate::eol::EolConversionMode;
+use crate::eol::TargetEolStrategy;
 use crate::file_util::check_symlink_support;
 use crate::file_util::copy_async_to_sync;
 use crate::file_util::try_symlink;
@@ -104,6 +109,7 @@ use crate::ref_name::WorkspaceNameBuf;
 use crate::repo_path::RepoPath;
 use crate::repo_path::RepoPathBuf;
 use crate::repo_path::RepoPathComponent;
+use crate::settings::UserSettings;
 use crate::store::Store;
 use crate::tree::Tree;
 use crate::working_copy::CheckoutError;
@@ -435,6 +441,24 @@ impl<'a> IntoIterator for FileStates<'a> {
     }
 }
 
+#[derive(Clone, Debug, Default)]
+/// Options that controls the behavior of the [`TreeState`] created.
+pub struct TreeStateSettings {
+    /// Configuring auto-converting CRLF line endings into LF when you add a
+    /// file to the backend, and vice versa when it checks out code onto your
+    /// filesystem.
+    pub eol_conversion_mode: EolConversionMode,
+}
+
+impl TreeStateSettings {
+    /// Create [`TreeStateSettings`] from [`UserSettings`].
+    pub fn try_from_user_settings(user_settings: &UserSettings) -> Result<Self, ConfigGetError> {
+        Ok(Self {
+            eol_conversion_mode: EolConversionMode::try_from_settings(user_settings)?,
+        })
+    }
+}
+
 pub struct TreeState {
     store: Arc<Store>,
     working_copy_path: PathBuf,
@@ -450,6 +474,8 @@ pub struct TreeState {
     /// the repo is configured to use the Watchman filesystem monitor and
     /// Watchman has been queried at least once.
     watchman_clock: Option<crate::protos::working_copy::WatchmanClock>,
+
+    target_eol_strategy: TargetEolStrategy,
 }
 
 fn file_state_from_proto(proto: &crate::protos::working_copy::FileState) -> FileState {
@@ -790,13 +816,20 @@ impl TreeState {
         store: Arc<Store>,
         working_copy_path: PathBuf,
         state_path: PathBuf,
+        tree_state_settings: &TreeStateSettings,
     ) -> Result<TreeState, TreeStateError> {
-        let mut wc = TreeState::empty(store, working_copy_path, state_path);
+        let target_eol_strategy = create_target_eol_strategy(tree_state_settings);
+        let mut wc = TreeState::empty(store, working_copy_path, state_path, target_eol_strategy);
         wc.save()?;
         Ok(wc)
     }
 
-    fn empty(store: Arc<Store>, working_copy_path: PathBuf, state_path: PathBuf) -> TreeState {
+    fn empty(
+        store: Arc<Store>,
+        working_copy_path: PathBuf,
+        state_path: PathBuf,
+        target_eol_strategy: TargetEolStrategy,
+    ) -> TreeState {
         let tree_id = store.empty_merged_tree_id();
         TreeState {
             store,
@@ -808,6 +841,7 @@ impl TreeState {
             own_mtime: MillisSinceEpoch(0),
             symlink_support: check_symlink_support().unwrap_or(false),
             watchman_clock: None,
+            target_eol_strategy,
         }
     }
 
@@ -815,11 +849,13 @@ impl TreeState {
         store: Arc<Store>,
         working_copy_path: PathBuf,
         state_path: PathBuf,
+        tree_state_settings: &TreeStateSettings,
     ) -> Result<TreeState, TreeStateError> {
+        let target_eol_strategy = create_target_eol_strategy(tree_state_settings);
         let tree_state_path = state_path.join("tree_state");
         let file = match File::open(&tree_state_path) {
             Err(ref err) if err.kind() == io::ErrorKind::NotFound => {
-                return TreeState::init(store, working_copy_path, state_path);
+                return TreeState::init(store, working_copy_path, state_path, tree_state_settings);
             }
             Err(err) => {
                 return Err(TreeStateError::ReadTreeState {
@@ -830,7 +866,7 @@ impl TreeState {
             Ok(file) => file,
         };
 
-        let mut wc = TreeState::empty(store, working_copy_path, state_path);
+        let mut wc = TreeState::empty(store, working_copy_path, state_path, target_eol_strategy);
         wc.read(&tree_state_path, file)?;
         Ok(wc)
     }
@@ -1024,6 +1060,7 @@ impl TreeState {
                 progress,
                 max_new_file_size,
                 conflict_marker_style,
+                target_eol_strategy: self.target_eol_strategy.clone(),
             };
             let directory_to_visit = DirectoryToVisit {
                 dir: RepoPathBuf::root(),
@@ -1171,6 +1208,7 @@ struct FileSnapshotter<'a> {
     progress: Option<&'a SnapshotProgress<'a>>,
     max_new_file_size: u64,
     conflict_marker_style: ConflictMarkerStyle,
+    target_eol_strategy: TargetEolStrategy,
 }
 
 impl FileSnapshotter<'_> {
@@ -1547,18 +1585,32 @@ impl FileSnapshotter<'_> {
                 .cloned()
                 .flatten()
                 .unwrap_or_else(CopyId::placeholder);
-            // If the file contained a conflict before and is a normal file on
-            // disk, we try to parse any conflict markers in the file into a
-            // conflict.
-            let content = fs::read(disk_path).map_err(|err| SnapshotError::Other {
+            let mut contents = vec![];
+            let file = File::open(disk_path).map_err(|err| SnapshotError::Other {
                 message: format!("Failed to open file {}", disk_path.display()),
                 err: err.into(),
             })?;
+            self.target_eol_strategy
+                .convert_eol_for_snapshot(BlockingAsyncReader::new(file))
+                .await
+                .map_err(|err| SnapshotError::Other {
+                    message: "Failed to convert the EOL".to_string(),
+                    err: err.into(),
+                })?
+                .read_to_end(&mut contents)
+                .await
+                .map_err(|err| SnapshotError::Other {
+                    message: "Failed to read the EOL converted contents".to_string(),
+                    err: err.into(),
+                })?;
+            // If the file contained a conflict before and is a normal file on
+            // disk, we try to parse any conflict markers in the file into a
+            // conflict.
             let new_file_ids = conflicts::update_from_content(
                 &old_file_ids,
                 self.store(),
                 repo_path,
-                &content,
+                &contents,
                 self.conflict_marker_style,
                 materialized_conflict_data.map_or(MIN_CONFLICT_MARKER_LEN, |data| {
                     data.conflict_marker_len as usize
@@ -1603,10 +1655,15 @@ impl FileSnapshotter<'_> {
             message: format!("Failed to open file {}", disk_path.display()),
             err: err.into(),
         })?;
-        Ok(self
-            .store()
-            .write_file(path, &mut BlockingAsyncReader::new(file))
-            .await?)
+        let mut contents = self
+            .target_eol_strategy
+            .convert_eol_for_snapshot(BlockingAsyncReader::new(file))
+            .await
+            .map_err(|err| SnapshotError::Other {
+                message: "Failed to convert the EOL".to_string(),
+                err: err.into(),
+            })?;
+        Ok(self.store().write_file(path, &mut contents).await?)
     }
 
     async fn write_symlink_to_store(
@@ -1642,13 +1699,14 @@ impl FileSnapshotter<'_> {
 
 /// Functions to update local-disk files from the store.
 impl TreeState {
-    fn write_file(
+    async fn write_file(
         &self,
         disk_path: &Path,
-        contents: impl AsyncRead,
+        contents: impl AsyncRead + Send + Unpin,
         executable: bool,
+        apply_eol_conversion: bool,
     ) -> Result<FileState, CheckoutError> {
-        let mut file = OpenOptions::new()
+        let mut file = File::options()
             .write(true)
             .create_new(true) // Don't overwrite un-ignored file. Don't follow symlink.
             .open(disk_path)
@@ -1656,10 +1714,24 @@ impl TreeState {
                 message: format!("Failed to open file {} for writing", disk_path.display()),
                 err: err.into(),
             })?;
+        let contents = if apply_eol_conversion {
+            self.target_eol_strategy
+                .convert_eol_for_update(contents)
+                .await
+                .map_err(|err| CheckoutError::Other {
+                    message: "Failed to convert the EOL for the content".to_string(),
+                    err: err.into(),
+                })?
+        } else {
+            Box::new(contents)
+        };
         let size = copy_async_to_sync(contents, &mut file)
-            .block_on()
+            .await
             .map_err(|err| CheckoutError::Other {
-                message: format!("Failed to write file {}", disk_path.display()),
+                message: format!(
+                    "Failed to write the content to the file {}",
+                    disk_path.display()
+                ),
                 err: err.into(),
             })?;
         self.set_executable(disk_path, executable)?;
@@ -1892,13 +1964,15 @@ impl TreeState {
                     continue;
                 }
                 MaterializedTreeValue::File(file) => {
-                    self.write_file(&disk_path, file.reader, file.executable)?
+                    self.write_file(&disk_path, file.reader, file.executable, true)
+                        .await?
                 }
                 MaterializedTreeValue::Symlink { id: _, target } => {
                     if self.symlink_support {
                         self.write_symlink(&disk_path, target)?
                     } else {
-                        self.write_file(&disk_path, target.as_bytes(), false)?
+                        self.write_file(&disk_path, target.as_bytes(), false, false)
+                            .await?
                     }
                 }
                 MaterializedTreeValue::GitSubmodule(_) => {
@@ -2029,6 +2103,7 @@ pub struct LocalWorkingCopy {
     state_path: PathBuf,
     checkout_state: OnceCell<CheckoutState>,
     tree_state: OnceCell<TreeState>,
+    tree_state_settings: TreeStateSettings,
 }
 
 impl WorkingCopy for LocalWorkingCopy {
@@ -2072,6 +2147,7 @@ impl WorkingCopy for LocalWorkingCopy {
             // TODO: It's expensive to reload the whole tree. We should copy it from `self` if it
             // hasn't changed.
             tree_state: OnceCell::new(),
+            tree_state_settings: self.tree_state_settings.clone(),
         };
         let old_operation_id = wc.operation_id().clone();
         let old_tree_id = wc.tree_id()?.clone();
@@ -2100,6 +2176,7 @@ impl LocalWorkingCopy {
         state_path: PathBuf,
         operation_id: OperationId,
         workspace_name: WorkspaceNameBuf,
+        user_settings: &UserSettings,
     ) -> Result<LocalWorkingCopy, WorkingCopyStateError> {
         let proto = crate::protos::working_copy::Checkout {
             operation_id: operation_id.to_bytes(),
@@ -2111,19 +2188,28 @@ impl LocalWorkingCopy {
             .open(state_path.join("checkout"))
             .unwrap();
         file.write_all(&proto.encode_to_vec()).unwrap();
-        let tree_state =
-            TreeState::init(store.clone(), working_copy_path.clone(), state_path.clone()).map_err(
-                |err| WorkingCopyStateError {
-                    message: "Failed to initialize working copy state".to_string(),
-                    err: err.into(),
-                },
-            )?;
+        let tree_state_settings = TreeStateSettings::try_from_user_settings(user_settings)
+            .map_err(|err| WorkingCopyStateError {
+                message: "Failed to read the tree state settings".to_string(),
+                err: err.into(),
+            })?;
+        let tree_state = TreeState::init(
+            store.clone(),
+            working_copy_path.clone(),
+            state_path.clone(),
+            &tree_state_settings,
+        )
+        .map_err(|err| WorkingCopyStateError {
+            message: "Failed to initialize working copy state".to_string(),
+            err: err.into(),
+        })?;
         Ok(LocalWorkingCopy {
             store,
             working_copy_path,
             state_path,
             checkout_state: OnceCell::new(),
             tree_state: OnceCell::with_value(tree_state),
+            tree_state_settings,
         })
     }
 
@@ -2131,14 +2217,21 @@ impl LocalWorkingCopy {
         store: Arc<Store>,
         working_copy_path: PathBuf,
         state_path: PathBuf,
-    ) -> LocalWorkingCopy {
-        LocalWorkingCopy {
+        user_settings: &UserSettings,
+    ) -> Result<LocalWorkingCopy, WorkingCopyStateError> {
+        let tree_state_settings = TreeStateSettings::try_from_user_settings(user_settings)
+            .map_err(|err| WorkingCopyStateError {
+                message: "Failed to read the tree state settings".to_string(),
+                err: err.into(),
+            })?;
+        Ok(LocalWorkingCopy {
             store,
             working_copy_path,
             state_path,
             checkout_state: OnceCell::new(),
             tree_state: OnceCell::new(),
-        }
+            tree_state_settings,
+        })
     }
 
     pub fn state_path(&self) -> &Path {
@@ -2180,18 +2273,18 @@ impl LocalWorkingCopy {
 
     #[instrument(skip_all)]
     fn tree_state(&self) -> Result<&TreeState, WorkingCopyStateError> {
-        self.tree_state
-            .get_or_try_init(|| {
-                TreeState::load(
-                    self.store.clone(),
-                    self.working_copy_path.clone(),
-                    self.state_path.clone(),
-                )
-            })
+        self.tree_state.get_or_try_init(|| {
+            TreeState::load(
+                self.store.clone(),
+                self.working_copy_path.clone(),
+                self.state_path.clone(),
+                &self.tree_state_settings,
+            )
             .map_err(|err| WorkingCopyStateError {
                 message: "Failed to read working copy state".to_string(),
                 err: err.into(),
             })
+        })
     }
 
     fn tree_state_mut(&mut self) -> Result<&mut TreeState, WorkingCopyStateError> {
@@ -2248,6 +2341,7 @@ impl WorkingCopyFactory for LocalWorkingCopyFactory {
         state_path: PathBuf,
         operation_id: OperationId,
         workspace_name: WorkspaceNameBuf,
+        settings: &UserSettings,
     ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError> {
         Ok(Box::new(LocalWorkingCopy::init(
             store,
@@ -2255,6 +2349,7 @@ impl WorkingCopyFactory for LocalWorkingCopyFactory {
             state_path,
             operation_id,
             workspace_name,
+            settings,
         )?))
     }
 
@@ -2263,12 +2358,14 @@ impl WorkingCopyFactory for LocalWorkingCopyFactory {
         store: Arc<Store>,
         working_copy_path: PathBuf,
         state_path: PathBuf,
+        settings: &UserSettings,
     ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError> {
         Ok(Box::new(LocalWorkingCopy::load(
             store,
             working_copy_path,
             state_path,
-        )))
+            settings,
+        )?))
     }
 }
 
diff --git a/lib/src/working_copy.rs b/lib/src/working_copy.rs
index 8586cfdfee0..e8fc9f2215b 100644
--- a/lib/src/working_copy.rs
+++ b/lib/src/working_copy.rs
@@ -46,6 +46,7 @@ use crate::repo::RewriteRootCommit;
 use crate::repo_path::InvalidRepoPathError;
 use crate::repo_path::RepoPath;
 use crate::repo_path::RepoPathBuf;
+use crate::settings::UserSettings;
 use crate::store::Store;
 use crate::transaction::TransactionCommitError;
 
@@ -88,6 +89,7 @@ pub trait WorkingCopyFactory {
         state_path: PathBuf,
         operation_id: OperationId,
         workspace_name: WorkspaceNameBuf,
+        settings: &UserSettings,
     ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError>;
 
     /// Load an existing working copy.
@@ -96,6 +98,7 @@ pub trait WorkingCopyFactory {
         store: Arc<Store>,
         working_copy_path: PathBuf,
         state_path: PathBuf,
+        settings: &UserSettings,
     ) -> Result<Box<dyn WorkingCopy>, WorkingCopyStateError>;
 }
 
diff --git a/lib/src/workspace.rs b/lib/src/workspace.rs
index 3c6647ed61f..ef9bcd4dd7d 100644
--- a/lib/src/workspace.rs
+++ b/lib/src/workspace.rs
@@ -147,6 +147,7 @@ fn init_working_copy(
         working_copy_state_path.clone(),
         repo.op_id().clone(),
         workspace_name,
+        repo.settings(),
     )?;
     let working_copy_type_path = working_copy_state_path.join("type");
     fs::write(&working_copy_type_path, working_copy.name()).context(&working_copy_type_path)?;
@@ -590,6 +591,7 @@ impl WorkspaceLoader for DefaultWorkspaceLoader {
             repo_loader.store().clone(),
             self.workspace_root.clone(),
             self.working_copy_state_path.clone(),
+            user_settings,
         )?;
         let workspace = Workspace::new(
             &self.workspace_root,


Test patch:
diff --git a/cli/tests/test_diffedit_command.rs b/cli/tests/test_diffedit_command.rs
index 0205b562abb..14cf3446a9d 100644
--- a/cli/tests/test_diffedit_command.rs
+++ b/cli/tests/test_diffedit_command.rs
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+use bstr::ByteSlice as _;
 use indoc::indoc;
 
 use crate::common::TestEnvironment;
@@ -736,3 +737,109 @@ fn test_diffedit_restore_descendants() {
     [EOF]
     "#);
 }
+
+#[test]
+fn test_diffedit_external_tool_eol_conversion() {
+    // Create 2 changes: one creates a file with a single LF, another changes the
+    // file to contain 2 LFs. The diff editor should see the same EOL in both the
+    // before file and the after file. And when the diff editor adds another EOL to
+    // update, we should always see 3 LFs in the store.
+
+    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");
+    let file_path = "file";
+
+    // Use the none eol-conversion setting to check in as is.
+    let eol_conversion_none_config = "working-copy.eol-conversion='none'";
+    work_dir.write_file(file_path, "\n");
+    work_dir
+        .run_jj(["commit", "--config", eol_conversion_none_config, "-m", "1"])
+        .success();
+    work_dir.write_file(file_path, "\n\n");
+    work_dir
+        .run_jj(["commit", "--config", eol_conversion_none_config, "-m", "2"])
+        .success();
+
+    std::fs::write(
+        &edit_script,
+        [
+            "dump file after-file",
+            "reset file",
+            "dump file before-file",
+        ]
+        .join("\0"),
+    )
+    .unwrap();
+    let test_eol_conversion_config = "working-copy.eol-conversion='input-output'";
+    work_dir
+        .run_jj([
+            "diffedit",
+            "-r",
+            "@-",
+            "--config",
+            test_eol_conversion_config,
+        ])
+        .success();
+    let before_file_contents = std::fs::read(test_env.env_root().join("before-file")).unwrap();
+    let before_file_lines = before_file_contents
+        .lines_with_terminator()
+        .collect::<Vec<_>>();
+    let after_file_contents = std::fs::read(test_env.env_root().join("after-file")).unwrap();
+    let after_file_lines = after_file_contents
+        .lines_with_terminator()
+        .collect::<Vec<_>>();
+    assert_eq!(before_file_lines[0], after_file_lines[0]);
+    fn get_eol(line: &[u8]) -> &'static str {
+        if line.ends_with(b"\r\n") {
+            "\r\n"
+        } else if line.ends_with(b"\n") {
+            "\n"
+        } else {
+            ""
+        }
+    }
+    let first_eol = get_eol(after_file_lines[0]);
+    let second_eol = get_eol(after_file_lines[1]);
+    assert_eq!(first_eol, second_eol);
+    assert_eq!(
+        first_eol, "\n",
+        "The EOL the external diff editor receives must be LF to align with the builtin diff \
+         editor."
+    );
+    let eol = first_eol;
+
+    // With the previous diffedit command, file now contains the same content as
+    // commit 1, i.e., 1 LF. We create another commit 3 with the 2-LF file, so that
+    // the file shows up in the next diffedit command.
+    work_dir.write_file(file_path, "\n\n");
+    work_dir
+        .run_jj(["squash", "--config", eol_conversion_none_config, "-m", "2"])
+        .success();
+
+    std::fs::write(&edit_script, format!("write file\n{eol}{eol}{eol}")).unwrap();
+    work_dir
+        .run_jj([
+            "diffedit",
+            "-r",
+            "@-",
+            "--config",
+            test_eol_conversion_config,
+        ])
+        .success();
+
+    work_dir
+        .run_jj(["new", "root()", "--config", eol_conversion_none_config])
+        .success();
+    work_dir
+        .run_jj([
+            "new",
+            "description(2)",
+            "--config",
+            eol_conversion_none_config,
+        ])
+        .success();
+    let file_content = work_dir.read_file(file_path);
+    assert_eq!(file_content, b"\n\n\n");
+}
diff --git a/lib/tests/runner.rs b/lib/tests/runner.rs
index e0b36a6102b..d92577c87c0 100644
--- a/lib/tests/runner.rs
+++ b/lib/tests/runner.rs
@@ -12,6 +12,7 @@ mod test_commit_builder;
 mod test_commit_concurrent;
 mod test_conflicts;
 mod test_default_revset_graph_iterator;
+mod test_eol;
 mod test_evolution_predecessors;
 mod test_fix;
 mod test_git;
diff --git a/lib/tests/test_eol.rs b/lib/tests/test_eol.rs
new file mode 100644
index 00000000000..992e2d7f037
--- /dev/null
+++ b/lib/tests/test_eol.rs
@@ -0,0 +1,505 @@
+// Copyright 2025 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.
+
+use std::fs::File;
+use std::io::Write as _;
+
+use bstr::ByteSlice as _;
+use jj_lib::config::ConfigLayer;
+use jj_lib::config::ConfigSource;
+use jj_lib::repo::Repo as _;
+use jj_lib::repo::StoreFactories;
+use jj_lib::rewrite::merge_commit_trees;
+use jj_lib::settings::UserSettings;
+use jj_lib::working_copy::CheckoutOptions;
+use jj_lib::workspace::default_working_copy_factories;
+use jj_lib::workspace::Workspace;
+use test_case::test_case;
+use testutils::base_user_config;
+use testutils::commit_with_tree;
+use testutils::repo_path;
+use testutils::TestRepoBackend;
+use testutils::TestWorkspace;
+
+static LF_FILE_CONTENT: &[u8] = b"aaa\nbbbb\nccccc\n";
+static CRLF_FILE_CONTENT: &[u8] = b"aaa\r\nbbbb\r\nccccc\r\n";
+static MIXED_EOL_FILE_CONTENT: &[u8] = b"aaa\nbbbb\r\nccccc\n";
+static BINARY_FILE_CONTENT: &[u8] = b"\0";
+
+struct Config {
+    extra_setting: &'static str,
+    file_content: &'static [u8],
+}
+
+fn base_user_settings_with_extra_configs(extra_settings: &str) -> UserSettings {
+    let mut config = base_user_config();
+    config.add_layer(
+        ConfigLayer::parse(ConfigSource::User, extra_settings)
+            .expect("Failed to parse the settings"),
+    );
+    UserSettings::from_config(config).expect("Failed to create the UserSettings from the config")
+}
+
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: LF_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion input-output LF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: CRLF_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion input-output CRLF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: MIXED_EOL_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion input-output mixed EOL file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: BINARY_FILE_CONTENT,
+} => BINARY_FILE_CONTENT; "eol-conversion input-output binary file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: LF_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion input LF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: CRLF_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion input CRLF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: MIXED_EOL_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion input mixed EOL file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: BINARY_FILE_CONTENT,
+} => BINARY_FILE_CONTENT; "eol-conversion input binary file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: LF_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion none LF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: CRLF_FILE_CONTENT,
+} => CRLF_FILE_CONTENT; "eol-conversion none CRLF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: MIXED_EOL_FILE_CONTENT,
+} => MIXED_EOL_FILE_CONTENT; "eol-conversion none mixed EOL file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: BINARY_FILE_CONTENT,
+} => BINARY_FILE_CONTENT; "eol-conversion none binary file")]
+fn test_eol_conversion_snapshot(
+    Config {
+        extra_setting,
+        file_content,
+    }: Config,
+) -> Vec<u8> {
+    // This test creates snapshots with different working-copy.eol-conversion
+    // configurations, where proper EOL conversion should apply before writing files
+    // back to the store. Then files are checked out with
+    // working-copy.eol-conversion = "none", which won't touch the EOLs, so that we
+    // can tell whether the exact EOLs written to the store are expected.
+
+    let extra_setting = format!("{extra_setting}\n");
+    let user_settings = base_user_settings_with_extra_configs(&extra_setting);
+    let mut test_workspace =
+        TestWorkspace::init_with_backend_and_settings(TestRepoBackend::Git, &user_settings);
+    let file_repo_path = repo_path("test-eol-file");
+    let file_disk_path = file_repo_path
+        .to_fs_path(test_workspace.workspace.workspace_root())
+        .unwrap();
+
+    testutils::write_working_copy_file(
+        test_workspace.workspace.workspace_root(),
+        file_repo_path,
+        file_content,
+    );
+    let tree = test_workspace.snapshot().unwrap();
+    let new_tree = test_workspace.snapshot().unwrap();
+    assert_eq!(
+        new_tree.id(),
+        tree.id(),
+        "The working copy should be clean."
+    );
+    let file_added_commit = commit_with_tree(test_workspace.repo.store(), tree.id());
+
+    // Create a commit with the file removed, so that later when we checkout the
+    // file_added_commit, the test file is recreated.
+    std::fs::remove_file(&file_disk_path).unwrap();
+    let tree = test_workspace.snapshot().unwrap();
+    let file_removed_commit = commit_with_tree(test_workspace.repo.store(), tree.id());
+    let workspace = &mut test_workspace.workspace;
+    workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &file_removed_commit,
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+    assert!(!file_disk_path.exists());
+
+    let user_settings =
+        base_user_settings_with_extra_configs("working-copy.eol-conversion = \"none\"\n");
+    // Reload the workspace with the new working-copy.eol-conversion = "none"
+    // setting to verify the EOL of files previously written to the store.
+    let mut workspace = Workspace::load(
+        &user_settings,
+        test_workspace.workspace.workspace_root(),
+        &StoreFactories::default(),
+        &default_working_copy_factories(),
+    )
+    .expect("Failed to reload the workspace");
+    // We have to query the Commit again. The Workspace is backed by a different
+    // Store from the original Commit.
+    let file_added_commit = workspace
+        .repo_loader()
+        .store()
+        .get_commit(file_added_commit.id())
+        .expect("Failed to find the commit with the test file");
+    workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &file_added_commit,
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+    assert!(file_disk_path.exists());
+    let new_tree = test_workspace.snapshot().unwrap();
+    assert_eq!(
+        new_tree.id(),
+        *file_added_commit.tree_id(),
+        "The working copy should be clean."
+    );
+
+    std::fs::read(&file_disk_path).expect("Failed to read the checked out test file")
+}
+
+// Create a conflict commit in a CRLF EOL file, and append another line with the
+// CRLF EOL to the file, create a snapshot on the modified merge conflict,
+// checkout the snapshot with the given setting, and return the content of the
+// file.
+fn create_conflict_snapshot_and_read(extra_setting: &str) -> Vec<u8> {
+    // Use the working-copy.eol-conversion = "none" setting to write files to the
+    // store as is.
+    let no_eol_conversion_settings =
+        base_user_settings_with_extra_configs("working-copy.eol-conversion = \"none\"\n");
+    let mut test_workspace = TestWorkspace::init_with_backend_and_settings(
+        TestRepoBackend::Git,
+        &no_eol_conversion_settings,
+    );
+    let file_repo_path = repo_path("test-eol-file");
+    let file_disk_path = file_repo_path
+        .to_fs_path(test_workspace.workspace.workspace_root())
+        .unwrap();
+
+    // The commit graph:
+    // C (conflict)
+    // |\
+    // A B
+    // |/
+    // (empty)
+    let root_commit = test_workspace.repo.store().root_commit();
+    testutils::write_working_copy_file(
+        test_workspace.workspace.workspace_root(),
+        file_repo_path,
+        "a\r\n",
+    );
+    let tree = test_workspace.snapshot().unwrap();
+    let mut tx = test_workspace.repo.start_transaction();
+    let parent1_commit = tx
+        .repo_mut()
+        .new_commit(vec![root_commit.id().clone()], tree.id())
+        .write()
+        .unwrap();
+    tx.commit("commit parent1").unwrap();
+
+    test_workspace
+        .workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &root_commit,
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+    testutils::write_working_copy_file(
+        test_workspace.workspace.workspace_root(),
+        file_repo_path,
+        "b\r\n",
+    );
+    let tree = test_workspace.snapshot().unwrap();
+    let mut tx = test_workspace.repo.start_transaction();
+    let parent2_commit = tx
+        .repo_mut()
+        .new_commit(vec![root_commit.id().clone()], tree.id())
+        .write()
+        .unwrap();
+    tx.commit("commit parent2").unwrap();
+
+    // Reload the repo to pick up the new commits.
+    test_workspace.repo = test_workspace.repo.reload_at_head().unwrap();
+    // Create the merge commit.
+    let tree =
+        merge_commit_trees(&*test_workspace.repo, &[parent1_commit, parent2_commit]).unwrap();
+    let merge_commit = commit_with_tree(test_workspace.repo.store(), tree.id());
+    // Append new texts to the file with conflicts to make sure the last line is not
+    // conflict markers.
+    test_workspace
+        .workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &merge_commit,
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+    let mut file = File::options().append(true).open(&file_disk_path).unwrap();
+    file.write_all(b"c\r\n").unwrap();
+    drop(file);
+
+    let extra_setting = format!("{extra_setting}\n");
+    let user_settings = base_user_settings_with_extra_configs(&extra_setting);
+    // Reload the Workspace to apply the settings under testing.
+    test_workspace.workspace = Workspace::load(
+        &user_settings,
+        test_workspace.workspace.workspace_root(),
+        &StoreFactories::default(),
+        &default_working_copy_factories(),
+    )
+    .expect("Failed to reload the workspace");
+    let tree = test_workspace.snapshot().unwrap();
+    let new_tree = test_workspace.snapshot().unwrap();
+    assert_eq!(
+        new_tree.id(),
+        tree.id(),
+        "The working copy should be clean."
+    );
+    // Create the new merge commit with the conflict file appended.
+    let merge_commit = commit_with_tree(test_workspace.repo.store(), tree.id());
+
+    // Reload the Workspace with the working-copy.eol-conversion = "none" setting to
+    // check the EOL of the file written to the store previously.
+    test_workspace.workspace = Workspace::load(
+        &no_eol_conversion_settings,
+        test_workspace.workspace.workspace_root(),
+        &StoreFactories::default(),
+        &default_working_copy_factories(),
+    )
+    .expect("Failed to reload the workspace");
+    // Checkout the empty commit to clear the directory, so that the test file will
+    // be recreated.
+    test_workspace
+        .workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &test_workspace.workspace.repo_loader().store().root_commit(),
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+    // We have to query the Commit again. The Workspace is backed by a different
+    // Store from the original Commit.
+    let merge_commit = test_workspace
+        .workspace
+        .repo_loader()
+        .store()
+        .get_commit(merge_commit.id())
+        .expect("Failed to find the commit with the test file");
+    test_workspace
+        .workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &merge_commit,
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+
+    assert!(std::fs::exists(&file_disk_path).unwrap());
+    std::fs::read(&file_disk_path).unwrap()
+}
+
+#[test]
+fn test_eol_conversion_input_output_snapshot_conflicts() {
+    let contents =
+        create_conflict_snapshot_and_read(r#"working-copy.eol-conversion = "input-output""#);
+    for line in contents.lines_with_terminator() {
+        assert!(
+            !line.ends_with(b"\r\n"),
+            "{:?} should not end with CRLF",
+            line.to_str_lossy().as_ref()
+        );
+    }
+}
+
+#[test]
+fn test_eol_conversion_input_snapshot_conflicts() {
+    let contents = create_conflict_snapshot_and_read(r#"working-copy.eol-conversion = "input""#);
+    for line in contents.lines_with_terminator() {
+        assert!(
+            !line.ends_with(b"\r\n"),
+            "{:?} should not end with CRLF",
+            line.to_str_lossy().as_ref()
+        );
+    }
+}
+
+#[test]
+fn test_eol_conversion_none_snapshot_conflicts() {
+    let contents = create_conflict_snapshot_and_read(r#"working-copy.eol-conversion = "none""#);
+    // We only check the last line, because it is only guaranteed that the last line
+    // is not the conflict markers. The conflict markers in the store are supposed
+    // to use the LF EOL.
+    let line = contents.lines_with_terminator().next_back().unwrap();
+    assert!(
+        line.ends_with(b"\r\n"),
+        "{:?} should end with CRLF",
+        line.to_str_lossy().as_ref()
+    );
+}
+
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: LF_FILE_CONTENT,
+} => CRLF_FILE_CONTENT; "eol-conversion input-output LF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: CRLF_FILE_CONTENT,
+} => CRLF_FILE_CONTENT; "eol-conversion input-output CRLF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: MIXED_EOL_FILE_CONTENT,
+} => CRLF_FILE_CONTENT; "eol-conversion input-output mixed EOL file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input-output""#,
+    file_content: BINARY_FILE_CONTENT,
+} => BINARY_FILE_CONTENT; "eol-conversion input-output binary file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: LF_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion input LF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: CRLF_FILE_CONTENT,
+} => CRLF_FILE_CONTENT; "eol-conversion input CRLF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: MIXED_EOL_FILE_CONTENT,
+} => MIXED_EOL_FILE_CONTENT; "eol-conversion input mixed EOL file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "input""#,
+    file_content: BINARY_FILE_CONTENT,
+} => BINARY_FILE_CONTENT; "eol-conversion input binary file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: LF_FILE_CONTENT,
+} => LF_FILE_CONTENT; "eol-conversion none LF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: CRLF_FILE_CONTENT,
+} => CRLF_FILE_CONTENT; "eol-conversion none CRLF only file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: MIXED_EOL_FILE_CONTENT,
+} => MIXED_EOL_FILE_CONTENT; "eol-conversion none mixed EOL file")]
+#[test_case(Config {
+    extra_setting: r#"working-copy.eol-conversion = "none""#,
+    file_content: BINARY_FILE_CONTENT,
+} => BINARY_FILE_CONTENT; "eol-conversion none binary file")]
+fn test_eol_conversion_checkout(
+    Config {
+        extra_setting,
+        file_content,
+    }: Config,
+) -> Vec<u8> {
+    // This test checks in files with working-copy.eol-conversion = "none", so that
+    // the store stores files as is. Then we use jj to check out those files with
+    // different working-copy.eol-conversion configurations to verify if the EOLs
+    // are converted as expected.
+
+    let no_eol_conversion_settings =
+        base_user_settings_with_extra_configs("working-copy.eol-conversion = \"none\"\n");
+    // Use the working-copy.eol-conversion = "none" setting, so that the input files
+    // are stored as is.
+    let mut test_workspace = TestWorkspace::init_with_backend_and_settings(
+        TestRepoBackend::Git,
+        &no_eol_conversion_settings,
+    );
+    let file_repo_path = repo_path("test-eol-file");
+    let file_disk_path = file_repo_path
+        .to_fs_path(test_workspace.workspace.workspace_root())
+        .unwrap();
+    testutils::write_working_copy_file(
+        test_workspace.workspace.workspace_root(),
+        file_repo_path,
+        file_content,
+    );
+    let tree = test_workspace.snapshot().unwrap();
+    let commit = commit_with_tree(test_workspace.repo.store(), tree.id());
+
+    // Checkout the empty commit to clear the directory, so that later when we
+    // checkout, files are recreated.
+    test_workspace
+        .workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &test_workspace.workspace.repo_loader().store().root_commit(),
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+    assert!(!std::fs::exists(&file_disk_path).unwrap());
+
+    let extra_setting = format!("{extra_setting}\n");
+    let user_settings = base_user_settings_with_extra_configs(&extra_setting);
+    // Change the working-copy.eol-conversion setting to the configuration under
+    // testing.
+    test_workspace.workspace = Workspace::load(
+        &user_settings,
+        test_workspace.workspace.workspace_root(),
+        &StoreFactories::default(),
+        &default_working_copy_factories(),
+    )
+    .expect("Failed to reload the workspace");
+    // We have to query the Commit again. The Workspace is backed by a different
+    // Store from the original Commit.
+    let commit = test_workspace
+        .workspace
+        .repo_loader()
+        .store()
+        .get_commit(commit.id())
+        .expect("Failed to find the commit with the test file");
+    // Check out the commit with the test file. TreeState::update should update the
+    // EOL accordingly.
+    test_workspace
+        .workspace
+        .check_out(
+            test_workspace.repo.op_id().clone(),
+            None,
+            &commit,
+            &CheckoutOptions::empty_for_test(),
+        )
+        .unwrap();
+
+    // When we take a snapshot now, the tree may not be clean, because the EOL our
+    // snapshot creates may not align with what is currently used in store. e.g.
+    // with working-copy.eol-conversion = "input-output", the test-eol-file may have
+    // CRLF line endings in the store, but the snapshot will change the EOL to LF,
+    // hence the diff.
+
+    assert!(std::fs::exists(&file_disk_path).unwrap());
+    std::fs::read(&file_disk_path).unwrap()
+}
diff --git a/lib/tests/test_local_working_copy.rs b/lib/tests/test_local_working_copy.rs
index fa589599e67..a7ec1db409d 100644
--- a/lib/tests/test_local_working_copy.rs
+++ b/lib/tests/test_local_working_copy.rs
@@ -807,8 +807,13 @@ fn test_checkout_discard() {
     // The change should be reflected in the working copy but not saved
     assert!(!file1_path.to_fs_path_unchecked(&workspace_root).is_file());
     assert!(file2_path.to_fs_path_unchecked(&workspace_root).is_file());
-    let reloaded_wc =
-        LocalWorkingCopy::load(store.clone(), workspace_root.clone(), state_path.clone());
+    let reloaded_wc = LocalWorkingCopy::load(
+        store.clone(),
+        workspace_root.clone(),
+        state_path.clone(),
+        repo.settings(),
+    )
+    .unwrap();
     assert!(reloaded_wc.file_states().unwrap().contains_path(file1_path));
     assert!(!reloaded_wc.file_states().unwrap().contains_path(file2_path));
     drop(locked_ws);
@@ -819,7 +824,8 @@ fn test_checkout_discard() {
     assert!(!wc.file_states().unwrap().contains_path(file2_path));
     assert!(!file1_path.to_fs_path_unchecked(&workspace_root).is_file());
     assert!(file2_path.to_fs_path_unchecked(&workspace_root).is_file());
-    let reloaded_wc = LocalWorkingCopy::load(store.clone(), workspace_root, state_path);
+    let reloaded_wc =
+        LocalWorkingCopy::load(store.clone(), workspace_root, state_path, repo.settings()).unwrap();
     assert!(reloaded_wc.file_states().unwrap().contains_path(file1_path));
     assert!(!reloaded_wc.file_states().unwrap().contains_path(file2_path));
 }
diff --git a/lib/tests/test_local_working_copy_sparse.rs b/lib/tests/test_local_working_copy_sparse.rs
index 99a1bb2a678..24b0a89e0b3 100644
--- a/lib/tests/test_local_working_copy_sparse.rs
+++ b/lib/tests/test_local_working_copy_sparse.rs
@@ -125,7 +125,9 @@ fn test_sparse_checkout() {
         repo.store().clone(),
         ws.workspace_root().to_path_buf(),
         wc.state_path().to_path_buf(),
-    );
+        repo.settings(),
+    )
+    .unwrap();
     assert_eq!(
         wc.file_states().unwrap().paths().collect_vec(),
         vec![dir1_file1_path, dir1_file2_path, dir1_subdir1_file1_path]
diff --git a/lib/testutils/src/lib.rs b/lib/testutils/src/lib.rs
index 3982ccc0057..951d8df3918 100644
--- a/lib/testutils/src/lib.rs
+++ b/lib/testutils/src/lib.rs
@@ -602,7 +602,7 @@ pub fn write_random_commit(mut_repo: &mut MutableRepo) -> Commit {
     create_random_commit(mut_repo).write().unwrap()
 }
 
-pub fn write_working_copy_file(workspace_root: &Path, path: &RepoPath, contents: &str) {
+pub fn write_working_copy_file(workspace_root: &Path, path: &RepoPath, contents: impl AsRef<[u8]>) {
     let path = path.to_fs_path(workspace_root).unwrap();
     if let Some(parent) = path.parent() {
         fs::create_dir_all(parent).unwrap();
@@ -613,7 +613,7 @@ pub fn write_working_copy_file(workspace_root: &Path, path: &RepoPath, contents:
         .truncate(true)
         .open(path)
         .unwrap();
-    file.write_all(contents.as_bytes()).unwrap();
+    file.write_all(contents.as_ref()).unwrap();
 }
 
 pub struct CommitGraphBuilder<'repo> {