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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions cmd/entire/cli/integration_test/hook_overwrite_husky_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//go:build integration

package integration

import (
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// huskyHScript is the core of the husky v9 `_/h` dispatcher (HUSKY=0 opt-out,
// ~/.config/husky/init.sh sourcing, and node_modules/.bin PATH prepend trimmed).
// Git runs `.husky/_/<hook>`, which sources this script, which then executes
// `.husky/<hook>` if present via `sh -e`.
const huskyHScript = `#!/usr/bin/env sh
n=$(basename "$0")
s=$(dirname "$(dirname "$0")")/$n
[ ! -f "$s" ] && exit 0
sh -e "$s" "$@"
`

func seedHuskyLayout(t *testing.T, env *TestEnv) (ownedDir, userDir string) {
t.Helper()
repoDir := env.RepoDir
userDir = filepath.Join(repoDir, ".husky")
ownedDir = filepath.Join(userDir, "_")
require.NoError(t, os.MkdirAll(ownedDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(ownedDir, "h"), []byte(huskyHScript), 0o755))

cmd := exec.CommandContext(t.Context(), "git", "config", "core.hooksPath", ".husky/_")
cmd.Dir = repoDir
require.NoError(t, cmd.Run())

// Intentional hooksPath mutation for the husky layout under test.
configData, err := os.ReadFile(env.gitConfigPath())
require.NoError(t, err)
env.AcceptGitConfigChanges(string(configData))

writeHuskyStubs(t, ownedDir)
return ownedDir, userDir
}

func writeHuskyStubs(t *testing.T, ownedDir string) {
t.Helper()
stub := "#!/usr/bin/env sh\n. \"$(dirname \"$0\")/h\"\n"
for _, hookName := range strategy.ManagedGitHookNames() {
require.NoError(t, os.WriteFile(filepath.Join(ownedDir, hookName), []byte(stub), 0o755))
}
}

// TestHusky_MidTurnPrepare_CommitsKeepTrailer covers the real #784 failure mode:
// husky/npm prepare regenerates `.husky/_` mid-turn. Entire must install into
// `.husky/` user hooks so mid-turn commits still get Entire-Checkpoint trailers
// without waiting for the next user-prompt-submit.
func TestHusky_MidTurnPrepare_CommitsKeepTrailer(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t)
ownedDir, userDir := seedHuskyLayout(t, env)

// Point hooks at the shared test binary via absolute path (GUI/agent PATH
// is irrelevant; this matches `entire enable --absolute-git-hook-path`).
env.WriteSettings(map[string]any{
"enabled": true,
"absolute_git_hook_path": true,
"strategy_options": map[string]any{"filtered_fetches": true, "commit_linking": "always"},
})

sess := env.NewSession()
err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(
sess.ID, "Create files A and B", sess.TranscriptPath)
require.NoError(t, err)

require.True(t, strategy.IsGitHookInstalledInDir(t.Context(), env.RepoDir),
"EnsureSetup should install Entire into .husky/ user hooks")

for _, hookName := range strategy.ManagedGitHookNames() {
userHook := filepath.Join(userDir, hookName)
data, readErr := os.ReadFile(userHook)
require.NoError(t, readErr, "Entire hook %s should live in .husky/", hookName)
require.Contains(t, string(data), "Entire CLI hooks")

ownedHook, readErr := os.ReadFile(filepath.Join(ownedDir, hookName))
require.NoError(t, readErr)
assert.NotContains(t, string(ownedHook), "Entire CLI hooks",
"husky-owned stub %s must remain untouched", hookName)
}

env.WriteFile("fileA.go", "package main\n\nfunc A() {}\n")
env.WriteFile("fileB.go", "package main\n\nfunc B() {}\n")
sess.CreateTranscript("Create files A and B", []FileChange{
{Path: "fileA.go", Content: "package main\n\nfunc A() {}\n"},
{Path: "fileB.go", Content: "package main\n\nfunc B() {}\n"},
})

// Commit 1 via real git so husky stubs → .husky/ Entire hooks run.
env.GitAdd("fileA.go")
commitViaGit(t, env, "Add file A")
cpID1 := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash())
require.NotEmpty(t, cpID1, "first commit should have checkpoint trailer through husky stubs")

// Mid-turn: husky prepare regenerates .husky/_ (issue #784).
writeHuskyStubs(t, ownedDir)
require.True(t, strategy.IsGitHookInstalledInDir(t.Context(), env.RepoDir),
"Entire user hooks in .husky/ must survive husky prepare")

// Commit 2 same turn — must still get a trailer (no next-prompt recovery).
env.GitAdd("fileB.go")
commitViaGit(t, env, "Add file B")
cpID2 := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash())
require.NotEmpty(t, cpID2,
"second mid-turn commit after husky prepare must still have Entire-Checkpoint trailer")
assert.NotEqual(t, cpID1, cpID2, "each commit should get its own checkpoint id")
}

func commitViaGit(t *testing.T, env *TestEnv, message string) {
t.Helper()
cmd := exec.CommandContext(t.Context(), "git", "-c", "core.editor=true", "commit", "-m", message)
cmd.Dir = env.RepoDir
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test User",
"GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Test User",
"GIT_COMMITTER_EMAIL=test@example.com",
// Agent-style: no interactive TTY for prepare-commit-msg fast path.
"GIT_TERMINAL_PROMPT=0",
)
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git commit %q failed: %s", message, out)
}

// TestHusky_PreExistingUserHook_BackedUpAndChained covers tracked `.husky/`
// territory: a pre-existing mode-0644 user hook is renamed to `.pre-entire`,
// Entire's wrapper is installed, and the backup still runs (via sh -e) on commit.
// RemoveGitHook restores the original script.
func TestHusky_PreExistingUserHook_BackedUpAndChained(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t)
_, userDir := seedHuskyLayout(t, env)

sentinel := filepath.Join(env.RepoDir, "user-hook-ran")
custom := "#!/bin/sh\ntouch \"" + sentinel + "\"\n"
userHook := filepath.Join(userDir, "prepare-commit-msg")
require.NoError(t, os.WriteFile(userHook, []byte(custom), 0o644))

env.WriteSettings(map[string]any{
"enabled": true,
"absolute_git_hook_path": true,
"strategy_options": map[string]any{"filtered_fetches": true, "commit_linking": "always"},
})

sess := env.NewSession()
err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(
sess.ID, "Touch preexisting husky hook", sess.TranscriptPath)
require.NoError(t, err)

backupPath := userHook + ".pre-entire"
backupData, err := os.ReadFile(backupPath)
require.NoError(t, err, "preexisting user hook should be backed up to .pre-entire")
require.Equal(t, custom, string(backupData))

installed, err := os.ReadFile(userHook)
require.NoError(t, err)
require.Contains(t, string(installed), "Entire CLI hooks")
require.Contains(t, string(installed), "sh -e")

env.WriteFile("chain.go", "package main\n")
sess.CreateTranscript("Touch preexisting husky hook", []FileChange{
{Path: "chain.go", Content: "package main\n"},
})
env.GitAdd("chain.go")
commitViaGit(t, env, "Exercise chained husky user hook")

cpID := env.GetCheckpointIDFromCommitMessage(env.GetHeadHash())
require.NotEmpty(t, cpID, "Entire prepare-commit-msg should still add trailer")
_, err = os.Stat(sentinel)
require.NoError(t, err, "mode-0644 preexisting user hook must run via chain")

// Byte-for-byte restore on RemoveGitHook is covered by
// TestInstallGitHook_ChainsPreExistingHuskyUserHook (strategy package;
// GetHooksDir is CWD-based and cannot be used from parallel TestEnv).
}
15 changes: 9 additions & 6 deletions cmd/entire/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,11 @@ func updateGlobalSettings(ctx context.Context, cmd *cobra.Command, w io.Writer,
}

if cmd.Flags().Changed(flagForce) || cmd.Flags().Changed(flagAbsoluteGitHookPath) || cmd.Flags().Changed(flagLocalDev) {
if _, err := strategy.InstallGitHook(ctx, true, s.LocalDev, s.AbsoluteGitHookPath); err != nil {
_, huskySafe, err := strategy.InstallGitHook(ctx, true, s.LocalDev, s.AbsoluteGitHookPath)
if err != nil {
return fmt.Errorf("failed to reinstall git hook: %w", err)
}
strategy.CheckAndWarnHookManagers(ctx, w, s.LocalDev, s.AbsoluteGitHookPath)
strategy.CheckAndWarnHookManagers(ctx, w, s.LocalDev, s.AbsoluteGitHookPath, huskySafe)
fmt.Fprintln(w, " ✓ Reinstalled git hook")
}

Expand Down Expand Up @@ -1274,10 +1275,11 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent

// Use settings values (merged from existing config + flags) for hook installation
// This ensures re-running `entire enable` without flags preserves existing settings
if _, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath); err != nil {
_, huskySafe, err := strategy.InstallGitHook(ctx, true, settings.LocalDev, settings.AbsoluteGitHookPath)
if err != nil {
return fmt.Errorf("failed to install git hooks: %w", err)
}
strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath)
strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath, huskySafe)
fmt.Fprintln(w, " ✓ Installed hooks")

configDisplay := configDisplayProject
Expand Down Expand Up @@ -1949,10 +1951,11 @@ func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Ag
hookLocalDev := mergedSettings.LocalDev || opts.LocalDev
hookAbsoluteGitHookPath := mergedSettings.AbsoluteGitHookPath || opts.AbsoluteGitHookPath

if _, err := strategy.InstallGitHook(ctx, true, hookLocalDev, hookAbsoluteGitHookPath); err != nil {
_, huskySafe, err := strategy.InstallGitHook(ctx, true, hookLocalDev, hookAbsoluteGitHookPath)
if err != nil {
return fmt.Errorf("failed to install git hooks: %w", err)
}
strategy.CheckAndWarnHookManagers(ctx, w, hookLocalDev, hookAbsoluteGitHookPath)
strategy.CheckAndWarnHookManagers(ctx, w, hookLocalDev, hookAbsoluteGitHookPath, huskySafe)

if installedHooks == 0 {
msg := fmt.Sprintf("Hooks for %s already installed", ag.Description())
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1249,7 +1249,7 @@ func TestRunUninstall_Force_RemovesGitHooks(t *testing.T) {
writeSettings(t, testSettingsEnabled)

// Install git hooks
if _, err := strategy.InstallGitHook(context.Background(), true, false, false); err != nil {
if _, _, err := strategy.InstallGitHook(context.Background(), true, false, false); err != nil {
t.Fatalf("InstallGitHook() error = %v", err)
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func EnsureSetup(ctx context.Context) error {
// Install generic hooks (they delegate to strategy at runtime)
if !IsGitHookInstalled(ctx) {
localDev, absoluteHookPath := hookSettingsFromConfig(ctx)
if _, err := InstallGitHook(ctx, true, localDev, absoluteHookPath); err != nil {
if _, _, err := InstallGitHook(ctx, true, localDev, absoluteHookPath); err != nil {
return fmt.Errorf("failed to install git hooks: %w", err)
}
}
Expand Down
21 changes: 18 additions & 3 deletions cmd/entire/cli/strategy/hook_managers.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ func detectHookManagers(repoRoot string) []hookManager {

// hookManagerWarning builds a warning string for detected hook managers.
// cmdPrefix is the CLI command prefix (e.g., "entire" or "./scripts/entire-dev").
func hookManagerWarning(managers []hookManager, cmdPrefix string) string {
// huskySafe is true when the current core.hooksPath is a husky `_` dir with the
// dispatcher present (Entire will install into the parent user-hook directory).
func hookManagerWarning(managers []hookManager, cmdPrefix string, huskySafe bool) string {
if len(managers) == 0 {
return ""
}
Expand All @@ -75,6 +77,17 @@ func hookManagerWarning(managers []hookManager, cmdPrefix string) string {

for _, m := range managers {
if m.OverwritesHooks {
// Husky v9 safe path: dispatcher present under core.hooksPath=`_/`.
if m.Name == "Husky" && huskySafe {
fmt.Fprintf(&b, "Note: %s detected (%s)\n", m.Name, m.ConfigPath)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, " Entire installs git hooks into the husky user-hook directory\n")
fmt.Fprintf(&b, " (parent of core.hooksPath), which survives husky/npm prepare.\n")
fmt.Fprintf(&b, " Regenerable `_` stubs are never replaced with Entire hooks.\n")
fmt.Fprintf(&b, "\n")
continue
}

fmt.Fprintf(&b, "Warning: %s detected (%s)\n", m.Name, m.ConfigPath)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, " %s may overwrite hooks installed by Entire on npm install.\n", m.Name)
Expand Down Expand Up @@ -122,7 +135,9 @@ func extractCommandLine(hookContent string) string {
// to w if any are found.
// localDev controls whether the warning references "go run" or the "entire" binary.
// absolutePath embeds the full binary path for GUI git clients.
func CheckAndWarnHookManagers(ctx context.Context, w io.Writer, localDev, absolutePath bool) {
// huskySafeInstall must match InstallGitHook's second return value so the Husky
// advisory cannot disagree with where hooks were just installed.
func CheckAndWarnHookManagers(ctx context.Context, w io.Writer, localDev, absolutePath, huskySafeInstall bool) {
repoRoot, err := paths.WorktreeRoot(ctx)
if err != nil {
return
Expand All @@ -138,7 +153,7 @@ func CheckAndWarnHookManagers(ctx context.Context, w io.Writer, localDev, absolu
// Best-effort: hook manager warnings are advisory, skip on resolution failure
return
}
warning := hookManagerWarning(managers, cmdPrefix)
warning := hookManagerWarning(managers, cmdPrefix, huskySafeInstall)
if warning != "" {
fmt.Fprintln(w)
fmt.Fprint(w, warning)
Expand Down
Loading
Loading