jj/lib/tests/test_eol.rs
Scott Taylor 5aa71d59a9 lib: replace MergedTreeId with MergedTree and Merge<TreeId>
After the previous commit, `MergedTree` and `MergedTreeId` are almost
identical, with the only difference being that `MergedTree` is attached
to a `Store` instance. `MergedTreeId` is also equivalent to
`Merge<TreeId>`, since it is just a wrapper around it.

In the future, `MergedTree` might contain additional metadata like
conflict labels. Therefore, I replaced `MergedTreeId` with `MergedTree`
wherever I think it would be required to pass this additional metadata,
or where the additional methods provided by `MergedTree` would be
useful. In any remaining places, I replaced it with `Merge<TreeId>`.

I also renamed some of the `tree_id()` methods to `tree_ids()` for
consistency, since now they return a merge of individual tree IDs
instead of a single "merged tree ID". Similarly, `MergedTree` no longer
has an `id()` method, since tree IDs won't fully identify a `MergedTree`
once it contains additional metadata.
2025-11-08 14:06:58 +00:00

585 lines
22 KiB
Rust

// 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::workspace::Workspace;
use jj_lib::workspace::default_working_copy_factories;
use pollster::FutureExt as _;
use test_case::test_case;
use testutils::TestRepoBackend;
use testutils::TestWorkspace;
use testutils::assert_tree_eq;
use testutils::base_user_config;
use testutils::commit_with_tree;
use testutils::repo_path;
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_tree_eq!(new_tree, tree, "The working copy should be clean.");
let file_added_commit = commit_with_tree(test_workspace.repo.store(), tree);
// 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);
let workspace = &mut test_workspace.workspace;
workspace
.check_out(
test_workspace.repo.op_id().clone(),
None,
&file_removed_commit,
)
.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,
)
.unwrap();
assert!(file_disk_path.exists());
let new_tree = test_workspace.snapshot().unwrap();
assert_tree_eq!(
new_tree,
file_added_commit.tree(),
"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)
.write()
.unwrap();
tx.commit("commit parent1").unwrap();
test_workspace
.workspace
.check_out(test_workspace.repo.op_id().clone(), None, &root_commit)
.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)
.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])
.block_on()
.unwrap();
let merge_commit = commit_with_tree(test_workspace.repo.store(), tree);
// 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)
.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_tree_eq!(new_tree, tree, "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);
// 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(),
)
.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)
.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()
);
}
struct UpdateConflictsTestConfig {
parent1_contents: &'static str,
parent2_contents: &'static str,
extra_setting: &'static str,
expected_eol: &'static str,
expected_conflict_side1: &'static str,
expected_conflict_side2: &'static str,
}
#[test_case(UpdateConflictsTestConfig {
parent1_contents: "a\n",
parent2_contents: "b\n",
extra_setting: r#"working-copy.eol-conversion = "none""#,
expected_eol: "\n",
expected_conflict_side1: "a\n",
expected_conflict_side2: "b\n",
}; "LF parents with none settings")]
#[test_case(UpdateConflictsTestConfig {
parent1_contents: "a\n",
parent2_contents: "b\n",
extra_setting: r#"working-copy.eol-conversion = "input-output""#,
expected_eol: "\r\n",
expected_conflict_side1: "a\r\n",
expected_conflict_side2: "b\r\n",
}; "LF parents with input-output settings")]
#[test_case(UpdateConflictsTestConfig {
parent1_contents: "a\r\n",
parent2_contents: "b\r\n",
extra_setting: r#"working-copy.eol-conversion = "input-output""#,
expected_eol: "\r\n",
expected_conflict_side1: "a\r\n",
expected_conflict_side2: "b\r\n",
}; "CRLF parents with input-output settings")]
fn test_eol_conversion_update_conflicts(
UpdateConflictsTestConfig {
parent1_contents,
parent2_contents,
extra_setting,
expected_eol,
expected_conflict_side1,
expected_conflict_side2,
}: UpdateConflictsTestConfig,
) {
// Create a conflict commit with 2 given contents on one file, checkout that
// conflict with the given EOL conversion settings, and test if the EOL matches.
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();
// The commit graph:
// C (conflict)
// |\
// A B
// |/
// (empty)
let root_commit = test_workspace.repo.store().root_commit();
let mut tx = test_workspace.repo.start_transaction();
let tree = testutils::create_tree(&test_workspace.repo, &[(file_repo_path, parent1_contents)]);
let parent1_commit = tx
.repo_mut()
.new_commit(vec![root_commit.id().clone()], tree)
.write()
.unwrap();
let tree = testutils::create_tree(&test_workspace.repo, &[(file_repo_path, parent2_contents)]);
let parent2_commit = tx
.repo_mut()
.new_commit(vec![root_commit.id().clone()], tree)
.write()
.unwrap();
tx.commit("commit parent 2").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])
.block_on()
.unwrap();
let merge_commit = commit_with_tree(test_workspace.repo.store(), tree);
// Checkout the merge commit.
test_workspace
.workspace
.check_out(test_workspace.repo.op_id().clone(), None, &merge_commit)
.unwrap();
let contents = std::fs::read(&file_disk_path).unwrap();
for line in contents.lines_with_terminator() {
assert!(
line.ends_with_str(expected_eol),
"{:?} should end with {:?}",
&*line.to_str_lossy(),
expected_eol
);
}
let hunks =
jj_lib::conflicts::parse_conflict(&contents, 2, jj_lib::conflicts::MIN_CONFLICT_MARKER_LEN)
.unwrap();
let hunk = &hunks[0];
assert!(!hunk.is_resolved());
let sides = hunk.iter().collect::<Vec<_>>();
assert_eq!(sides[0], expected_conflict_side1);
assert_eq!(sides[2], expected_conflict_side2);
}
#[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);
// 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(),
)
.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)
.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()
}