11package cmd
22
33import (
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.
2031type hookPayload struct {
@@ -118,7 +129,8 @@ func runClaudeInit(cmd *cobra.Command, _ []string) error {
118129}
119130
120131func 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
194215func 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.
281309func 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.
0 commit comments