// Copyright 2024 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.

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::path::PathBuf;

use indoc::formatdoc;
use indoc::indoc;
use jj_lib::file_util::symlink_file;
use testutils::TestResult;

use crate::common::TestEnvironment;
use crate::common::to_toml_value;

fn set_up_fake_formatter(test_env: &mut TestEnvironment, args: &[&str]) {
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    test_env.add_config(formatdoc! {"
        [fix.tools.fake-formatter]
        command = {command}
        patterns = ['all()']
        ",
        command = toml_edit::Value::from_iter(
            [formatter_path.to_str().unwrap()]
                .iter()
                .chain(args)
                .copied()
        )
    });
    test_env.add_paths_to_normalize(formatter_path, "$FAKE_FORMATTER_PATH");
}

#[test]
fn test_config_no_tools() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir.write_file("file", "content\n");
    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: No `fix.tools` are configured
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    content
    [EOF]
    ");
}

#[test]
fn test_config_nonexistent_tool() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    test_env.add_config(
        r###"
        [fix.tools.my-tool]
        command = ["nonexistent-fix-tool-binary"]
        patterns = ["glob:**"]
        "###,
    );

    work_dir.write_file("file", "content\n");
    let output = work_dir.run_jj(["fix"]);
    // We inform the user about the non-existent tool
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Failed to start `nonexistent-fix-tool-binary`
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
}

#[test]
fn test_config_multiple_tools() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--uppercase"]
        patterns = ["foo"]

        [fix.tools.tool-2]
        command = [{formatter}, "--lowercase"]
        patterns = ["bar"]
        "###,
    ));

    work_dir.write_file("foo", "Foo\n");
    work_dir.write_file("bar", "Bar\n");
    work_dir.write_file("baz", "Baz\n");

    work_dir.run_jj(["fix"]).success();

    let output = work_dir.run_jj(["file", "show", "foo", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    FOO
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    bar
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    Baz
    [EOF]
    ");
}

#[test]
fn test_config_multiple_tools_with_same_name() {
    let mut test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());

    // Multiple definitions with the same `name` are not allowed, because it is
    // likely to be a mistake, and mistakes are risky when they rewrite files.
    test_env.add_config(format!(
        r###"
        [fix.tools.my-tool]
        command = [{formatter}, "--uppercase"]
        patterns = ["foo"]

        [fix.tools.my-tool]
        command = [{formatter}, "--lowercase"]
        patterns = ["bar"]
        "###,
    ));

    work_dir.write_file("foo", "Foo\n");
    work_dir.write_file("bar", "Bar\n");

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: Configuration cannot be parsed as TOML document
    Caused by: TOML parse error at line 6, column 20
      |
    6 |         [fix.tools.my-tool]
      |                    ^^^^^^^
    duplicate key

    Hint: Check the config file: $TEST_ENV/config/config0002.toml
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    test_env.set_config_path("/dev/null");
    let work_dir = test_env.work_dir("repo");
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    Foo
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    Bar
    [EOF]
    ");
}

#[test]
fn test_config_disabled_tools() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        # default is enabled
        command = [{formatter}, "--uppercase"]
        patterns = ["foo"]

        [fix.tools.tool-2]
        enabled = true
        command = [{formatter}, "--lowercase"]
        patterns = ["bar"]

        [fix.tools.tool-3]
        enabled = false
        command = [{formatter}, "--lowercase"]
        patterns = ["baz"]
        "###
    ));

    work_dir.write_file("foo", "Foo\n");
    work_dir.write_file("bar", "Bar\n");
    work_dir.write_file("baz", "Baz\n");

    work_dir.run_jj(["fix"]).success();

    let output = work_dir.run_jj(["file", "show", "foo", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    FOO
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    bar
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    Baz
    [EOF]
    ");
}

#[test]
fn test_config_disabled_tools_warning_when_all_tools_are_disabled() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-2]
        enabled = false
        command = [{formatter}, "--lowercase"]
        patterns = ["bar"]
        "###
    ));

    work_dir.write_file("bar", "Bar\n");

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Config error: At least one entry of `fix.tools` must be enabled.
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");
}

#[test]
fn test_config_tables_overlapping_patterns() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());

    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--append", "tool-1"]
        patterns = ["foo", "bar"]

        [fix.tools.tool-2]
        command = [{formatter}, "--append", "tool-2"]
        patterns = ["bar", "baz"]
        "###,
    ));

    work_dir.write_file("foo", "foo\n");
    work_dir.write_file("bar", "bar\n");
    work_dir.write_file("baz", "baz\n");

    work_dir.run_jj(["fix"]).success();

    let output = work_dir.run_jj(["file", "show", "foo", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    foo
    tool-1[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    bar
    tool-1tool-2[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    baz
    tool-2[EOF]
    ");
}

#[test]
fn test_config_tables_all_commands_missing() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    test_env.add_config(
        r###"
        [fix.tools.my-tool-missing-command-1]
        patterns = ["foo"]

        [fix.tools.my-tool-missing-command-2]
        patterns = ['glob:"ba*"']
        "###,
    );

    work_dir.write_file("foo", "foo\n");

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output.normalize_backslash(), @"
    ------- stderr -------
    Config error: Invalid type or value for fix.tools.my-tool-missing-command-1
    Caused by: missing field `command`

    Hint: Check the config file: $TEST_ENV/config/config0002.toml
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj(["file", "show", "foo", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    foo
    [EOF]
    ");
}

#[test]
fn test_config_tables_some_commands_missing() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--uppercase"]
        patterns = ["foo"]

        [fix.tools.my-tool-missing-command]
        patterns = ['bar']
        "###,
    ));

    work_dir.write_file("foo", "foo\n");

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output.normalize_backslash(), @"
    ------- stderr -------
    Config error: Invalid type or value for fix.tools.my-tool-missing-command
    Caused by: missing field `command`

    Hint: Check the config file: $TEST_ENV/config/config0002.toml
    For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
    [EOF]
    [exit status: 1]
    ");

    let output = work_dir.run_jj(["file", "show", "foo", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    foo
    [EOF]
    ");
}

#[test]
fn test_config_tables_empty_patterns_list() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.my-tool-empty-patterns]
        command = [{formatter}, "--uppercase"]
        patterns = []
        "###,
    ));

    work_dir.write_file("foo", "foo\n");

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");

    let output = work_dir.run_jj(["file", "show", "foo", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    foo
    [EOF]
    ");
}

#[test]
fn test_config_filesets() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.my-tool-match-one]
        command = [{formatter}, "--uppercase"]
        patterns = ['glob:"a*"']

        [fix.tools.my-tool-match-two]
        command = [{formatter}, "--reverse"]
        patterns = ['glob:"b*"']

        [fix.tools.my-tool-match-none]
        command = [{formatter}, "--append", "SHOULD NOT APPEAR"]
        patterns = ['glob:"this-doesnt-match-anything-*"']
        "###,
    ));

    work_dir.write_file("a1", "a1\n");
    work_dir.write_file("b1", "b1\n");
    work_dir.write_file("b2", "b2\n");

    work_dir.run_jj(["fix"]).success();

    let output = work_dir.run_jj(["file", "show", "a1", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    A1
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "b1", "-r", "@"]);
    insta::assert_snapshot!(output, @"

    1b[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "b2", "-r", "@"]);
    insta::assert_snapshot!(output, @"

    2b[EOF]
    ");
}

#[test]
fn test_relative_paths() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool]
        command = [{formatter}, "--stdout", "Fixed!"]
        patterns = ['glob:"foo*"']
        "###,
    ));

    let sub_dir = work_dir.create_dir("dir");
    work_dir.write_file("foo1", "unfixed\n");
    work_dir.write_file("foo2", "unfixed\n");
    work_dir.write_file("dir/foo3", "unfixed\n");

    // Positional arguments are cwd-relative, but the configured patterns are
    // repo-relative, so this command fixes the empty intersection of those
    // filesets.
    sub_dir.run_jj(["fix", "foo3"]).success();
    let output = work_dir.run_jj(["file", "show", "foo1", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    unfixed
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "foo2", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    unfixed
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "dir/foo3", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    unfixed
    [EOF]
    ");

    // Positional arguments can specify a subset of the configured fileset.
    sub_dir.run_jj(["fix", "../foo1"]).success();
    let output = work_dir.run_jj(["file", "show", "foo1", "-r", "@"]);
    insta::assert_snapshot!(output, @"Fixed![EOF]");
    let output = work_dir.run_jj(["file", "show", "foo2", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    unfixed
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "dir/foo3", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    unfixed
    [EOF]
    ");

    // The current directory does not change the interpretation of the config, so
    // foo2 is fixed but not dir/foo3.
    sub_dir.run_jj(["fix"]).success();
    let output = work_dir.run_jj(["file", "show", "foo1", "-r", "@"]);
    insta::assert_snapshot!(output, @"Fixed![EOF]");
    let output = work_dir.run_jj(["file", "show", "foo2", "-r", "@"]);
    insta::assert_snapshot!(output, @"Fixed![EOF]");
    let output = work_dir.run_jj(["file", "show", "dir/foo3", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    unfixed
    [EOF]
    ");

    // The output filtered to a non-existent file should display a warning.
    let output = work_dir.run_jj(["fix", "nonexistent"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: No matching entries for paths: nonexistent
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
}

#[test]
fn test_relative_tool_path_from_subdirectory() -> TestResult {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // Copy the fake-formatter into the workspace as a relative tool
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    let formatter_name = formatter_path.file_name().unwrap().to_str().unwrap();
    let tool_dir = work_dir.create_dir("tools");
    let workspace_formatter_path = tool_dir.root().join(formatter_name);
    std::fs::copy(formatter_path, &workspace_formatter_path)?;
    work_dir.write_file(".gitignore", "tools/\n");
    let formatter_relative_path = PathBuf::from_iter(["$root", "tools", formatter_name]);
    test_env.add_config(format!(
        r###"
        [fix.tools.a]
        command = [{path}, "--uppercase"]
        patterns = ['glob:"**/*.txt"']
        "###,
        path = to_toml_value(formatter_relative_path.to_str().unwrap())
    ));

    // Create a test file and subdirectory
    work_dir.write_file("test.txt", "hello world\n");
    let sub_dir = work_dir.create_dir("subdir");
    work_dir.write_file("subdir/nested.txt", "nested content\n");
    work_dir.run_jj(["debug", "snapshot"]).success();
    let setup_opid = work_dir.current_operation_id();

    // Run fix from workspace root
    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm 57a27b36 (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 2 files, removed 0 files
    [EOF]
    ");

    let output = work_dir.run_jj(["file", "show", "test.txt", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    HELLO WORLD
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "subdir/nested.txt", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    NESTED CONTENT
    [EOF]
    ");

    // Reset so the fix tools should have an effect again
    work_dir.run_jj(["op", "restore", &setup_opid]).success();

    // Run fix from the subdirectory
    let output = sub_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm 05404d5b (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 2 files, removed 0 files
    [EOF]
    ");

    let output = work_dir.run_jj(["file", "show", "test.txt", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    HELLO WORLD
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "subdir/nested.txt", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    NESTED CONTENT
    [EOF]
    ");
    Ok(())
}

#[test]
fn test_fix_empty_commit() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
}

#[test]
fn test_fix_leaf_commit() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "unaffected");
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "affected");

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: rlvkpnrz f5c11961 (no description set)
    Parent commit (@-)      : qpvuntsm b37955c0 (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@-"]);
    insta::assert_snapshot!(output, @"unaffected[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"AFFECTED[EOF]");
}

#[test]
fn test_fix_parent_commit() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    // Using one file name for all commits adds coverage of some possible bugs.
    work_dir.write_file("file", "parent");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "parent"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "child1");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "child1"])
        .success();
    work_dir.run_jj(["new", "-r", "parent"]).success();
    work_dir.write_file("file", "child2");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "child2"])
        .success();

    let output = work_dir.run_jj(["fix", "-s", "parent"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 3 commits of 3 checked.
    Working copy  (@) now at: mzvwutvl e7ba6d31 child2 | (no description set)
    Parent commit (@-)      : qpvuntsm 49f1ddd5 parent | (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "parent"]);
    insta::assert_snapshot!(output, @"PARENT[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "child1"]);
    insta::assert_snapshot!(output, @"CHILD1[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "child2"]);
    insta::assert_snapshot!(output, @"CHILD2[EOF]");
}

#[test]
fn test_fix_sibling_commit() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "parent");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "parent"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "child1");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "child1"])
        .success();
    work_dir.run_jj(["new", "-r", "parent"]).success();
    work_dir.write_file("file", "child2");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "child2"])
        .success();

    let output = work_dir.run_jj(["fix", "-s", "child1"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "parent"]);
    insta::assert_snapshot!(output, @"parent[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "child1"]);
    insta::assert_snapshot!(output, @"CHILD1[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "child2"]);
    insta::assert_snapshot!(output, @"child2[EOF]");
}

#[test]
fn test_fix_descendant_commits() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("parent", "parent");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "parent"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("child1", "child1");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "child1"])
        .success();
    work_dir.run_jj(["new", "-r", "parent"]).success();
    work_dir.write_file("child2", "child2");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "child2"])
        .success();

    let output = work_dir.run_jj(["fix", "-s", "parent", "child1", "child2", "nonexistent"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: No matching entries for paths: nonexistent
    Fixed 2 commits of 3 checked.
    Working copy  (@) now at: mzvwutvl afe0ade0 child2 | (no description set)
    Parent commit (@-)      : qpvuntsm c9cb6288 parent | (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "parent", "-r", "parent"]);
    insta::assert_snapshot!(output, @"parent[EOF]");
    let output = work_dir.run_jj(["file", "show", "child1", "-r", "child1"]);
    insta::assert_snapshot!(output, @"CHILD1[EOF]");
    let output = work_dir.run_jj(["file", "show", "child2", "-r", "child2"]);
    insta::assert_snapshot!(output, @"CHILD2[EOF]");
}

#[test]
fn test_default_revset() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "trunk1");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "trunk1"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "trunk2");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "trunk2"])
        .success();
    work_dir.run_jj(["new", "trunk1"]).success();
    work_dir.write_file("file", "foo");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "foo"])
        .success();
    work_dir.run_jj(["new", "trunk1"]).success();
    work_dir.write_file("file", "bar1");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "bar1"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "bar2");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "bar2"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "bar3");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "bar3"])
        .success();
    work_dir.run_jj(["edit", "bar2"]).success();

    // With no args and no revset configuration, we fix `reachable(@, mutable())`,
    // which includes bar{1,2,3} and excludes trunk{1,2} (which is immutable) and
    // foo (which is mutable but not reachable).
    test_env.add_config(r#"revset-aliases."immutable_heads()" = "trunk2""#);
    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 3 commits of 3 checked.
    Working copy  (@) now at: yostqsxw 932b950d bar2 | (no description set)
    Parent commit (@-)      : yqosqzyt 8a37ed67 bar1 | (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "trunk1"]);
    insta::assert_snapshot!(output, @"trunk1[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "trunk2"]);
    insta::assert_snapshot!(output, @"trunk2[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "foo"]);
    insta::assert_snapshot!(output, @"foo[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "bar1"]);
    insta::assert_snapshot!(output, @"BAR1[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "bar2"]);
    insta::assert_snapshot!(output, @"BAR2[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "bar3"]);
    insta::assert_snapshot!(output, @"BAR3[EOF]");
}

#[test]
fn test_custom_default_revset() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    work_dir.write_file("file", "foo");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "foo"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "bar");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "bar"])
        .success();

    // Check out a different commit so that the schema default `reachable(@,
    // mutable())` would behave differently from our customized default.
    work_dir.run_jj(["new", "-r", "foo"]).success();
    test_env.add_config(r#"revsets.fix = "bar""#);

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "foo"]);
    insta::assert_snapshot!(output, @"foo[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "bar"]);
    insta::assert_snapshot!(output, @"BAR[EOF]");
}

#[test]
fn test_fix_immutable_commit() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "immutable");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "immutable"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "mutable");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "mutable"])
        .success();
    test_env.add_config(r#"revset-aliases."immutable_heads()" = "immutable""#);

    let output = work_dir.run_jj(["fix", "-s", "immutable"]);
    insta::assert_snapshot!(output, @r#"
    ------- stderr -------
    Error: Commit a86b2eccaaab is immutable
    Hint: Could not modify commit: qpvuntsm a86b2ecc immutable | (no description set)
    Hint: Immutable commits are used to protect shared history.
    Hint: For more information, see:
          - https://docs.jj-vcs.dev/latest/config/#set-of-immutable-commits
          - `jj help -k config`, "Set of immutable commits"
    Hint: This operation would rewrite 1 immutable commits.
    [EOF]
    [exit status: 1]
    "#);
    let output = work_dir.run_jj(["file", "show", "file", "-r", "immutable"]);
    insta::assert_snapshot!(output, @"immutable[EOF]");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "mutable"]);
    insta::assert_snapshot!(output, @"mutable[EOF]");
}

#[test]
fn test_fix_empty_file() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "");

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"");
}

#[test]
fn test_fix_large_file() {
    let mut test_env = TestEnvironment::default();
    // Set to byte mode, so that fake-formatter doesn't read from the stdin all at
    // once.
    set_up_fake_formatter(&mut test_env, &["--lowercase", "--byte-mode"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    // 512KB should be larger than most default page size, so that the stdin pipe
    // will be blocked if fake-formatter doesn't read, and the stdout pipe will
    // be blocked if jj doesn't read.
    let mut large_contents = b"A".repeat(0x800_000);
    large_contents.push(b'\n');
    work_dir.write_file("file", &large_contents);

    work_dir
        .run_jj([
            "config",
            "set",
            "--repo",
            "snapshot.max-new-file-size",
            &large_contents.len().to_string(),
        ])
        .success();
    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm d500c021 (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    let stdout_bytes = output.stdout.raw().as_bytes();
    assert_eq!(stdout_bytes.len(), large_contents.len());
    assert!(stdout_bytes == large_contents.to_ascii_lowercase());
}

#[test]
fn test_fix_some_paths() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file1", "foo");
    work_dir.write_file("file2", "bar");

    let output = work_dir.run_jj(["fix", "-s", "@", "file1"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm 0279baba (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file1"]);
    insta::assert_snapshot!(output, @"FOO[EOF]");
    let output = work_dir.run_jj(["file", "show", "file2"]);
    insta::assert_snapshot!(output, @"bar[EOF]");
}

#[test]
fn test_fix_cyclic() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--reverse"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "content\n");

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm ce361156 (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"

    tnetnoc[EOF]
    ");

    let output = work_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm 547f589b (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"

    content[EOF]
    ");
}

#[test]
fn test_deduplication() {
    // Append all fixed content to a log file. Note that fix tools are always run
    // from the workspace root, so this will always write to $root/$path-fixlog.
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase", "--tee", "$path-fixlog"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");

    // There are at least two interesting cases: the content is repeated immediately
    // in the child commit, or later in another descendant.
    work_dir.write_file("file", "foo\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "a"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "bar\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "b"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "bar\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c"])
        .success();
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("file", "foo\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "d"])
        .success();

    let output = work_dir.run_jj(["fix", "-s", "a"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 4 commits of 4 checked.
    Working copy  (@) now at: yqosqzyt 9849a250 d | (no description set)
    Parent commit (@-)      : mzvwutvl 9544f381 c | (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "a"]);
    insta::assert_snapshot!(output, @"
    FOO
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "b"]);
    insta::assert_snapshot!(output, @"
    BAR
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "c"]);
    insta::assert_snapshot!(output, @"
    BAR
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "d"]);
    insta::assert_snapshot!(output, @"
    FOO
    [EOF]
    ");

    // Each new content string only appears once in the log, because all the other
    // inputs (like file name) were identical, and so the results were reused. We
    // sort the log because the order of execution inside `jj fix` is undefined.
    insta::assert_snapshot!(sorted_lines(work_dir.root().join("file-fixlog")), @"
    BAR
    FOO
    ");
}

fn sorted_lines(path: PathBuf) -> String {
    let mut log: Vec<_> = std::fs::read_to_string(path.as_os_str())
        .unwrap()
        .lines()
        .map(String::from)
        .collect();
    log.sort();
    log.join("\n")
}

#[test]
fn test_executed_but_nothing_changed() {
    // Show that the tool ran by causing a side effect with --tee, and test that we
    // do the right thing when the tool's output is exactly equal to its input.
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--tee", "$path-copy"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "content\n");

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    content
    [EOF]
    ");
    let copy_content = work_dir.read_file("file-copy");
    insta::assert_snapshot!(copy_content, @"content");

    // fix tools are always run from the workspace root, regardless of working
    // directory at time of invocation.
    let sub_dir = work_dir.create_dir("dir");
    let output = sub_dir.run_jj(["fix"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");

    let copy_content = work_dir.read_file("file-copy");
    insta::assert_snapshot!(copy_content, @"
    content
    content
    ");
    assert!(!sub_dir.root().join("file-copy").exists());
}

#[test]
fn test_failure() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--fail"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "content");

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: Fix tool `$FAKE_FORMATTER_PATH` exited with non-zero exit code for `file`
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"content[EOF]");
}

#[test]
fn test_stderr_success() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(
        &mut test_env,
        &["--stderr", "error", "--stdout", "new content"],
    );
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "old content");

    // TODO: Associate the stderr lines with the relevant tool/file/commit instead
    // of passing it through directly.
    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    file:
    error
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm cb75cbcb (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"new content[EOF]");
}

#[test]
fn test_stderr_failure() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(
        &mut test_env,
        &["--stderr", "error", "--stdout", "new content", "--fail"],
    );
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "old content");

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    file:
    error
    Warning: Fix tool `$FAKE_FORMATTER_PATH` exited with non-zero exit code for `file`
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"old content[EOF]");
}

#[test]
fn test_missing_command() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    test_env.add_config(indoc! {"
        [fix.tools.bad-tool]
        command = ['this_executable_shouldnt_exist']
        patterns = ['all()']
    "});
    // TODO: We should display a warning about invalid tool configurations. When we
    // support multiple tools, we should also keep going to see if any of the other
    // executions succeed.
    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
}

#[test]
fn test_fix_file_types() -> TestResult {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "content");
    work_dir.create_dir("dir");
    symlink_file("file", work_dir.root().join("link"))?;

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm 0184b215 (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"CONTENT[EOF]");
    Ok(())
}

#[cfg(unix)]
#[test]
fn test_fix_executable() -> TestResult {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let path = work_dir.root().join("file");
    work_dir.write_file("file", "content");
    let mut permissions = std::fs::metadata(&path)?.permissions();
    permissions.set_mode(permissions.mode() | 0o111);
    std::fs::set_permissions(&path, permissions)?;

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: qpvuntsm 5293bf26 (no description set)
    Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"CONTENT[EOF]");
    let executable = std::fs::metadata(&path)?.permissions().mode() & 0o111;
    assert_eq!(executable, 0o111);
    Ok(())
}

#[test]
fn test_fix_trivial_merge_commit() {
    // All the changes are attributable to a parent, so none are fixed (in the same
    // way that none would be shown in `jj diff -r @`).
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file_a", "content a");
    work_dir.write_file("file_c", "content c");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "a"])
        .success();
    work_dir.run_jj(["new", "@-"]).success();
    work_dir.write_file("file_b", "content b");
    work_dir.write_file("file_c", "content c");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "b"])
        .success();
    work_dir.run_jj(["new", "a", "b"]).success();

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file_a", "-r", "@"]);
    insta::assert_snapshot!(output, @"content a[EOF]");
    let output = work_dir.run_jj(["file", "show", "file_b", "-r", "@"]);
    insta::assert_snapshot!(output, @"content b[EOF]");
    let output = work_dir.run_jj(["file", "show", "file_c", "-r", "@"]);
    insta::assert_snapshot!(output, @"content c[EOF]");
}

#[test]
fn test_fix_adding_merge_commit() {
    // None of the changes are attributable to a parent, so they are all fixed (in
    // the same way that they would be shown in `jj diff -r @`).
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file_a", "content a");
    work_dir.write_file("file_c", "content c");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "a"])
        .success();
    work_dir.run_jj(["new", "@-"]).success();
    work_dir.write_file("file_b", "content b");
    work_dir.write_file("file_c", "content c");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "b"])
        .success();
    work_dir.run_jj(["new", "a", "b"]).success();
    work_dir.write_file("file_a", "change a");
    work_dir.write_file("file_b", "change b");
    work_dir.write_file("file_c", "change c");
    work_dir.write_file("file_d", "change d");

    let output = work_dir.run_jj(["fix", "-s", "@"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: mzvwutvl 9f580aac (no description set)
    Parent commit (@-)      : qpvuntsm 93f04460 a | (no description set)
    Parent commit (@-)      : kkmpptxz ad4fc36c b | (no description set)
    Added 0 files, modified 4 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file_a", "-r", "@"]);
    insta::assert_snapshot!(output, @"CHANGE A[EOF]");
    let output = work_dir.run_jj(["file", "show", "file_b", "-r", "@"]);
    insta::assert_snapshot!(output, @"CHANGE B[EOF]");
    let output = work_dir.run_jj(["file", "show", "file_c", "-r", "@"]);
    insta::assert_snapshot!(output, @"CHANGE C[EOF]");
    let output = work_dir.run_jj(["file", "show", "file_d", "-r", "@"]);
    insta::assert_snapshot!(output, @"CHANGE D[EOF]");
}

#[test]
fn test_fix_both_sides_of_conflict() {
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "content a\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "a"])
        .success();
    work_dir.run_jj(["new", "@-"]).success();
    work_dir.write_file("file", "content b\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "b"])
        .success();
    work_dir.run_jj(["new", "a", "b"]).success();

    // The conflicts are not different from the merged parent, so they would not be
    // fixed if we didn't fix the parents also.
    let output = work_dir.run_jj(["fix", "-s", "a", "-s", "b"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 3 commits of 3 checked.
    Working copy  (@) now at: mzvwutvl 1bfa5dc3 (conflict) (empty) (no description set)
    Parent commit (@-)      : qpvuntsm 0eae0dae a | (no description set)
    Parent commit (@-)      : kkmpptxz eb61ba8d b | (no description set)
    Added 0 files, modified 1 files, removed 0 files
    Warning: There are unresolved conflicts at these paths:
    file    2-sided conflict
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "a"]);
    insta::assert_snapshot!(output, @"
    CONTENT A
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "b"]);
    insta::assert_snapshot!(output, @"
    CONTENT B
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @r"
    <<<<<<< conflict 1 of 1
    %%%%%%% diff from: zzzzzzzz 00000000
    \\\\\\\        to: qpvuntsm c8346c1c
    +CONTENT A
    +++++++ kkmpptxz 7c4518cb
    CONTENT B
    >>>>>>> conflict 1 of 1 ends
    [EOF]
    ");
}

#[test]
fn test_fix_resolve_conflict() {
    // If both sides of the conflict look the same after being fixed, the conflict
    // will be resolved.
    let mut test_env = TestEnvironment::default();
    set_up_fake_formatter(&mut test_env, &["--uppercase"]);
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    work_dir.write_file("file", "Content\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "a"])
        .success();
    work_dir.run_jj(["new", "@-"]).success();
    work_dir.write_file("file", "cOnTeNt\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "b"])
        .success();
    work_dir.run_jj(["new", "a", "b"]).success();

    // The conflicts are not different from the merged parent, so they would not be
    // fixed if we didn't fix the parents also.
    let output = work_dir.run_jj(["fix", "-s", "a", "-s", "b"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 3 commits of 3 checked.
    Working copy  (@) now at: mzvwutvl c4e4665e (empty) (no description set)
    Parent commit (@-)      : qpvuntsm 7a0dbb95 a | (no description set)
    Parent commit (@-)      : kkmpptxz 5d9510ab b | (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "file", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    CONTENT
    [EOF]
    ");
}

#[test]
fn test_all_files() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());

    // Consider a few cases:
    // File A:     in patterns,     changed in child
    // File B:     in patterns, NOT changed in child
    // File C: NOT in patterns, NOT changed in child
    // File D: NOT in patterns,     changed in child
    // Some files will be in subdirectories to make sure we're covering that aspect
    // of matching.
    test_env.add_config(format!(
        r###"
        [fix.tools.tool]
        command = [{formatter}, "--append", "fixed"]
        patterns = ["a/a", "b/b"]
        "###,
    ));

    work_dir.create_dir("a");
    work_dir.create_dir("b");
    work_dir.create_dir("c");
    work_dir.write_file("a/a", "parent aaa\n");
    work_dir.write_file("b/b", "parent bbb\n");
    work_dir.write_file("c/c", "parent ccc\n");
    work_dir.write_file("ddd", "parent ddd\n");
    work_dir.run_jj(["commit", "-m", "parent"]).success();

    work_dir.write_file("a/a", "child aaa\n");
    work_dir.write_file("ddd", "child ddd\n");
    work_dir.run_jj(["describe", "-m", "child"]).success();

    // Specifying files means exactly those files will be fixed in each revision,
    // although some like file C won't have any tools configured to make changes to
    // them. Specified but unfixed files are silently skipped, whether they lack
    // configuration, are ignored, don't exist, aren't normal files, etc.
    let output = work_dir.run_jj([
        "fix",
        "--include-unchanged-files",
        "b/b",
        "c/c",
        "does_not.exist",
    ]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Warning: No matching entries for paths: does_not.exist
    Fixed 2 commits of 2 checked.
    Working copy  (@) now at: rlvkpnrz d8503fee child
    Parent commit (@-)      : qpvuntsm 62c6ee98 parent
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");

    let output = work_dir.run_jj(["file", "show", "a/a", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent aaa
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "b/b", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent bbb
    fixed[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "c/c", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent ccc
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "ddd", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent ddd
    [EOF]
    ");

    let output = work_dir.run_jj(["file", "show", "a/a", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    child aaa
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "b/b", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    parent bbb
    fixed[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "c/c", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    parent ccc
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "ddd", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    child ddd
    [EOF]
    ");

    // Not specifying files means all files will be fixed in each revision.
    let output = work_dir.run_jj(["fix", "--include-unchanged-files"]);
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 2 commits of 2 checked.
    Working copy  (@) now at: rlvkpnrz 16aeb14c child
    Parent commit (@-)      : qpvuntsm 5257b8ec parent
    Added 0 files, modified 2 files, removed 0 files
    [EOF]
    ");

    let output = work_dir.run_jj(["file", "show", "a/a", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent aaa
    fixed[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "b/b", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent bbb
    fixedfixed[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "c/c", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent ccc
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "ddd", "-r", "@-"]);
    insta::assert_snapshot!(output, @"
    parent ddd
    [EOF]
    ");

    let output = work_dir.run_jj(["file", "show", "a/a", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    child aaa
    fixed[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "b/b", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    parent bbb
    fixedfixed[EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "c/c", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    parent ccc
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "ddd", "-r", "@"]);
    insta::assert_snapshot!(output, @"
    child ddd
    [EOF]
    ");
}

#[test]
fn test_fix_with_line_ranges() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--uppercase"]
        patterns = ["foo", "baz", "qux"]
        line-range-arg = "--line-ranges=$first-$last"

        [fix.tools.tool-2]
        command = [{formatter}, "--lowercase"]
        patterns = ["bar", "baz"]
        line-range-arg = "--line-ranges=$first-$last"
        "###,
    ));

    // Initial commit.
    work_dir.write_file("foo", "Foo1\nFoo2\nFoo3\n");
    work_dir.write_file("bar", "Bar1\nBar2\nBar3\n");
    work_dir.write_file("baz", "unmodified\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c1"])
        .success();

    // Create new commit modifying foo and bar.
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("foo", "Foo1\nFoo2-Mod\nFoo3\n");
    work_dir.write_file("bar", "Bar1-Mod\nBar2\nBar3\n");
    work_dir.write_file("baz", "unmodified\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c2"])
        .success();

    // Commit c3 doing more modifications to foo and bar.
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("foo", "Foo1\nFoo2\nFoo3-Mod\n");
    work_dir.remove_file("bar");
    work_dir.write_file("baz", "");
    work_dir.write_file("qux", "newfile\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c3"])
        .success();

    // Run `jj fix` on the second commit.
    let output = work_dir.run_jj(["fix", "-s", "c2"]).success();
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 2 commits of 2 checked.
    Working copy  (@) now at: mzvwutvl 7d52a6e8 c3 | (no description set)
    Parent commit (@-)      : kkmpptxz a8e4c2e8 c2 | (no description set)
    Added 0 files, modified 2 files, removed 0 files
    [EOF]
    ");

    // Check that the formatter was not applied to the first commit.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    Foo1
    Foo2
    Foo3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    Bar1
    Bar2
    Bar3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    unmodified
    [EOF]
    ");

    // Check that the formatter was applied to the second commit.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    Foo1
    FOO2-MOD
    Foo3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    bar1-mod
    Bar2
    Bar3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    unmodified
    [EOF]
    ");

    // Check that the formatter was applied to the third commit but in different
    // line ranges.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c3"]);
    insta::assert_snapshot!(output, @r"
    Foo1
    Foo2
    FOO3-MOD
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "c3"]);
    insta::assert_snapshot!(output, @"");
    let output = work_dir.run_jj(["file", "show", "qux", "-r", "c3"]);
    insta::assert_snapshot!(output, @r"
    NEWFILE
    [EOF]
    ");
}

#[test]
fn test_fix_with_run_tool_if_zero_line_ranges() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--uppercase"]
        patterns = ["foo", "baz"]
        line-range-arg = "--line-ranges=$first-$last"
        run-tool-if-zero-line-ranges = true

        [fix.tools.tool-2]
        command = [{formatter}, "--lowercase"]
        patterns = ["bar"]
        line-range-arg = "--line-ranges=$first-$last"
        run-tool-if-zero-line-ranges = false

        [fix.tools.tool-3]
        command = [{formatter}, "--uppercase", "--split-even-length-lines"]
        patterns = ["qux"]
        line-range-arg = "--line-ranges=$first-$last"
        run-tool-if-zero-line-ranges = true
        "###,
    ));

    // Initial commit.
    work_dir.write_file("foo", "Foo1\nFoo2\nFoo3\n");
    work_dir.write_file("bar", "Bar1\nBar2\nBar3\n");
    work_dir.write_file("qux", "Qux1\nQux2\nQux3\n");
    work_dir.write_file("baz", "unmodified\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c1"])
        .success();

    // Create a new commit doing a deletion of a line in foo and bar (which should
    // produce empty line ranges).
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("foo", "Foo1\nFoo3\n");
    work_dir.write_file("bar", "Bar1\nBar3\n");
    work_dir.write_file("baz", "unmodified\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c2"])
        .success();

    // Run `jj fix` on the second commit.
    let output = work_dir.run_jj(["fix", "-s", "c2"]).success();
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: kkmpptxz 4613d090 c2 | (no description set)
    Parent commit (@-)      : qpvuntsm 074c5dba c1 | (no description set)
    Added 0 files, modified 1 files, removed 0 files
    [EOF]
    ");

    // Check that the formatter was not applied to the first commit.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    Foo1
    Foo2
    Foo3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    Bar1
    Bar2
    Bar3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    unmodified
    [EOF]
    ");

    // Check that the formatter was applied to the second commit where
    // run-tool-if-zero-line-ranges is true.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    FOO1
    FOO3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    Bar1
    Bar3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    unmodified
    [EOF]
    ");

    // Create a new commit doing a deletion of a line in qux. This commit's parent
    // is c1.
    work_dir.run_jj(["new", "-r", "c1"]).success();
    work_dir.write_file("qux", "Qux1\nQux3\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c3"])
        .success();

    // Run `jj fix` on the third commit. This command should pass, but the
    // fake-formatter should fail to format qux because `--split-even-length-lines`
    // requires line-ranges to be passed into the fake formatter and qux should
    // not have any line ranges passed due to run-tool-if-zero-line-ranges.
    // This should result in no changes to qux.
    let output = work_dir.run_jj(["fix", "-s", "c3"]).success();
    let output = output.normalize_stderr_with(|s| {
        s.replace(formatter_path.to_str().unwrap(), "fake-formatter")
            .replace(".exe", "")
    });
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    qux:
    error: the following required arguments were not provided:
      --line-ranges <LINE_RANGES>

    Usage: fake-formatter --line-ranges <LINE_RANGES> --uppercase --split-even-length-lines

    For more information, try '--help'.

    Warning: Fix tool `fake-formatter` exited with non-zero exit code for `qux`
    Fixed 0 commits of 1 checked.
    Nothing changed.
    [EOF]
    ");

    // Check that the formatter was not applied to the third commit.
    let output = work_dir.run_jj(["file", "show", "qux", "-r", "c3"]);
    insta::assert_snapshot!(output, @r"
    Qux1
    Qux3
    [EOF]
    ");
}

#[test]
fn test_fix_with_all_lines_arg() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--uppercase"]
        patterns = ["foo", "baz", "qux"]
        line-range-arg = "--line-ranges=$first-$last"

        [fix.tools.tool-2]
        command = [{formatter}, "--lowercase"]
        patterns = ["bar"]
        "###,
    ));

    // Initial commit.
    work_dir.write_file("foo", "Foo1\nFoo2\nFoo3\n");
    work_dir.write_file("bar", "Bar1\nBar2\nBar3\n");
    work_dir.write_file("baz", "unmodified\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c1"])
        .success();

    // Create new commit modifying foo and bar.
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("foo", "Foo1\nFoo2-Mod\nFoo3\n");
    work_dir.write_file("bar", "Bar1\nBar2-Mod\nBar3\n");
    work_dir.write_file("baz", "unmodified\n");
    work_dir.write_file("qux", "newfile\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c2"])
        .success();

    // Run `jj fix` on the second commit with --all-lines arg.
    let output = work_dir
        .run_jj(["fix", "--all-lines", "-s", "c2"])
        .success();
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: kkmpptxz d875fd1f c2 | (no description set)
    Parent commit (@-)      : qpvuntsm 81234495 c1 | (no description set)
    Added 0 files, modified 3 files, removed 0 files
    [EOF]
    ");

    // Check that the formatter was not applied to the first commit.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    Foo1
    Foo2
    Foo3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    Bar1
    Bar2
    Bar3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    unmodified
    [EOF]
    ");

    // Check that the formatter was applied to the second commit. And that the
    // whole-file was formatted for changed files. And whole file was formatted
    // for formatters that don't support line ranges.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    FOO1
    FOO2-MOD
    FOO3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "bar", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    bar1
    bar2-mod
    bar3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "baz", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    unmodified
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "qux", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    NEWFILE
    [EOF]
    ");
}

#[test]
fn test_fix_with_line_ranges_multiple_formatters() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--split-even-length-lines"]
        patterns = ["foo", "boo"]
        line-range-arg = "--line-ranges=$first-$last"

        [fix.tools.tool-2]
        command = [{formatter}, "--uppercase"]
        patterns = ["foo", "boo"]
        line-range-arg = "--line-ranges=$first-$last"
        "###,
    ));

    // Initial commit.
    work_dir.write_file("foo", "ab\ncd\nefg\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c1"])
        .success();

    // Create new commit to deal with split even lines and uppercase.
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("foo", "abcd\nefgh\n");
    work_dir.write_file("boo", "abcd\nefgh\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c2"])
        .success();

    // Run `jj fix` on the second commit.
    let output = work_dir.run_jj(["fix", "-s", "c2"]).success();
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: kkmpptxz 4c5f12f5 c2 | (no description set)
    Parent commit (@-)      : qpvuntsm 37f1e5c6 c1 | (no description set)
    Added 0 files, modified 2 files, removed 0 files
    [EOF]
    ");

    // Check that the formatter was not applied to the first commit.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    ab
    cd
    efg
    [EOF]
    ");

    // Check that split lines was applied first and then upper was only applied to
    // the second half.
    let output = work_dir.run_jj(["file", "show", "foo", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    ab
    cd
    EF
    GH
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "boo", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    AB
    CD
    EF
    GH
    [EOF]
    ");
}

#[test]
fn test_fix_with_line_ranges_and_include_unchanged_files_all_lines() {
    let test_env = TestEnvironment::default();
    test_env.run_jj_in(".", ["git", "init", "repo"]).success();
    let work_dir = test_env.work_dir("repo");
    let formatter_path = assert_cmd::cargo::cargo_bin!("fake-formatter");
    assert!(formatter_path.is_file());
    let formatter = to_toml_value(formatter_path.to_str().unwrap());
    test_env.add_config(format!(
        r###"
        [fix.tools.tool-1]
        command = [{formatter}, "--uppercase"]
        patterns = ["all()"]
        line-range-arg = "--line-ranges=$first-$last"
        "###,
    ));

    // Initial commit.
    work_dir.write_file("changed.txt", "Foo1\nFoo2\nFoo3\n");
    work_dir.write_file("unchanged.txt", "baz1\nbaz2\nbaz3\n");
    work_dir.write_file("empty.txt", "content\n");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c1"])
        .success();

    // Create new commit modifying changed.txt.
    work_dir.run_jj(["new"]).success();
    work_dir.write_file("changed.txt", "Foo1\nFoo2-Mod\nFoo3\n");
    work_dir.write_file("unchanged.txt", "baz1\nbaz2\nbaz3\n");
    work_dir.write_file("newfile.txt", "new\n");
    work_dir.write_file("empty.txt", "");
    work_dir
        .run_jj(["bookmark", "create", "-r@", "c2"])
        .success();

    // Run `jj fix` on the second commit, including unchanged files.
    let output = work_dir
        .run_jj([
            "fix",
            "--include-unchanged-files",
            "--all-lines",
            "-s",
            "c2",
        ])
        .success();
    insta::assert_snapshot!(output, @"
    ------- stderr -------
    Fixed 1 commits of 1 checked.
    Working copy  (@) now at: kkmpptxz 1fb2bf8f c2 | (no description set)
    Parent commit (@-)      : qpvuntsm 42db21c2 c1 | (no description set)
    Added 0 files, modified 3 files, removed 0 files
    [EOF]
    ");

    // Check that the formatter was not applied to the first commit.
    let output = work_dir.run_jj(["file", "show", "changed.txt", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    Foo1
    Foo2
    Foo3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "unchanged.txt", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    baz1
    baz2
    baz3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "empty.txt", "-r", "c1"]);
    insta::assert_snapshot!(output, @r"
    content
    [EOF]
    ");

    // Check that the formatter was applied to the second commit and all lines were
    // affected.
    let output = work_dir.run_jj(["file", "show", "changed.txt", "-r", "c2"]);
    insta::assert_snapshot!(output, @"
    FOO1
    FOO2-MOD
    FOO3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "unchanged.txt", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    BAZ1
    BAZ2
    BAZ3
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "newfile.txt", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"
    NEW
    [EOF]
    ");
    let output = work_dir.run_jj(["file", "show", "empty.txt", "-r", "c2"]);
    insta::assert_snapshot!(output, @r"");
}
