1
0
mirror of https://github.com/j178/prek.git synced 2026-04-25 02:11:36 +02:00

Honor repo and worktree core.hooksPath (#1892)

## Summary

This takes a smaller approach than #1673.

Instead of mutating Git config during install, it lets `prek` ask Git
for the effective hooks directory and:

- honors repo-local (`git config --local`) `core.hooksPath`
- honors worktree-local (`git config --worktree`) `core.hooksPath`
- continues to refuse global/system `core.hooksPath` by default
- aligns `prek uninstall` with the same behavior
- updates the CLI reference and FAQ to document the behavior

This keeps the existing safety boundary for externally configured hook
locations while making linked worktrees and repo-owned hook directories
work without extra wrapper logic.

## Testing

- `cargo test -p prek --test install`
- `PREK_GENERATE=1 cargo test --bin prek
cli::_gen::generate_cli_reference -- --exact`

## Context

Closes #1672
Closes #1673

---------

Co-authored-by: Jo <10510431+j178@users.noreply.github.com>
This commit is contained in:
Sadjow Leão
2026-04-13 06:11:44 -03:00
committed by GitHub
parent 08c1f706ce
commit dc98c4792d
8 changed files with 668 additions and 34 deletions
+62 -6
View File
@@ -39,11 +39,41 @@ pub(crate) async fn install(
printer: Printer,
git_dir: Option<&Path>,
) -> Result<ExitStatus> {
if git_dir.is_none() && git::has_hooks_path_set().await? {
// Upstream `pre-commit` deliberately refuses to install whenever
// `git config core.hooksPath` resolves to a non-empty value. It does not
// distinguish whether that effective value comes from local, worktree,
// global, or system config. Once Git sees any effective `core.hooksPath`,
// it stops consulting `.git/hooks` and runs hooks only from that configured
// directory. If the value is external to this repository, "installing
// anyway" would mean either writing into a user-managed shared hooks
// directory or mutating the user's Git config to point back at this repo.
// Both cross the repository's ownership boundary.
//
// In prek, we keep that safety boundary but narrow the refusal to the
// 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()
&& git::has_hooks_path_set().await?
&& !git::has_repo_hooks_path_set().await?
{
anyhow::bail!(
"Cowardly refusing to install hooks with `core.hooksPath` set.\nhint: Run these commands to remove core.hooksPath:\nhint: {}\nhint: {}",
"git config --unset-all --local core.hooksPath".cyan(),
"git config --unset-all --global core.hooksPath".cyan()
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(),
);
}
@@ -65,7 +95,7 @@ pub(crate) async fn install(
let hooks_path = if let Some(dir) = git_dir {
dir.join("hooks")
} else {
git::get_git_common_dir().await?.join("hooks")
git::get_git_hooks_dir().await?
};
fs_err::create_dir_all(&hooks_path)?;
@@ -387,9 +417,35 @@ pub(crate) async fn uninstall(
hook_types: Vec<HookType>,
all: bool,
printer: Printer,
git_dir: Option<&Path>,
) -> Result<ExitStatus> {
if git_dir.is_none()
&& git::has_hooks_path_set().await?
&& !git::has_repo_hooks_path_set().await?
{
anyhow::bail!(
concat!(
"Refusing to uninstall 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(),
);
}
let project = Project::discover(config.as_deref(), &CWD).ok();
let hooks_path = git::get_git_common_dir().await?.join("hooks");
let hooks_path = if let Some(dir) = git_dir {
dir.join("hooks")
} else {
git::get_git_hooks_dir().await?
};
let types: Vec<HookType> = if all {
HookType::value_variants().to_vec()
+17 -6
View File
@@ -225,7 +225,10 @@ pub(crate) struct GlobalArgs {
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
/// Install prek Git shims under the `.git/hooks/` directory.
/// Install prek Git shims into Git's effective hooks directory.
///
/// By default this is `.git/hooks/`, but repo-local or worktree-local
/// `core.hooksPath` is honored when set.
///
/// The Git shims installed by this command are determined by `--hook-type`
/// or `default_install_hook_types` in the config file, falling back to
@@ -268,7 +271,7 @@ pub(crate) enum Command {
InitTemplateDir(InitTemplateDirArgs),
/// Try the pre-commit hooks in the current repo.
TryRepo(Box<TryRepoArgs>),
/// The implementation of the prek Git shim that is installed in the `.git/hooks/` directory.
/// The implementation of the prek Git shim that is installed in Git's effective hooks directory.
#[command(hide = true)]
HookImpl(HookImplArgs),
/// Utility commands.
@@ -340,10 +343,9 @@ pub(crate) struct InstallArgs {
/// 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 set. Git itself will still
/// ignore `.git/hooks` while `core.hooksPath` is configured, so ensure your Git
/// configuration points to the directory where the shim is installed if you want
/// it to be executed.
/// 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.
#[arg(long, value_name = "GIT_DIR", value_hint = ValueHint::DirPath)]
pub(crate) git_dir: Option<PathBuf>,
}
@@ -402,6 +404,15 @@ pub(crate) struct UninstallArgs {
/// Use `--all` to remove all prek-managed hooks.
#[arg(short = 't', long = "hook-type", value_name = "HOOK_TYPE", value_enum)]
pub(crate) hook_types: Vec<HookType>,
/// Uninstall Git shims from the `hooks` subdirectory of the given git directory (`<GIT_DIR>/hooks/`).
///
/// When this flag is used, `prek uninstall` bypasses the safety check that normally
/// refuses to modify shims while `core.hooksPath` is configured outside the repo.
/// It only removes shims from `<GIT_DIR>/hooks`; Git may still use the configured
/// `core.hooksPath` until that config changes.
#[arg(long, value_name = "GIT_DIR", value_hint = ValueHint::DirPath)]
pub(crate) git_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Args)]
+70 -9
View File
@@ -28,6 +28,11 @@ pub(crate) enum Error {
#[error(transparent)]
UTF8(#[from] Utf8Error),
#[error(
"Git resolved hooks directory to the current directory (`{0}`). Unset `core.hooksPath` or set it to a real directory path."
)]
InvalidHooksPath(PathBuf),
}
pub(crate) static GIT: LazyLock<Result<PathBuf, which::Error>> =
@@ -198,6 +203,38 @@ pub(crate) async fn get_git_common_dir() -> Result<PathBuf, Error> {
}
}
pub(crate) async fn get_git_hooks_dir() -> Result<PathBuf, Error> {
// Ask Git for the effective hooks directory instead of reconstructing it
// ourselves. That lets Git apply the full precedence chain for
// `core.hooksPath`, including local/worktree config, linked worktrees, bare
// + worktree layouts, and repo-owned config loaded through `include.path`
// / `includeIf`.
let output = git_cmd("get git hooks dir")?
.arg("rev-parse")
.arg("--git-path")
.arg("hooks")
.check(true)
.output()
.await?;
let hooks_dir = if output.stdout.trim_ascii().is_empty() {
get_git_common_dir().await?.join("hooks")
} else {
PathBuf::from(String::from_utf8_lossy(&output.stdout).trim_ascii())
};
let cleaned = hooks_dir.clean();
// `core.hooksPath=` is a particularly dangerous case: Git treats it as
// configured, but resolves `--git-path hooks` to the current directory. If
// we accepted that value, install/uninstall would write or remove hook
// shims from the worktree root. Keep the explicit `core.hooksPath=.` case
// working, but reject the empty-string variant.
if cleaned == Path::new(".") && config_value_is_empty(None, "core.hooksPath").await? {
Err(Error::InvalidHooksPath(cleaned))
} else {
Ok(hooks_dir)
}
}
pub(crate) async fn get_staged_files(root: &Path) -> Result<Vec<PathBuf>, Error> {
let output = git_cmd("get staged files")?
.current_dir(root)
@@ -560,19 +597,43 @@ pub(crate) async fn clone_repo(
clone_repo_attempt(rev, path, terminal_prompt).await
}
pub(crate) async fn has_hooks_path_set() -> Result<bool> {
let output = git_cmd("get git hooks path")?
.arg("config")
async fn get_config_value(scope: Option<&str>, key: &str) -> Result<Option<Vec<u8>>, Error> {
let mut cmd = git_cmd("get git config value")?;
cmd.arg("config").arg("--includes");
if let Some(scope) = scope {
cmd.arg(scope);
}
let output = cmd
.arg("--null")
.arg("--get")
.arg("core.hooksPath")
.arg(key)
.check(false)
.output()
.await?;
if output.status.success() {
Ok(!output.stdout.trim_ascii().is_empty())
} else {
Ok(false)
}
Ok(output.status.success().then_some(output.stdout))
}
async fn has_config_value(scope: Option<&str>, key: &str) -> Result<bool, Error> {
// An empty config value still counts as configured and can affect Git's
// path resolution, e.g. `core.hooksPath=` makes `--git-path hooks`
// resolve to the current directory.
Ok(get_config_value(scope, key).await?.is_some())
}
async fn config_value_is_empty(scope: Option<&str>, key: &str) -> Result<bool, Error> {
Ok(get_config_value(scope, key)
.await?
.as_deref()
.is_some_and(|value| value.strip_suffix(b"\0").unwrap_or(value).is_empty()))
}
pub(crate) async fn has_hooks_path_set() -> Result<bool, Error> {
has_config_value(None, "core.hooksPath").await
}
pub(crate) async fn has_repo_hooks_path_set() -> Result<bool, Error> {
Ok(has_config_value(Some("--local"), "core.hooksPath").await?
|| has_config_value(Some("--worktree"), "core.hooksPath").await?)
}
/// Compute the file mode for a newly created file based on `core.sharedRepository`.
+8 -1
View File
@@ -271,7 +271,14 @@ async fn run(cli: Cli) -> Result<ExitStatus> {
Command::Uninstall(args) => {
show_settings!(args);
cli::uninstall(cli.globals.config, args.hook_types, args.all, printer).await
cli::uninstall(
cli.globals.config,
args.hook_types,
args.all,
printer,
args.git_dir.as_deref(),
)
.await
}
Command::Run(args) => {
show_settings!(args);
+496 -7
View File
@@ -354,7 +354,7 @@ fn install_with_git_dir() {
}
#[test]
fn install_with_git_dir_allows_hooks_path_set() {
fn install_with_local_hooks_path_installs_to_configured_directory() {
let context = TestContext::new();
context.init_project();
@@ -364,18 +364,81 @@ fn install_with_git_dir_allows_hooks_path_set() {
.success();
cmd_snapshot!(context.filters(), context.install(), @r#"
success: true
exit_code: 0
----- stdout -----
prek installed at `custom-hooks/pre-commit`
----- stderr -----
"#);
context
.work_dir()
.child(".git/hooks/pre-commit")
.assert(predicates::path::missing());
context
.work_dir()
.child("custom-hooks/pre-commit")
.assert(predicates::path::exists());
insta::with_settings!(
{ filters => context.filters() },
{
assert_snapshot!(context.read("custom-hooks/pre-commit"), @r#"
#!/bin/sh
# File generated by prek: https://github.com/j178/prek
# ID: 182c10f181da4464a3eec51b83331688
HERE="$(cd "$(dirname "$0")" && pwd)"
PREK="[CURRENT_EXE]"
# Check if the full path to prek is executable, otherwise fallback to PATH
if [ ! -x "$PREK" ]; then
PREK="prek"
fi
exec "$PREK" hook-impl --hook-dir "$HERE" --script-version 4 --hook-type=pre-commit -- "$@"
"#);
}
);
}
#[test]
fn install_with_git_dir_allows_external_hooks_path_set() {
let context = TestContext::new();
context.init_project();
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.env("GIT_CONFIG_GLOBAL", &global_gitconfig);
cmd_snapshot!(context.filters(), install, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Cowardly refusing to install hooks with `core.hooksPath` set.
hint: Run these commands to remove core.hooksPath:
hint: git config --unset-all --local core.hooksPath
hint: git config --unset-all --global core.hooksPath
"#);
error: Refusing to install hooks because `core.hooksPath` is configured outside this repository.
cmd_snapshot!(context.filters(), context.install().arg("--git-dir").arg(".git"), @r#"
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.
git config --unset-all --global core.hooksPath
git config --unset-all --system core.hooksPath
git config --local core.hooksPath <path>
");
let mut install = context.install();
install
.arg("--git-dir")
.arg(".git")
.env("GIT_CONFIG_GLOBAL", &global_gitconfig);
cmd_snapshot!(context.filters(), install, @r#"
success: true
exit_code: 0
----- stdout -----
@@ -385,6 +448,163 @@ fn install_with_git_dir_allows_hooks_path_set() {
"#);
}
#[test]
fn install_refuses_empty_external_hooks_path_set() {
let context = TestContext::new();
context.init_project();
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", ""])
.assert()
.success();
let mut install = context.install();
install.env("GIT_CONFIG_GLOBAL", &global_gitconfig);
cmd_snapshot!(context.filters(), install, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Refusing to install hooks because `core.hooksPath` is configured outside this repository.
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.
git config --unset-all --global core.hooksPath
git config --unset-all --system core.hooksPath
git config --local core.hooksPath <path>
");
}
#[test]
fn install_refuses_empty_local_hooks_path_set() {
let context = TestContext::new();
context.init_project();
git_cmd(context.work_dir())
.args(["config", "core.hooksPath", ""])
.assert()
.success();
cmd_snapshot!(context.filters(), context.install(), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Git resolved hooks directory to the current directory (`.`). Unset `core.hooksPath` or set it to a real directory path.
"#);
}
#[test]
fn install_with_dot_hooks_path_installs_to_repo_root() {
let context = TestContext::new();
context.init_project();
git_cmd(context.work_dir())
.args(["config", "core.hooksPath", "."])
.assert()
.success();
context.install().assert().success();
context
.work_dir()
.child(".git/hooks/pre-commit")
.assert(predicates::path::missing());
context
.work_dir()
.child("pre-commit")
.assert(predicates::path::exists());
}
#[test]
fn install_with_included_local_hooks_path_installs_to_configured_directory() -> anyhow::Result<()> {
let context = TestContext::new();
context.init_project();
context
.work_dir()
.child("included-hooks.cfg")
.write_str(indoc! {r"
[core]
hooksPath = custom-hooks
"})?;
git_cmd(context.work_dir())
.args(["config", "--local", "include.path", "../included-hooks.cfg"])
.assert()
.success();
context.install().assert().success();
context
.work_dir()
.child(".git/hooks/pre-commit")
.assert(predicates::path::missing());
context
.work_dir()
.child("custom-hooks/pre-commit")
.assert(predicates::path::exists());
Ok(())
}
#[test]
fn install_with_worktree_hooks_path_installs_to_configured_directory() -> anyhow::Result<()> {
let context = TestContext::new();
context.init_project();
context.work_dir().child("README.md").write_str("hello\n")?;
context.git_add(".");
context.git_commit("Initial commit");
git_cmd(context.work_dir())
.args(["config", "extensions.worktreeConfig", "true"])
.assert()
.success();
git_cmd(context.work_dir())
.args(["worktree", "add", "worktree", "HEAD"])
.assert()
.success();
let worktree = context.work_dir().child("worktree");
let output = git_cmd(&worktree)
.args(["rev-parse", "--path-format=absolute", "--git-dir"])
.output()?;
let worktree_git_dir = String::from_utf8(output.stdout)?.trim().to_string();
let worktree_hooks = std::path::PathBuf::from(&worktree_git_dir).join("hooks");
let worktree_hooks_str = worktree_hooks.display().to_string();
git_cmd(&worktree)
.args([
"config",
"--worktree",
"core.hooksPath",
&worktree_hooks_str,
])
.assert()
.success();
let mut install = context.install();
install.current_dir(&worktree);
install.assert().success();
context
.work_dir()
.child(".git/hooks/pre-commit")
.assert(predicates::path::missing());
assert!(
worktree_hooks.join("pre-commit").exists(),
"expected hook to be installed into {}",
worktree_hooks.display()
);
Ok(())
}
/// Hook permissions should be standard when `core.sharedRepository` is not set.
#[test]
#[cfg(unix)]
@@ -763,6 +983,275 @@ fn uninstall() -> anyhow::Result<()> {
Ok(())
}
#[test]
fn uninstall_with_local_hooks_path_removes_configured_hook() {
let context = TestContext::new();
context.init_project();
git_cmd(context.work_dir())
.args(["config", "core.hooksPath", "custom-hooks"])
.assert()
.success();
context.install().assert().success();
cmd_snapshot!(context.filters(), context.uninstall(), @r#"
success: true
exit_code: 0
----- stdout -----
Uninstalled `pre-commit`
----- stderr -----
"#);
context
.work_dir()
.child("custom-hooks/pre-commit")
.assert(predicates::path::missing());
}
#[test]
fn uninstall_with_worktree_hooks_path_removes_configured_hook() -> anyhow::Result<()> {
let context = TestContext::new();
context.init_project();
context.work_dir().child("README.md").write_str("hello\n")?;
context.git_add(".");
context.git_commit("Initial commit");
git_cmd(context.work_dir())
.args(["config", "extensions.worktreeConfig", "true"])
.assert()
.success();
git_cmd(context.work_dir())
.args(["worktree", "add", "worktree", "HEAD"])
.assert()
.success();
let worktree = context.work_dir().child("worktree");
let output = git_cmd(&worktree)
.args(["rev-parse", "--path-format=absolute", "--git-dir"])
.output()?;
let worktree_git_dir = String::from_utf8(output.stdout)?.trim().to_string();
let worktree_hooks = std::path::PathBuf::from(&worktree_git_dir).join("hooks");
let worktree_hooks_str = worktree_hooks.display().to_string();
git_cmd(&worktree)
.args([
"config",
"--worktree",
"core.hooksPath",
&worktree_hooks_str,
])
.assert()
.success();
context.install().assert().success();
assert!(
context.work_dir().join(".git/hooks/pre-commit").exists(),
"expected hook to remain installed in the main worktree",
);
let mut install = context.install();
install.current_dir(&worktree);
install.assert().success();
assert!(
worktree_hooks.join("pre-commit").exists(),
"expected hook to be installed into {}",
worktree_hooks.display()
);
let mut uninstall = context.uninstall();
uninstall.current_dir(&worktree);
cmd_snapshot!(context.filters(), uninstall, @r#"
success: true
exit_code: 0
----- stdout -----
Uninstalled `pre-commit`
----- stderr -----
"#);
assert!(
!worktree_hooks.join("pre-commit").exists(),
"expected hook to be removed from {}",
worktree_hooks.display()
);
context
.work_dir()
.child(".git/hooks/pre-commit")
.assert(predicates::path::exists());
Ok(())
}
#[test]
fn uninstall_refuses_external_hooks_path_set() {
let context = TestContext::new();
context.init_project();
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 uninstall = context.uninstall();
uninstall.env("GIT_CONFIG_GLOBAL", &global_gitconfig);
cmd_snapshot!(context.filters(), uninstall, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Refusing to uninstall hooks because `core.hooksPath` is configured outside this repository.
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.
git config --unset-all --global core.hooksPath
git config --unset-all --system core.hooksPath
git config --local core.hooksPath <path>
");
}
#[test]
fn uninstall_with_git_dir_allows_external_hooks_path_set() {
let context = TestContext::new();
context.init_project();
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("--git-dir")
.arg(".git")
.env("GIT_CONFIG_GLOBAL", &global_gitconfig);
install.assert().success();
let mut uninstall = context.uninstall();
uninstall
.arg("--git-dir")
.arg(".git")
.env("GIT_CONFIG_GLOBAL", &global_gitconfig);
cmd_snapshot!(context.filters(), uninstall, @r#"
success: true
exit_code: 0
----- stdout -----
Uninstalled `pre-commit`
----- stderr -----
"#);
context
.work_dir()
.child(".git/hooks/pre-commit")
.assert(predicates::path::missing());
}
#[test]
fn uninstall_refuses_empty_external_hooks_path_set() {
let context = TestContext::new();
context.init_project();
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", ""])
.assert()
.success();
let mut uninstall = context.uninstall();
uninstall.env("GIT_CONFIG_GLOBAL", &global_gitconfig);
cmd_snapshot!(context.filters(), uninstall, @"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Refusing to uninstall hooks because `core.hooksPath` is configured outside this repository.
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.
git config --unset-all --global core.hooksPath
git config --unset-all --system core.hooksPath
git config --local core.hooksPath <path>
");
}
#[test]
fn uninstall_refuses_empty_local_hooks_path_set() {
let context = TestContext::new();
context.init_project();
git_cmd(context.work_dir())
.args(["config", "core.hooksPath", ""])
.assert()
.success();
cmd_snapshot!(context.filters(), context.uninstall(), @r#"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Git resolved hooks directory to the current directory (`.`). Unset `core.hooksPath` or set it to a real directory path.
"#);
}
#[test]
fn uninstall_with_dot_hooks_path_removes_root_hook() {
let context = TestContext::new();
context.init_project();
git_cmd(context.work_dir())
.args(["config", "core.hooksPath", "."])
.assert()
.success();
context.install().assert().success();
context.uninstall().assert().success();
context
.work_dir()
.child("pre-commit")
.assert(predicates::path::missing());
}
#[test]
fn uninstall_with_included_local_hooks_path_removes_configured_hook() -> anyhow::Result<()> {
let context = TestContext::new();
context.init_project();
context
.work_dir()
.child("included-hooks.cfg")
.write_str(indoc! {r"
[core]
hooksPath = custom-hooks
"})?;
git_cmd(context.work_dir())
.args(["config", "--local", "include.path", "../included-hooks.cfg"])
.assert()
.success();
context.install().assert().success();
context.uninstall().assert().success();
context
.work_dir()
.child("custom-hooks/pre-commit")
.assert(predicates::path::missing());
Ok(())
}
/// `prek uninstall --all` should remove all prek-managed hooks.
#[test]
fn uninstall_all_managed_hooks() -> anyhow::Result<()> {
+1 -1
View File
@@ -2370,7 +2370,7 @@ fn selectors_completion() -> Result<()> {
success: true
exit_code: 0
----- stdout -----
install Install prek Git shims under the `.git/hooks/` directory
install Install prek Git shims into Git's effective hooks directory
prepare-hooks Prepare environments for all hooks used in the config file
run Run hooks
list List hooks configured in the current workspace
+7 -3
View File
@@ -12,7 +12,7 @@ prek [OPTIONS] [HOOK|PROJECT]... [COMMAND]
<h3 class="cli-reference">Commands</h3>
<dl class="cli-reference"><dt><a href="#prek-install"><code>prek install</code></a></dt><dd><p>Install prek Git shims under the <code>.git/hooks/</code> directory</p></dd>
<dl class="cli-reference"><dt><a href="#prek-install"><code>prek install</code></a></dt><dd><p>Install prek Git shims into Git's effective hooks directory</p></dd>
<dt><a href="#prek-prepare-hooks"><code>prek prepare-hooks</code></a></dt><dd><p>Prepare environments for all hooks used in the config file</p></dd>
<dt><a href="#prek-run"><code>prek run</code></a></dt><dd><p>Run hooks</p></dd>
<dt><a href="#prek-list"><code>prek list</code></a></dt><dd><p>List hooks configured in the current workspace</p></dd>
@@ -29,7 +29,9 @@ prek [OPTIONS] [HOOK|PROJECT]... [COMMAND]
## prek install
Install prek Git shims under the `.git/hooks/` directory.
Install prek Git shims into Git's effective hooks directory.
By default this is `.git/hooks/`, but repo-local or worktree-local `core.hooksPath` is honored when set.
The Git shims installed by this command are determined by `--hook-type` or `default_install_hook_types` in the config file, falling back to `pre-commit` when neither is set.
@@ -71,7 +73,7 @@ prek install [OPTIONS] [HOOK|PROJECT]...
<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--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 set. Git itself will still ignore <code>.git/hooks</code> while <code>core.hooksPath</code> is configured, so ensure your Git configuration points to the directory where the shim is installed if you want it to be executed.</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>
</dd><dt id="prek-install--hook-type"><a href="#prek-install--hook-type"><code>--hook-type</code></a>, <code>-t</code> <i>hook-type</i></dt><dd><p>Which Git shim(s) to install.</p>
<p>Specifies which Git hook type(s) you want to install shims for. Can be specified multiple times to install shims for multiple hook types.</p>
@@ -398,6 +400,8 @@ prek uninstall [OPTIONS]
<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-uninstall--config"><a href="#prek-uninstall--config"><code>--config</code></a>, <code>-c</code> <i>config</i></dt><dd><p>Path to alternate config file</p>
</dd><dt id="prek-uninstall--git-dir"><a href="#prek-uninstall--git-dir"><code>--git-dir</code></a> <i>git-dir</i></dt><dd><p>Uninstall Git shims from 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 uninstall</code> bypasses the safety check that normally refuses to modify shims while <code>core.hooksPath</code> is configured outside the repo. It only removes shims from <code>&lt;GIT_DIR&gt;/hooks</code>; Git may still use the configured <code>core.hooksPath</code> until that config changes.</p>
</dd><dt id="prek-uninstall--help"><a href="#prek-uninstall--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
</dd><dt id="prek-uninstall--hook-type"><a href="#prek-uninstall--hook-type"><code>--hook-type</code></a>, <code>-t</code> <i>hook-type</i></dt><dd><p>Which Git shim(s) to uninstall.</p>
<p>Specifies which Git hook type(s) you want to uninstall shims for. Can be specified multiple times to uninstall shims for multiple hook types.</p>
+7 -1
View File
@@ -18,13 +18,19 @@ In short, it installs the Git shims **and** prepares the environments for the ho
It's a little confusing because it refers to two different kinds of hooks:
1. **Git shims** – Scripts placed inside `.git/hooks/`, such as `.git/hooks/pre-commit`, that Git executes during lifecycle events. Both prek and ppc drop a small shim here so Git automatically runs them on `git commit`.
1. **Git shims** – Scripts placed in Git's effective hooks directory, usually `.git/hooks/` unless `core.hooksPath` points elsewhere. Both prek and ppc drop a small shim here so Git automatically runs them on `git commit`.
2. **prek-managed hooks** – The tools listed in `.pre-commit-config.yaml`. When prek runs, it executes these hooks and prepares whatever runtime they need (for example, creating a Python virtual environment and installing the hook's dependencies before execution).
Running `prek install` installs the first type: it writes the Git shim so that Git knows to call prek. Which Git shims get installed is determined by `--hook-type` or `default_install_hook_types` in the config file, and defaults to `pre-commit` if neither is set. This is not affected by a hook's `stages` field in the config: `stages` controls when a configured hook may run, not which Git shims `prek install` writes.
Adding `--prepare-hooks` tells prek to do that **and** proactively create the environments and caches required by the hooks that prek manages. That way, the next time Git invokes prek through the shim, the managed hooks are ready to run without additional setup. The older `--install-hooks` spelling remains as an alias.
## How does `prek install` interact with `core.hooksPath` and worktrees?
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`.
## How do I use hooks from private repositories?
prek supports cloning hooks from private repositories that require authentication.