11
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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/<name>.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)
|
||||
}
|
||||
Reference in New Issue
Block a user