Skip to content

Add an external Git hooks backend for Rush, Husky, and other hook managers #1250

@SnowingFox

Description

@SnowingFox

Problem or use case

Entire CLI currently decides whether Git hooks are installed by resolving Git's active hooks directory with git rev-parse --git-path hooks and checking the five managed hook files for the Entire CLI hooks marker:

  • prepare-commit-msg
  • commit-msg
  • post-commit
  • post-rewrite
  • pre-push

That works when Entire owns the active hook files directly. It misreports health in repositories where another tool owns the active hook wrappers and dispatches to source hooks stored elsewhere.

The result is a false negative or unsafe repair path:

  • entire status / entire doctor can report Entire Git hooks as missing even when the manager's source hook already includes Entire.
  • entire enable --force can replace active wrapper files owned by Rush, Husky, or another manager.
  • Users cannot express that the source of truth lives outside Git's active hooks directory.

Rush: Rush generates active wrappers in .git/hooks/*, while user-owned hooks live in common/git-hooks/*. The generated wrappers should not be treated as the only installation truth.

Husky (v9, standard husky init): Running prepare / husky sets core.hooksPath to .husky/_. Git executes thin stubs in .husky/_/* (each sources .husky/_/h); Husky then runs the user hook at .husky/<hook-name> if it exists. The _/ tree is generated and usually gitignored—users edit .husky/pre-commit, not .husky/_/pre-commit.

This differs from Rush in two important ways:

  • Entire must never write into .husky/_/*. Replacing those stubs breaks Husky's dispatch chain; following core.hooksPath alone is especially dangerous here.
  • Husky repos often start with only .husky/pre-commit. Entire's five lifecycle hooks may be missing, partial, or merged with existing commands (lint, tests, etc.)—not five standalone wrapper files like Rush's common/git-hooks/*.

Legacy Husky v8 installs may still use core.hooksPath=.husky without the _/ split; external mode should rely on configured external_dir, not hard-coded Husky layout detection.

Examples: input → execution → output

Fictional repo layouts below. Wrapper shapes follow common Rush / Husky v9 behavior; names and scripts are made up.

Rush

Input (on disk after rush update, Entire appended to source hooks by hand or entire enable):

my-monorepo/
├── .git/hooks/prepare-commit-msg          # Rush-generated wrapper (no Entire marker)
├── .git/hooks/commit-msg                  # Rush-generated wrapper (no Entire marker)
├── .git/hooks/post-commit                 # …
├── .git/hooks/post-rewrite                # …
├── .git/hooks/pre-push                    # …
└── common/git-hooks/
    ├── prepare-commit-msg                 # source of truth (Entire + optional team hooks)
    ├── commit-msg
    ├── post-commit
    ├── post-rewrite
    └── pre-push

.git/hooks/prepare-commit-msg (wrapper — Rush owns this file):

#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_IMPLEMENTATION_PATH="$SCRIPT_DIR/../../common/git-hooks/prepare-commit-msg"

if [[ -f "$SCRIPT_IMPLEMENTATION_PATH" ]]; then
  "$SCRIPT_IMPLEMENTATION_PATH" "$@"
else
  echo "Git hook missing in repo; run rush install or rush update." >&2
  exit 1
fi

common/git-hooks/prepare-commit-msg (source — user / Entire own this file):

#!/bin/sh
# Entire CLI hooks
if command -v entire >/dev/null 2>&1; then
  entire hooks git prepare-commit-msg "$1" "$2" 2>/dev/null || true
fi

common/git-hooks/commit-msg (source may chain multiple tools):

#!/bin/sh
set -e
# Entire CLI hooks
if command -v entire >/dev/null 2>&1; then
  entire hooks git commit-msg "$1" || true
fi
node scripts/validate-commit-msg.js --edit "$1"

Execution (what actually runs on git commit):

git commit
  → .git/hooks/prepare-commit-msg          # wrapper (no Entire marker here)
      → common/git-hooks/prepare-commit-msg
          → entire hooks git prepare-commit-msg …
  → .git/hooks/commit-msg
      → common/git-hooks/commit-msg
          → entire hooks git commit-msg …
          → node scripts/validate-commit-msg.js …

Output (today vs desired):

# Today — direct backend follows core.hooksPath / .git/hooks only
$ entire doctor
Entire Git hooks: missing (0/5 managed hooks marked in .git/hooks)

# Desired — external backend with external_dir: "common/git-hooks"
$ entire doctor
Git hooks backend: external (common/git-hooks)
Entire Git hooks: installed (5/5 source hooks include Entire)
Note: active wrappers live in .git/hooks; refresh with rush install or rush update if source hooks changed

Husky (v9)

Input (on disk after pnpm prepare / husky init, before Entire):

my-app/
├── .git/config                            # core.hooksPath = .husky/_
├── .husky/
│   ├── pre-commit                         # user hook (e.g. lint)
│   └── _/
│       ├── h                              # Husky dispatcher (generated)
│       ├── pre-commit                     # stub → sources h → runs ../pre-commit
│       ├── prepare-commit-msg             # stub (no ../prepare-commit-msg yet)
│       └── …

.husky/pre-commit:

npm run lint-staged

.husky/_/pre-commit:

#!/usr/bin/env sh
. "$(dirname "$0")/h"

.husky/_/h (abbreviated):

n=$(basename "$0")
s=$(dirname "$(dirname "$0")")/$n   # e.g. .husky/pre-commit
[ ! -f "$s" ] && exit 0
sh -e "$s" "$@"

Input (after entire enable with external_dir: ".husky"):

.husky/prepare-commit-msg (new or updated — Entire appended, existing lines preserved):

#!/bin/sh
# Entire CLI hooks
if command -v entire >/dev/null 2>&1; then
  entire hooks git prepare-commit-msg "$1" "$2" 2>/dev/null || true
fi

.husky/_/prepare-commit-msg (unchanged stub):

#!/usr/bin/env sh
. "$(dirname "$0")/h"

Execution (what actually runs on git commit):

git commit
  → .husky/_/pre-commit                    # Git hooksPath points here
      → .husky/_/h
          → .husky/pre-commit
              → npm run lint-staged

git commit   # after Entire hooks added for prepare-commit-msg / commit-msg / …
  → .husky/_/prepare-commit-msg
      → .husky/_/h
          → .husky/prepare-commit-msg
              → entire hooks git prepare-commit-msg …

Output (today vs desired):

# Today — direct backend installs into core.hooksPath (.husky/_)
$ entire enable
Installed 5 hooks into .husky/_            # breaks Husky stubs that source h

$ entire doctor
Entire Git hooks: installed (5/5 in .husky/_)   # misleading; Husky dispatch may be broken

# Desired — external backend with external_dir: ".husky"
$ entire enable
Appended Entire to .husky/prepare-commit-msg, .husky/commit-msg, …
Skipped .husky/_/* (Husky-managed stubs)

$ entire doctor
Git hooks backend: external (.husky)
Entire Git hooks: partial (3/5 hooks include Entire)
Note: Git executes .husky/_/*; run pnpm prepare after editing .husky/*

Desired behavior

When an external hook manager owns the active wrappers, Entire should read and write source hooks in a configured directory, report accurate health, and never destroy manager-owned wrappers—even with --force.

Rush example:

# .entire/settings.json
{
  "git_hooks": {
    "backend": "external",
    "external_dir": "common/git-hooks"
  }
}

$ entire doctor
Git hooks backend: external (common/git-hooks)
Entire Git hooks: installed (5/5 source hooks marked)

$ entire enable
Writing Entire hooks to common/git-hooks/ (external backend)
Skipped .git/hooks/* — managed by Rush wrappers

$ entire enable --force
Updating common/git-hooks/* only
Did not modify .git/hooks/* or other manager-owned wrappers

Husky example:

# .entire/settings.json
{
  "git_hooks": {
    "backend": "external",
    "external_dir": ".husky"
  }
}

$ entire doctor
Git hooks backend: external (.husky)
Entire Git hooks: partial (3/5 hooks include Entire)
Note: Husky runs hooks from .husky/_; refresh with `pnpm prepare` after editing .husky/*

$ entire enable
Appended Entire commands to .husky/prepare-commit-msg, .husky/commit-msg, ...
Created .husky/post-commit (new)
Skipped .husky/_/* — Husky-managed stubs

$ entire enable --force
Updated .husky/* only; did not modify .husky/_/*

After enabling in external mode, users refresh the external manager when needed—for example rush update / rush install (Rush) or pnpm prepare / husky (Husky, regenerates .husky/_/*). Doctor should say so when source hooks include Entire but active wrapper linkage cannot be proven.

Default behavior for repositories without git_hooks stays unchanged (direct backend, active hooks directory only).

Proposed solution

Add a top-level git_hooks setting:

{
  "git_hooks": {
    "backend": "external",
    "external_dir": "common/git-hooks"
  }
}

Default when absent or explicit:

{
  "git_hooks": {
    "backend": "direct"
  }
}

direct (default): Preserve existing behavior—resolve Git's active hooks directory and check/install Entire's five managed hooks there.

external: Resolve external_dir relative to the repository root unless absolute. Check each managed hook under external_dir for Entire integration (marker and/or known command lines). Do not treat .git/hooks, core.hooksPath, or manager-generated paths like .husky/_ as the only source of truth. Do not overwrite active wrappers owned by the external manager, including when entire enable --force is used.

Manager-specific install behavior under external:

  • Rush-style layouts (external_dir: "common/git-hooks"): write or update full hook scripts there; Rush wrappers in .git/hooks/* stay untouched.
  • Husky-style layouts (external_dir: ".husky"): create or update user hooks in .husky/* only—append/chain Entire commands into existing hook files when present; never replace .husky/_/* stubs.

Health output should distinguish:

  • Entire integration present in configured source hooks (installed / partial).
  • Source hooks updated but manager wrappers may need refresh (actionable hint, not a hard failure—for Husky, suggest pnpm prepare or equivalent).

Implementation notes / test plan:

  1. Settings schema — Accept direct and external (+ required external_dir); reject external without external_dir; default unchanged when git_hooks is absent.
  2. Direct parity — Existing install/health tests pass with no git_hooks setting.
  3. External health — Rush: .git/hooks/* wrappers + marked common/git-hooks/* → installed. Husky: core.hooksPath=.husky/_, Entire lines in .husky/* → installed/partial; stubs in .husky/_/* are ignored for marker checks. Missing Entire integration on any required hook → partial/missing.
  4. External install safetyentire enable / --force writes only under external_dir (.husky/*, not .husky/_/*); Rush .git/hooks/* and Husky .husky/_/* bytes unchanged.
  5. Doctor/status — Report backend + external directory; suggest manager refresh when linkage cannot be proven.

Risks:

  • Marker-only detection is compatible with existing hook files but can be spoofed; a future managed block with explicit begin/end markers would be more robust.
  • External health is intentionally weaker than direct—it proves configured source files include Entire, not that Git will execute them after the manager regenerates wrappers.
  • Entire should not auto-run rush update, pnpm prepare, etc.; wrapper generation stays manager-specific unless a future explicit integration is designed.

Alternatives or workarounds

  • Manual hooks only: Copy Entire hook snippets into the manager's source directory by hand (for Husky, append lines to .husky/<hook> and run pnpm prepare) and ignore entire doctor Git hook warnings. Fragile and easy to drift from Entire updates.
  • direct + overwrite wrappers: Run entire enable --force against .git/hooks or core.hooksPath. Breaks Rush/Husky wrapper chains and gets overwritten on the next manager refresh.
  • Disable Entire Git hooks: Use agent-only integration without lifecycle hooks; loses automatic checkpoint capture on commit/push.
  • Fork hook install logic per manager: Hard-code Rush vs Husky paths in Entire. Does not scale; a single external backend with configurable external_dir covers Rush, Husky, and custom layouts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions