package git import ( "bytes" "context" "errors" "os" "os/exec" "path/filepath" "strconv" "time" ) const hookTimeout = 30 * time.Second const ( scriptPostCreate = ".spx/worktree-post-create.sh" scriptPreRemove = ".spx/worktree-pre-remove.sh" scriptImplTabPreCreate = ".spx/impl-tab-pre-create.sh" scriptImplTabPostClose = ".spx/impl-tab-post-close.sh" ) // HookStatus is the outcome of a lifecycle hook execution. type HookStatus string const ( HookStatusSkipped HookStatus = "skipped" HookStatusOk HookStatus = "ok" HookStatusFailed HookStatus = "failed" HookStatusTimeout HookStatus = "timeout" HookStatusEnoent HookStatus = "enoent" ) // HookContext carries the parameters injected into hook scripts as env vars. type HookContext struct { WorkspaceRoot string WorktreePath string Branch string IssueNumber int MainBranch string // CustomScriptPath overrides the default .spx/.sh location. // Relative paths are resolved against WorkspaceRoot. CustomScriptPath string } // HookResult is returned by every hook function regardless of outcome. // Hooks never return a fatal error — failures are captured here. type HookResult struct { Status HookStatus ExitCode int Stdout string Stderr string ErrorMessage string ScriptPath string } func resolveScript(ctx HookContext, defaultRel string) string { custom := ctx.CustomScriptPath if custom != "" { if filepath.IsAbs(custom) { return custom } return filepath.Join(ctx.WorkspaceRoot, custom) } return filepath.Join(ctx.WorkspaceRoot, defaultRel) } func runHook(ctx context.Context, hctx HookContext, defaultRel string) HookResult { scriptPath := resolveScript(hctx, defaultRel) if _, err := os.Stat(scriptPath); errors.Is(err, os.ErrNotExist) { return HookResult{Status: HookStatusSkipped, ScriptPath: scriptPath} } runCtx, cancel := context.WithTimeout(ctx, hookTimeout) defer cancel() var stdout, stderr bytes.Buffer cmd := exec.CommandContext(runCtx, "bash", scriptPath) cmd.Dir = hctx.WorktreePath cmd.Stdout = &stdout cmd.Stderr = &stderr cmd.Env = append(os.Environ(), "WORKTREE_PATH="+hctx.WorktreePath, "WORKSPACE_ROOT="+hctx.WorkspaceRoot, "BRANCH="+hctx.Branch, "ISSUE_NUMBER="+strconv.Itoa(hctx.IssueNumber), "MAIN_BRANCH="+hctx.MainBranch, ) err := cmd.Run() stdoutStr := stdout.String() stderrStr := stderr.String() if err == nil { return HookResult{ Status: HookStatusOk, ExitCode: 0, Stdout: stdoutStr, Stderr: stderrStr, ScriptPath: scriptPath, } } // Context deadline exceeded means our 30s timeout fired. if errors.Is(runCtx.Err(), context.DeadlineExceeded) { return HookResult{ Status: HookStatusTimeout, ErrorMessage: "hook script timed out (30s)", Stdout: stdoutStr, Stderr: stderrStr, ScriptPath: scriptPath, } } // bash binary not found on PATH. var exitErr *exec.ExitError if !errors.As(err, &exitErr) { return HookResult{ Status: HookStatusEnoent, ErrorMessage: err.Error(), Stdout: stdoutStr, Stderr: stderrStr, ScriptPath: scriptPath, } } return HookResult{ Status: HookStatusFailed, ExitCode: exitErr.ExitCode(), ErrorMessage: err.Error(), Stdout: stdoutStr, Stderr: stderrStr, ScriptPath: scriptPath, } } // RunPostCreateHook runs .spx/worktree-post-create.sh after a worktree is created. func RunPostCreateHook(ctx context.Context, hctx HookContext) HookResult { return runHook(ctx, hctx, scriptPostCreate) } // RunPreRemoveHook runs .spx/worktree-pre-remove.sh before a worktree is removed. func RunPreRemoveHook(ctx context.Context, hctx HookContext) HookResult { return runHook(ctx, hctx, scriptPreRemove) } // RunImplTabPreCreateHook runs .spx/impl-tab-pre-create.sh before an impl CC tab opens. func RunImplTabPreCreateHook(ctx context.Context, hctx HookContext) HookResult { return runHook(ctx, hctx, scriptImplTabPreCreate) } // RunImplTabPostCloseHook runs .spx/impl-tab-post-close.sh after an impl CC tab closes. func RunImplTabPostCloseHook(ctx context.Context, hctx HookContext) HookResult { return runHook(ctx, hctx, scriptImplTabPostClose) }