Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 42 additions & 29 deletions crates/prek/src/cli/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub(crate) async fn install(
skips: Vec<String>,
hook_types: Vec<HookType>,
prepare_hooks: bool,
overwrite: bool,
force: bool,
allow_missing_config: bool,
refresh: bool,
printer: Printer,
Expand All @@ -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 <path>".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 <path>".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
Expand All @@ -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 {
Expand All @@ -112,7 +125,7 @@ pub(crate) async fn install(
selectors.as_ref(),
hook_type,
&hooks_path,
overwrite,
force,
allow_missing_config,
hook_mode,
printer,
Expand Down Expand Up @@ -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()
)?;
}
Expand Down
9 changes: 6 additions & 3 deletions crates/prek/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,9 +329,12 @@ pub(crate) struct InstallArgs {
#[arg(long = "skip", value_name = "HOOK|PROJECT", add = ArgValueCompleter::new(selector_completer))]
pub(crate) skips: Vec<String>,

/// 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")]
Expand Down
2 changes: 1 addition & 1 deletion crates/prek/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
args.skips,
args.hook_types,
args.prepare_hooks,
args.overwrite,
args.force,
args.allow_missing_config,
cli.globals.refresh,
printer,
Expand Down
62 changes: 56 additions & 6 deletions crates/prek/tests/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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 <path>
Expand All @@ -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();
Expand All @@ -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 <path>
Expand Down Expand Up @@ -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 -----
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <GIT_DIR>` instead when you need to choose an explicit installation target.

## How do I use hooks from private repositories?

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ prek install [OPTIONS] [HOOK|PROJECT]...
<li><code>always</code>: Enables colored output regardless of the detected environment</li>
<li><code>never</code>: Disables colored output</li>
</ul></dd><dt id="prek-install--config"><a href="#prek-install--config"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>
</dd><dt id="prek-install--force"><a href="#prek-install--force"><code>--force</code></a>, <code>--overwrite</code>, <code>-f</code></dt><dd><p>Force installation and overwrite existing Git shims.</p>
<p>If <code>core.hooksPath</code> is configured outside this repository, install the shims into this repository's default hooks directory.</p>
</dd><dt id="prek-install--git-dir"><a href="#prek-install--git-dir"><code>--git-dir</code></a> <i>git-dir</i></dt><dd><p>Install Git shims into the <code>hooks</code> subdirectory of the given git directory (<code>&lt;GIT_DIR&gt;/hooks/</code>).</p>
<p>When this flag is used, <code>prek install</code> bypasses the safety check that normally refuses to install shims while <code>core.hooksPath</code> is configured outside the repo. It only writes shims to <code>&lt;GIT_DIR&gt;/hooks</code>; Git will keep using <code>core.hooksPath</code> until that config changes.</p>
</dd><dt id="prek-install--help"><a href="#prek-install--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
Expand All @@ -94,7 +96,6 @@ prek install [OPTIONS] [HOOK|PROJECT]...
</ul></dd><dt id="prek-install--log-file"><a href="#prek-install--log-file"><code>--log-file</code></a> <i>log-file</i></dt><dd><p>Write trace logs to the specified file. If not specified, trace logs will be written to <code>$PREK_HOME/prek.log</code></p>
</dd><dt id="prek-install--no-progress"><a href="#prek-install--no-progress"><code>--no-progress</code></a></dt><dd><p>Hide all progress outputs.</p>
<p>For example, spinners or progress bars.</p>
</dd><dt id="prek-install--overwrite"><a href="#prek-install--overwrite"><code>--overwrite</code></a>, <code>-f</code></dt><dd><p>Overwrite existing Git shims</p>
</dd><dt id="prek-install--prepare-hooks"><a href="#prek-install--prepare-hooks"><code>--prepare-hooks</code></a>, <code>--install-hooks</code></dt><dd><p>Also prepare environments for all hooks used in the config file</p>
</dd><dt id="prek-install--quiet"><a href="#prek-install--quiet"><code>--quiet</code></a>, <code>-q</code></dt><dd><p>Use quiet output.</p>
<p>Repeating this option, e.g., <code>-qq</code>, will enable a silent mode in which prek will write no output to stdout.</p>
Expand Down
Loading