Files
ai-workflow-skill/internal/cli/orch/worktree.go
T

504 lines
14 KiB
Go

package orch
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"ai-workflow-skill/packages/coord-core/protocol"
"ai-workflow-skill/packages/coord-core/store"
"github.com/spf13/cobra"
)
func newDispatchWorkspacePreparer(cmd *cobra.Command, opts dispatchOptions) store.DispatchWorkspacePreparer {
ctx := cmd.Context()
return func(task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
effectiveOpts, useWorktree := resolveDispatchWorktreeOptions(task, opts)
if !useWorktree {
return store.DispatchWorkspace{}, func() {}, nil
}
return provisionDispatchWorkspace(ctx, effectiveOpts, task, attemptNo)
}
}
func newAttemptReuseWorkspacePreparer(cmd *cobra.Command, task store.Task, attempt *store.TaskAttempt) store.DispatchWorkspacePreparer {
if attempt == nil || attempt.WorktreePath == "" {
return nil
}
workspaceRoot, ok := deriveWorkspaceRootFromAttempt(task.RunID, task.TaskID, attempt.WorktreePath)
if !ok {
return nil
}
baseRef := attempt.BaseRef
if strings.TrimSpace(baseRef) == "" {
baseRef = attempt.BaseCommit
}
opts := dispatchOptions{
repoPath: attempt.WorktreePath,
workspaceRoot: workspaceRoot,
strictWorktree: true,
baseRef: baseRef,
}
return newDispatchWorkspacePreparer(cmd, opts)
}
func dispatchUsesWorktree(opts dispatchOptions) bool {
return strings.TrimSpace(opts.workspaceRoot) != "" ||
opts.strictWorktree
}
func resolveDispatchWorktreeOptions(task store.Task, opts dispatchOptions) (dispatchOptions, bool) {
if dispatchUsesWorktree(opts) {
return opts, true
}
if !taskLooksLikeCodeWork(task) {
return opts, false
}
auto := opts
auto.strictWorktree = true
return auto, true
}
func taskLooksLikeCodeWork(task store.Task) bool {
if acceptanceJSONLooksCodeLike(task.AcceptanceJSON) {
return true
}
return roleLooksCodeLike(task.DefaultTo)
}
func acceptanceJSONLooksCodeLike(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return false
}
return acceptanceValueLooksCodeLike(value)
}
func acceptanceValueLooksCodeLike(value any) bool {
switch typed := value.(type) {
case map[string]any:
for key, raw := range typed {
lowerKey := strings.ToLower(strings.TrimSpace(key))
switch lowerKey {
case "code", "code_task", "writes_code", "worktree":
if boolValue, ok := raw.(bool); ok && boolValue {
return true
}
case "kind", "task_type", "mode", "type":
if stringValue, ok := raw.(string); ok && isCodeLikeMarker(stringValue) {
return true
}
}
if acceptanceValueLooksCodeLike(raw) {
return true
}
}
case []any:
for _, item := range typed {
if acceptanceValueLooksCodeLike(item) {
return true
}
}
case string:
return isCodeLikeMarker(typed)
}
return false
}
func roleLooksCodeLike(role string) bool {
role = strings.ToLower(strings.TrimSpace(role))
if role == "" {
return false
}
for _, token := range splitIdentifierTokens(role) {
switch token {
case "backend", "frontend", "front", "admin", "ui", "fullstack", "foundation", "db", "database", "mobile", "ios", "android", "web", "platform", "infra", "api":
return true
}
}
return false
}
func isCodeLikeMarker(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "code", "code_task", "code-task", "code-change", "code_change", "implementation", "patch", "diff", "repo":
return true
default:
return false
}
}
func splitIdentifierTokens(value string) []string {
return strings.FieldsFunc(value, func(r rune) bool {
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
})
}
func provisionDispatchWorkspace(ctx context.Context, opts dispatchOptions, task store.Task, attemptNo int) (store.DispatchWorkspace, func(), error) {
repoRoot, err := resolveRepoRoot(ctx, opts.repoPath)
if err != nil {
return store.DispatchWorkspace{}, nil, err
}
workspaceRoot := resolveWorkspaceRoot(repoRoot, opts.workspaceRoot)
if err := ensureWorkspaceRootIgnored(repoRoot, workspaceRoot); err != nil {
return store.DispatchWorkspace{}, nil, err
}
baseRef, baseCommit, err := resolveDispatchBase(ctx, repoRoot, workspaceRoot, opts.baseRef, opts.strictWorktree)
if err != nil {
return store.DispatchWorkspace{}, nil, err
}
branchName := buildAttemptBranchName(task.RunID, task.TaskID, attemptNo)
worktreePath := buildAttemptWorktreePath(workspaceRoot, task.RunID, task.TaskID, attemptNo)
if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
return store.DispatchWorkspace{}, nil, fmt.Errorf("create worktree parent dir: %w", err)
}
if _, err := os.Stat(worktreePath); err == nil {
return store.DispatchWorkspace{}, nil, fmt.Errorf("%w: worktree path already exists: %s", store.ErrInvalidState, worktreePath)
} else if err != nil && !os.IsNotExist(err) {
return store.DispatchWorkspace{}, nil, fmt.Errorf("stat worktree path: %w", err)
}
if _, _, err := runGit(ctx, repoRoot, "worktree", "add", "-b", branchName, worktreePath, baseCommit); err != nil {
return store.DispatchWorkspace{}, nil, err
}
cleanup := func() {
_, _, _ = runGit(context.Background(), repoRoot, "worktree", "remove", "--force", worktreePath)
_, _, _ = runGit(context.Background(), repoRoot, "branch", "-D", branchName)
_ = os.RemoveAll(worktreePath)
}
return store.DispatchWorkspace{
BaseRef: baseRef,
BaseCommit: baseCommit,
BranchName: branchName,
WorktreePath: worktreePath,
WorkspaceStatus: "created",
}, cleanup, nil
}
func resolveRepoRoot(ctx context.Context, repoPath string) (string, error) {
startPath := strings.TrimSpace(repoPath)
if startPath == "" {
var err error
startPath, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("get current working directory: %w", err)
}
}
absPath, err := filepath.Abs(startPath)
if err != nil {
return "", fmt.Errorf("resolve repo path: %w", err)
}
if _, _, err := runGit(ctx, absPath, "rev-parse", "--show-toplevel"); err != nil {
return "", protocol.InvalidInput("repo-path must point to a Git worktree", err)
}
commonDir, err := resolveCommonGitDir(ctx, absPath)
if err != nil {
return "", protocol.InvalidInput("repo-path must point to a Git worktree", err)
}
return filepath.Dir(commonDir), nil
}
func resolveDispatchBase(ctx context.Context, repoRoot, workspaceRoot, requestedBaseRef string, strict bool) (string, string, error) {
baseRef := strings.TrimSpace(requestedBaseRef)
if baseRef != "" {
baseCommit, err := resolveCommit(ctx, repoRoot, baseRef)
if err != nil {
return "", "", protocol.InvalidInput("base-ref must resolve to a commit", err)
}
return baseRef, baseCommit, nil
}
if strict {
dirty, err := repoHasUncommittedChanges(ctx, repoRoot, workspaceRoot)
if err != nil {
return "", "", err
}
if dirty {
return "", "", fmt.Errorf("%w: repository has uncommitted changes; specify --base-ref or clean the repo", store.ErrInvalidState)
}
}
baseCommit, err := resolveCommit(ctx, repoRoot, "HEAD")
if err != nil {
return "", "", protocol.InvalidInput("failed to resolve HEAD commit", err)
}
return "HEAD", baseCommit, nil
}
func resolveCommit(ctx context.Context, repoRoot, ref string) (string, error) {
stdout, _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
if err != nil {
return "", err
}
return strings.TrimSpace(stdout), nil
}
func repoHasUncommittedChanges(ctx context.Context, repoRoot, workspaceRoot string) (bool, error) {
stdout, _, err := runGit(ctx, repoRoot, "status", "--porcelain")
if err != nil {
return false, fmt.Errorf("check repository status: %w", err)
}
for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if len(line) >= 3 {
path := strings.TrimSpace(line[3:])
if shouldIgnoreStatusPath(repoRoot, workspaceRoot, path) {
continue
}
}
return true, nil
}
return false, nil
}
func resolveWorkspaceRoot(repoRoot, configuredRoot string) string {
root := strings.TrimSpace(configuredRoot)
if root == "" {
return filepath.Join(repoRoot, ".orch", "worktrees")
}
if filepath.IsAbs(root) {
return root
}
return filepath.Join(repoRoot, root)
}
func ensureWorkspaceRootIgnored(repoRoot, workspaceRoot string) error {
relative, err := filepath.Rel(repoRoot, workspaceRoot)
if err != nil {
return fmt.Errorf("resolve workspace root exclude path: %w", err)
}
if relative == "." || strings.HasPrefix(relative, "..") {
return nil
}
pattern := filepath.ToSlash(relative)
if !strings.HasSuffix(pattern, "/") {
pattern += "/"
}
excludePath := filepath.Join(repoRoot, ".git", "info", "exclude")
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
return fmt.Errorf("create git info dir: %w", err)
}
content, err := os.ReadFile(excludePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read git exclude file: %w", err)
}
if strings.Contains(string(content), pattern) {
return nil
}
appendContent := pattern + "\n"
if len(content) > 0 && !strings.HasSuffix(string(content), "\n") {
appendContent = "\n" + appendContent
}
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("open git exclude file: %w", err)
}
defer file.Close()
if _, err := file.WriteString(appendContent); err != nil {
return fmt.Errorf("append git exclude file: %w", err)
}
return nil
}
func shouldIgnoreStatusPath(repoRoot, workspaceRoot, statusPath string) bool {
relative, err := filepath.Rel(repoRoot, workspaceRoot)
if err != nil || relative == "." || strings.HasPrefix(relative, "..") {
return false
}
relative = filepath.ToSlash(relative)
statusPath = filepath.ToSlash(strings.Trim(statusPath, `"`))
return statusPath == relative || strings.HasPrefix(statusPath, relative+"/")
}
func buildAttemptBranchName(runID, taskID string, attemptNo int) string {
return fmt.Sprintf(
"orch/%s/%s/attempt-%d",
sanitizeGitSegment(runID),
sanitizeGitSegment(taskID),
attemptNo,
)
}
func buildAttemptWorktreePath(workspaceRoot, runID, taskID string, attemptNo int) string {
return filepath.Join(
workspaceRoot,
sanitizePathSegment(runID),
sanitizePathSegment(taskID),
fmt.Sprintf("attempt-%d", attemptNo),
)
}
func deriveWorkspaceRootFromAttempt(runID, taskID, worktreePath string) (string, bool) {
suffix := filepath.Join(
sanitizePathSegment(runID),
sanitizePathSegment(taskID),
filepath.Base(worktreePath),
)
parent := filepath.Dir(worktreePath)
if filepath.Base(parent) != sanitizePathSegment(taskID) {
return "", false
}
runDir := filepath.Dir(parent)
if filepath.Base(runDir) != sanitizePathSegment(runID) {
return "", false
}
root := filepath.Dir(runDir)
if filepath.Clean(filepath.Join(root, suffix)) != filepath.Clean(worktreePath) {
return "", false
}
return root, true
}
func sanitizeGitSegment(value string) string {
return sanitizeSegment(value)
}
func sanitizePathSegment(value string) string {
return sanitizeSegment(value)
}
func sanitizeSegment(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "item"
}
var b strings.Builder
lastDash := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if r == '-' || r == '_' || r == '.' {
if !lastDash {
b.WriteByte('-')
lastDash = true
}
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
result := strings.Trim(b.String(), "-.")
if result == "" {
return "item"
}
if strings.HasSuffix(result, ".lock") {
result = strings.TrimSuffix(result, ".lock") + "-lock"
}
return result
}
func runGit(ctx context.Context, repoRoot string, args ...string) (string, string, error) {
cmdArgs := append([]string{"-C", repoRoot}, args...)
cmd := exec.CommandContext(ctx, "git", cmdArgs...)
output, err := cmd.CombinedOutput()
if err == nil {
return string(output), "", nil
}
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return "", message, fmt.Errorf("git %s: %s", strings.Join(args, " "), message)
}
func cleanupAttemptWorktree(ctx context.Context, attempt store.TaskAttempt, force bool) error {
if strings.TrimSpace(attempt.WorktreePath) == "" {
return nil
}
if _, err := os.Stat(attempt.WorktreePath); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("stat worktree path: %w", err)
}
repoRoot, err := resolveRepoRootFromExistingWorktree(ctx, attempt.WorktreePath)
if err != nil {
if force {
return os.RemoveAll(attempt.WorktreePath)
}
return err
}
args := []string{"worktree", "remove"}
if force {
args = append(args, "--force")
}
args = append(args, attempt.WorktreePath)
if _, _, err := runGit(ctx, repoRoot, args...); err != nil {
if force {
return os.RemoveAll(attempt.WorktreePath)
}
return err
}
return nil
}
func resolveRepoRootFromExistingWorktree(ctx context.Context, worktreePath string) (string, error) {
commonDir, err := resolveCommonGitDir(ctx, worktreePath)
if err != nil {
return "", err
}
return filepath.Dir(commonDir), nil
}
func resolveCommonGitDir(ctx context.Context, repoPath string) (string, error) {
stdout, _, err := runGit(ctx, repoPath, "rev-parse", "--path-format=absolute", "--git-common-dir")
if err != nil {
return "", err
}
commonDir := strings.TrimSpace(stdout)
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(repoPath, commonDir)
}
return filepath.Clean(commonDir), nil
}