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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions cmd/entire/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
//
Expand Down
62 changes: 62 additions & 0 deletions cmd/entire/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
194 changes: 194 additions & 0 deletions cmd/entire/cli/integration_test/external_hooks_test.go
Original file line number Diff line number Diff line change
@@ -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/<hook> 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/<hook>.
// 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/_/<hook>: 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/<hook>: 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)
}
}
}
Loading