157 lines
4.2 KiB
Go
157 lines
4.2 KiB
Go
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)
|
|
}
|