diff --git a/crates/prek/src/cli/install.rs b/crates/prek/src/cli/install.rs index 6900ebe0b..9f3f7169e 100644 --- a/crates/prek/src/cli/install.rs +++ b/crates/prek/src/cli/install.rs @@ -31,7 +31,7 @@ pub(crate) async fn install( skips: Vec, hook_types: Vec, prepare_hooks: bool, - overwrite: bool, + force: bool, allow_missing_config: bool, refresh: bool, printer: Printer, @@ -51,29 +51,47 @@ pub(crate) async fn install( // actual unsafe case. Repo-owned `core.hooksPath` values are safe to honor // implicitly when they come from local/worktree scope, including repo // config reached through `include.path` / `includeIf`, because those values - // are part of the repository's own Git setup. Only hooksPath values that - // resolve entirely from external config still require the explicit - // `--git-dir` escape hatch. - if git_dir.is_none() + // are part of the repository's own Git setup. For an external value, + // `--force` deliberately installs into the repository's default hooks + // directory without touching the shared configured directory. `--git-dir` + // remains the escape hatch for an explicit target. + let has_external_hooks_path = git_dir.is_none() && git::has_hooks_path_set().await? - && !git::has_repo_hooks_path_set().await? - { - anyhow::bail!( - concat!( - "Refusing to install hooks because `core.hooksPath` is configured outside this repository.\n", - "\n{} Git will execute hooks from the configured global/system hooks directory, not from this repository's hooks directory.\n", - "\n{} Remove the global/system setting, or move `core.hooksPath` into repo scope for this repository instead.\n", - " {}\n", - " {}\n", - " {}\n", - ), - "note:".yellow().bold(), - "hint:".yellow().bold(), - "git config --unset-all --global core.hooksPath".cyan(), - "git config --unset-all --system core.hooksPath".cyan(), - "git config --local core.hooksPath ".cyan(), + && !git::has_repo_hooks_path_set().await?; + + let hooks_path = if let Some(dir) = git_dir { + dir.join("hooks") + } else if has_external_hooks_path { + if !force { + anyhow::bail!( + concat!( + "Refusing to install hooks because `core.hooksPath` is configured outside this repository.\n", + "\n{} Git will execute hooks from the configured global/system hooks directory, not from this repository's hooks directory.\n", + "\n{} To install into this repository's default hooks directory anyway, rerun with:\n", + " {}\n", + "\nOtherwise, remove the global/system setting or move `core.hooksPath` into repo scope:\n", + " {}\n", + " {}\n", + " {}\n", + ), + "note:".yellow().bold(), + "hint:".yellow().bold(), + "prek install --force".cyan(), + "git config --unset-all --global core.hooksPath".cyan(), + "git config --unset-all --system core.hooksPath".cyan(), + "git config --local core.hooksPath ".cyan(), + ); + } + + let hooks_path = git::get_git_common_dir().await?.join("hooks"); + warn_user!( + "`core.hooksPath` is configured outside this repository. Installing Git shims to `{}` because `--force` was used.", + hooks_path.user_display().cyan() ); - } + hooks_path + } else { + git::get_git_hooks_dir().await? + }; let hook_mode = git::get_shared_repository_file_mode(0o755) .await @@ -90,11 +108,6 @@ pub(crate) async fn install( }; let hook_types = get_hook_types(hook_types, project.as_ref(), config.as_deref()); - let hooks_path = if let Some(dir) = git_dir { - dir.join("hooks") - } else { - git::get_git_hooks_dir().await? - }; fs_err::create_dir_all(&hooks_path)?; let selectors = if let Some(project) = &project { @@ -112,7 +125,7 @@ pub(crate) async fn install( selectors.as_ref(), hook_type, &hooks_path, - overwrite, + force, allow_missing_config, hook_mode, printer, @@ -244,7 +257,7 @@ fn install_hook_script( } else { writeln!( printer.stdout(), - "Migration mode: prek will also run legacy hook `{}`. Use `--overwrite` to remove legacy hooks.", + "Migration mode: prek will also run legacy hook `{}`. Use `--force` to remove legacy hooks.", legacy_path.user_display().yellow() )?; } diff --git a/crates/prek/src/cli/mod.rs b/crates/prek/src/cli/mod.rs index 02ccf030c..688ba10b0 100644 --- a/crates/prek/src/cli/mod.rs +++ b/crates/prek/src/cli/mod.rs @@ -329,9 +329,12 @@ pub(crate) struct InstallArgs { #[arg(long = "skip", value_name = "HOOK|PROJECT", add = ArgValueCompleter::new(selector_completer))] pub(crate) skips: Vec, - /// Overwrite existing Git shims. - #[arg(short = 'f', long)] - pub(crate) overwrite: bool, + /// Force installation and overwrite existing Git shims. + /// + /// If `core.hooksPath` is configured outside this repository, install the + /// shims into this repository's default hooks directory. + #[arg(short = 'f', long, alias = "overwrite")] + pub(crate) force: bool, /// Also prepare environments for all hooks used in the config file. #[arg(long, alias = "install-hooks")] diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index 491bc8af8..76befbc75 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -241,7 +241,7 @@ async fn run(cli: Cli) -> Result { args.skips, args.hook_types, args.prepare_hooks, - args.overwrite, + args.force, args.allow_missing_config, cli.globals.refresh, printer, diff --git a/crates/prek/tests/install.rs b/crates/prek/tests/install.rs index 12e63a4bb..79886ecb2 100644 --- a/crates/prek/tests/install.rs +++ b/crates/prek/tests/install.rs @@ -56,7 +56,7 @@ fn install() -> anyhow::Result<()> { exit_code: 0 ----- stdout ----- Hook already exists at `.git/hooks/pre-commit`, moved it to `.git/hooks/pre-commit.legacy` - Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--overwrite` to remove legacy hooks. + Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--force` to remove legacy hooks. prek installed at `.git/hooks/pre-commit` prek installed at `.git/hooks/post-commit` @@ -285,7 +285,10 @@ fn install_with_git_dir_allows_external_hooks_path_set() { note: Git will execute hooks from the configured global/system hooks directory, not from this repository's hooks directory. - hint: Remove the global/system setting, or move `core.hooksPath` into repo scope for this repository instead. + hint: To install into this repository's default hooks directory anyway, rerun with: + prek install --force + + Otherwise, remove the global/system setting or move `core.hooksPath` into repo scope: git config --unset-all --global core.hooksPath git config --unset-all --system core.hooksPath git config --local core.hooksPath @@ -306,6 +309,50 @@ fn install_with_git_dir_allows_external_hooks_path_set() { "#); } +#[test] +fn install_force_uses_repository_hooks_with_external_hooks_path_set() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + context.work_dir().child("custom-hooks").create_dir_all()?; + context + .work_dir() + .child("custom-hooks/pre-commit") + .write_str("#!/bin/sh\necho global hook\n")?; + + let global_gitconfig = context.work_dir().join("global.gitconfig"); + git_cmd(context.work_dir()) + .env("GIT_CONFIG_GLOBAL", &global_gitconfig) + .args(["config", "--global", "core.hooksPath", "custom-hooks"]) + .assert() + .success(); + + let mut install = context.install(); + install + .arg("-f") + .env("GIT_CONFIG_GLOBAL", &global_gitconfig); + cmd_snapshot!(context.filters(), install, @r#" + success: true + exit_code: 0 + ----- stdout ----- + prek installed at `.git/hooks/pre-commit` + + ----- stderr ----- + warning: `core.hooksPath` is configured outside this repository. Installing Git shims to `.git/hooks` because `--force` was used. + "#); + + context + .work_dir() + .child(".git/hooks/pre-commit") + .assert(predicates::path::exists()); + assert_eq!( + context.read("custom-hooks/pre-commit"), + "#!/bin/sh\necho global hook\n" + ); + + Ok(()) +} + #[test] fn install_refuses_empty_external_hooks_path_set() { let context = TestContext::new(); @@ -330,7 +377,10 @@ fn install_refuses_empty_external_hooks_path_set() { note: Git will execute hooks from the configured global/system hooks directory, not from this repository's hooks directory. - hint: Remove the global/system setting, or move `core.hooksPath` into repo scope for this repository instead. + hint: To install into this repository's default hooks directory anyway, rerun with: + prek install --force + + Otherwise, remove the global/system setting or move `core.hooksPath` into repo scope: git config --unset-all --global core.hooksPath git config --unset-all --system core.hooksPath git config --local core.hooksPath @@ -646,12 +696,12 @@ fn install_with_existing_legacy_hook() -> anyhow::Result<()> { .child(".git/hooks/pre-commit.legacy") .write_str("#!/bin/sh\necho 'legacy'\n")?; - // Without --overwrite, we should stay in migration mode. + // Without --force, we should stay in migration mode. cmd_snapshot!(context.filters(), context.install(), @r" success: true exit_code: 0 ----- stdout ----- - Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--overwrite` to remove legacy hooks. + Migration mode: prek will also run legacy hook `.git/hooks/pre-commit.legacy`. Use `--force` to remove legacy hooks. prek installed at `.git/hooks/pre-commit` ----- stderr ----- @@ -661,7 +711,7 @@ fn install_with_existing_legacy_hook() -> anyhow::Result<()> { .child(".git/hooks/pre-commit.legacy") .assert(predicates::path::exists()); - // With --overwrite, the legacy script should be removed. + // The previous --overwrite spelling remains a compatibility alias for --force. cmd_snapshot!(context.filters(), context.install().arg("--overwrite"), @r#" success: true exit_code: 0 diff --git a/docs/faq.md b/docs/faq.md index 401139f93..3a0e2d910 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -31,7 +31,9 @@ Adding `--prepare-hooks` tells prek to do that **and** proactively create the en If `core.hooksPath` is set in repo-local (`git config --local`) or worktree-local (`git config --worktree`) config, `prek install` and `prek uninstall` will honor it and operate on Git's effective hooks directory. -If `core.hooksPath` is only configured globally or system-wide, prek refuses to install or uninstall by default. That setting may be shared across repositories, so prek avoids mutating a hook location it does not own. In that case, remove or change the global/system `core.hooksPath`. +If `core.hooksPath` is only configured globally or system-wide, prek refuses to install or uninstall by default. That setting may be shared across repositories, so prek avoids mutating a hook location it does not own. + +Use `prek install --force` to install into the repository's default hooks directory anyway. Use `--git-dir ` instead when you need to choose an explicit installation target. ## How do I use hooks from private repositories? diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 89fba7518..36a3ad0c5 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -72,6 +72,8 @@ prek install [OPTIONS] [HOOK|PROJECT]...
  • always: Enables colored output regardless of the detected environment
  • never: Disables colored output
  • --config, -c config

    Path to alternate config file

    +
    --force, --overwrite, -f

    Force installation and overwrite existing Git shims.

    +

    If core.hooksPath is configured outside this repository, install the shims into this repository's default hooks directory.

    --git-dir git-dir

    Install Git shims into the hooks subdirectory of the given git directory (<GIT_DIR>/hooks/).

    When this flag is used, prek install bypasses the safety check that normally refuses to install shims while core.hooksPath is configured outside the repo. It only writes shims to <GIT_DIR>/hooks; Git will keep using core.hooksPath until that config changes.

    --help, -h

    Display the concise help for this command

    @@ -94,7 +96,6 @@ prek install [OPTIONS] [HOOK|PROJECT]...
    --log-file log-file

    Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

    --no-progress

    Hide all progress outputs.

    For example, spinners or progress bars.

    -
    --overwrite, -f

    Overwrite existing Git shims

    --prepare-hooks, --install-hooks

    Also prepare environments for all hooks used in the config file

    --quiet, -q

    Use quiet output.

    Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.