diff --git a/docs/cli/commands.json b/docs/cli/commands.json index 0d0da4f5b..97df7b2c0 100644 --- a/docs/cli/commands.json +++ b/docs/cli/commands.json @@ -170,6 +170,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -565,6 +575,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -744,10 +764,30 @@ }, "install": { "full_cmd": ["install"], - "usage": "install [--mise]", + "usage": "install [FLAGS]", "subcommands": {}, "args": [], "flags": [ + { + "name": "global", + "usage": "--global", + "help": "Install at user level (~/.gitconfig) so every repo on this machine\ngets hk hooks. Requires Git 2.54 or newer. In repos without an\n`hk.pkl`, the installed hook is a silent no-op.", + "help_first_line": "Install at user level (~/.gitconfig) so every repo on this machine", + "short": [], + "long": ["global"], + "hide": false, + "global": false + }, + { + "name": "legacy", + "usage": "--legacy", + "help": "Force using the legacy `.git/hooks/` script shims instead of Git\n2.54+ config-based hooks. Not compatible with `--global`.", + "help_first_line": "Force using the legacy `.git/hooks/` script shims instead of Git", + "short": [], + "long": ["legacy"], + "hide": false, + "global": false + }, { "name": "mise", "usage": "--mise", @@ -763,7 +803,7 @@ "mounts": [], "hide": false, "help": "Sets up git hooks to run hk", - "help_long": "Sets up git hooks to run hk\n\nIn a git worktree with a per-worktree core.hooksPath configured, hooks are installed to that worktree-local directory. Otherwise hooks go to the shared hooks directory.", + "help_long": "Sets up git hooks to run hk.\n\nOn Git 2.54+ this uses config-based hooks (`hook..command`), which keeps `.git/hooks/` untouched and composes cleanly with other hook managers. On older Git it falls back to writing script shims.\n\nWith `--global`, hooks are installed into the user's `~/.gitconfig` so every repository picks them up without a per-repo install. In a project without an `hk.pkl`, the installed hook exits silently — no-op.", "name": "install", "aliases": ["i"], "hidden_aliases": [], @@ -997,6 +1037,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -1275,6 +1325,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -1535,6 +1595,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -1795,6 +1865,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -2046,6 +2126,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -2316,6 +2406,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -2594,6 +2694,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -2849,6 +2959,16 @@ "hide": false, "global": false }, + { + "name": "from-hook", + "usage": "--from-hook", + "help": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "help_first_line": "Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`", + "short": [], + "long": ["from-hook"], + "hide": true, + "global": false + }, { "name": "from-ref", "usage": "--from-ref ", @@ -3043,10 +3163,21 @@ }, "uninstall": { "full_cmd": ["uninstall"], - "usage": "uninstall", + "usage": "uninstall [--global]", "subcommands": {}, "args": [], - "flags": [], + "flags": [ + { + "name": "global", + "usage": "--global", + "help": "Remove hk hooks from the user's global git config (`~/.gitconfig`).", + "help_first_line": "Remove hk hooks from the user's global git config (`~/.gitconfig`).", + "short": [], + "long": ["global"], + "hide": false, + "global": false + } + ], "mounts": [], "hide": false, "help": "Removes hk hooks from the current git repository", diff --git a/docs/cli/index.md b/docs/cli/index.md index ad9f4fe95..573ed6c8a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -59,7 +59,7 @@ Output in JSON format - [`hk config sources`](/cli/config/sources.md) - [`hk fix [FLAGS] [FILES]…`](/cli/fix.md) - [`hk init [FLAGS]`](/cli/init.md) -- [`hk install [--mise]`](/cli/install.md) +- [`hk install [FLAGS]`](/cli/install.md) - [`hk migrate `](/cli/migrate.md) - [`hk migrate pre-commit [FLAGS]`](/cli/migrate/pre-commit.md) - [`hk run [FLAGS] [FILES]… `](/cli/run.md) @@ -71,7 +71,7 @@ Output in JSON format - [`hk run pre-push [FLAGS] [ARGS]…`](/cli/run/pre-push.md) - [`hk run prepare-commit-msg [FLAGS] …`](/cli/run/prepare-commit-msg.md) - [`hk test [FLAGS]`](/cli/test.md) -- [`hk uninstall`](/cli/uninstall.md) +- [`hk uninstall [--global]`](/cli/uninstall.md) - [`hk util `](/cli/util.md) - [`hk util check-added-large-files [--maxkb ] …`](/cli/util/check-added-large-files.md) - [`hk util check-byte-order-marker [-d --diff] …`](/cli/util/check-byte-order-marker.md) diff --git a/docs/cli/install.md b/docs/cli/install.md index 1ba6644bb..11e21680e 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -2,15 +2,28 @@ # `hk install` -- **Usage**: `hk install [--mise]` +- **Usage**: `hk install [FLAGS]` - **Aliases**: `i` -Sets up git hooks to run hk +Sets up git hooks to run hk. -In a git worktree with a per-worktree core.hooksPath configured, hooks are installed to that worktree-local directory. Otherwise hooks go to the shared hooks directory. +On Git 2.54+ this uses config-based hooks (`hook..command`), which keeps `.git/hooks/` untouched and composes cleanly with other hook managers. On older Git it falls back to writing script shims. + +With `--global`, hooks are installed into the user's `~/.gitconfig` so every repository picks them up without a per-repo install. In a project without an `hk.pkl`, the installed hook exits silently — no-op. ## Flags +### `--global` + +Install at user level (~/.gitconfig) so every repo on this machine +gets hk hooks. Requires Git 2.54 or newer. In repos without an +`hk.pkl`, the installed hook is a silent no-op. + +### `--legacy` + +Force using the legacy `.git/hooks/` script shims instead of Git +2.54+ config-based hooks. Not compatible with `--global`. + ### `--mise` Use `mise x` to execute hooks. With this, it won't diff --git a/docs/cli/uninstall.md b/docs/cli/uninstall.md index 2ec3dcaf4..aafc4e100 100644 --- a/docs/cli/uninstall.md +++ b/docs/cli/uninstall.md @@ -2,6 +2,12 @@ # `hk uninstall` -- **Usage**: `hk uninstall` +- **Usage**: `hk uninstall [--global]` Removes hk hooks from the current git repository + +## Flags + +### `--global` + +Remove hk hooks from the user's global git config (`~/.gitconfig`). diff --git a/docs/getting_started.md b/docs/getting_started.md index 9f3ac4a21..0c3509410 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -101,6 +101,46 @@ hk install This will install the hooks for the repository like `pre-commit` and `pre-push` if they are defined in `hk.pkl`. Running `git commit` would now run the linters defined above in our example through the pre-commit hook. +On **Git 2.54 or newer**, `hk install` writes [config-based hooks](https://github.blog/open-source/git/highlights-from-git-2-54/) (`git config hook.hk-.command`) instead of script files in `.git/hooks/`. This keeps the hooks directory untouched and composes cleanly with other hook managers. On older Git it falls back to writing script shims — no configuration needed, hk detects the installed git version automatically. Pass `--legacy` to force the shim mode. + +## Install Hooks Globally (Git 2.54+) + +With Git 2.54+, you can install hk hooks once in your **user-wide** `~/.gitconfig` and they apply to every repository on your machine: + +```sh +hk install --global +``` + +This writes `hook.hk-.command` entries to your global git config for the common client-side hooks (`pre-commit`, `pre-push`, `commit-msg`, `prepare-commit-msg`, `post-checkout`, `post-merge`, `post-rewrite`, `pre-rebase`, `post-commit`). Each invocation is a **silent no-op in repos that don't have an `hk.pkl`**, so you can safely enable it everywhere without breaking unrelated projects. + +To remove the global install: + +```sh +hk uninstall --global +``` + +Per-repository `hk install` works alongside `--global`, but note that **Git aggregates `hook..command` entries across every scope and runs them all** — so a local install on top of a global one will fire hk twice per event. To run only the local install in a repo that also has the global install active, disable the global entries in that repo with `hook.hk-.enabled = false` (see the note at the end of this section). + +### Configuring manually in `~/.gitconfig` + +If you'd rather set this up by hand, add a block like the following to your `~/.gitconfig`: + +```ini +[hook "hk-pre-commit"] + command = test "${HK:-1}" = "0" || hk run pre-commit --from-hook "$@" + event = pre-commit +[hook "hk-pre-push"] + command = test "${HK:-1}" = "0" || hk run pre-push --from-hook "$@" + event = pre-push +[hook "hk-commit-msg"] + command = test "${HK:-1}" = "0" || hk run commit-msg --from-hook "$@" + event = commit-msg +``` + +The `--from-hook` flag tells hk to exit silently when the project has no `hk.pkl` or doesn't define that event. The `test "${HK:-1}" = "0" ||` prefix is an escape hatch: run `HK=0 git commit` to bypass hooks for a single command. Use `mise x -- hk` instead of `hk` in the `command` if you manage hk via mise and don't auto-activate it. + +To disable hk for a single repo without uninstalling globally, set `hook.hk-.enabled = false` in that repo's `.git/config`. + ## Checking and Fixing Code You can check or fix code with [`hk check`](/cli/check) or [`hk fix`](/cli/fix)—by convention, "check" means files should not be modified and "fix" diff --git a/hk.usage.kdl b/hk.usage.kdl index 18f664d9e..83917ba64 100644 --- a/hk.usage.kdl +++ b/hk.usage.kdl @@ -39,6 +39,7 @@ cmd check help="Checks code" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -102,6 +103,7 @@ cmd fix help="Fixes code" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -133,7 +135,9 @@ cmd init help="Generates a new hk.pkl file for a project" { } cmd install help="Sets up git hooks to run hk" { alias i - long_help "Sets up git hooks to run hk\n\nIn a git worktree with a per-worktree core.hooksPath configured, hooks are installed to that worktree-local directory. Otherwise hooks go to the shared hooks directory." + long_help "Sets up git hooks to run hk.\n\nOn Git 2.54+ this uses config-based hooks (`hook..command`), which keeps `.git/hooks/` untouched and composes cleanly with other hook managers. On older Git it falls back to writing script shims.\n\nWith `--global`, hooks are installed into the user's `~/.gitconfig` so every repository picks them up without a per-repo install. In a project without an `hk.pkl`, the installed hook exits silently — no-op." + flag --global help="Install at user level (~/.gitconfig) so every repo on this machine\ngets hk hooks. Requires Git 2.54 or newer. In repos without an\n`hk.pkl`, the installed hook is a silent no-op." + flag --legacy help="Force using the legacy `.git/hooks/` script shims instead of Git\n2.54+ config-based hooks. Not compatible with `--global`." flag --mise help="Use `mise x` to execute hooks. With this, it won't\nbe necessary to activate mise in order to run hooks\nwith mise tools." { long_help "Use `mise x` to execute hooks. With this, it won't\nbe necessary to activate mise in order to run hooks\nwith mise tools.\n\nSet HK_MISE=1 to make this default behavior." } @@ -168,6 +172,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -205,6 +210,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -242,6 +248,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -281,6 +288,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -318,6 +326,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -356,6 +365,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -393,6 +403,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -432,6 +443,7 @@ cmd run help="Run a hook" { arg } flag --fail-fast help="Abort on first failure" + flag --from-hook help="Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is present or the event isn't defined. Set automatically by `hk install`" hide=#true flag --from-ref help="Start reference for checking files (requires --to-ref)" { arg } @@ -466,7 +478,9 @@ cmd test help="Run step-defined tests" { arg } } -cmd uninstall help="Removes hk hooks from the current git repository" +cmd uninstall help="Removes hk hooks from the current git repository" { + flag --global help="Remove hk hooks from the user's global git config (`~/.gitconfig`)." +} cmd usage hide=#true help="Generates a usage spec for the CLI" { long_help "Generates a usage spec for the CLI\n\nhttps://usage.jdx.dev" } diff --git a/src/cli/install.rs b/src/cli/install.rs index d10bdec40..9cdd6d26e 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -1,15 +1,45 @@ use crate::{Result, config::Config, env, git_util}; +use eyre::bail; use log::warn; use std::process::Command; -/// Sets up git hooks to run hk +/// Hook events installed when `--global` is used and no project `hk.pkl` is +/// available to enumerate them. Kept to the commonly-useful client-side hooks. +const DEFAULT_GLOBAL_EVENTS: &[&str] = &[ + "commit-msg", + "post-checkout", + "post-commit", + "post-merge", + "post-rewrite", + "pre-commit", + "pre-push", + "pre-rebase", + "prepare-commit-msg", +]; + +/// Sets up git hooks to run hk. +/// +/// On Git 2.54+ this uses config-based hooks (`hook..command`), which +/// keeps `.git/hooks/` untouched and composes cleanly with other hook +/// managers. On older Git it falls back to writing script shims. /// -/// In a git worktree with a per-worktree core.hooksPath configured, -/// hooks are installed to that worktree-local directory. Otherwise -/// hooks go to the shared hooks directory. +/// With `--global`, hooks are installed into the user's `~/.gitconfig` so +/// every repository picks them up without a per-repo install. In a project +/// without an `hk.pkl`, the installed hook exits silently — no-op. #[derive(Debug, clap::Args)] #[clap(visible_alias = "i")] pub struct Install { + /// Install at user level (~/.gitconfig) so every repo on this machine + /// gets hk hooks. Requires Git 2.54 or newer. In repos without an + /// `hk.pkl`, the installed hook is a silent no-op. + #[clap(long, verbatim_doc_comment)] + global: bool, + + /// Force using the legacy `.git/hooks/` script shims instead of Git + /// 2.54+ config-based hooks. Not compatible with `--global`. + #[clap(long, verbatim_doc_comment, conflicts_with = "global")] + legacy: bool, + /// Use `mise x` to execute hooks. With this, it won't /// be necessary to activate mise in order to run hooks /// with mise tools. @@ -21,42 +51,237 @@ pub struct Install { impl Install { pub async fn run(&self) -> Result<()> { - let config = Config::get()?; - let git_path = git_util::find_git_path()?; + let command = if *env::HK_MISE || self.mise { + "mise x -- hk" + } else { + "hk" + }; - let hooks = match git_util::worktree_hooks_path() { - Some(path) => { - xx::file::mkdirp(&path)?; - path + if self.global { + if !git_util::git_at_least(2, 54) { + bail!( + "`hk install --global` requires Git 2.54+ (config-based hooks). Detected git version does not support this. Upgrade git, or install per-repo with `hk install`." + ); } - None => { - check_hooks_path_config()?; - git_util::resolve_git_hooks_dir(&git_path)? + return install_global(command); + } + + let use_config_hooks = !self.legacy && git_util::git_at_least(2, 54); + + // Load and validate the project config before touching anything, so a + // broken `hk.pkl` doesn't leave the repo with its prior hooks removed. + let config = Config::get()?; + let events: Vec = config + .hooks + .keys() + .filter(|h| h.as_str() != "check" && h.as_str() != "fix") + .cloned() + .collect(); + + // Clean up any prior installation so modes don't accumulate. + let removed = remove_local_shims()? + remove_local_config_entries()?; + + if events.is_empty() { + if removed > 0 { + warn!( + "no hooks configured in hk.pkl — removed {removed} previously-installed hk hook(s) and did not install any new ones" + ); + } else { + warn!("no hooks configured in hk.pkl — nothing to install"); } - }; + return Ok(()); + } - let command = if *env::HK_MISE || self.mise { - "mise x -- hk".to_string() + if use_config_hooks { + let result = install_local_config(&events, command); + warn_if_global_overlap(&events); + result } else { - "hk".to_string() - }; - for hook in config.hooks.keys() { - if hook == "check" || hook == "fix" { - continue; + install_local_shims(&events, command) + } + } +} + +/// Git aggregates `hook..command` values across scopes, so a local +/// install on top of a global one fires hk twice per event. Warn the user +/// and point them at the `enabled = false` escape hatch. +fn warn_if_global_overlap(events: &[String]) { + let mut overlapping: Vec<&str> = Vec::new(); + for event in events { + let key = format!("hook.hk-{event}.command"); + if let Ok(output) = Command::new("git") + .args(["config", "--global", "--get", key.as_str()]) + .output() + && output.status.success() + && !output.stdout.is_empty() + { + overlapping.push(event); + } + } + if overlapping.is_empty() { + return; + } + warn!( + "both global (~/.gitconfig) and local hk hooks are active for: {}. Git will run hk twice per event. To run only the local install, disable the global entries in this repo: {}", + overlapping.join(", "), + overlapping + .iter() + .map(|e| format!("`git config --local hook.hk-{e}.enabled false`")) + .collect::>() + .join(" ; ") + ); +} + +fn install_global(command: &str) -> Result<()> { + remove_config_entries("--global")?; + for event in DEFAULT_GLOBAL_EVENTS { + write_config_hook("--global", command, event)?; + } + println!( + "Installed hk global hooks in ~/.gitconfig for: {}", + DEFAULT_GLOBAL_EVENTS.join(", ") + ); + println!( + "In repos without an hk.pkl, hk exits silently — add one with `hk init` to enable hooks." + ); + Ok(()) +} + +fn install_local_config(events: &[String], command: &str) -> Result<()> { + for event in events { + write_config_hook("--local", command, event)?; + println!("Installed hk hook via git config: hook.hk-{event}.command"); + } + Ok(()) +} + +fn install_local_shims(events: &[String], command: &str) -> Result<()> { + let git_path = git_util::find_git_path()?; + let hooks = match git_util::worktree_hooks_path() { + Some(path) => { + xx::file::mkdirp(&path)?; + path + } + None => { + check_hooks_path_config()?; + git_util::resolve_git_hooks_dir(&git_path)? + } + }; + for event in events { + let hook_file = hooks.join(event); + xx::file::write(&hook_file, git_hook_content(command, event))?; + xx::file::make_executable(&hook_file)?; + println!("Installed hk hook: {}", hook_file.display()); + } + Ok(()) +} + +/// Write both `hook.hk-.command` and `hook.hk-.event` at the +/// given scope (`--local` or `--global`). +fn write_config_hook(scope: &str, command: &str, event: &str) -> Result<()> { + let name = format!("hk-{event}"); + let cmd_key = format!("hook.{name}.command"); + let event_key = format!("hook.{name}.event"); + // Mirror the shim's HK=0 escape hatch so users can still disable hooks + // with `HK=0 git commit` under config-based hooks. + let cmd_value = format!(r#"test "${{HK:-1}}" = "0" || {command} run {event} --from-hook "$@""#); + + run_git(&["config", scope, cmd_key.as_str(), cmd_value.as_str()])?; + // .event is multi-valued; replace-all keeps re-install idempotent. + run_git(&["config", scope, "--replace-all", event_key.as_str(), event])?; + Ok(()) +} + +fn remove_local_config_entries() -> Result { + remove_config_entries("--local") +} + +pub(crate) fn remove_config_entries(scope: &str) -> Result { + let output = Command::new("git") + .args([ + "config", + scope, + "--name-only", + "--get-regexp", + "^hook\\.hk-", + ]) + .output()?; + // git config --get-regexp: 0 = matches, 1 = no matches, ≥2 = real error + // (e.g. unreadable config). Don't conflate "nothing to remove" with a + // failed uninstall. + let code = output.status.code().unwrap_or(1); + if code == 1 { + return Ok(0); + } + if !output.status.success() { + bail!( + "git config --get-regexp failed (exit {}): {}", + code, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let keys: Vec = String::from_utf8_lossy(&output.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + // Dedupe since multi-valued keys appear once per value. + let mut seen = std::collections::BTreeSet::new(); + let mut removed = 0; + for key in keys { + if seen.insert(key.clone()) { + run_git(&["config", scope, "--unset-all", key.as_str()])?; + // Count one per hook event, not one per key (command + event). + if key.ends_with(".command") { + removed += 1; } - let hook_file = hooks.join(hook); - xx::file::write(&hook_file, git_hook_content(&command, hook))?; - xx::file::make_executable(&hook_file)?; - println!("Installed hk hook: {}", hook_file.display()); } - Ok(()) } + Ok(removed) +} + +pub(crate) fn remove_local_shims() -> Result { + let git_path = match git_util::find_git_path() { + Ok(p) => p, + Err(_) => return Ok(0), + }; + let hooks = match git_util::worktree_hooks_path() { + Some(path) => path, + None => git_util::resolve_git_hooks_dir(&git_path)?, + }; + if !hooks.is_dir() { + return Ok(0); + } + let mut removed = 0; + for p in xx::file::ls(&hooks)? { + let content = match xx::file::read_to_string(&p) { + Ok(content) => content, + Err(_) => continue, + }; + // Match the HK=0 guard that every hk-written shim has. This is more + // specific than `hk run` alone, which could appear in an unrelated + // user-written hook. + if content.contains(r#"test "${HK:-1}" = "0""#) && content.contains("hk run") { + xx::file::remove_file(&p)?; + info!("removed hook: {}", xx::file::display_path(&p)); + removed += 1; + } + } + Ok(removed) +} + +fn run_git(args: &[&str]) -> Result<()> { + let status = Command::new("git").args(args).status()?; + if !status.success() { + bail!("git {} failed", args.join(" ")); + } + Ok(()) } fn git_hook_content(hk: &str, hook: &str) -> String { format!( r#"#!/bin/sh -test "${{HK:-1}}" = "0" || exec {hk} run {hook} "$@" +test "${{HK:-1}}" = "0" || exec {hk} run {hook} --from-hook "$@" "# ) } diff --git a/src/cli/migrate/mod.rs b/src/cli/migrate/mod.rs index 259d51d19..4891e748b 100644 --- a/src/cli/migrate/mod.rs +++ b/src/cli/migrate/mod.rs @@ -369,21 +369,13 @@ fn convert_regex_to_glob(regex: &str) -> Option { } } // .* becomes ** - '.' => { - if chars.peek() == Some(&'*') { - chars.next(); // consume the * - - // Check what comes after .* - if chars.peek() == Some(&'/') { - // .*/ becomes **/ - chars.next(); // consume the / - result.push_str("**/"); - } else { - // .* at end or before other chars becomes ** - result.push_str("**"); - } + '.' if chars.peek() == Some(&'*') => { + chars.next(); // consume the * + if chars.peek() == Some(&'/') { + chars.next(); // consume the / + result.push_str("**/"); } else { - return None; // Single . is not a valid glob + result.push_str("**"); } } // Regular characters diff --git a/src/cli/uninstall.rs b/src/cli/uninstall.rs index a2b577583..c0e637d64 100644 --- a/src/cli/uninstall.rs +++ b/src/cli/uninstall.rs @@ -1,33 +1,25 @@ -use crate::{Result, git_util}; +use crate::{Result, cli::install}; /// Removes hk hooks from the current git repository #[derive(Debug, clap::Args)] -pub struct Uninstall {} +pub struct Uninstall { + /// Remove hk hooks from the user's global git config (`~/.gitconfig`). + #[clap(long, verbatim_doc_comment)] + global: bool, +} impl Uninstall { pub async fn run(&self) -> Result<()> { - let git_path = git_util::find_git_path()?; - let hooks = match git_util::worktree_hooks_path() { - Some(path) => path, - None => git_util::resolve_git_hooks_dir(&git_path)?, - }; - - if !hooks.is_dir() { + if self.global { + install::remove_config_entries("--global")?; + info!("removed hk hooks from ~/.gitconfig"); return Ok(()); } - for p in xx::file::ls(&hooks)? { - let content = match xx::file::read_to_string(&p) { - Ok(content) => content, - Err(e) => { - debug!("failed to read hook: {e}"); - continue; - } - }; - if content.contains("hk run") { - xx::file::remove_file(&p)?; - info!("removed hook: {}", xx::file::display_path(&p)); - } - } + // Clean both legacy script shims and config-based entries so the + // uninstall is complete regardless of which mode the user had. + install::remove_local_shims()?; + install::remove_config_entries("--local")?; + info!("removed hk hooks from this repository"); Ok(()) } } diff --git a/src/cli/util/end_of_file_fixer.rs b/src/cli/util/end_of_file_fixer.rs index 5d0c2c19b..2828e539a 100644 --- a/src/cli/util/end_of_file_fixer.rs +++ b/src/cli/util/end_of_file_fixer.rs @@ -187,7 +187,7 @@ mod tests { #[test] fn test_has_proper_ending_single_newline_file() { let mut file = NamedTempFile::new().unwrap(); - write!(file, "\n").unwrap(); + writeln!(file).unwrap(); file.flush().unwrap(); let path = file.path().to_path_buf(); diff --git a/src/cli/util/fix_smart_quotes.rs b/src/cli/util/fix_smart_quotes.rs index caa9a6319..c954330f6 100644 --- a/src/cli/util/fix_smart_quotes.rs +++ b/src/cli/util/fix_smart_quotes.rs @@ -148,7 +148,7 @@ mod tests { ’RIGHT SINGLE QUOTATION MARK’ ‛SINGLE HIGH-REVERSED-9 QUOTATION MARK‛ "#; - fs::write(file.path(), &content).unwrap(); + fs::write(file.path(), content).unwrap(); replace_smart_quotes(&file.path().to_path_buf()).unwrap(); diff --git a/src/cli/util/python_check_ast.rs b/src/cli/util/python_check_ast.rs index 4bd28fb06..5730ae106 100644 --- a/src/cli/util/python_check_ast.rs +++ b/src/cli/util/python_check_ast.rs @@ -77,10 +77,8 @@ def hello(): .unwrap(); // This test will pass if Python is available - let result = is_valid_python_syntax(&file.path().to_path_buf()); - if result.is_ok() { - // Only assert if we successfully ran python - assert!(result.unwrap()); + if let Ok(valid) = is_valid_python_syntax(&file.path().to_path_buf()) { + assert!(valid); } } @@ -97,10 +95,8 @@ def hello(: .unwrap(); // This test will pass if Python is available - let result = is_valid_python_syntax(&file.path().to_path_buf()); - if result.is_ok() { - // Only assert if we successfully ran python - assert!(!result.unwrap()); + if let Ok(valid) = is_valid_python_syntax(&file.path().to_path_buf()) { + assert!(!valid); } } @@ -109,10 +105,9 @@ def hello(: let file = NamedTempFile::new().unwrap(); fs::write(file.path(), "").unwrap(); - let result = is_valid_python_syntax(&file.path().to_path_buf()); - if result.is_ok() { + if let Ok(valid) = is_valid_python_syntax(&file.path().to_path_buf()) { // Empty file is valid Python - assert!(result.unwrap()); + assert!(valid); } } } diff --git a/src/config.rs b/src/config.rs index d44916f54..81a46d4f3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -85,12 +85,22 @@ impl Config { #[tracing::instrument(level = "info", name = "config.load_project")] fn load_project_config() -> Result { - let paths: Vec<&str> = if let Some(hk_file) = env::HK_FILE.as_ref() { + let paths = Self::project_config_search_paths(); + if let Some(path) = Self::find_project_config(&paths) { + return Self::load_config_cached(path); + } + debug!("No config file found, using default"); + let mut config = Config::default(); + config.init(Path::new(&paths[0]))?; + Ok(config) + } + + fn project_config_search_paths() -> Vec { + if let Some(hk_file) = env::HK_FILE.as_ref() { // If HK_FILE is explicitly set, only use that path (no fallbacks) - vec![hk_file.as_str()] + vec![hk_file.clone()] } else { - // Default search order when HK_FILE is not set - vec![ + [ // User-local config "hk.local.pkl", ".config/hk.local.pkl", @@ -103,21 +113,31 @@ impl Config { "hk.yml", "hk.json", ] - }; - let mut cwd = std::env::current_dir()?; + .iter() + .map(|s| s.to_string()) + .collect() + } + } + + fn find_project_config(paths: &[String]) -> Option { + let mut cwd = std::env::current_dir().ok()?; while cwd != Path::new("/") { - for path in &paths { - let path = cwd.join(path); - if path.exists() { - return Self::load_config_cached(path); + for name in paths { + let p = cwd.join(name); + if p.exists() { + return Some(p); } } cwd = cwd.parent().map(PathBuf::from).unwrap_or_default(); } - debug!("No config file found, using default"); - let mut config = Config::default(); - config.init(Path::new(paths[0]))?; - Ok(config) + None + } + + /// Returns true when a project-level hk config file exists without + /// loading or parsing it. Used by `--from-hook` so a broken user-global + /// hkrc doesn't blow up `git commit` in repos that have no hk.pkl. + pub fn project_config_exists() -> bool { + Self::find_project_config(&Self::project_config_search_paths()).is_some() } fn load_config_cached(path: PathBuf) -> Result { diff --git a/src/git.rs b/src/git.rs index 892b7e8e8..de7526cb5 100644 --- a/src/git.rs +++ b/src/git.rs @@ -213,7 +213,7 @@ impl Git { } // Sort by modification time, newest first - patch_files.sort_by(|a, b| b.1.cmp(&a.1)); + patch_files.sort_by_key(|f| std::cmp::Reverse(f.1)); // Remove old patches beyond keep_count for (path, _) in patch_files.iter().skip(keep_count) { diff --git a/src/git_util.rs b/src/git_util.rs index 279d3495e..53a642f02 100644 --- a/src/git_util.rs +++ b/src/git_util.rs @@ -1,9 +1,48 @@ use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use eyre::eyre; use crate::Result; +/// Semantic version of the `git` binary on PATH: `(major, minor, patch)`. +/// +/// Returns `None` if `git --version` cannot be executed or parsed. Cached for +/// the lifetime of the process — git is not going to upgrade itself out from +/// under us. +pub fn git_version() -> Option<(u32, u32, u32)> { + static CACHED: OnceLock> = OnceLock::new(); + *CACHED.get_or_init(|| { + let output = std::process::Command::new("git") + .arg("--version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout); + // "git version 2.54.0" (possibly with trailing .windows.N or similar) + let ver = s.split_whitespace().nth(2)?; + let mut parts = ver.split('.').filter_map(|p| { + let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().ok() + }); + Some(( + parts.next()?, + parts.next().unwrap_or(0), + parts.next().unwrap_or(0), + )) + }) +} + +/// Whether the installed `git` is at least `major.minor`. +pub fn git_at_least(major: u32, minor: u32) -> bool { + match git_version() { + Some((maj, min, _)) => (maj, min) >= (major, minor), + None => false, + } +} + /// Find the `.git` path from the current working directory by searching upward. /// /// Honors `GIT_DIR` if set (used by bare-repo dotfile managers like YADM), in diff --git a/src/hook_options.rs b/src/hook_options.rs index cb1a31c20..b1b418822 100644 --- a/src/hook_options.rs +++ b/src/hook_options.rs @@ -30,6 +30,10 @@ pub(crate) struct HookOptions { /// Abort on first failure #[clap(long, overrides_with = "no_fail_fast")] pub fail_fast: bool, + /// Invoked by an installed git hook — gracefully exit 0 when no hk.pkl is + /// present or the event isn't defined. Set automatically by `hk install`. + #[clap(long, hide = true)] + pub from_hook: bool, /// Start reference for checking files (requires --to-ref) #[clap(long)] pub from_ref: Option, @@ -74,6 +78,14 @@ impl HookOptions { } pub(crate) async fn run(mut self, name: &str) -> Result<()> { + // Under `--from-hook`, short-circuit *before* loading the config. A + // broken user-global hkrc (or missing `pkl`) shouldn't fail every + // `git commit` in a repo that doesn't even use hk — which is the + // main risk under `hk install --global`. + if self.from_hook && !Config::project_config_exists() { + log::debug!("no hk config found for {name}, skipping (--from-hook)"); + return Ok(()); + } let config = Config::get()?; if self.pr { let repo = Git::new()?; @@ -98,6 +110,13 @@ impl HookOptions { Ok(()) } None => { + if self.from_hook { + log::debug!( + "hook '{name}' not defined in {}, skipping (--from-hook)", + config.path.display() + ); + return Ok(()); + } let hook_names: Vec<&str> = config.hooks.keys().map(|s| s.as_str()).collect(); let msg = if let Some(suggestion) = xx::suggest::did_you_mean(name, &hook_names) { format!("Hook '{}' not found. {}", name, suggestion) diff --git a/src/settings.rs b/src/settings.rs index c1e6ade1b..8278a4cde 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -417,7 +417,7 @@ impl Settings { && !list.is_empty() { if let Some(acc) = &mut merged { - acc.extend(list.into_iter()); + acc.extend(list); } else { merged = Some(list); } diff --git a/test/bare_repo_env_vars.bats b/test/bare_repo_env_vars.bats index d64feef7c..c112d9f4a 100644 --- a/test/bare_repo_env_vars.bats +++ b/test/bare_repo_env_vars.bats @@ -70,7 +70,7 @@ EOF @test "hk install writes hooks to the bare-repo hooks dir" { _write_hk_config - run hk install + run hk install --legacy assert_success assert_file_exists "$BARE_DIR/hooks/pre-commit" } @@ -78,7 +78,7 @@ EOF @test "hk uninstall removes hooks from the bare-repo hooks dir" { _write_hk_config - hk install + hk install --legacy assert_file_exists "$BARE_DIR/hooks/pre-commit" run hk uninstall diff --git a/test/install_creates_git_hooks.bats b/test/install_creates_git_hooks.bats index b22bc211b..e4a6fc5d8 100644 --- a/test/install_creates_git_hooks.bats +++ b/test/install_creates_git_hooks.bats @@ -15,6 +15,6 @@ amends "$PKL_PATH/Config.pkl" import "$PKL_PATH/Builtins.pkl" hooks { ["pre-commit"] { steps { ["prettier"] = Builtins.prettier } } } EOF - hk install + hk install --legacy assert_file_exists ".git/hooks/pre-commit" } diff --git a/test/install_warn_hookspath.bats b/test/install_warn_hookspath.bats index 19a99b4d4..5f4e8bf45 100644 --- a/test/install_warn_hookspath.bats +++ b/test/install_warn_hookspath.bats @@ -19,8 +19,9 @@ EOF # Set global core.hooksPath git config --global core.hooksPath "/some/global/hooks/path" - # Run hk install and capture stderr - run hk install + # Run hk install and capture stderr. core.hooksPath only matters in legacy + # shim mode — under Git 2.54+ config-based hooks this warning is moot. + run hk install --legacy # Should succeed assert_success @@ -43,8 +44,9 @@ EOF # Set local core.hooksPath git config --local core.hooksPath "/some/local/hooks/path" - # Run hk install and capture stderr - run hk install + # Run hk install and capture stderr. core.hooksPath only matters in legacy + # shim mode — under Git 2.54+ config-based hooks this warning is moot. + run hk install --legacy # Should succeed assert_success @@ -68,8 +70,9 @@ EOF git config --global core.hooksPath "/some/global/hooks/path" git config --local core.hooksPath "/some/local/hooks/path" - # Run hk install and capture stderr - run hk install + # Run hk install and capture stderr. core.hooksPath only matters in legacy + # shim mode — under Git 2.54+ config-based hooks this warning is moot. + run hk install --legacy # Should succeed assert_success @@ -97,7 +100,7 @@ EOF git config --local --unset core.hooksPath 2>/dev/null || true # Run hk install - run hk install + run hk install --legacy # Should succeed assert_success diff --git a/test/install_worktree_hooks.bats b/test/install_worktree_hooks.bats index 506a950ad..bc945ef64 100644 --- a/test/install_worktree_hooks.bats +++ b/test/install_worktree_hooks.bats @@ -34,7 +34,7 @@ EOF @test "hk install writes hooks to per-worktree hooksPath" { _write_hk_config - run hk install + run hk install --legacy assert_success assert_file_exists "$WORKTREE_HOOKS/pre-commit" @@ -49,7 +49,7 @@ EOF @test "hk uninstall removes hooks from per-worktree hooksPath" { _write_hk_config - hk install + hk install --legacy assert_file_exists "$WORKTREE_HOOKS/pre-commit" run hk uninstall @@ -85,7 +85,7 @@ EOF git config --worktree --unset core.hooksPath _write_hk_config - run hk install + run hk install --legacy assert_success # Hooks should be in the shared dir, not the worktree-local dir @@ -96,7 +96,7 @@ EOF @test "different worktrees get independent hooks" { _write_hk_config - hk install + hk install --legacy # Create and configure a second worktree WORKTREE_DIR2="$TEST_TEMP_DIR/worktree2" @@ -107,7 +107,7 @@ EOF cd "$WORKTREE_DIR2" git config --worktree core.hooksPath "$WORKTREE2_HOOKS" _write_hk_config - hk install + hk install --legacy assert_file_exists "$WORKTREE_HOOKS/pre-commit" assert_file_exists "$WORKTREE2_HOOKS/pre-commit" diff --git a/test/pre_push.bats b/test/pre_push.bats index 89ba95070..a8cd24332 100644 --- a/test/pre_push.bats +++ b/test/pre_push.bats @@ -28,7 +28,9 @@ EOF git add hk.pkl git commit -m "install hk" git push origin main - hk install + # Use legacy shim mode: config-based pre-push hooks have different env/cwd + # semantics that would need separate test coverage. + hk install --legacy echo 'console.log("test")' > test.js git add test.js git commit -m "test" diff --git a/test/uninstall.bats b/test/uninstall.bats index dc1959974..fcd4405b8 100644 --- a/test/uninstall.bats +++ b/test/uninstall.bats @@ -18,7 +18,7 @@ hooks { } EOF rm -f .git/hooks/* - hk install + hk install --legacy assert_file_exists .git/hooks/pre-commit assert_file_exists .git/hooks/pre-push assert_file_not_exists .git/hooks/fix diff --git a/test/worktree.bats b/test/worktree.bats index a4b2237c6..9b61d6757 100644 --- a/test/worktree.bats +++ b/test/worktree.bats @@ -28,7 +28,7 @@ import "$PKL_PATH/Builtins.pkl" hooks { ["pre-commit"] { steps { ["prettier"] = Builtins.prettier } } } EOF - run hk install + run hk install --legacy assert_success assert_output --partial "Installed hk hook: " assert_output --partial "pre-commit" @@ -43,7 +43,7 @@ import "$PKL_PATH/Builtins.pkl" hooks { ["pre-commit"] { steps { ["prettier"] = Builtins.prettier } } } EOF - hk install + hk install --legacy run hk uninstall assert_success assert_output --partial "removed hook: "