Skip to content

Commit b334894

Browse files
authored
Merge pull request #15 from bkildow/fix/hook-resilience
Harden Claude Code hook handlers against hangs and edge cases
2 parents 983ad06 + 62b2a7e commit b334894

5 files changed

Lines changed: 137 additions & 18 deletions

File tree

cmd/claude.go

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package cmd
22

33
import (
4+
"context"
45
"encoding/json"
6+
"errors"
57
"fmt"
68
"io"
79
"os"
810
"path/filepath"
11+
"time"
912

1013
"github.com/bkildow/wt-cli/internal/claude"
1114
"github.com/bkildow/wt-cli/internal/config"
@@ -15,6 +18,14 @@ import (
1518
"github.com/spf13/cobra"
1619
)
1720

21+
// Timeouts for hook operations. Hooks run inside Claude Code's sandbox,
22+
// so they must complete promptly or risk blocking the agent session.
23+
const (
24+
hookCreateTimeout = 60 * time.Second
25+
hookRemoveTimeout = 30 * time.Second
26+
hookPayloadTimeout = 5 * time.Second
27+
)
28+
1829
// hookPayload is the JSON structure Claude Code sends on stdin for hook events.
1930
// See https://code.claude.com/docs/en/hooks for the payload schema.
2031
type hookPayload struct {
@@ -118,7 +129,8 @@ func runClaudeInit(cmd *cobra.Command, _ []string) error {
118129
}
119130

120131
func runClaudeHookWorktreeCreate(cmd *cobra.Command, _ []string) error {
121-
ctx := cmd.Context()
132+
ctx, cancel := context.WithTimeout(cmd.Context(), hookCreateTimeout)
133+
defer cancel()
122134

123135
hctx, err := loadHookContext()
124136
if err != nil {
@@ -128,6 +140,7 @@ func runClaudeHookWorktreeCreate(cmd *cobra.Command, _ []string) error {
128140
projectRoot, cfg := hctx.projectRoot, hctx.cfg
129141
gitDir := project.GitDirPath(projectRoot, cfg)
130142
runner := git.NewRunner(gitDir, false)
143+
runner.BatchMode = true
131144

132145
// Ensure git excludes are configured (non-fatal if sandbox blocks it).
133146
if err := project.EnsureGitExclude(gitDir, false); err != nil {
@@ -141,11 +154,19 @@ func runClaudeHookWorktreeCreate(cmd *cobra.Command, _ []string) error {
141154
branch := hctx.payload.Name
142155
worktreePath := filepath.Join(project.WorktreesPath(projectRoot, cfg), branch)
143156

144-
// If the worktree already exists, just return its path.
145-
if _, err := os.Stat(worktreePath); err == nil {
157+
// If the worktree already exists and is valid, just return its path.
158+
gitMarker := filepath.Join(worktreePath, ".git")
159+
if _, err := os.Stat(gitMarker); err == nil {
146160
fmt.Println(worktreePath)
147161
return nil
148162
}
163+
// Directory exists but is not a valid worktree — clean up leftover from failed create.
164+
if _, err := os.Stat(worktreePath); err == nil {
165+
ui.Warning("Directory exists but is not a valid worktree, recreating: " + worktreePath)
166+
if err := os.RemoveAll(worktreePath); err != nil {
167+
return fmt.Errorf("failed to clean up invalid worktree directory: %w", err)
168+
}
169+
}
149170

150171
hasRemote, err := runner.HasRemoteBranch(ctx, branch)
151172
if err != nil {
@@ -192,7 +213,8 @@ func runClaudeHookWorktreeCreate(cmd *cobra.Command, _ []string) error {
192213
}
193214

194215
func runClaudeHookWorktreeRemove(cmd *cobra.Command, _ []string) error {
195-
ctx := cmd.Context()
216+
ctx, cancel := context.WithTimeout(cmd.Context(), hookRemoveTimeout)
217+
defer cancel()
196218

197219
hctx, err := loadHookContext()
198220
if err != nil {
@@ -222,11 +244,12 @@ func runClaudeHookWorktreeRemove(cmd *cobra.Command, _ []string) error {
222244

223245
gitDir := project.GitDirPath(projectRoot, cfg)
224246
runner := git.NewRunner(gitDir, false)
247+
runner.BatchMode = true
225248

226249
// Force remove — Claude agents may have uncommitted changes.
227250
ui.Step("Removing worktree: " + branch)
228251
if err := runner.WorktreeRemove(ctx, worktreePath, true); err != nil {
229-
ui.Warning("Worktree remove failed: " + err.Error())
252+
return fmt.Errorf("worktree remove failed: %w", err)
230253
}
231254

232255
if err := runner.BranchDelete(ctx, branch, false); err != nil {
@@ -252,6 +275,12 @@ func loadHookContext() (*hookContext, error) {
252275
return nil, err
253276
}
254277

278+
// Replace process stdin with /dev/null so downstream operations
279+
// (teardown hooks, git commands) cannot read from Claude Code's pipe.
280+
if devNull, err := os.Open(os.DevNull); err == nil {
281+
os.Stdin = devNull
282+
}
283+
255284
// Validate required fields based on event type.
256285
switch payload.HookEventName {
257286
case claude.HookWorktreeCreate:
@@ -276,17 +305,31 @@ func loadHookContext() (*hookContext, error) {
276305
}
277306

278307
// readHookPayload parses the JSON hook payload from the given reader.
279-
// Uses json.NewDecoder so it returns as soon as one JSON object is read,
280-
// without waiting for EOF (which may never arrive from Claude Code's pipe).
308+
// Claude Code's pipe may never send EOF, so a timeout prevents indefinite blocking.
281309
func readHookPayload(r io.Reader) (hookPayload, error) {
282-
var payload hookPayload
283-
if err := json.NewDecoder(r).Decode(&payload); err != nil {
284-
if err == io.EOF {
285-
return hookPayload{}, fmt.Errorf("no payload received on stdin")
310+
type result struct {
311+
payload hookPayload
312+
err error
313+
}
314+
ch := make(chan result, 1)
315+
go func() {
316+
var p hookPayload
317+
err := json.NewDecoder(r).Decode(&p)
318+
ch <- result{p, err}
319+
}()
320+
321+
select {
322+
case res := <-ch:
323+
if res.err != nil {
324+
if errors.Is(res.err, io.EOF) {
325+
return hookPayload{}, fmt.Errorf("no payload received on stdin")
326+
}
327+
return hookPayload{}, fmt.Errorf("invalid JSON payload: %w", res.err)
286328
}
287-
return hookPayload{}, fmt.Errorf("invalid JSON payload: %w", err)
329+
return res.payload, nil
330+
case <-time.After(hookPayloadTimeout):
331+
return hookPayload{}, fmt.Errorf("timed out waiting for hook payload on stdin (no data received within 5s)")
288332
}
289-
return payload, nil
290333
}
291334

292335
// resolveProjectRoot determines the project root from the hook payload.

cmd/claude_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@ func TestReadHookPayload_noEOF(t *testing.T) {
8686
_ = pw.Close()
8787
}
8888

89+
func TestReadHookPayload_timeout(t *testing.T) {
90+
// Simulate a pipe that is open but never sends any data.
91+
// readHookPayload should time out instead of blocking forever.
92+
pr, pw := io.Pipe()
93+
defer func() { _ = pw.Close() }()
94+
95+
_, err := readHookPayload(pr)
96+
if err == nil {
97+
t.Fatal("expected timeout error")
98+
}
99+
if !strings.Contains(err.Error(), "timed out") {
100+
t.Errorf("error = %q, want timeout message", err.Error())
101+
}
102+
}
103+
89104
func TestResolveProjectRoot_noProject(t *testing.T) {
90105
payload := hookPayload{
91106
Cwd: "/nonexistent/path",

cmd/run_setup.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"errors"
55
"fmt"
66
"os"
7+
"os/signal"
8+
"syscall"
79
"time"
810

911
lipgloss "charm.land/lipgloss/v2"
@@ -26,16 +28,18 @@ func newRunSetupCmd() *cobra.Command {
2628
return cmd
2729
}
2830

29-
func runRunSetup(cmd *cobra.Command, args []string) error {
30-
ctx := cmd.Context()
31-
31+
func runRunSetup(cmd *cobra.Command, _ []string) error {
3232
worktreePath, _ := cmd.Flags().GetString("worktree-path")
3333
projectRoot, _ := cmd.Flags().GetString("project-root")
3434

3535
if worktreePath == "" || projectRoot == "" {
3636
return fmt.Errorf("--worktree-path and --project-root are required")
3737
}
3838

39+
// Cancel context on SIGTERM/SIGINT so running hooks are stopped.
40+
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGTERM, syscall.SIGINT)
41+
defer stop()
42+
3943
cfg, err := config.Load(projectRoot)
4044
if err != nil {
4145
return err
@@ -66,6 +70,16 @@ func runRunSetup(cmd *cobra.Command, args []string) error {
6670
return err
6771
}
6872

73+
// Mark setup as failed if we exit while still "running" (e.g., signal, panic).
74+
defer func() {
75+
if state.Status == project.SetupRunning {
76+
state.Status = project.SetupFailed
77+
state.Error = "setup process terminated unexpectedly"
78+
state.CompletedAt = time.Now()
79+
_ = project.WriteSetupState(worktreePath, state)
80+
}
81+
}()
82+
6983
var setupErr error
7084

7185
// Run serial hooks with progress tracking.

internal/git/git.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"errors"
88
"fmt"
9+
"os"
910
"os/exec"
1011
"strconv"
1112
"strings"
@@ -45,14 +46,23 @@ type Git interface {
4546
}
4647

4748
type Runner struct {
48-
GitDir string
49-
DryRun bool
49+
GitDir string
50+
DryRun bool
51+
BatchMode bool // Suppress interactive prompts (for non-TTY environments like hooks)
5052
}
5153

5254
func NewRunner(gitDir string, dryRun bool) *Runner {
5355
return &Runner{GitDir: gitDir, DryRun: dryRun}
5456
}
5557

58+
// batchEnv returns environment variables that suppress interactive git prompts.
59+
func batchEnv() []string {
60+
env := os.Environ()
61+
env = append(env, "GIT_TERMINAL_PROMPT=0")
62+
env = append(env, "GIT_SSH_COMMAND=ssh -o BatchMode=yes")
63+
return env
64+
}
65+
5666
func (r *Runner) Run(ctx context.Context, args ...string) (string, error) {
5767
fullArgs := append([]string{"--git-dir", r.GitDir}, args...)
5868
cmdStr := "git " + strings.Join(fullArgs, " ")
@@ -67,6 +77,9 @@ func (r *Runner) Run(ctx context.Context, args ...string) (string, error) {
6777
var stdout, stderr bytes.Buffer
6878
cmd.Stdout = &stdout
6979
cmd.Stderr = &stderr
80+
if r.BatchMode {
81+
cmd.Env = batchEnv()
82+
}
7083

7184
if err := cmd.Run(); err != nil {
7285
return "", fmt.Errorf("%s: %w\n%s", cmdStr, err, stderr.String())

internal/git/git_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,40 @@ import (
1111
"github.com/bkildow/wt-cli/internal/ui"
1212
)
1313

14+
func TestBatchEnv(t *testing.T) {
15+
env := batchEnv()
16+
17+
var hasTerminalPrompt, hasSSH bool
18+
for _, v := range env {
19+
if v == "GIT_TERMINAL_PROMPT=0" {
20+
hasTerminalPrompt = true
21+
}
22+
if strings.HasPrefix(v, "GIT_SSH_COMMAND=") && strings.Contains(v, "BatchMode=yes") {
23+
hasSSH = true
24+
}
25+
}
26+
if !hasTerminalPrompt {
27+
t.Error("batchEnv missing GIT_TERMINAL_PROMPT=0")
28+
}
29+
if !hasSSH {
30+
t.Error("batchEnv missing GIT_SSH_COMMAND with BatchMode=yes")
31+
}
32+
}
33+
34+
func TestRunnerBatchMode(t *testing.T) {
35+
// BatchMode defaults to false.
36+
r := NewRunner("/tmp/fake", false)
37+
if r.BatchMode {
38+
t.Error("BatchMode should default to false")
39+
}
40+
41+
// Setting BatchMode should not affect DryRun.
42+
r.BatchMode = true
43+
if r.DryRun {
44+
t.Error("setting BatchMode should not affect DryRun")
45+
}
46+
}
47+
1448
func TestParseRemoteBranches(t *testing.T) {
1549
tests := []struct {
1650
name string

0 commit comments

Comments
 (0)