Skip to content

docs(install): recommend global hooks as primary setup path#855

Merged
jdx merged 1 commit intomainfrom
feat/config-based-hooks
Apr 23, 2026
Merged

docs(install): recommend global hooks as primary setup path#855
jdx merged 1 commit intomainfrom
feat/config-based-hooks

Conversation

@jdx
Copy link
Copy Markdown
Owner

@jdx jdx commented Apr 23, 2026

Summary

Leads the install docs with hk install --global as the recommended setup path. The global install is a silent no-op in repos without an hk.pkl, so it's safe to enable once per machine and avoids re-running hk install in every clone.

Follow-up to #853 (the config-based hooks + --global feature that this documents).

What changed

  • docs/getting_started.md — restructured: binary install → Install Hooks (recommended: global) → Project Setup → config example. Per-repo hk install is now framed as an alternative for pre-Git-2.54 or selective-repo use. The overlap warning (Git aggregates hook.<name>.command across scopes) and the manual ~/.gitconfig setup are retained as subsections.
  • src/cli/install.rs — rewrote the Install struct docstring and --global flag help so hk install --help leads with the recommendation.
  • docs/cli/install.md, hk.usage.kdl, docs/cli/commands.json — regenerated via hk usageusage g markdown.
  • Renamed the pre-existing "Global Configuration" section to "Global `hkrc` Configuration" in getting_started.md to disambiguate from global hooks (the sections are adjacent and share the word "global").
  • docs/package.json — added "version": "0.0.0" + "private": true so the bun workspace resolves cleanly (the pre-push docs:build hook was failing on missing version).

Why

New users land on getting_started.md; leading with per-repo hk install made them re-install in every clone and gave no signal that the global option existed. The --from-hook short-circuit (also from #853) makes global genuinely safe to enable everywhere, so it deserves the headline.

Test plan

  • hk check --all passes.
  • Pre-push hook (runs full docs build) passes.
  • Review rendered getting_started and cli/install pages after merge.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements global git hook support for hk using Git 2.54+ config-based hooks, adding --global, --legacy, and --from-hook flags to manage these hooks safely across repositories. Documentation and tests were updated to support these features. Review feedback identified several compilation issues due to missing info! macro imports and the use of unstable let_chains syntax, as well as an opportunity to optimize the removal of git configuration sections.

Comment thread src/cli/install.rs
@@ -1,15 +1,48 @@
use crate::{Result, config::Config, env, git_util};
use eyre::bail;
use log::warn;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The info! macro is used later in this file (line 269) but is not imported. This will cause a compilation error unless the log macros are globally available via a crate-level #[macro_use]. Given that warn is explicitly imported, info should be as well for consistency and correctness.

Suggested change
use log::warn;
use log::{info, warn};

Comment thread src/cli/install.rs
Comment on lines +115 to +122
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This code uses the let_chains feature (if let ... && ...), which is currently unstable in Rust (see tracking issue #53667). Unless this project specifically requires a nightly toolchain and has the feature enabled, this will cause a compilation error on stable Rust. It is safer to use nested if statements.

Suggested change
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 let Ok(output) = Command::new("git")
.args(["config", "--global", "--get", key.as_str()])
.output()
{
if output.status.success() && !output.stdout.is_empty() {
overlapping.push(event);
}
}

Comment thread src/cli/uninstall.rs
@@ -1,33 +1,25 @@
use crate::{Result, git_util};
use crate::{Result, cli::install};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The info! macro is used in this file (lines 15 and 22) but is not imported. This will lead to a compilation error.

Suggested change
use crate::{Result, cli::install};
use crate::{Result, cli::install};
use log::info;

Comment thread src/cli/install.rs
Comment on lines +202 to +244
pub(crate) fn remove_config_entries(scope: &str) -> Result<usize> {
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> = 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)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of remove_config_entries is inefficient as it spawns a new git config --unset-all process for every single key found. For a standard installation with 9 hooks, this results in 18 process spawns. Additionally, unsetting keys individually leaves empty section headers (e.g., [hook "hk-pre-commit"]) in the git config file.

A more efficient and cleaner approach is to identify the unique section names and use git config --remove-section, which removes all keys within the section and the section header itself in a single call per hook.

pub(crate) fn remove_config_entries(scope: &str) -> Result<usize> {
    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 stdout = String::from_utf8_lossy(&output.stdout);
    let mut sections = std::collections::BTreeSet::new();
    for key in stdout.lines().filter(|s| !s.trim().is_empty()) {
        if let Some(section) = key.rsplitn(2, '.').nth(1) {
            sections.insert(section.to_string());
        }
    }
    let removed = sections.len();
    for section in sections {
        run_git(&["config", scope, "--remove-section", &section])?;
    }
    Ok(removed)
}

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 23, 2026

Greptile Summary

This PR promotes hk install --global as the recommended setup path, adds Git 2.54+ config-based hook installation at both global and local scopes, and restructures the docs accordingly. The logic in install.rs is well-structured — idempotent cleanup before re-install, correct exit-code handling in remove_config_entries, and a warn_if_global_overlap guard for double-fire detection.

  • One open concern: run_git (used for all git config writes) calls .status() and discards stderr, so failures like permission-denied or a locked config file surface only as \"git config … failed\" with no reason. remove_config_entries already uses .output() and includes stderr in the error — run_git should follow the same pattern.

Confidence Score: 4/5

Safe to merge after addressing the run_git stderr omission; all other changes are docs/copy and well-guarded logic.

One P1 remains in run_git: failures from git config writes (the primary write path for the new feature) surface no diagnostic information, making debugging hard for users. All other findings are P2 or lower.

src/cli/install.rs — run_git stderr handling

Important Files Changed

Filename Overview
src/cli/install.rs Core logic for config-based hook install/uninstall; run_git uses .status() so stderr is discarded on failure, and warn_if_global_overlap is not called in the legacy branch — both are open threads flagged by prior review.
docs/getting_started.md Getting started docs restructured to lead with hk install --global; adds manual .gitconfig example, per-repo alternative, and hkrc distinction. Content is accurate and well-organized.
docs/cli/install.md CLI reference updated to promote --global as recommended path; description and flag help text updated consistently.
docs/cli/commands.json Generated/serialized CLI help strings updated in sync with install.md and hk.usage.kdl; no issues.
hk.usage.kdl KDL usage spec updated to match new doc strings; changes are consistent with other files.
docs/package.json Added version and private:true fields — standard housekeeping to prevent accidental npm publish.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[hk install] --> B{--global?}
    B -- yes --> C{Git >= 2.54?}
    C -- no --> D[bail! requires Git 2.54+]
    C -- yes --> E[remove_config_entries --global]
    E --> F[write_config_hook --global for each DEFAULT_GLOBAL_EVENT]
    F --> G[Print success message]
    B -- no --> H[use_config_hooks = !legacy && git>=2.54]
    H --> I[Config::get — load hk.pkl]
    I --> J[remove_local_shims + remove_local_config_entries]
    J --> K{events empty?}
    K -- yes --> L[warn and return]
    K -- no --> M{use_config_hooks?}
    M -- yes --> N[install_local_config]
    N --> O[warn_if_global_overlap]
    M -- no --> P[install_local_shims]
    N --> Q[write_config_hook --local per event]
    P --> R[write shim file per event in .git/hooks/]
Loading

Comments Outside Diff (1)

  1. src/cli/install.rs, line 276-281 (link)

    P1 run_git swallows stderr on failure

    Command::status() does not capture stderr, so when a git config write fails (e.g. permission denied, locked config file) the error message only contains the subcommand args. remove_config_entries already uses Command::output() and surfaces stderr — the same pattern should be applied here so failures are actionable.

    fn run_git(args: &[&str]) -> Result<()> {
        let output = Command::new("git").args(args).output()?;
        if !output.status.success() {
            bail!(
                "git {} failed (exit {}): {}",
                args.join(" "),
                output.status.code().unwrap_or(-1),
                String::from_utf8_lossy(&output.stderr).trim()
            );
        }
        Ok(())
    }

    Fix in Claude Code

Fix All in Claude Code

Reviews (2): Last reviewed commit: "docs(install): recommend global hooks as..." | Re-trigger Greptile

Comment thread src/cli/install.rs
Comment thread src/cli/install.rs
Comment thread src/cli/install.rs
Lead with `hk install --global` in getting_started.md and the install CLI
reference. The global install is a silent no-op in repos without an
`hk.pkl`, so it's safe to enable everywhere and saves users from re-running
`hk install` in every clone. Per-repo install is now framed as an
alternative for pre-Git-2.54 or selective-repo use.

Also renames the pre-existing "Global Configuration" section to
"Global `hkrc` Configuration" to disambiguate from global hooks.
Regenerates docs/cli from updated clap docstrings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jdx jdx force-pushed the feat/config-based-hooks branch from 5305307 to c47b15a Compare April 23, 2026 17:46
@jdx jdx changed the title feat(install): Git 2.54 config-based hooks with --global support docs(install): recommend global hooks as primary setup path Apr 23, 2026
@jdx jdx enabled auto-merge (squash) April 23, 2026 17:56
@jdx jdx merged commit 1dce241 into main Apr 23, 2026
21 checks passed
@jdx jdx deleted the feat/config-based-hooks branch April 23, 2026 17:57
@jdx jdx mentioned this pull request Apr 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant