This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+156
View File
@@ -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)
}