mod common;

use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir};
use indoc::indoc;
use prek_consts::env_vars::EnvVars;
use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_HOOKS_YAML};

use crate::common::{TestContext, cmd_snapshot, git_cmd};

#[test]
fn basic_discovery() -> Result<()> {
    let context = TestContext::new();
    let cwd = context.work_dir();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'
          verbose: true
    "};

    context.setup_workspace(
        &[
            "project2",
            "project3",
            "nested/project4",
            "project3/project5",
        ],
        config,
    )?;
    context.git_add(".");

    // Run from the root directory
    cmd_snapshot!(context.filters(), context.run(), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ nested/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/nested/project4
        ['.pre-commit-config.yaml']
    ✓ project3/project5
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project5
        ['.pre-commit-config.yaml']
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'nested/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']
        [TEMP_DIR]/
        ['project3/project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Run from a subdirectory
    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project2")), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/project2
      ['.pre-commit-config.yaml']

    ----- stderr -----
    ");

    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project2")).arg("--all-files"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/project2
      ['.pre-commit-config.yaml']

    ----- stderr -----
    ");

    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project3")), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project5
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project5
        ['.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("--cd").arg(cwd.join("project3")), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project5
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project5
        ['.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Ignore `project5` in `project3`
    context
        .work_dir()
        .child("project3/.prekignore")
        .write_str("project5/\n")?;
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("--cd").arg(cwd.join("project3")), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/project3
      ['.pre-commit-config.yaml', '.prekignore', 'project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Ignoring everything under project3, but when runs from project3, it’s still getting picked up.
    context
        .work_dir()
        .child("project3/.prekignore")
        .write_str("*\n")?;
    context.git_add(".");
    cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("--cd").arg(cwd.join("project3")), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/project3
      ['.pre-commit-config.yaml', '.prekignore', 'project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    Ok(())
}

#[test]
fn same_depth_project_concurrency_has_stable_output() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    context.write_pre_commit_config("repos: []");

    let config = indoc! {r#"
    repos:
      - repo: local
        hooks:
        - id: slow-hook
          name: Slow Hook
          language: system
          entry: python3 -c "from pathlib import Path; import time; name = Path.cwd().name; log = Path('..') / 'events.log'; log.open('a').write('start ' + name + '\n'); time.sleep(0.5); log.open('a').write('end ' + name + '\n')"
          always_run: true
          pass_filenames: false
    "#};

    for project in ["a", "b"] {
        let project_dir = context.work_dir().child(project);
        project_dir.create_dir_all()?;
        project_dir
            .child(".pre-commit-config.yaml")
            .write_str(config)?;
        project_dir.child("file.txt").write_str("")?;
    }
    context.git_add(".");

    let mut run = context.run();
    run.arg("--all-files")
        .env(EnvVars::PREK_CONCURRENT_HOOKS, "2");
    cmd_snapshot!(context.filters(), run, @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ a
      Slow Hook..............................................................Passed
    ✓ b
      Slow Hook..............................................................Passed

    ----- stderr -----
    "#);

    let events = context.read("events.log");
    let start_a = events.find("start a").expect("a should start");
    let end_a = events.find("end a").expect("a should end");
    let start_b = events.find("start b").expect("b should start");
    let end_b = events.find("end b").expect("b should end");

    assert!(start_a < end_a);
    assert!(start_b < end_b);
    assert!(start_a < end_b);
    assert!(start_b < end_a);

    Ok(())
}

#[test]
fn fail_fast_stops_after_current_project_level() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    context.write_pre_commit_config(indoc! {r#"
    repos:
      - repo: local
        hooks:
        - id: root-hook
          name: Root Hook
          language: system
          entry: python3 -c "print('root ran')"
          always_run: true
    "#});

    let failing_config = indoc! {r#"
    repos:
      - repo: local
        hooks:
        - id: failing-hook
          name: Failing Hook
          language: system
          entry: python3 -c "import sys; sys.exit(1)"
          always_run: true
          fail_fast: true
    "#};
    let passing_config = indoc! {r#"
    repos:
      - repo: local
        hooks:
        - id: passing-hook
          name: Passing Hook
          language: system
          entry: python3 -c "print('sibling ran')"
          always_run: true
    "#};

    let project_a = context.work_dir().child("a");
    project_a.create_dir_all()?;
    project_a
        .child(".pre-commit-config.yaml")
        .write_str(failing_config)?;

    let project_b = context.work_dir().child("b");
    project_b.create_dir_all()?;
    project_b
        .child(".pre-commit-config.yaml")
        .write_str(passing_config)?;

    context.git_add(".");

    let mut run = context.run();
    run.arg("--all-files")
        .env(EnvVars::PREK_CONCURRENT_HOOKS, "2");
    cmd_snapshot!(context.filters(), run, @r#"
    success: false
    exit_code: 1
    ----- stdout -----
    × a
      Failing Hook...........................................................Failed
      - hook id: failing-hook
      - exit code: 1
    ✓ b
      Passing Hook...........................................................Passed

    ----- stderr -----
    "#);

    Ok(())
}

#[test]
fn config_not_staged() -> Result<()> {
    let context = TestContext::new();
    let cwd = context.work_dir();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'
          verbose: true
    "};
    context.setup_workspace(
        &[
            "project2",
            "project3",
            "nested/project4",
            "project3/project5",
        ],
        config,
    )?;
    context.git_add(".");

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd-modified
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'
          verbose: true
    "};
    // Setup again to modify files after git add
    context.setup_workspace(
        &[
            "project2",
            "project3",
            "nested/project4",
            "project3/project5",
        ],
        config,
    )?;

    // Run from the root directory
    cmd_snapshot!(context.filters(), context.run(), @r"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: The following configuration files are not staged, `git add` them first:
      .pre-commit-config.yaml
      nested/project4/.pre-commit-config.yaml
      project2/.pre-commit-config.yaml
      project3/.pre-commit-config.yaml
      project3/project5/.pre-commit-config.yaml
    ");

    // Run from a subdirectory
    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project3")), @r"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: The following configuration files are not staged, `git add` them first:
      .pre-commit-config.yaml
      project5/.pre-commit-config.yaml
    ");

    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.join("project2")), @r"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: prek configuration file is not staged, run `git add .pre-commit-config.yaml` to stage it
    ");

    Ok(())
}

#[test]
fn run_with_selectors() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'
          verbose: true
    "};

    context.setup_workspace(
        &[
            "project2",
            "project3",
            "nested/project4",
            "project3/project5",
        ],
        config,
    )?;
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("project2/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ nested/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/nested/project4
        ['.pre-commit-config.yaml']
    ✓ project3/project5
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project5
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'nested/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']
        [TEMP_DIR]/
        ['project3/project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("nested/").arg("--skip").arg("project3/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'nested/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']
        [TEMP_DIR]/
        ['project3/project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("show-cwd"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ nested/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/nested/project4
        ['.pre-commit-config.yaml']
    ✓ project3/project5
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project5
        ['.pre-commit-config.yaml']
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'nested/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']
        [TEMP_DIR]/
        ['project3/project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("project2:show-cwd"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    cmd_snapshot!(context.filters(), context.run().arg(".:show-cwd"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/
      ['.pre-commit-config.yaml', 'nested/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']
      [TEMP_DIR]/
      ['project3/project5/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("show-cwd"), @r"
    success: false
    exit_code: 1
    ----- stdout -----

    ----- stderr -----
    error: No hooks found after filtering with the given selectors
    ");

    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2:show-cwd").arg("--skip").arg("nested:show-cwd"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ nested/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/nested/project4
        ['.pre-commit-config.yaml']
    ✓ project3/project5
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project5
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'nested/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']
        [TEMP_DIR]/
        ['project3/project5/.pre-commit-config.yaml']

    ----- stderr -----
    warning: selector `--skip=nested:show-cwd` did not match any hooks
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("non-exist"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ nested/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/nested/project4
        ['.pre-commit-config.yaml']
    ✓ project3/project5
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project5
        ['.pre-commit-config.yaml']
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project5/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'nested/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']
        [TEMP_DIR]/
        ['project3/project5/.pre-commit-config.yaml']

    ----- stderr -----
    warning: selector `--skip=non-exist` did not match any hooks
    "#);

    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("../"), @r"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: Invalid selector: `../`
      caused by: Invalid project path: `../`
      caused by: path is outside the workspace root
    ");

    cmd_snapshot!(context.filters(), context.run().current_dir(context.work_dir().join("project2")), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/project2
      ['.pre-commit-config.yaml']

    ----- stderr -----
    ");

    Ok(())
}

#[test]
fn run_with_mixed_project_and_hook_selectors() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    context.write_pre_commit_config(indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: root-hook
          name: root hook
          entry: echo root
          language: system
          pass_filenames: false
    "});

    let sub = context.work_dir().child("sub");
    sub.create_dir_all()?;
    sub.child(".pre-commit-config.yaml").write_str(indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: sub-hook
          name: sub hook
          entry: echo sub
          language: system
          pass_filenames: false
    "})?;
    sub.child("file.txt").write_str("")?;

    let empty = context.work_dir().child("empty");
    empty.create_dir_all()?;
    empty
        .child(".pre-commit-config.yaml")
        .write_str("repos: []\n")?;

    let unselected = context.work_dir().child("unselected");
    unselected.create_dir_all()?;
    unselected
        .child(".pre-commit-config.yaml")
        .write_str("invalid: config\n")?;

    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("--all-files").arg("sub/").arg(".:root-hook"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ sub
      sub hook...............................................................Passed
    ✓ <workspace>
      root hook..............................................................Passed

    ----- stderr -----
    ");

    cmd_snapshot!(context.filters(), context.run().arg("--all-files").arg("sub/").arg("root-hook"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ sub
      sub hook...............................................................Passed
    ✓ <workspace>
      root hook..............................................................Passed

    ----- stderr -----
    ");

    cmd_snapshot!(context.filters(), context.run().arg("--all-files").arg("empty/").arg("root-hook"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    root hook................................................................Passed

    ----- stderr -----
    ");

    Ok(())
}

#[test]
fn skips() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'
          verbose: true
    "};

    context.setup_workspace(&["project2", "project3", "project3/project4"], config)?;
    context.git_add(".");

    // Test CLI skip
    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project3/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project4
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project4/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Test PREK_SKIP environment variable
    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_SKIP, "project2/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project3/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project4
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project4/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Test SKIP environment variable
    cmd_snapshot!(context.filters(), context.run().env(EnvVars::SKIP, "project2/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project3/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project4
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project4/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Test precedence: CLI --skip overrides PREK_SKIP
    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project2/").env(EnvVars::PREK_SKIP, "project3/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project3/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project4
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project4/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Test precedence: PREK_SKIP overrides SKIP
    cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_SKIP, "project2/").env(EnvVars::SKIP, "project3/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project3/project4
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3/project4
        ['.pre-commit-config.yaml']
    ✓ project3
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project3
        ['.pre-commit-config.yaml', 'project4/.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // Test multiple selectors in environment variable
    cmd_snapshot!(context.filters(), context.run().env("PREK_SKIP", "project2/,project3/,non-exist-hook"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/
      ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']

    ----- stderr -----
    warning: selector `PREK_SKIP=non-exist-hook` did not match any hooks
    ");

    // Add an invalid config
    context
        .work_dir()
        .child("project3/.pre-commit-config.yaml")
        .write_str("invalid_yaml: [")?;
    context.git_add(".");

    // Should error out because of the invalid config
    cmd_snapshot!(context.filters(), context.run(), @"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: Failed to parse `project3/.pre-commit-config.yaml`
      caused by: error: line 1 column 15: unclosed bracket '['
     --> <input>:1:15
      |
    1 | invalid_yaml: [
      |               ^ unclosed bracket '['
    ");

    // Should skip the invalid config
    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("project3/"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml', 'project3/project4/.pre-commit-config.yaml', 'project3/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    Ok(())
}

#[test]
fn workspace_no_projects() {
    let context = TestContext::new();
    context.init_project();

    context.write_pre_commit_config("repos: []");
    context.git_add(".");

    cmd_snapshot!(context.filters(), context.run().arg("--skip").arg("."), @r"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.

    hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.
    ");
}

#[test]
fn gitignore_respected() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sorted(sys.argv[1:]))'
          verbose: true
    "};

    // Create a project structure with directories that should be ignored
    context.setup_workspace(
        &[
            "src",
            "node_modules/ignored", // Should be ignored by .gitignore
            "target/ignored",       // Should be ignored by .gitignore
        ],
        config,
    )?;

    // Create .gitignore that ignores node_modules and target
    context
        .work_dir()
        .child(".gitignore")
        .write_str("node_modules/\ntarget/\n")?;

    context.git_add(".");

    // Run from the root - should not discover projects in node_modules or target
    cmd_snapshot!(context.filters(), context.run(), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ src
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/src
        ['.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.gitignore', '.pre-commit-config.yaml', 'src/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    Ok(())
}

#[test]
fn nested_project_exclude_is_relative() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    // Regression test for nested workspaces:
    // `exclude` must be evaluated against paths *relative to each project root*.
    //
    // Concretely:
    // - In the nested project, the file is seen as `excluded_by_project` and should be excluded by `^excluded_by_project$`.
    // - In the root project, the same file is seen as `nested/excluded_by_project` and should NOT be excluded.
    let config = indoc! {r#"
    exclude: \.pre-commit-config\.yaml$|^excluded_by_project$
    repos:
      - repo: local
        hooks:
        - id: show-files
          name: Show Files
          language: python
          entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print("  - {}".format(f)) for f in sys.argv[1:]]'
          pass_filenames: true
          verbose: true
    "#};

    // Workspace with a nested project.
    context.setup_workspace(&["nested"], config)?;

    // A root-level file which should be excluded by the root project (path is `excluded_by_project`).
    // This keeps the snapshot focused on the nested files, while proving the regex is not
    // accidentally matching `nested/excluded_by_project`.
    context
        .work_dir()
        .child("excluded_by_project")
        .write_str("")?;

    // Files inside the nested project: one that should be included and one excluded.
    context.work_dir().child("nested/include").write_str("")?;
    context
        .work_dir()
        .child("nested/excluded_by_project")
        .write_str("")?;

    context.git_add(".");

    // When running from the root with --all-files, the nested project's exclude
    // pattern should see paths relative to `nested/`, so `noinclude` is excluded
    // there but still visible from the root project.
    cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ nested
      Show Files.............................................................Passed
      - hook id: show-files
      - duration: [TIME]

        Processing 1 files
          - include
    ✓ <workspace>
      Show Files.............................................................Passed
      - hook id: show-files
      - duration: [TIME]

        Processing 2 files
          - nested/excluded_by_project
          - nested/include

    ----- stderr -----
    "#);

    Ok(())
}

/// Tests that `--files` arguments references files in other projects, should be filtered out properly.
#[test]
fn reference_files_across_projects() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: echo
          name: echo
          language: system
          entry: echo
          verbose: true
    "};

    // Create a project structure with directories that should be ignored
    context.setup_workspace(&["frontend", "backend"], config)?;

    let cwd = context.work_dir();
    cwd.child("backend/app.py")
        .write_str("print('Hello from backend')")?;
    context.git_add(".");
    // Run with --files referencing a file in another project
    cmd_snapshot!(context.filters(), context.run().current_dir(cwd.child("frontend")).arg("--files").arg("../backend/app.py").arg("../backend/non-exist.py"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    echo.................................................(no files to check)Skipped

    ----- stderr -----
    warning: This file does not exist and will be ignored: `../backend/non-exist.py`
    ");

    Ok(())
}

#[test]
fn submodule_discovery() -> Result<()> {
    let context = TestContext::new();
    let cwd = context.work_dir();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'
          verbose: true
    "};

    context.setup_workspace(&["project2"], config)?;

    // Create a submodule
    let submodule_path = cwd.child("submodule");
    let submodule_context = TestContext::new_at(submodule_path.to_path_buf());

    submodule_context.init_project();
    submodule_context.write_pre_commit_config(config);
    submodule_context.git_add(".");
    submodule_context.git_commit("Initial commit");

    // Add submodule to the main project
    git_cmd(cwd)
        .args(["submodule", "add", "./submodule"])
        .assert()
        .success();
    context.git_add(".");

    // 1. Test that workspace discovery does not recurse into git submodules
    cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.gitmodules', '.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    // 2. Test that current directory is in the submodule with a .pre-commit-config
    cmd_snapshot!(context.filters(), context.run().current_dir(&submodule_path).arg("--all-files"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    Show CWD.................................................................Passed
    - hook id: show-cwd
    - duration: [TIME]

      [TEMP_DIR]/submodule
      ['.pre-commit-config.yaml']

    ----- stderr -----
    ");

    // 3. Test that current directory is in the submodule without .pre-commit-config
    // Remove the config file in the submodule
    fs_err::remove_file(submodule_path.join(".pre-commit-config.yaml"))?;
    submodule_context.git_add(".");
    submodule_context.git_commit("Remove config");

    cmd_snapshot!(context.filters(), context.run().current_dir(&submodule_path), @r"
    success: false
    exit_code: 2
    ----- stdout -----

    ----- stderr -----
    error: No `prek.toml` or `.pre-commit-config.yaml` found in the current directory or parent directories.

    hint: If you just added one, rerun your command with the `--refresh` flag to rescan the workspace.
    ");

    Ok(())
}

#[test]
fn cookiecutter_template_directories_are_skipped() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    let config = indoc! {r"
    repos:
      - repo: local
        hooks:
        - id: show-cwd
          name: Show CWD
          language: python
          entry: python -c 'import sys, os; print(os.getcwd()); print(sys.argv[1:])'
          verbose: true
    "};

    context.setup_workspace(&["project2", "{{cookiecutter.project_slug}}"], config)?;

    // Stage only the configs that should participate in discovery.
    context.git_add(".pre-commit-config.yaml");
    context.git_add("project2/.pre-commit-config.yaml");

    // The cookiecutter directory would otherwise be discovered as a project.
    cmd_snapshot!(context.filters(), context.run().arg("--refresh").arg("--all-files"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ project2
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/project2
        ['.pre-commit-config.yaml']
    ✓ <workspace>
      Show CWD...............................................................Passed
      - hook id: show-cwd
      - duration: [TIME]

        [TEMP_DIR]/
        ['.pre-commit-config.yaml', 'project2/.pre-commit-config.yaml']

    ----- stderr -----
    "#);

    Ok(())
}

#[test]
fn orphan_projects() -> Result<()> {
    let context = TestContext::new();
    context.init_project();

    // Create a hook that shows which files it processes
    let config = indoc! {r#"
    exclude: \.pre-commit-config\.yaml$
    repos:
      - repo: local
        hooks:
        - id: show-files
          name: Show Files
          language: python
          entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print("  - {}".format(f)) for f in sys.argv[1:]]'
          pass_filenames: true
          verbose: true
    "#};

    // Setup workspace with nested projects
    context
        .work_dir()
        .child("src/backend/.pre-commit-config.yaml")
        .write_str(config)?;
    context
        .work_dir()
        .child("src/.pre-commit-config.yaml")
        .write_str(config)?;
    context
        .work_dir()
        .child(".pre-commit-config.yaml")
        .write_str(config)?;

    // Create test files
    context
        .work_dir()
        .child("src/backend/test.py")
        .write_str("")?;
    context.work_dir().child("src/test.py").write_str("")?;
    context.work_dir().child("test.py").write_str("")?;
    context.git_add(".");

    // Without `orphan`: files in subprojects are processed multiple times
    cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ src/backend
      Show Files.............................................................Passed
      - hook id: show-files
      - duration: [TIME]

        Processing 1 files
          - test.py
    ✓ src
      Show Files.............................................................Passed
      - hook id: show-files
      - duration: [TIME]

        Processing 2 files
          - backend/test.py
          - test.py
    ✓ <workspace>
      Show Files.............................................................Passed
      - hook id: show-files
      - duration: [TIME]

        Processing 3 files
          - src/backend/test.py
          - src/test.py
          - test.py

    ----- stderr -----
    "#);

    // Enable `orphan`
    context
        .work_dir()
        .child("src/backend/.pre-commit-config.yaml")
        .write_str(indoc! {r#"
        orphan: true
        exclude: \.pre-commit-config\.yaml$
        repos:
          - repo: local
            hooks:
            - id: show-files
              name: Show Files
              language: python
              entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print("  - {}".format(f)) for f in sys.argv[1:]]'
              pass_filenames: true
              verbose: true
    "#})?;

    // `files` match nothing, but files are still "consumed"
    context
        .work_dir()
        .child("src/.pre-commit-config.yaml")
        .write_str(indoc! {r#"
        orphan: true
        files: ^$
        exclude: \.pre-commit-config\.yaml$
        repos:
          - repo: local
            hooks:
            - id: show-files
              name: Show Files
              language: python
              entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print("  - {}".format(f)) for f in sys.argv[1:]]'
              pass_filenames: true
              verbose: true
    "#})?;

    context
        .work_dir()
        .child(".pre-commit-config.yaml")
        .write_str(indoc! {r#"
        orphan: false
        exclude: \.pre-commit-config\.yaml$
        repos:
          - repo: local
            hooks:
            - id: show-files
              name: Show Files
              language: python
              entry: python -c 'import sys; print("Processing {} files".format(len(sys.argv[1:]))); [print("  - {}".format(f)) for f in sys.argv[1:]]'
              pass_filenames: true
              verbose: true
    "#})?;

    // In orphan project, files are "consumed" and not processed again in parent projects
    cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ src/backend
      Show Files.............................................................Passed
      - hook id: show-files
      - duration: [TIME]

        Processing 1 files
          - test.py
    ✓ src
      Show Files.........................................(no files to check)Skipped
    ✓ <workspace>
      Show Files.............................................................Passed
      - hook id: show-files
      - duration: [TIME]

        Processing 1 files
          - test.py

    ----- stderr -----
    "#);

    // If hooks in orphan projects are not selected, files should be "consumed" as well
    cmd_snapshot!(context.filters(), context.run().arg("--all-files").arg("--skip").arg("src/"), @r"
    success: true
    exit_code: 0
    ----- stdout -----
    Show Files...............................................................Passed
    - hook id: show-files
    - duration: [TIME]

      Processing 1 files
        - test.py

    ----- stderr -----
    ");

    Ok(())
}

fn setup_relative_repo_path_project() -> Result<TestContext> {
    let context = TestContext::new();
    context.init_project();

    // Create a local hook repository at the root level
    let hook_repo = context.work_dir().child("hook-repo");
    hook_repo.create_dir_all()?;

    git_cmd(&hook_repo).args(["init"]).assert().success();

    hook_repo.child(PRE_COMMIT_HOOKS_YAML).write_str(indoc! {r"
        - id: test-hook
          name: Test Hook
          entry: echo test
          language: system
          always_run: true
    "})?;

    git_cmd(&hook_repo).args(["add", "."]).assert().success();

    git_cmd(&hook_repo)
        .args(["commit", "-m", "Initial commit"])
        .assert()
        .success();

    // Get the commit SHA
    let output = git_cmd(&hook_repo).args(["rev-parse", "HEAD"]).output()?;
    let commit_sha = String::from_utf8_lossy(&output.stdout).trim().to_string();

    // Create a subproject that references the hook repo with a relative path
    let subproject = context.work_dir().child("subproject");
    subproject.create_dir_all()?;

    // From subproject/, ../hook-repo should resolve to the hook-repo at root
    subproject
        .child(PRE_COMMIT_CONFIG_YAML)
        .write_str(&indoc::formatdoc! {r"
        repos:
          - repo: ../hook-repo
            rev: {commit_sha}
            hooks:
              - id: test-hook
                always_run: true
    "})?;

    subproject.child("test.txt").write_str("test content")?;

    // Root config so workspace discovery works
    context.write_pre_commit_config(indoc! {r"
        repos:
          - repo: local
            hooks:
              - id: noop
                name: Noop
                entry: echo noop
                language: system
                always_run: true
    "});

    context.git_add(".");

    Ok(context)
}

/// Test that relative repo paths in subproject configs resolve from the config
/// file's directory, not from the process's current working directory.
///
/// Regression test for <https://github.com/j178/prek/issues/1065>
#[test]
fn relative_repo_path_resolution() -> Result<()> {
    let context = setup_relative_repo_path_project()?;

    // Run from the root directory - the relative path ../hook-repo should resolve
    // from subproject/.pre-commit-config.yaml's location, not from CWD
    cmd_snapshot!(context.filters(), context.run(), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    ✓ subproject
      Test Hook..............................................................Passed
    ✓ <workspace>
      Noop...................................................................Passed

    ----- stderr -----
    "#);

    Ok(())
}

#[test]
fn relative_repo_path_resolution_with_explicit_relative_config() -> Result<()> {
    let context = setup_relative_repo_path_project()?;

    cmd_snapshot!(context.filters(), context.run()
        .arg("--config")
        .arg("subproject/.pre-commit-config.yaml"), @r#"
    success: true
    exit_code: 0
    ----- stdout -----
    Test Hook................................................................Passed

    ----- stderr -----
    "#);

    Ok(())
}
