Skip to content

Commit 34506cc

Browse files
peyton-altclaude
andcommitted
fix(enable): catch ENOTDIR hooks paths and pin the nonexistent-path boundary
Review follow-ups: core.hooksPath pointing below a non-directory (/dev/null/hooks) stat-fails with ENOTDIR and skipped the guidance; extend the guard to cover it. Add a boundary test proving a configured-but-missing hooks path still gets created by MkdirAll. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 07ce70c commit 34506cc

2 files changed

Lines changed: 84 additions & 3 deletions

File tree

cmd/entire/cli/strategy/hooks.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"runtime"
1111
"strings"
1212
"sync"
13+
"syscall"
1314

1415
"github.com/entireio/cli/cmd/entire/cli/settings"
1516
)
@@ -294,7 +295,11 @@ func InstallGitHook(ctx context.Context, silent, localDev, absolutePath bool) (i
294295
return 0, err
295296
}
296297

297-
if info, statErr := os.Stat(hooksDir); statErr == nil && !info.IsDir() {
298+
info, statErr := os.Stat(hooksDir)
299+
notDir := statErr == nil && !info.IsDir()
300+
// ENOTDIR: a path component of hooksDir is itself a non-directory
301+
// (e.g. core.hooksPath=/dev/null/hooks) — same misconfiguration.
302+
if notDir || errors.Is(statErr, syscall.ENOTDIR) {
298303
return 0, fmt.Errorf("git resolves the hooks directory to %s, which is not a directory — core.hooksPath is likely set to disable git hooks\n"+
299304
"Entire requires git hooks to capture sessions. See where it is set with:\n"+
300305
" git config --show-origin --get-all core.hooksPath\n"+

cmd/entire/cli/strategy/hooks_test.go

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"github.com/entireio/cli/cmd/entire/cli/paths"
1515
)
1616

17+
const goosWindows = "windows"
18+
1719
// readEntireDevScript returns the contents of the committed scripts/entire-dev
1820
// launcher, located relative to this test file's position in the source tree.
1921
func readEntireDevScript(t *testing.T) string {
@@ -328,7 +330,7 @@ func TestInstallGitHook_HooksPathNotADirectory(t *testing.T) {
328330
}
329331

330332
hooksPath := "/dev/null"
331-
if runtime.GOOS == "windows" {
333+
if runtime.GOOS == goosWindows {
332334
hooksPath = filepath.Join(tmpDir, "not-a-dir")
333335
if err := os.WriteFile(hooksPath, []byte("x"), 0o600); err != nil {
334336
t.Fatalf("failed to create non-directory hooks path: %v", err)
@@ -360,6 +362,80 @@ func TestInstallGitHook_HooksPathNotADirectory(t *testing.T) {
360362
}
361363
}
362364

365+
func TestInstallGitHook_HooksPathUnderNonDirectory(t *testing.T) {
366+
// core.hooksPath pointing below a non-directory (e.g. /dev/null/hooks)
367+
// makes os.Stat fail with ENOTDIR instead of succeeding on a non-dir;
368+
// the guidance must fire for this variant too.
369+
if runtime.GOOS == goosWindows {
370+
t.Skip("ENOTDIR detection is POSIX-specific; Windows falls back to the raw mkdir error")
371+
}
372+
tmpDir := t.TempDir()
373+
ctx := context.Background()
374+
375+
cmd := exec.CommandContext(ctx, "git", "init")
376+
cmd.Dir = tmpDir
377+
if err := cmd.Run(); err != nil {
378+
t.Fatalf("failed to init git repo: %v", err)
379+
}
380+
cmd = exec.CommandContext(ctx, "git", "config", "core.hooksPath", "/dev/null/hooks")
381+
cmd.Dir = tmpDir
382+
if err := cmd.Run(); err != nil {
383+
t.Fatalf("failed to set core.hooksPath: %v", err)
384+
}
385+
386+
t.Chdir(tmpDir)
387+
ClearHooksDirCache()
388+
paths.ClearWorktreeRootCache()
389+
390+
_, err := InstallGitHook(ctx, true, false, false)
391+
if err == nil {
392+
t.Fatal("InstallGitHook() should fail when hooks path is under a non-directory")
393+
}
394+
if !strings.Contains(err.Error(), "core.hooksPath") {
395+
t.Errorf("error should name core.hooksPath, got: %s", err)
396+
}
397+
}
398+
399+
func TestInstallGitHook_HooksPathNonexistentIsCreated(t *testing.T) {
400+
// A configured-but-missing core.hooksPath is legitimate: the guard must
401+
// not fire, and MkdirAll must create the directory and install hooks.
402+
tmpDir := t.TempDir()
403+
ctx := context.Background()
404+
405+
cmd := exec.CommandContext(ctx, "git", "init")
406+
cmd.Dir = tmpDir
407+
if err := cmd.Run(); err != nil {
408+
t.Fatalf("failed to init git repo: %v", err)
409+
}
410+
hooksPath := filepath.Join(tmpDir, "githooks-not-yet-created")
411+
cmd = exec.CommandContext(ctx, "git", "config", "core.hooksPath", hooksPath)
412+
cmd.Dir = tmpDir
413+
if err := cmd.Run(); err != nil {
414+
t.Fatalf("failed to set core.hooksPath: %v", err)
415+
}
416+
417+
t.Chdir(tmpDir)
418+
ClearHooksDirCache()
419+
paths.ClearWorktreeRootCache()
420+
421+
count, err := InstallGitHook(ctx, true, false, false)
422+
if err != nil {
423+
t.Fatalf("InstallGitHook() should create a nonexistent hooks path: %v", err)
424+
}
425+
if count == 0 {
426+
t.Fatal("InstallGitHook() should install hooks into the created directory")
427+
}
428+
for _, hook := range gitHookNames {
429+
data, readErr := os.ReadFile(filepath.Join(hooksPath, hook))
430+
if readErr != nil {
431+
t.Fatalf("expected hook %s in created hooks dir: %v", hook, readErr)
432+
}
433+
if !strings.Contains(string(data), entireHookMarker) {
434+
t.Errorf("hook %s should contain Entire marker", hook)
435+
}
436+
}
437+
}
438+
363439
func TestInstallGitHook_WorktreeInstallsInCommonHooks(t *testing.T) {
364440
mainRepo, worktreeDir := initHooksWorktreeRepo(t)
365441
t.Chdir(worktreeDir)
@@ -1776,7 +1852,7 @@ func TestResolveHookExePath(t *testing.T) {
17761852
t.Parallel()
17771853
got, err := resolveHookExePath(exe, func(string) (string, error) {
17781854
return "", junctionErr
1779-
}, "windows")
1855+
}, goosWindows)
17801856
if err != nil {
17811857
t.Fatalf("windows should fall back, got error: %v", err)
17821858
}

0 commit comments

Comments
 (0)