From b6f90c706add9e4437811b6ba49d79754399bef8 Mon Sep 17 00:00:00 2001 From: snowingfox <1503401882@qq.com> Date: Tue, 26 May 2026 16:09:33 +0800 Subject: [PATCH] Add external git hooks backend (#1250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New git_hooks.backend = "external" setting redirects Entire's hook detection from .git/hooks/ to a user-managed external_dir (e.g. .husky, common/git-hooks). In external mode Entire never writes to the user's hook directory or to .git/hooks/ — it only checks for the marker "Entire CLI hooks" in scripts the user maintains. - settings: GitHooksSettings (backend + external_dir) with whole-block merge, repo-relative + no-".." validation, strict enum on backend - strategy: GetGitHookDetectionDir branches detection by backend; InstallGitHook / RemoveGitHook / CheckAndWarnHookManagers short-circuit in external mode via shared externalGitHooksDir helper - setup: verifyExternalGitHooksPrecondition gates every enable variant on external_dir existence and surfaces a 5-script instructional help message (FormatExternalDirMissingHelp, shared with doctor); status line uses PrintExternalHookStatusIfActive - doctor: new checkExternalGitHooks surfaces ✓/✗ for external_dir, contributes to overall doctor exit code, stays silent in direct mode - tests: settings (7) + hooks (6) + hook_managers (1) + doctor (3) + integration end-to-end Husky-shape (2) - docs: new docs/architecture/external-git-hooks.md covering rationale, user contract, all 5 hook script templates, migration paths Co-authored-by: Cursor Entire-Checkpoint: f184ee6ced2f --- CHANGELOG.md | 12 + cmd/entire/cli/doctor.go | 42 +++ cmd/entire/cli/doctor_test.go | 62 ++++ .../integration_test/external_hooks_test.go | 194 +++++++++++ cmd/entire/cli/settings/settings.go | 74 +++++ cmd/entire/cli/settings/settings_test.go | 118 +++++++ cmd/entire/cli/setup.go | 56 +++- cmd/entire/cli/strategy/hook_managers.go | 8 + cmd/entire/cli/strategy/hook_managers_test.go | 31 ++ cmd/entire/cli/strategy/hooks.go | 129 ++++++- cmd/entire/cli/strategy/hooks_test.go | 314 ++++++++++++++++++ docs/architecture/external-git-hooks.md | 205 ++++++++++++ 12 files changed, 1242 insertions(+), 3 deletions(-) create mode 100644 cmd/entire/cli/integration_test/external_hooks_test.go create mode 100644 docs/architecture/external-git-hooks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb61c800..4f4d26096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- External git hooks backend (`git_hooks.backend = "external"`): Entire + detects user-managed hook scripts via marker presence in a configured + `external_dir` (e.g. `.husky/`, `common/git-hooks/`) instead of writing + to `.git/hooks/`. Compatible with Husky, Rush, and similar managers. + Entire never writes to `external_dir` in this mode — users own the hook + scripts. See `docs/architecture/external-git-hooks.md` for the required + marker contract and dispatch invocations. Closes [#1250](https://github.com/entireio/cli/issues/1250). + ## [0.6.2] - 2026-05-18 ### Added diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index c871a3695..025ab8e73 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -151,6 +151,13 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { // Agent-specific: Codex hook trust state. checkCodexHookTrust(cmd) + // External git hooks backend health (opt-in via .entire/settings.json). + if extErr := checkExternalGitHooks(cmd); extErr != nil { + if finalErr == nil { + finalErr = NewSilentError(extErr) + } + } + // Stuck sessions // Load all session states states, err := strategy.ListSessionStates(ctx) @@ -681,6 +688,41 @@ func checkV2RefExistence(cmd *cobra.Command, repo *git.Repository) error { return nil } +// checkExternalGitHooks reports the health of the external git hooks +// backend when configured. Direct mode (the default) is silent — matching +// the codex check pattern where opt-in features are only surfaced when the +// repo has opted in. external mode with a missing external_dir surfaces +// the full instructional message and returns a non-nil error so doctor's +// final exit code reflects the issue. Doctor itself does NOT abort. +func checkExternalGitHooks(cmd *cobra.Command) error { + ctx := cmd.Context() + s, err := settings.Load(ctx) + if err != nil { + // Settings unloadable in a doctor context = either not in a repo or + // a malformed file; the relevant errors surface from the upstream + // metadata checks. Don't double-report here. + return nil //nolint:nilerr // intentional: skip the check, do not propagate + } + if !s.IsExternalGitHooks() { + return nil + } + + w := cmd.OutOrStdout() + extDir := s.ExternalHookDir() + root, rErr := paths.WorktreeRoot(ctx) + if rErr != nil { + fmt.Fprintf(w, "✗ External git hooks: cannot resolve repo root: %v\n", rErr) + return fmt.Errorf("external git hooks: %w", rErr) + } + absDir := filepath.Clean(filepath.Join(root, extDir)) + if _, statErr := os.Stat(absDir); os.IsNotExist(statErr) { + fmt.Fprintf(w, "✗ External git hooks: external_dir %q not found\n\n%s", extDir, strategy.FormatExternalDirMissingHelp(extDir)) + return fmt.Errorf("external_dir %q not found", extDir) + } + fmt.Fprintf(w, "✓ External git hooks: external_dir %q exists\n", extDir) + return nil +} + // checkCodexHookTrust warns about two kinds of drift in the Codex hook // setup: // diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index 2a25812db..cbbe4e108 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -1017,3 +1017,65 @@ trusted_hash = "sha256:ccc" require.Contains(t, out, "entire enable") require.NotContains(t, out, "Codex hook trust: REVIEW NEEDED") } + +// TestCheckExternalGitHooks_SilentInDirectMode — direct mode (no git_hooks +// block) should produce no doctor output, matching the codex check pattern: +// checks only appear when they apply. Adding a "(not applicable)" line for +// the default mode would be noise for the 95% case. +func TestCheckExternalGitHooks_SilentInDirectMode(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + cmd, stdout, _ := newTestCmd(t) + require.NoError(t, checkExternalGitHooks(cmd)) + require.Empty(t, stdout.String(), "direct mode should produce no output") +} + +// TestCheckExternalGitHooks_OKWhenDirExists — happy path. external_dir +// exists, doctor reports it with the canonical ✓ marker. +func TestCheckExternalGitHooks_OKWhenDirExists(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + entireDir := filepath.Join(dir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + )) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".husky"), 0o755)) + + cmd, stdout, _ := newTestCmd(t) + require.NoError(t, checkExternalGitHooks(cmd)) + out := stdout.String() + require.Contains(t, out, "✓ External git hooks") + require.Contains(t, out, ".husky") +} + +// TestCheckExternalGitHooks_FailsWithFullHelpWhenDirMissing — failure path. +// external_dir missing → ✗ marker + the same instructional message used by +// `entire enable`. Doctor does NOT abort; the issue surfaces in the final +// exit code (verified separately). +func TestCheckExternalGitHooks_FailsWithFullHelpWhenDirMissing(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + entireDir := filepath.Join(dir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + )) + + cmd, stdout, _ := newTestCmd(t) + require.Error(t, checkExternalGitHooks(cmd), "missing external_dir should produce non-nil status") + out := stdout.String() + require.Contains(t, out, "✗ External git hooks") + require.Contains(t, out, ".husky") + // Same key phrases as InstallGitHook's error message — single source of truth. + for _, s := range []string{"Required setup", "# Entire CLI hooks", "prepare-commit-msg", "commit-msg", "post-commit", "post-rewrite", "pre-push"} { + require.Contains(t, out, s, "missing %q from doctor help text", s) + } +} diff --git a/cmd/entire/cli/integration_test/external_hooks_test.go b/cmd/entire/cli/integration_test/external_hooks_test.go new file mode 100644 index 000000000..b4b2a753d --- /dev/null +++ b/cmd/entire/cli/integration_test/external_hooks_test.go @@ -0,0 +1,194 @@ +//go:build integration + +package integration + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/jsonutil" +) + +// writeExternalHooksSettings overwrites .entire/settings.json with the +// external git hooks backend pointed at the given external_dir. +// Replaces (not merges) because InitEntire's writer used the wrong shape +// for our discriminated union — full overwrite is the cleanest path. +func writeExternalHooksSettings(t *testing.T, env *TestEnv, externalDir string) { + t.Helper() + settings := map[string]any{ + "enabled": true, + "local_dev": true, + "strategy_options": map[string]any{ + "filtered_fetches": true, + }, + "git_hooks": map[string]any{ + "backend": "external", + "external_dir": externalDir, + }, + } + data, err := jsonutil.MarshalIndentWithNewline(settings, "", " ") + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + settingsPath := filepath.Join(env.RepoDir, ".entire", "settings.json") + if err := os.WriteFile(settingsPath, data, 0o644); err != nil { + t.Fatalf("write settings: %v", err) + } +} + +// snapshotFiles returns a flat map of relpath→contents for every regular +// file under root. Used for byte-identical comparison after `entire enable` +// runs in external mode — the contract says nothing outside marker reads +// should change on disk. +func snapshotFiles(t *testing.T, root string) map[string]string { + t.Helper() + out := make(map[string]string) + err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + out[rel] = string(data) + return nil + }) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("snapshot %s: %v", root, err) + } + return out +} + +func mapsEqual(a, b map[string]string) (string, bool) { + for k, v := range a { + bv, ok := b[k] + if !ok { + return "missing key " + k, false + } + if bv != v { + return "content changed for " + k, false + } + } + for k := range b { + if _, ok := a[k]; !ok { + return "unexpected new key " + k, false + } + } + return "", true +} + +// TestEnable_ExternalBackend_HuskyShape_EndToEnd is the end-to-end tracer for +// the external git hooks backend. It exercises the full `entire enable` path +// in a Husky-shaped repository (.husky/_/* stubs + .husky/ user scripts) +// and asserts: +// 1. exit code 0 +// 2. stdout contains the variant-3 hint line +// 3. .git/hooks/ is byte-identical before and after (no install) +// 4. .husky/ is byte-identical before and after (no writes to user dir) +// +// This test is the safety net for the design contract: external mode must +// be detection-only. If any cycle's GREEN implementation accidentally writes +// to .git/hooks/ or .husky/, this test catches it. +func TestEnable_ExternalBackend_HuskyShape_EndToEnd(t *testing.T) { + t.Parallel() + env := NewRepoWithCommit(t) + + // Husky shape: stubs in .husky/_/, user scripts in .husky/. + // Each user script carries the Entire marker and a dispatch call so + // IsGitHookInstalled detects them. + huskyDir := filepath.Join(env.RepoDir, ".husky") + huskyStubsDir := filepath.Join(huskyDir, "_") + if err := os.MkdirAll(huskyStubsDir, 0o755); err != nil { + t.Fatal(err) + } + + managedHooks := []string{"prepare-commit-msg", "commit-msg", "post-commit", "post-rewrite", "pre-push"} + for _, h := range managedHooks { + // .husky/_/: Husky's stub (we don't write or touch these) + stubContent := "#!/bin/sh\n# managed by husky — DO NOT EDIT\n" + h + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(huskyStubsDir, h), []byte(stubContent), 0o755); err != nil { + t.Fatal(err) + } + // .husky/: user-owned script containing the Entire marker + userContent := "#!/bin/sh\n# Entire CLI hooks\nentire hooks git " + h + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(huskyDir, h), []byte(userContent), 0o755); err != nil { + t.Fatal(err) + } + } + + // Configure external backend pointed at .husky/ + writeExternalHooksSettings(t, env, ".husky") + + hooksDir := filepath.Join(env.RepoDir, ".git", "hooks") + gitHooksBefore := snapshotFiles(t, hooksDir) + huskyBefore := snapshotFiles(t, huskyDir) + + output, err := env.RunCLIWithError("enable") + if err != nil { + t.Fatalf("enable failed: %v\noutput:\n%s", err, output) + } + + // Variant-3 success hint + wantHint := "Git hooks: external (.husky)" + if !strings.Contains(output, wantHint) { + t.Errorf("expected output to contain %q\nfull output:\n%s", wantHint, output) + } + + // .git/hooks/ unchanged (entire never wrote here) + gitHooksAfter := snapshotFiles(t, hooksDir) + if msg, ok := mapsEqual(gitHooksBefore, gitHooksAfter); !ok { + t.Errorf(".git/hooks/ changed in external mode: %s\nbefore: %d files\nafter: %d files", + msg, len(gitHooksBefore), len(gitHooksAfter)) + } + + // .husky/ unchanged (user-owned directory; we promised not to touch it) + huskyAfter := snapshotFiles(t, huskyDir) + if msg, ok := mapsEqual(huskyBefore, huskyAfter); !ok { + t.Errorf(".husky/ changed in external mode: %s\nbefore: %d files\nafter: %d files", + msg, len(huskyBefore), len(huskyAfter)) + } +} + +// TestEnable_ExternalBackend_MissingDir_AbortsWithHelp pins down the failure +// path: external + dir absent → exit non-zero + full instructional message +// printed. We don't assert the entire 30+ line block, just the key markers +// that prove it came from FormatExternalDirMissingHelp. +func TestEnable_ExternalBackend_MissingDir_AbortsWithHelp(t *testing.T) { + t.Parallel() + env := NewRepoWithCommit(t) + + // Configure external backend pointing to a directory that does NOT exist + writeExternalHooksSettings(t, env, ".husky") + + output, err := env.RunCLIWithError("enable") + if err == nil { + t.Fatalf("enable should fail when external_dir is missing\noutput:\n%s", output) + } + + // Output must contain the instructional message key phrases + mustContain := []string{ + `.husky`, + "Required setup for external git hooks", + "# Entire CLI hooks", + "prepare-commit-msg", + "commit-msg", + "post-commit", + "post-rewrite", + "pre-push", + } + for _, s := range mustContain { + if !strings.Contains(output, s) { + t.Errorf("output missing %q\nfull output:\n%s", s, output) + } + } +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 36ffcaf6b..1528c503d 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log/slog" "os" @@ -130,11 +131,66 @@ type EntireSettings struct { // nil/true = sign (default), false = skip signing. SignCheckpointCommits *bool `json:"sign_checkpoint_commits,omitempty"` + // GitHooks configures which directory Entire uses for git hook detection + // and installation. When nil or backend="direct", Entire manages hooks in + // .git/hooks/ directly. When backend="external", Entire only detects + // marker presence in the user-owned external_dir and never writes hooks. + GitHooks *GitHooksSettings `json:"git_hooks,omitempty"` + // Deprecated: no longer used. Exists to tolerate old settings files // that still contain "strategy": "auto-commit" or similar. Strategy string `json:"strategy,omitempty"` } +// GitHooksSettings configures git hook backend behavior. +type GitHooksSettings struct { + // Backend selects the hook management mode: "direct" (default) or "external". + Backend string `json:"backend"` + + // ExternalDir is a repo-relative path to the user-owned hook directory + // (e.g. ".husky" for Husky v9, "common/git-hooks" for Rush). + // Required when Backend == "external". + ExternalDir string `json:"external_dir,omitempty"` +} + +// Validate returns an error if the git hooks configuration is invalid. +func (g *GitHooksSettings) Validate() error { + if g == nil { + return nil + } + switch g.Backend { + case "", "direct": + return nil + case "external": + if g.ExternalDir == "" { + return errors.New(`git_hooks.external_dir is required when backend = "external"`) + } + if filepath.IsAbs(g.ExternalDir) { + return fmt.Errorf("git_hooks.external_dir must be repo-relative (got %q)", g.ExternalDir) + } + if strings.Contains(g.ExternalDir, "..") { + return fmt.Errorf("git_hooks.external_dir must not contain %q (got %q)", "..", g.ExternalDir) + } + return nil + default: + return fmt.Errorf("git_hooks.backend must be %q or %q (got %q)", "direct", "external", g.Backend) + } +} + +// IsExternalGitHooks reports whether the external git hooks backend is active. +func (s *EntireSettings) IsExternalGitHooks() bool { + return s != nil && s.GitHooks != nil && s.GitHooks.Backend == "external" +} + +// ExternalHookDir returns the configured external hook directory, or empty +// string when the external backend is not active. +func (s *EntireSettings) ExternalHookDir() string { + if !s.IsExternalGitHooks() { + return "" + } + return s.GitHooks.ExternalDir +} + // ClonePreferences stores clone-local, uncommitted preferences that should be // shared by linked worktrees in the same git clone. // @@ -377,6 +433,9 @@ func loadMergedSettings(settingsFileAbs, preferencesFileAbs, localSettingsFileAb if err := settings.SummaryGeneration.Validate(); err != nil { return nil, fmt.Errorf("merged settings invalid: %w", err) } + if err := settings.GitHooks.Validate(); err != nil { + return nil, fmt.Errorf("merged settings invalid: %w", err) + } return settings, nil } @@ -527,6 +586,10 @@ func loadFromFile(filePath string) (*EntireSettings, error) { return nil, fmt.Errorf("invalid commit_linking value %q: must be %q or %q", settings.CommitLinking, CommitLinkingAlways, CommitLinkingPrompt) } + if err := settings.GitHooks.Validate(); err != nil { + return nil, fmt.Errorf("settings invalid: %w", err) + } + // SummaryGeneration is NOT validated here — individual files may // legitimately contain only a model (provider comes from another file). // Validation happens after merge in Load(). @@ -633,6 +696,17 @@ func mergeJSON(settings *EntireSettings, data []byte) error { settings.Review = review } + // git_hooks is a discriminated union (backend selects the shape), so it + // uses wholesale replace like review — field-level merge could produce + // illegal combinations such as {backend:"direct", external_dir:".husky"}. + if gitHooksRaw, ok := raw["git_hooks"]; ok { + var gh GitHooksSettings + if err := json.Unmarshal(gitHooksRaw, &gh); err != nil { + return fmt.Errorf("parsing git_hooks field: %w", err) + } + settings.GitHooks = &gh + } + // Merge redaction sub-fields if present (field-level, not wholesale replace). if redactionRaw, ok := raw["redaction"]; ok { if settings.Redaction == nil { diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index a8fe0339e..1fb2f0953 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -1271,3 +1271,121 @@ func TestReviewConfig_IsZero(t *testing.T) { }) } } + +func TestLoad_GitHooks_ExternalParsesCorrectly(t *testing.T) { + setupSettingsDir(t, + `{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`, + "", + ) + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !s.IsExternalGitHooks() { + t.Error("IsExternalGitHooks() = false, want true") + } + if got := s.ExternalHookDir(); got != ".husky" { + t.Errorf("ExternalHookDir() = %q, want %q", got, ".husky") + } +} + +func TestLoad_GitHooks_DirectIsDefault(t *testing.T) { + setupSettingsDir(t, `{"enabled": true}`, "") + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if s.IsExternalGitHooks() { + t.Error("IsExternalGitHooks() = true, want false for missing git_hooks") + } + if got := s.ExternalHookDir(); got != "" { + t.Errorf("ExternalHookDir() = %q, want empty", got) + } +} + +func TestLoad_GitHooks_ExplicitDirectBackend(t *testing.T) { + setupSettingsDir(t, + `{"enabled": true, "git_hooks": {"backend": "direct"}}`, + "", + ) + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if s.IsExternalGitHooks() { + t.Error("IsExternalGitHooks() = true, want false for backend=direct") + } +} + +func TestLoad_GitHooks_ExternalMissingExternalDir_ReturnsError(t *testing.T) { + setupSettingsDir(t, + `{"enabled": true, "git_hooks": {"backend": "external"}}`, + "", + ) + _, err := Load(context.Background()) + if err == nil { + t.Fatal("Load() should return error when backend=external but external_dir is missing") + } + if !strings.Contains(err.Error(), "external_dir") { + t.Errorf("error should mention external_dir, got: %v", err) + } +} + +func TestLoad_GitHooks_ExternalDir_RejectsPathTraversal(t *testing.T) { + setupSettingsDir(t, + `{"enabled": true, "git_hooks": {"backend": "external", "external_dir": "../escape"}}`, + "", + ) + _, err := Load(context.Background()) + if err == nil { + t.Fatal("Load() should reject external_dir containing '..'") + } + if !strings.Contains(err.Error(), "..") { + t.Errorf("error should mention '..', got: %v", err) + } +} + +func TestLoad_GitHooks_ExternalDir_RejectsAbsolutePath(t *testing.T) { + setupSettingsDir(t, + `{"enabled": true, "git_hooks": {"backend": "external", "external_dir": "/tmp/hooks"}}`, + "", + ) + _, err := Load(context.Background()) + if err == nil { + t.Fatal("Load() should reject absolute external_dir") + } + if !strings.Contains(err.Error(), "repo-relative") { + t.Errorf("error should mention 'repo-relative', got: %v", err) + } +} + +func TestLoad_GitHooks_InvalidBackend_ReturnsError(t *testing.T) { + setupSettingsDir(t, + `{"enabled": true, "git_hooks": {"backend": "externl"}}`, + "", + ) + _, err := Load(context.Background()) + if err == nil { + t.Fatal("Load() should return error for typo in backend value") + } + if !strings.Contains(err.Error(), "externl") { + t.Errorf("error should contain the invalid value, got: %v", err) + } +} + +func TestMergeJSON_GitHooks_LocalOverridesProjectWholeBlock(t *testing.T) { + setupSettingsDir(t, + `{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`, + `{"git_hooks": {"backend": "direct"}}`, + ) + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if s.IsExternalGitHooks() { + t.Error("IsExternalGitHooks() = true, want false after local override to direct") + } + if got := s.ExternalHookDir(); got != "" { + t.Errorf("ExternalHookDir() = %q, want empty after whole-block override", got) + } +} diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index c656cb1d6..7c965b3d6 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -303,7 +303,9 @@ func updateGlobalSettings(ctx context.Context, cmd *cobra.Command, w io.Writer, return fmt.Errorf("failed to reinstall git hook: %w", err) } strategy.CheckAndWarnHookManagers(ctx, w, s.LocalDev, s.AbsoluteGitHookPath) - fmt.Fprintln(w, " ✓ Reinstalled git hook") + if !strategy.PrintExternalHookStatusIfActive(ctx, w) { + fmt.Fprintln(w, " ✓ Reinstalled git hook") + } } fmt.Fprintf(w, "✓ Settings updated (%s)\n", configDisplay) @@ -829,6 +831,15 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, return NewSilentError(errors.New("missing agent name")) } + // External git hooks health gate: if user opted into external mode + // but external_dir is missing on disk, every enable variant (bare, + // --force, --agent) should refuse with the same instructional + // message. Centralized here so all downstream branches get the + // same protection rather than duplicating the check in each. + if extErr := verifyExternalGitHooksPrecondition(ctx, cmd.ErrOrStderr()); extErr != nil { + return extErr + } + if agentName != "" { ag, err := agent.Get(types.AgentName(agentName)) if err != nil { @@ -1030,7 +1041,9 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent return fmt.Errorf("failed to install git hooks: %w", err) } strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath) - fmt.Fprintln(w, " ✓ Installed hooks") + if !strategy.PrintExternalHookStatusIfActive(ctx, w) { + fmt.Fprintln(w, " ✓ Installed hooks") + } configDisplay := configDisplayProject if shouldUseLocal { @@ -1089,10 +1102,48 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent } // printEnabledStatus prints agents and a hint about `entire agent`. +// verifyExternalGitHooksPrecondition fails fast when the external git hooks +// backend is configured but external_dir does not exist on disk. The error +// carries the same instructional message used by `entire doctor`, so users +// see one canonical setup procedure regardless of which command exposed the +// issue first. Returns nil in direct mode and in healthy external mode. +// +// Called early in enable's RunE so every variant (bare, --force, --agent) +// is gated by the same check; duplicating it in each branch would invite +// drift. +func verifyExternalGitHooksPrecondition(ctx context.Context, errW io.Writer) error { + s, err := settings.Load(ctx) + if err != nil { + // Settings can't be loaded — could mean fresh repo (no .entire/ yet). + // The actual setup flow will surface a more specific error if needed; + // don't block enable here on a load failure that may be expected. + return nil //nolint:nilerr // intentional: skip the precondition, let the main flow report + } + if !s.IsExternalGitHooks() { + return nil + } + extDir := s.ExternalHookDir() + root, rErr := paths.WorktreeRoot(ctx) + if rErr != nil { + fmt.Fprintf(errW, "external git hooks: cannot resolve repo root: %v\n", rErr) + return NewSilentError(fmt.Errorf("external git hooks: %w", rErr)) + } + absDir := filepath.Clean(filepath.Join(root, extDir)) + if _, statErr := os.Stat(absDir); os.IsNotExist(statErr) { + fmt.Fprintf(errW, "external_dir %q not found in repo root\n\n%s", extDir, strategy.FormatExternalDirMissingHelp(extDir)) + return NewSilentError(fmt.Errorf("external_dir %q not found", extDir)) + } + return nil +} + func printEnabledStatus(ctx context.Context, w io.Writer) { if displayNames := InstalledAgentDisplayNames(ctx); len(displayNames) > 0 { fmt.Fprintf(w, "Agents: %s\n", strings.Join(displayNames, ", ")) } + // Surface external git hooks state on re-enable too, so users running + // `entire enable` repeatedly see the same one-line acknowledgement that + // Entire is wired up to their hook manager rather than to .git/hooks/. + strategy.PrintExternalHookStatusIfActive(ctx, w) fmt.Fprintln(w, "\nTo add more agents, run `entire agent add `.") } @@ -1526,6 +1577,7 @@ func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Ag return fmt.Errorf("failed to install git hooks: %w", err) } strategy.CheckAndWarnHookManagers(ctx, w, settings.LocalDev, settings.AbsoluteGitHookPath) + strategy.PrintExternalHookStatusIfActive(ctx, w) if installedHooks == 0 { msg := fmt.Sprintf("Hooks for %s already installed", ag.Description()) diff --git a/cmd/entire/cli/strategy/hook_managers.go b/cmd/entire/cli/strategy/hook_managers.go index d6e1d7095..500dde892 100644 --- a/cmd/entire/cli/strategy/hook_managers.go +++ b/cmd/entire/cli/strategy/hook_managers.go @@ -123,6 +123,14 @@ func extractCommandLine(hookContent string) string { // 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) { + // External backend = user explicitly opted into a hook manager (Husky / Rush / + // etc.) and configured Entire to coexist via marker detection. Warning that + // the manager exists would be noise, and the suggested "add these lines" + // fix is already the user's own setup path. + if _, ok := externalGitHooksDir(ctx); ok { + return + } + repoRoot, err := paths.WorktreeRoot(ctx) if err != nil { return diff --git a/cmd/entire/cli/strategy/hook_managers_test.go b/cmd/entire/cli/strategy/hook_managers_test.go index 846c9a409..7a8d975d8 100644 --- a/cmd/entire/cli/strategy/hook_managers_test.go +++ b/cmd/entire/cli/strategy/hook_managers_test.go @@ -493,3 +493,34 @@ func TestCheckAndWarnHookManagers_WithHusky(t *testing.T) { t.Errorf("expected warning output, got %q", output) } } + +func TestCheckAndWarnHookManagers_ExternalBackend_IsSilent(t *testing.T) { + // Needs t.Chdir (via initHooksTestRepo), cannot be parallel + tmpDir, _ := initHooksTestRepo(t) + + // Create .husky/_/ directory (Husky fingerprint that would normally trigger warning) + if err := os.MkdirAll(filepath.Join(tmpDir, ".husky", "_"), 0o755); err != nil { + t.Fatalf("failed to create .husky/_/: %v", err) + } + + // External settings: user explicitly opted into external mode, so the warning + // is noise (user already knows about Husky and chose to integrate with it). + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + CheckAndWarnHookManagers(context.Background(), &buf, false, false) + + if buf.Len() != 0 { + t.Errorf("expected no output in external mode, got %q", buf.String()) + } +} diff --git a/cmd/entire/cli/strategy/hooks.go b/cmd/entire/cli/strategy/hooks.go index 394a45b65..2688cafe4 100644 --- a/cmd/entire/cli/strategy/hooks.go +++ b/cmd/entire/cli/strategy/hooks.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" "strings" "sync" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/settings" ) @@ -131,9 +133,37 @@ func getHooksDirInPath(ctx context.Context, dir string) (string, error) { return filepath.Clean(hooksDir), nil } +// externalGitHooksDir reports whether the external git hooks backend is +// configured and, if so, returns the configured external_dir verbatim +// (still repo-relative). Returns ("", false) in direct mode and when +// settings cannot be loaded — callers fall through to direct-mode logic. +// Centralizing this load+check keeps every external-mode short-circuit +// site uniform. +func externalGitHooksDir(ctx context.Context) (string, bool) { + s, err := settings.Load(ctx) + if err != nil || !s.IsExternalGitHooks() { + return "", false + } + return s.ExternalHookDir(), true +} + +// GetGitHookDetectionDir returns the directory whose files prove Entire's +// Git hooks are installed. Direct mode returns the active git hooks dir; +// external mode returns repoRoot/external_dir. +func GetGitHookDetectionDir(ctx context.Context) (string, error) { + if extDir, ok := externalGitHooksDir(ctx); ok { + root, rErr := paths.WorktreeRoot(ctx) + if rErr != nil { + return "", fmt.Errorf("resolve worktree root for external git hooks: %w", rErr) + } + return filepath.Clean(filepath.Join(root, extDir)), nil + } + return GetHooksDir(ctx) +} + // IsGitHookInstalled checks if all generic Entire CLI hooks are installed. func IsGitHookInstalled(ctx context.Context) bool { - hooksDir, err := GetHooksDir(ctx) + hooksDir, err := GetGitHookDetectionDir(ctx) if err != nil { return false } @@ -263,6 +293,23 @@ func isWindowsAbsoluteHookCommand(cmdPrefix string) bool { // absolutePath embeds the full binary path in hooks for GUI git clients. // Returns the number of hooks that were installed (0 if all already up to date). func InstallGitHook(ctx context.Context, silent, localDev, absolutePath bool) (int, error) { + if extDir, ok := externalGitHooksDir(ctx); ok { + root, rErr := paths.WorktreeRoot(ctx) + if rErr != nil { + return 0, fmt.Errorf("external git hooks: cannot resolve repo root: %w", rErr) + } + absDir := filepath.Clean(filepath.Join(root, extDir)) + if _, statErr := os.Stat(absDir); os.IsNotExist(statErr) { + return 0, fmt.Errorf("external_dir %q not found in repo root\n\n%s", extDir, FormatExternalDirMissingHelp(extDir)) + } + // External mode = no install. The variant-3 status line is emitted by + // PrintExternalHookStatusIfActive at the setup.go call sites, so it + // honors the surrounding section formatting and isn't suppressed by + // the silent flag (which exists to silence the verbose direct-mode + // "✓ Installed git hooks (...)" line during non-interactive flows). + return 0, nil + } + hooksDir, err := GetHooksDir(ctx) if err != nil { return 0, err @@ -341,6 +388,12 @@ func writeHookFile(path, content string) (bool, error) { // If a .pre-entire backup exists, it is restored. // Returns the number of hooks removed. func RemoveGitHook(ctx context.Context) (int, error) { + // External backend = Entire never installed hooks (neither in .git/hooks/ + // nor in external_dir). Detection-only contract: removal must touch nothing. + if _, ok := externalGitHooksDir(ctx); ok { + return 0, nil + } + hooksDir, err := GetHooksDir(ctx) if err != nil { return 0, err @@ -461,3 +514,77 @@ func hookSettingsFromConfig(ctx context.Context) (localDev, absoluteHookPath boo } return s.LocalDev, s.AbsoluteGitHookPath } + +// PrintExternalHookStatusIfActive writes the variant-3 status line ("✓ Git +// hooks: external ()") to w when the external backend is configured, +// and returns true so the caller can skip its direct-mode status line. +// Returns false (and writes nothing) in direct mode, so callers can fall +// through to their existing print. +// +// Lives in the strategy package next to InstallGitHook so the wording stays +// adjacent to the implementation it summarizes — direct-mode call sites +// inside setup.go just gate their existing prints on this helper's return. +func PrintExternalHookStatusIfActive(ctx context.Context, w io.Writer) bool { + extDir, ok := externalGitHooksDir(ctx) + if !ok { + return false + } + fmt.Fprintf(w, " ✓ Git hooks: external (%s)\n", extDir) + return true +} + +// FormatExternalDirMissingHelp returns the instructional message shown when +// git_hooks.backend == "external" but external_dir does not exist on disk. +// +// Design philosophy: external git hooks are explicitly the user's +// responsibility. Entire never writes to external_dir; it only detects the +// marker "Entire CLI hooks" in user-owned scripts. This message is therefore +// *instructional* ("here is what you must create") rather than *directive* +// ("run this command to fix it") — the latter would suggest Entire could +// auto-resolve the situation, which is the inverse of the design intent. +// +// Both `entire enable` (aborts on missing dir) and `entire doctor` +// (continues, reports as health issue) use this same text so users see one +// canonical setup procedure regardless of which command surfaced the issue +// first. +func FormatExternalDirMissingHelp(extDir string) string { + var b strings.Builder + fmt.Fprintf(&b, "Required setup for external git hooks:\n\n") + fmt.Fprintf(&b, " External backend means Entire reads hook scripts from your repo, but\n") + fmt.Fprintf(&b, " does not write them. You are responsible for creating these %d scripts\n", len(gitHookNames)) + fmt.Fprintf(&b, " under external_dir (%s/).\n\n", extDir) + fmt.Fprintf(&b, " Each script must:\n") + fmt.Fprintf(&b, " 1. Be executable (chmod +x).\n") + fmt.Fprintf(&b, " 2. Contain the marker comment: # %s\n", entireHookMarker) + fmt.Fprintf(&b, " 3. Invoke Entire's dispatcher with the matching event name.\n\n") + for _, h := range gitHookNames { + fmt.Fprintf(&b, " %s/%s:\n", extDir, h) + fmt.Fprintf(&b, " #!/bin/sh\n") + fmt.Fprintf(&b, " # %s\n", entireHookMarker) + fmt.Fprintf(&b, " %s\n\n", externalDispatchInvocation(h)) + } + fmt.Fprintf(&b, " To switch back to direct mode (Entire writes .git/hooks itself),\n") + fmt.Fprintf(&b, " remove the \"git_hooks\" block from .entire/settings.json (the\n") + fmt.Fprintf(&b, " default is \"direct\").\n") + return b.String() +} + +// externalDispatchInvocation returns the recommended dispatch line for the +// given hook in external mode. The args mirror what direct-mode install +// scripts pass (see buildHookSpecs) so users get the same wiring. +func externalDispatchInvocation(hookName string) string { + switch hookName { + case "prepare-commit-msg": + return `entire hooks git prepare-commit-msg "$1" "$2" 2>/dev/null || true` + case "commit-msg": + return `entire hooks git commit-msg "$1" || true` + case "post-commit": + return `entire hooks git post-commit 2>/dev/null || true` + case "post-rewrite": + return `entire hooks git post-rewrite "$1" 2>/dev/null || true` + case "pre-push": + return `entire hooks git pre-push "$1" || true` + default: + return fmt.Sprintf(`entire hooks git %s "$@"`, hookName) + } +} diff --git a/cmd/entire/cli/strategy/hooks_test.go b/cmd/entire/cli/strategy/hooks_test.go index a2b803a95..9d6ddf217 100644 --- a/cmd/entire/cli/strategy/hooks_test.go +++ b/cmd/entire/cli/strategy/hooks_test.go @@ -1590,3 +1590,317 @@ func TestRemoveGitHook_PermissionDenied(t *testing.T) { t.Errorf("error should mention 'failed to remove hooks', got: %v", err) } } + +func TestInstallGitHook_ExternalBackend_MissingDir_ReturnsHelpError(t *testing.T) { + repoDir, hooksDir := initHooksTestRepo(t) + + // External settings pointing to a directory that does NOT exist on disk + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + hooksBefore := snapshotDir(t, hooksDir) + + ctx := context.Background() + installed, err := InstallGitHook(ctx, false, false, false) + if err == nil { + t.Fatal("InstallGitHook() should return error when external_dir does not exist") + } + if installed != 0 { + t.Errorf("InstallGitHook() installed %d hooks on error, want 0", installed) + } + + // Error message must contain: the missing dir name, the marker contract, + // all 5 hook script names, and a path back to direct mode. + msg := err.Error() + mustContain := []string{ + ".husky", + "# Entire CLI hooks", + "prepare-commit-msg", + "commit-msg", + "post-commit", + "post-rewrite", + "pre-push", + "direct", + } + for _, s := range mustContain { + if !strings.Contains(msg, s) { + t.Errorf("error message missing %q\nfull message:\n%s", s, msg) + } + } + + // .git/hooks/ must still be untouched (no partial install) + hooksAfter := snapshotDir(t, hooksDir) + if !dirSnapshotsEqual(hooksBefore, hooksAfter) { + t.Error(".git/hooks/ was modified by failed InstallGitHook in external mode") + } +} + +// Variant-3 status line ("✓ Git hooks: external (.husky)") is emitted by +// strategy.PrintExternalHookStatusIfActive at the setup.go call sites, not +// by InstallGitHook itself (production always calls InstallGitHook with +// silent=true). The user-visible output is exercised end-to-end in +// integration_test/external_hooks_test.go. +func TestPrintExternalHookStatusIfActive_ExternalMode(t *testing.T) { + repoDir, _ := initHooksTestRepo(t) + + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + var buf strings.Builder + if printed := PrintExternalHookStatusIfActive(context.Background(), &buf); !printed { + t.Error("PrintExternalHookStatusIfActive returned false in external mode") + } + if !strings.Contains(buf.String(), "Git hooks: external (.husky)") { + t.Errorf("output missing variant-3 line; got:\n%s", buf.String()) + } +} + +func TestPrintExternalHookStatusIfActive_DirectMode(t *testing.T) { + repoDir, _ := initHooksTestRepo(t) + + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + var buf strings.Builder + if printed := PrintExternalHookStatusIfActive(context.Background(), &buf); printed { + t.Error("PrintExternalHookStatusIfActive returned true in direct mode") + } + if buf.Len() != 0 { + t.Errorf("output should be empty in direct mode; got:\n%s", buf.String()) + } +} + +func TestInstallGitHook_ExternalBackend_SkipsAndPreservesGitHooks(t *testing.T) { + repoDir, hooksDir := initHooksTestRepo(t) + + // Write external settings + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + // Create external_dir (must exist for this test path) + huskyDir := filepath.Join(repoDir, ".husky") + if err := os.MkdirAll(huskyDir, 0o755); err != nil { + t.Fatal(err) + } + + // Snapshot .git/hooks/ before install + hooksBefore := snapshotDir(t, hooksDir) + + ctx := context.Background() + installed, err := InstallGitHook(ctx, false, false, false) + if err != nil { + t.Fatalf("InstallGitHook() error = %v", err) + } + if installed != 0 { + t.Errorf("InstallGitHook() installed %d hooks, want 0 in external mode", installed) + } + + // .git/hooks/ must be byte-identical + hooksAfter := snapshotDir(t, hooksDir) + if !dirSnapshotsEqual(hooksBefore, hooksAfter) { + t.Error(".git/hooks/ was modified by InstallGitHook in external mode") + } +} + +// snapshotDir returns a map of filename→content for all regular files in dir. +func snapshotDir(t *testing.T, dir string) map[string]string { + t.Helper() + result := make(map[string]string) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return result + } + t.Fatalf("ReadDir(%s) error: %v", dir, err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + t.Fatalf("ReadFile(%s) error: %v", e.Name(), err) + } + result[e.Name()] = string(data) + } + return result +} + +func dirSnapshotsEqual(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func TestRemoveGitHook_ExternalBackend_IsNoOp(t *testing.T) { + repoDir, hooksDir := initHooksTestRepo(t) + + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + // Simulate leftover Entire hooks in .git/hooks/ from a previous direct-mode + // install (user switched direct → external). The design contract says + // external mode = detection-only, never touch anything. So even these + // leftover files must be left alone. + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatal(err) + } + for _, hook := range gitHookNames { + content := "#!/bin/sh\n# " + entireHookMarker + "\n" + if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + + // Pre-populate .husky/ as well (user-owned, must also remain intact) + huskyDir := filepath.Join(repoDir, ".husky") + if err := os.MkdirAll(huskyDir, 0o755); err != nil { + t.Fatal(err) + } + for _, hook := range gitHookNames { + content := "#!/bin/sh\n# " + entireHookMarker + "\nentire hooks git " + hook + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(huskyDir, hook), []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + + gitHooksBefore := snapshotDir(t, hooksDir) + huskyBefore := snapshotDir(t, huskyDir) + + ctx := context.Background() + removed, err := RemoveGitHook(ctx) + if err != nil { + t.Fatalf("RemoveGitHook() error = %v", err) + } + if removed != 0 { + t.Errorf("RemoveGitHook() removed %d, want 0 in external mode", removed) + } + + if !dirSnapshotsEqual(gitHooksBefore, snapshotDir(t, hooksDir)) { + t.Error(".git/hooks/ was modified by RemoveGitHook in external mode") + } + if !dirSnapshotsEqual(huskyBefore, snapshotDir(t, huskyDir)) { + t.Error("external_dir was modified by RemoveGitHook in external mode") + } +} + +func TestIsGitHookInstalled_ExternalBackend_ReadsExternalDir(t *testing.T) { + repoDir, _ := initHooksTestRepo(t) + + // Write external settings + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + // Create external_dir with marker-containing hook files + huskyDir := filepath.Join(repoDir, ".husky") + if err := os.MkdirAll(huskyDir, 0o755); err != nil { + t.Fatal(err) + } + for _, hook := range gitHookNames { + content := "#!/bin/sh\n# " + entireHookMarker + "\nentire hooks git " + hook + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(huskyDir, hook), []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + + // .git/hooks/ should have NO Entire hooks + // (initHooksTestRepo doesn't install any, so this is already the case) + + ctx := context.Background() + if !IsGitHookInstalled(ctx) { + t.Error("IsGitHookInstalled() = false, want true when external_dir has marker files") + } +} + +func TestIsGitHookInstalled_ExternalBackend_IgnoresGitHooksDir(t *testing.T) { + repoDir, hooksDir := initHooksTestRepo(t) + + // External settings pointing to .husky (which does NOT exist) + entireDir := filepath.Join(repoDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(entireDir, "settings.json"), + []byte(`{"enabled": true, "git_hooks": {"backend": "external", "external_dir": ".husky"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + // Put marker files in .git/hooks/ (should be ignored in external mode) + if err := os.MkdirAll(hooksDir, 0o755); err != nil { + t.Fatal(err) + } + for _, hook := range gitHookNames { + content := "#!/bin/sh\n# " + entireHookMarker + "\n" + if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(content), 0o755); err != nil { + t.Fatal(err) + } + } + + ctx := context.Background() + if IsGitHookInstalled(ctx) { + t.Error("IsGitHookInstalled() = true, want false when external_dir has no marker files (even though .git/hooks/ does)") + } +} diff --git a/docs/architecture/external-git-hooks.md b/docs/architecture/external-git-hooks.md new file mode 100644 index 000000000..1cf31e63c --- /dev/null +++ b/docs/architecture/external-git-hooks.md @@ -0,0 +1,205 @@ +# External Git Hooks Backend + +Entire's default mode (`direct`) writes wrapper scripts into `.git/hooks/` so +git invokes Entire on the canonical events (commit, push, rebase). That works +out of the box in plain repositories, but it conflicts with projects that +already use a hook manager — Husky, Rush, Lefthook, pre-commit — because the +manager owns either `.git/hooks/` directly or via `core.hooksPath`. Whoever +writes last wins, and Entire's hooks silently disappear after the next +`npm install`. + +The `external` backend solves this by inverting the responsibility: Entire +stops writing hooks entirely and instead reads them from a user-managed +directory. The user wires the Entire dispatcher into their own scripts; Entire +verifies the wiring via a marker comment but never touches the files. + +## Why + +`.git/hooks/` is a contested space: + +- Husky stages user scripts in `.husky/` and links them from `.git/hooks/` + on every `npm install`. Anything Entire wrote there gets overwritten. +- Rush ships hook templates in `common/git-hooks/` and copies them to + `.git/hooks/` during `rush install`. +- pre-commit / Lefthook / Overcommit follow similar patterns. + +Direct mode treats this conflict with a backup chain (`.pre-entire` + +inline chaining), which works for plain installs but breaks when the hook +manager itself re-runs and overwrites the chained script. External mode +sidesteps the whole class of bugs by yielding ownership of the hook files +back to whoever was managing them first. + +## Configuration + +Add a `git_hooks` block to `.entire/settings.json`: + +```json +{ + "enabled": true, + "git_hooks": { + "backend": "external", + "external_dir": ".husky" + } +} +``` + +`external_dir` is repo-relative. Common values: + +| Hook manager | `external_dir` | +| --- | --- | +| Husky v9 | `.husky` | +| Rush | `common/git-hooks` | +| Custom | any repo-relative path you control | + +Validation rules: + +- `backend` must be `"direct"` or `"external"`. Typos are rejected at + load time so silent misconfiguration is impossible. +- `external_dir` is required when `backend = "external"`. +- `external_dir` must be repo-relative (no leading `/`, no `..` segments). + +`external_dir` existence is NOT checked at parse time — that is a runtime +health concern surfaced by `entire enable` and `entire doctor`. + +## User contract + +In external mode, Entire considers a hook "installed" iff +`/` contains the marker comment `# Entire CLI hooks` +somewhere in the file. Detection reads only this marker; the actual +dispatch invocation is the user's responsibility. + +The five managed hooks Entire wants to see (these match direct-mode +installs byte-for-byte except they live in your directory): + +``` +/prepare-commit-msg +/commit-msg +/post-commit +/post-rewrite +/pre-push +``` + +Each script must: + +1. Be executable (`chmod +x`). +2. Contain the marker comment `# Entire CLI hooks`. +3. Invoke Entire's dispatcher with the matching event name. + +The recommended dispatch invocations (matching what direct mode installs): + +```sh +# .husky/prepare-commit-msg +#!/bin/sh +# Entire CLI hooks +entire hooks git prepare-commit-msg "$1" "$2" 2>/dev/null || true +``` + +```sh +# .husky/commit-msg +#!/bin/sh +# Entire CLI hooks +entire hooks git commit-msg "$1" || true +``` + +```sh +# .husky/post-commit +#!/bin/sh +# Entire CLI hooks +entire hooks git post-commit 2>/dev/null || true +``` + +```sh +# .husky/post-rewrite +#!/bin/sh +# Entire CLI hooks +entire hooks git post-rewrite "$1" 2>/dev/null || true +``` + +```sh +# .husky/pre-push +#!/bin/sh +# Entire CLI hooks +entire hooks git pre-push "$1" || true +``` + +You can intermix these calls with your existing hook content — the marker +just needs to be present somewhere in each file. Add error suppression, +guard against missing `entire` binaries, or call other tooling before/after +the dispatch as needed. + +## What Entire does and doesn't do + +**Does:** + +- Detect marker presence in `/` via + `IsGitHookInstalled` (read-only). +- Report `external_dir` health in `entire doctor` (exists/missing). +- Abort `entire enable` with the instructional message when + `external_dir` doesn't exist on disk. +- Silence the hook-manager warning that fires in direct mode (you've + already opted in to coexistence). + +**Does not:** + +- Write anything to `external_dir` or `.git/hooks/` in external mode. +- Append, edit, or normalize user-owned hook scripts. +- Validate the exact dispatch command string — only the marker is + checked. If you change the invocation form, detection still passes. +- Remove hooks on `entire disable` — `RemoveGitHook` is a no-op in + external mode regardless of what files exist. + +## Compatibility + +Tested layouts: + +- **Husky v9** — `.husky/_/` (Husky-generated stubs, untouched) + + `.husky/` (user scripts with the marker). +- **Rush** — `common/git-hooks/` with the marker. +- **Generic** — any repo-relative directory you control. + +## Migration: direct → external + +1. Pick or create the hook directory your manager controls + (e.g. `.husky/` after `npx husky init`). +2. For each of the five managed hooks, create a script in that directory + containing the marker comment and the dispatch invocation (see + examples above). `chmod +x` each script. +3. Add the `git_hooks` block to `.entire/settings.json`: + ```json + { "git_hooks": { "backend": "external", "external_dir": ".husky" } } + ``` +4. Run `entire doctor` — expect + `✓ External git hooks: external_dir ".husky" exists`. +5. The previously-installed `.git/hooks/` files from direct mode + are now stale. Entire will leave them alone (detection-only contract); + remove them manually if your hook manager doesn't already do so. + +## Migration: external → direct + +1. Remove the `git_hooks` block from `.entire/settings.json` (the + default is `direct`). +2. Run `entire enable --force` to reinstall hooks into `.git/hooks/`. +3. Decide whether to keep the scripts in your external directory. Entire + no longer cares about them but your hook manager probably still + invokes them, so empty/delete them if they'd duplicate Entire's + direct-mode work. + +## Troubleshooting + +**"external_dir not found in repo root"** — the directory you named in +`external_dir` doesn't exist on disk. Either create it (with the hook +scripts inside) or set `backend` back to `direct`. + +**"Marker present but hook doesn't fire"** — check that the script is +executable (`chmod +x`) and that your hook manager actually links it +into `.git/hooks/` (Husky v9 does this via `.husky/_/`; Rush does it on +`rush install`). + +**"Husky stubs in `.husky/_/` are being detected"** — they aren't. +Detection looks at `/`, not `/_/`. +The `_/` directory is Husky's plumbing, ignored by Entire's marker check. + +**`entire enable` says "Entire is already enabled" without the +external hint** — your `external_dir` does not exist; the precondition +check at the top of `enable` aborts before the success surface runs. The +error message lists the required scripts inline.