// Copyright 2020 Google LLC
//
// 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::OpenOptions;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::os::unix::net::UnixListener;
use std::sync::Arc;

use itertools::Itertools;
use jujutsu_lib::backend::{Conflict, ConflictPart, TreeValue};
use jujutsu_lib::gitignore::GitIgnoreFile;
#[cfg(unix)]
use jujutsu_lib::op_store::OperationId;
use jujutsu_lib::op_store::WorkspaceId;
use jujutsu_lib::repo::ReadonlyRepo;
use jujutsu_lib::repo_path::{RepoPath, RepoPathComponent, RepoPathJoin};
use jujutsu_lib::settings::UserSettings;
use jujutsu_lib::tree_builder::TreeBuilder;
use jujutsu_lib::working_copy::WorkingCopy;
use test_case::test_case;
use testutils::{create_random_commit, TestWorkspace};

#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_root(use_git: bool) {
    // Test that the working copy is clean and empty after init.
    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;

    let wc = test_workspace.workspace.working_copy_mut();
    assert_eq!(wc.sparse_patterns(), vec![RepoPath::root()]);
    let mut locked_wc = wc.start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    locked_wc.discard();
    let wc_commit_id = repo
        .view()
        .get_wc_commit_id(&WorkspaceId::default())
        .unwrap();
    let wc_commit = repo.store().get_commit(wc_commit_id).unwrap();
    assert_eq!(&new_tree_id, wc_commit.tree_id());
    assert_eq!(&new_tree_id, repo.store().empty_tree_id());
}

#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_checkout_file_transitions(use_git: bool) {
    // Tests switching between commits where a certain path is of one type in one
    // commit and another type in the other. Includes a "missing" type, so we cover
    // additions and removals as well.

    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;
    let store = repo.store().clone();
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    #[derive(Debug, Clone, Copy)]
    enum Kind {
        Missing,
        Normal,
        Executable,
        // Executable, but same content as Normal, to test transition where only the bit changed
        ExecutableNormalContent,
        Conflict,
        #[cfg_attr(windows, allow(dead_code))]
        Symlink,
        Tree,
        GitSubmodule,
    }

    fn write_path(
        settings: &UserSettings,
        repo: &Arc<ReadonlyRepo>,
        tree_builder: &mut TreeBuilder,
        kind: Kind,
        path: &RepoPath,
    ) {
        let store = repo.store();
        let value = match kind {
            Kind::Missing => {
                return;
            }
            Kind::Normal => {
                let id = testutils::write_file(store, path, "normal file contents");
                TreeValue::File {
                    id,
                    executable: false,
                }
            }
            Kind::Executable => {
                let id = testutils::write_file(store, path, "executable file contents");
                TreeValue::File {
                    id,
                    executable: true,
                }
            }
            Kind::ExecutableNormalContent => {
                let id = testutils::write_file(store, path, "normal file contents");
                TreeValue::File {
                    id,
                    executable: true,
                }
            }
            Kind::Conflict => {
                let base_file_id = testutils::write_file(store, path, "base file contents");
                let left_file_id = testutils::write_file(store, path, "left file contents");
                let right_file_id = testutils::write_file(store, path, "right file contents");
                let conflict = Conflict {
                    removes: vec![ConflictPart {
                        value: TreeValue::File {
                            id: base_file_id,
                            executable: false,
                        },
                    }],
                    adds: vec![
                        ConflictPart {
                            value: TreeValue::File {
                                id: left_file_id,
                                executable: false,
                            },
                        },
                        ConflictPart {
                            value: TreeValue::File {
                                id: right_file_id,
                                executable: false,
                            },
                        },
                    ],
                };
                let conflict_id = store.write_conflict(path, &conflict).unwrap();
                TreeValue::Conflict(conflict_id)
            }
            Kind::Symlink => {
                let id = store.write_symlink(path, "target").unwrap();
                TreeValue::Symlink(id)
            }
            Kind::Tree => {
                let mut sub_tree_builder = store.tree_builder(store.empty_tree_id().clone());
                let file_path = path.join(&RepoPathComponent::from("file"));
                write_path(
                    settings,
                    repo,
                    &mut sub_tree_builder,
                    Kind::Normal,
                    &file_path,
                );
                let id = sub_tree_builder.write_tree();
                TreeValue::Tree(id)
            }
            Kind::GitSubmodule => {
                let mut tx = repo.start_transaction(settings, "test");
                let id = create_random_commit(settings, repo)
                    .write_to_repo(tx.mut_repo())
                    .id()
                    .clone();
                tx.commit();
                TreeValue::GitSubmodule(id)
            }
        };
        tree_builder.set(path.clone(), value);
    }

    let mut kinds = vec![
        Kind::Missing,
        Kind::Normal,
        Kind::Executable,
        Kind::ExecutableNormalContent,
        Kind::Conflict,
        Kind::Tree,
    ];
    #[cfg(unix)]
    kinds.push(Kind::Symlink);
    if use_git {
        kinds.push(Kind::GitSubmodule);
    }
    let mut left_tree_builder = store.tree_builder(store.empty_tree_id().clone());
    let mut right_tree_builder = store.tree_builder(store.empty_tree_id().clone());
    let mut files = vec![];
    for left_kind in &kinds {
        for right_kind in &kinds {
            let path = RepoPath::from_internal_string(&format!("{:?}_{:?}", left_kind, right_kind));
            write_path(&settings, repo, &mut left_tree_builder, *left_kind, &path);
            write_path(&settings, repo, &mut right_tree_builder, *right_kind, &path);
            files.push((*left_kind, *right_kind, path));
        }
    }
    let left_tree_id = left_tree_builder.write_tree();
    let right_tree_id = right_tree_builder.write_tree();
    let left_tree = store.get_tree(&RepoPath::root(), &left_tree_id).unwrap();
    let right_tree = store.get_tree(&RepoPath::root(), &right_tree_id).unwrap();

    let wc = test_workspace.workspace.working_copy_mut();
    wc.check_out(repo.op_id().clone(), None, &left_tree)
        .unwrap();
    wc.check_out(repo.op_id().clone(), None, &right_tree)
        .unwrap();

    // Check that the working copy is clean.
    let mut locked_wc = wc.start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    locked_wc.discard();
    assert_eq!(new_tree_id, right_tree_id);

    for (_left_kind, right_kind, path) in &files {
        let wc_path = workspace_root.join(path.to_internal_file_string());
        let maybe_metadata = wc_path.symlink_metadata();
        match right_kind {
            Kind::Missing => {
                assert!(maybe_metadata.is_err(), "{:?} should not exist", path);
            }
            Kind::Normal => {
                assert!(maybe_metadata.is_ok(), "{:?} should exist", path);
                let metadata = maybe_metadata.unwrap();
                assert!(metadata.is_file(), "{:?} should be a file", path);
                #[cfg(unix)]
                assert_eq!(
                    metadata.permissions().mode() & 0o111,
                    0,
                    "{:?} should not be executable",
                    path
                );
            }
            Kind::Executable | Kind::ExecutableNormalContent => {
                assert!(maybe_metadata.is_ok(), "{:?} should exist", path);
                let metadata = maybe_metadata.unwrap();
                assert!(metadata.is_file(), "{:?} should be a file", path);
                #[cfg(unix)]
                assert_ne!(
                    metadata.permissions().mode() & 0o111,
                    0,
                    "{:?} should be executable",
                    path
                );
            }
            Kind::Conflict => {
                assert!(maybe_metadata.is_ok(), "{:?} should exist", path);
                let metadata = maybe_metadata.unwrap();
                assert!(metadata.is_file(), "{:?} should be a file", path);
                #[cfg(unix)]
                assert_eq!(
                    metadata.permissions().mode() & 0o111,
                    0,
                    "{:?} should not be executable",
                    path
                );
            }
            Kind::Symlink => {
                assert!(maybe_metadata.is_ok(), "{:?} should exist", path);
                let metadata = maybe_metadata.unwrap();
                assert!(
                    metadata.file_type().is_symlink(),
                    "{:?} should be a symlink",
                    path
                );
            }
            Kind::Tree => {
                assert!(maybe_metadata.is_ok(), "{:?} should exist", path);
                let metadata = maybe_metadata.unwrap();
                assert!(metadata.is_dir(), "{:?} should be a directory", path);
            }
            Kind::GitSubmodule => {
                // Not supported for now
                assert!(maybe_metadata.is_err(), "{:?} should not exist", path);
            }
        };
    }
}

#[test]
fn test_reset() {
    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, false);
    let repo = &test_workspace.repo;
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    let ignored_path = RepoPath::from_internal_string("ignored");
    let gitignore_path = RepoPath::from_internal_string(".gitignore");

    let tree_without_file = testutils::create_tree(repo, &[(&gitignore_path, "ignored\n")]);
    let tree_with_file = testutils::create_tree(
        repo,
        &[(&gitignore_path, "ignored\n"), (&ignored_path, "code")],
    );

    let wc = test_workspace.workspace.working_copy_mut();
    wc.check_out(repo.op_id().clone(), None, &tree_with_file)
        .unwrap();

    // Test the setup: the file should exist on disk and in the tree state.
    assert!(ignored_path.to_fs_path(&workspace_root).is_file());
    assert!(wc.file_states().contains_key(&ignored_path));

    // After we reset to the commit without the file, it should still exist on disk,
    // but it should not be in the tree state, and it should not get added when we
    // commit the working copy (because it's ignored).
    let mut locked_wc = wc.start_mutation();
    locked_wc.reset(&tree_without_file).unwrap();
    locked_wc.finish(repo.op_id().clone());
    assert!(ignored_path.to_fs_path(&workspace_root).is_file());
    assert!(!wc.file_states().contains_key(&ignored_path));
    let mut locked_wc = wc.start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    assert_eq!(new_tree_id, *tree_without_file.id());
    locked_wc.discard();

    // After we reset to the commit without the file, it should still exist on disk,
    // but it should not be in the tree state, and it should not get added when we
    // commit the working copy (because it's ignored).
    let mut locked_wc = wc.start_mutation();
    locked_wc.reset(&tree_without_file).unwrap();
    locked_wc.finish(repo.op_id().clone());
    assert!(ignored_path.to_fs_path(&workspace_root).is_file());
    assert!(!wc.file_states().contains_key(&ignored_path));
    let mut locked_wc = wc.start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    assert_eq!(new_tree_id, *tree_without_file.id());
    locked_wc.discard();

    // Now test the opposite direction: resetting to a commit where the file is
    // tracked. The file should become tracked (even though it's ignored).
    let mut locked_wc = wc.start_mutation();
    locked_wc.reset(&tree_with_file).unwrap();
    locked_wc.finish(repo.op_id().clone());
    assert!(ignored_path.to_fs_path(&workspace_root).is_file());
    assert!(wc.file_states().contains_key(&ignored_path));
    let mut locked_wc = wc.start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    assert_eq!(new_tree_id, *tree_with_file.id());
    locked_wc.discard();
}

#[test]
fn test_checkout_discard() {
    // Start a mutation, do a checkout, and then discard the mutation. The working
    // copy files should remain changed, but the state files should not be
    // written.
    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, false);
    let repo = test_workspace.repo.clone();
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    let file1_path = RepoPath::from_internal_string("file1");
    let file2_path = RepoPath::from_internal_string("file2");

    let store = repo.store();
    let tree1 = testutils::create_tree(&repo, &[(&file1_path, "contents")]);
    let tree2 = testutils::create_tree(&repo, &[(&file2_path, "contents")]);

    let wc = test_workspace.workspace.working_copy_mut();
    let state_path = wc.state_path().to_path_buf();
    wc.check_out(repo.op_id().clone(), None, &tree1).unwrap();

    // Test the setup: the file should exist on disk and in the tree state.
    assert!(file1_path.to_fs_path(&workspace_root).is_file());
    assert!(wc.file_states().contains_key(&file1_path));

    // Start a checkout
    let mut locked_wc = wc.start_mutation();
    locked_wc.check_out(&tree2).unwrap();
    // The change should be reflected in the working copy but not saved
    assert!(!file1_path.to_fs_path(&workspace_root).is_file());
    assert!(file2_path.to_fs_path(&workspace_root).is_file());
    let reloaded_wc = WorkingCopy::load(store.clone(), workspace_root.clone(), state_path.clone());
    assert!(reloaded_wc.file_states().contains_key(&file1_path));
    assert!(!reloaded_wc.file_states().contains_key(&file2_path));
    locked_wc.discard();

    // The change should remain in the working copy, but not in memory and not saved
    assert!(wc.file_states().contains_key(&file1_path));
    assert!(!wc.file_states().contains_key(&file2_path));
    assert!(!file1_path.to_fs_path(&workspace_root).is_file());
    assert!(file2_path.to_fs_path(&workspace_root).is_file());
    let reloaded_wc = WorkingCopy::load(store.clone(), workspace_root, state_path);
    assert!(reloaded_wc.file_states().contains_key(&file1_path));
    assert!(!reloaded_wc.file_states().contains_key(&file2_path));
}

#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_snapshot_racy_timestamps(use_git: bool) {
    // Tests that file modifications are detected even if they happen the same
    // millisecond as the updated working copy state.
    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    let file_path = workspace_root.join("file");
    let mut previous_tree_id = repo.store().empty_tree_id().clone();
    let wc = test_workspace.workspace.working_copy_mut();
    for i in 0..100 {
        {
            let mut file = OpenOptions::new()
                .create(true)
                .write(true)
                .open(&file_path)
                .unwrap();
            file.write_all(format!("contents {}", i).as_bytes())
                .unwrap();
        }
        let mut locked_wc = wc.start_mutation();
        let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
        locked_wc.discard();
        assert_ne!(new_tree_id, previous_tree_id);
        previous_tree_id = new_tree_id;
    }
}

#[cfg(unix)]
#[test]
fn test_snapshot_special_file() {
    // Tests that we ignore when special files (such as sockets and pipes) exist on
    // disk.
    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, false);
    let workspace_root = test_workspace.workspace.workspace_root().clone();
    let store = test_workspace.repo.store();

    let file1_path = RepoPath::from_internal_string("file1");
    let file1_disk_path = file1_path.to_fs_path(&workspace_root);
    std::fs::write(&file1_disk_path, "contents".as_bytes()).unwrap();
    let file2_path = RepoPath::from_internal_string("file2");
    let file2_disk_path = file2_path.to_fs_path(&workspace_root);
    std::fs::write(file2_disk_path, "contents".as_bytes()).unwrap();
    let socket_disk_path = workspace_root.join("socket");
    UnixListener::bind(&socket_disk_path).unwrap();
    // Test the setup
    assert!(socket_disk_path.exists());
    assert!(!socket_disk_path.is_file());

    // Snapshot the working copy with the socket file
    let wc = test_workspace.workspace.working_copy_mut();
    let mut locked_wc = wc.start_mutation();
    let tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    locked_wc.finish(OperationId::from_hex("abc123"));
    let tree = store.get_tree(&RepoPath::root(), &tree_id).unwrap();
    // Only the regular files should be in the tree
    assert_eq!(
        tree.entries().map(|(path, _value)| path).collect_vec(),
        vec![file1_path.clone(), file2_path.clone()]
    );
    assert_eq!(
        wc.file_states().keys().cloned().collect_vec(),
        vec![file1_path, file2_path.clone()]
    );

    // Replace a regular file by a socket and snapshot the working copy again
    std::fs::remove_file(&file1_disk_path).unwrap();
    UnixListener::bind(&file1_disk_path).unwrap();
    let mut locked_wc = wc.start_mutation();
    let tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    locked_wc.finish(OperationId::from_hex("abc123"));
    let tree = store.get_tree(&RepoPath::root(), &tree_id).unwrap();
    // Only the regular file should be in the tree
    assert_eq!(
        tree.entries().map(|(path, _value)| path).collect_vec(),
        vec![file2_path.clone()]
    );
    assert_eq!(
        wc.file_states().keys().cloned().collect_vec(),
        vec![file2_path]
    );
}

#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_gitignores(use_git: bool) {
    // Tests that .gitignore files are respected.

    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    let gitignore_path = RepoPath::from_internal_string(".gitignore");
    let added_path = RepoPath::from_internal_string("added");
    let modified_path = RepoPath::from_internal_string("modified");
    let removed_path = RepoPath::from_internal_string("removed");
    let ignored_path = RepoPath::from_internal_string("ignored");
    let subdir_modified_path = RepoPath::from_internal_string("dir/modified");
    let subdir_ignored_path = RepoPath::from_internal_string("dir/ignored");

    testutils::write_working_copy_file(&workspace_root, &gitignore_path, "ignored\n");
    testutils::write_working_copy_file(&workspace_root, &modified_path, "1");
    testutils::write_working_copy_file(&workspace_root, &removed_path, "1");
    std::fs::create_dir(workspace_root.join("dir")).unwrap();
    testutils::write_working_copy_file(&workspace_root, &subdir_modified_path, "1");

    let wc = test_workspace.workspace.working_copy_mut();
    let mut locked_wc = wc.start_mutation();
    let new_tree_id1 = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    locked_wc.finish(repo.op_id().clone());
    let tree1 = repo
        .store()
        .get_tree(&RepoPath::root(), &new_tree_id1)
        .unwrap();
    let files1 = tree1.entries().map(|(name, _value)| name).collect_vec();
    assert_eq!(
        files1,
        vec![
            gitignore_path.clone(),
            subdir_modified_path.clone(),
            modified_path.clone(),
            removed_path.clone(),
        ]
    );

    testutils::write_working_copy_file(
        &workspace_root,
        &gitignore_path,
        "ignored\nmodified\nremoved\n",
    );
    testutils::write_working_copy_file(&workspace_root, &added_path, "2");
    testutils::write_working_copy_file(&workspace_root, &modified_path, "2");
    std::fs::remove_file(removed_path.to_fs_path(&workspace_root)).unwrap();
    testutils::write_working_copy_file(&workspace_root, &ignored_path, "2");
    testutils::write_working_copy_file(&workspace_root, &subdir_modified_path, "2");
    testutils::write_working_copy_file(&workspace_root, &subdir_ignored_path, "2");

    let mut locked_wc = wc.start_mutation();
    let new_tree_id2 = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    locked_wc.discard();
    let tree2 = repo
        .store()
        .get_tree(&RepoPath::root(), &new_tree_id2)
        .unwrap();
    let files2 = tree2.entries().map(|(name, _value)| name).collect_vec();
    assert_eq!(
        files2,
        vec![
            gitignore_path,
            added_path,
            subdir_modified_path,
            modified_path,
        ]
    );
}

#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_gitignores_checkout_never_overwrites_ignored(use_git: bool) {
    // Tests that a .gitignore'd file doesn't get overwritten if check out a commit
    // where the file is tracked.

    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    // Write an ignored file called "modified" to disk
    let gitignore_path = RepoPath::from_internal_string(".gitignore");
    testutils::write_working_copy_file(&workspace_root, &gitignore_path, "modified\n");
    let modified_path = RepoPath::from_internal_string("modified");
    testutils::write_working_copy_file(&workspace_root, &modified_path, "garbage");

    // Create a tree that adds the same file but with different contents
    let mut tree_builder = repo
        .store()
        .tree_builder(repo.store().empty_tree_id().clone());
    testutils::write_normal_file(&mut tree_builder, &modified_path, "contents");
    let tree_id = tree_builder.write_tree();
    let tree = repo.store().get_tree(&RepoPath::root(), &tree_id).unwrap();

    // Now check out the tree that adds the file "modified" with contents
    // "contents". The exiting contents ("garbage") shouldn't be replaced in the
    // working copy.
    let wc = test_workspace.workspace.working_copy_mut();
    assert!(wc.check_out(repo.op_id().clone(), None, &tree).is_err());

    // Check that the old contents are in the working copy
    let path = workspace_root.join("modified");
    assert!(path.is_file());
    assert_eq!(std::fs::read(&path).unwrap(), b"garbage");
}

#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_gitignores_ignored_directory_already_tracked(use_git: bool) {
    // Tests that a .gitignore'd directory that already has a tracked file in it
    // does not get removed when snapshotting the working directory.

    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;

    // Add a .gitignore file saying to ignore the directory "ignored/"
    let gitignore_path = RepoPath::from_internal_string(".gitignore");
    testutils::write_working_copy_file(
        test_workspace.workspace.workspace_root(),
        &gitignore_path,
        "/ignored/\n",
    );
    let file_path = RepoPath::from_internal_string("ignored/file");

    // Create a tree that adds a file in the ignored directory
    let mut tree_builder = repo
        .store()
        .tree_builder(repo.store().empty_tree_id().clone());
    testutils::write_normal_file(&mut tree_builder, &file_path, "contents");
    let tree_id = tree_builder.write_tree();
    let tree = repo.store().get_tree(&RepoPath::root(), &tree_id).unwrap();

    // Check out the tree with the file in ignored/
    let wc = test_workspace.workspace.working_copy_mut();
    wc.check_out(repo.op_id().clone(), None, &tree).unwrap();

    // Check that the file is still in the tree created by snapshotting the working
    // copy (that it didn't get removed because the directory is ignored)
    let mut locked_wc = wc.start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    locked_wc.discard();
    let new_tree = repo
        .store()
        .get_tree(&RepoPath::root(), &new_tree_id)
        .unwrap();
    assert!(new_tree.path_value(&file_path).is_some());
}

#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_dotgit_ignored(use_git: bool) {
    // Tests that .git directories and files are always ignored (we could accept
    // them if the backend is not git).

    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    // Test with a .git/ directory (with a file in, since we don't write empty
    // trees)
    let dotgit_path = workspace_root.join(".git");
    std::fs::create_dir(&dotgit_path).unwrap();
    testutils::write_working_copy_file(
        &workspace_root,
        &RepoPath::from_internal_string(".git/file"),
        "contents",
    );
    let mut locked_wc = test_workspace.workspace.working_copy_mut().start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    assert_eq!(new_tree_id, *repo.store().empty_tree_id());
    locked_wc.discard();
    std::fs::remove_dir_all(&dotgit_path).unwrap();

    // Test with a .git file
    testutils::write_working_copy_file(
        &workspace_root,
        &RepoPath::from_internal_string(".git"),
        "contents",
    );
    let mut locked_wc = test_workspace.workspace.working_copy_mut().start_mutation();
    let new_tree_id = locked_wc.snapshot(GitIgnoreFile::empty()).unwrap();
    assert_eq!(new_tree_id, *repo.store().empty_tree_id());
    locked_wc.discard();
}

#[cfg(unix)]
#[test_case(false ; "local backend")]
#[test_case(true ; "git backend")]
fn test_existing_directory_symlink(use_git: bool) {
    let settings = testutils::user_settings();
    let mut test_workspace = TestWorkspace::init(&settings, use_git);
    let repo = &test_workspace.repo;
    let workspace_root = test_workspace.workspace.workspace_root().clone();

    // Creates a symlink in working directory, and a tree that will add a file under
    // the symlinked directory.
    std::os::unix::fs::symlink("..", workspace_root.join("parent")).unwrap();
    let mut tree_builder = repo
        .store()
        .tree_builder(repo.store().empty_tree_id().clone());
    testutils::write_normal_file(
        &mut tree_builder,
        &RepoPath::from_internal_string("parent/escaped"),
        "contents",
    );
    let tree_id = tree_builder.write_tree();
    let tree = repo.store().get_tree(&RepoPath::root(), &tree_id).unwrap();

    // Checkout should fail because "parent" already exists and is a symlink.
    let wc = test_workspace.workspace.working_copy_mut();
    assert!(wc.check_out(repo.op_id().clone(), None, &tree).is_err());

    // Therefore, "../escaped" shouldn't be created.
    assert!(!workspace_root.parent().unwrap().join("escaped").exists());
}