Add orch control commands

This commit is contained in:
2026-03-19 14:21:20 +08:00
parent f1785b314f
commit ae272855f6
10 changed files with 1644 additions and 43 deletions
+69
View File
@@ -0,0 +1,69 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type cancelOptions struct {
runID string
taskID string
reason string
}
func newCancelCmd(root *rootOptions) *cobra.Command {
opts := &cancelOptions{}
cmd := &cobra.Command{
Use: "cancel",
Short: "Cancel a task or an entire run",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
sqlDB, err := openOrchDB(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
result, err := store.NewOrchStore(sqlDB).Cancel(ctx, store.CancelControlInput{
RunID: opts.runID,
TaskID: opts.taskID,
Reason: opts.reason,
})
if err != nil {
return err
}
resp := protocol.Success{
OK: true,
Command: "cancel",
Data: map[string]any{
"run": result.Run,
"cancelled_tasks": result.CancelledTasks,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
if opts.taskID != "" {
_, err = fmt.Fprintf(cmd.OutOrStdout(), "cancelled task %s in run %s\n", opts.taskID, opts.runID)
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "cancelled run %s (%d tasks)\n", opts.runID, len(result.CancelledTasks))
return err
},
}
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID")
cmd.Flags().StringVar(&opts.reason, "reason", "", "Cancellation reason")
_ = cmd.MarkFlagRequired("run")
return cmd
}
+84
View File
@@ -0,0 +1,84 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type cleanupOptions struct {
runID string
taskID string
attemptNo int
allCompleted bool
force bool
}
func newCleanupCmd(root *rootOptions) *cobra.Command {
opts := &cleanupOptions{}
cmd := &cobra.Command{
Use: "cleanup",
Short: "Remove completed or abandoned attempt worktrees",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
sqlDB, err := openOrchDB(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
s := store.NewOrchStore(sqlDB)
candidates, err := s.ListCleanupCandidates(ctx, store.CleanupInput{
RunID: opts.runID,
TaskID: opts.taskID,
AttemptNo: opts.attemptNo,
AllCompleted: opts.allCompleted,
Force: opts.force,
})
if err != nil {
return err
}
records := make([]store.CleanupRecord, 0, len(candidates))
for _, candidate := range candidates {
if err := cleanupAttemptWorktree(ctx, candidate.Attempt, opts.force); err != nil {
return err
}
records = append(records, store.CleanupRecord{Attempt: candidate.Attempt})
}
cleaned, err := s.MarkAttemptsCleaned(ctx, records)
if err != nil {
return err
}
resp := protocol.Success{
OK: true,
Command: "cleanup",
Data: map[string]any{
"cleaned": cleaned,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "cleaned %d worktrees\n", len(cleaned))
return err
},
}
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID")
cmd.Flags().IntVar(&opts.attemptNo, "attempt", 0, "Specific attempt number")
cmd.Flags().BoolVar(&opts.allCompleted, "all-completed", false, "Clean all completed or abandoned worktrees in the run")
cmd.Flags().BoolVar(&opts.force, "force", false, "Force cleanup even for non-terminal worktrees")
_ = cmd.MarkFlagRequired("run")
return cmd
}
+421
View File
@@ -879,3 +879,424 @@ func TestOrchWaitTimesOutWithoutMatchingEvent(t *testing.T) {
t.Fatalf("expected next_event_id 0 on timeout, got %#v", nextEventID)
}
}
func TestOrchRetryCreatesNewAttempt(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
repoPath := initGitRepo(t)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"run", "init",
"--run", "run_blog_retry_001",
"--goal", "Validate retry behavior",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_retry_001",
"--task", "T1",
"--title", "Implement backend",
"--default-to", "worker-a",
)
dispatchOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_retry_001",
"--task", "T1",
"--repo-path", repoPath,
"--workspace-root", ".orch/worktrees",
"--strict-worktree",
)
var dispatchResp map[string]any
mustDecodeJSON(t, dispatchOut, &dispatchResp)
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
firstWorktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path")
runInboxCommand(
t,
"--db", dbPath,
"--json",
"claim",
"--agent", "worker-a",
"--thread", threadID,
)
runInboxCommand(
t,
"--db", dbPath,
"--json",
"fail",
"--agent", "worker-a",
"--thread", threadID,
"--summary", "Build failed",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"reconcile",
"--run", "run_blog_retry_001",
)
retryOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"retry",
"--run", "run_blog_retry_001",
"--task", "T1",
"--body", "Retry after fixing the failure.",
)
var retryResp map[string]any
mustDecodeJSON(t, retryOut, &retryResp)
if got := nestedString(t, retryResp, "data", "task", "status"); got != "dispatched" {
t.Fatalf("expected retried task to be dispatched, got %q", got)
}
if got := nestedValue(t, retryResp, "data", "attempt", "attempt_no").(float64); got != 2 {
t.Fatalf("expected retry attempt 2, got %#v", got)
}
secondThreadID := nestedString(t, retryResp, "data", "attempt", "thread_id")
if secondThreadID == threadID {
t.Fatalf("expected retry to create a new thread, got same thread %q", secondThreadID)
}
secondWorktreePath := nestedString(t, retryResp, "data", "attempt", "worktree_path")
if secondWorktreePath == firstWorktreePath {
t.Fatalf("expected retry to create a new worktree, got reused path %q", secondWorktreePath)
}
if _, err := os.Stat(secondWorktreePath); err != nil {
t.Fatalf("stat retry worktree %s: %v", secondWorktreePath, err)
}
}
func TestOrchReassignCancelsOldThreadAndDispatchesNewAttempt(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
repoPath := initGitRepo(t)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"run", "init",
"--run", "run_blog_reassign_001",
"--goal", "Validate reassign behavior",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_reassign_001",
"--task", "T1",
"--title", "Implement backend",
"--default-to", "worker-a",
)
dispatchOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_reassign_001",
"--task", "T1",
"--repo-path", repoPath,
"--workspace-root", ".orch/worktrees",
"--strict-worktree",
)
var dispatchResp map[string]any
mustDecodeJSON(t, dispatchOut, &dispatchResp)
originalThreadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
runInboxCommand(
t,
"--db", dbPath,
"--json",
"claim",
"--agent", "worker-a",
"--thread", originalThreadID,
)
runInboxCommand(
t,
"--db", dbPath,
"--json",
"update",
"--agent", "worker-a",
"--thread", originalThreadID,
"--status", "blocked",
"--summary", "Need product decision",
"--payload-json", `{"question":"Proceed with v1 scope?"}`,
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"reconcile",
"--run", "run_blog_reassign_001",
)
reassignOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"reassign",
"--run", "run_blog_reassign_001",
"--task", "T1",
"--to", "worker-b",
"--reason", "Try another worker with clearer ownership.",
)
var reassignResp map[string]any
mustDecodeJSON(t, reassignOut, &reassignResp)
if got := nestedString(t, reassignResp, "data", "attempt", "assigned_to"); got != "worker-b" {
t.Fatalf("expected reassigned attempt to target worker-b, got %q", got)
}
if got := nestedValue(t, reassignResp, "data", "attempt", "attempt_no").(float64); got != 2 {
t.Fatalf("expected reassign attempt 2, got %#v", got)
}
newThreadID := nestedString(t, reassignResp, "data", "attempt", "thread_id")
if newThreadID == originalThreadID {
t.Fatalf("expected reassignment to create a new thread, got %q", newThreadID)
}
showOut := runInboxCommand(
t,
"--db", dbPath,
"--json",
"show",
"--thread", originalThreadID,
)
var showResp map[string]any
mustDecodeJSON(t, showOut, &showResp)
if got := nestedString(t, showResp, "data", "thread", "status"); got != "cancelled" {
t.Fatalf("expected old reassigned thread to be cancelled, got %q", got)
}
}
func TestOrchCancelTaskAndRun(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
runOrchCommand(
t,
"--db", dbPath,
"--json",
"run", "init",
"--run", "run_blog_cancel_001",
"--goal", "Validate cancel behavior",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_cancel_001",
"--task", "T1",
"--title", "Implement backend",
"--default-to", "worker-a",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_cancel_001",
"--task", "T2",
"--title", "Implement frontend",
"--default-to", "worker-b",
)
dispatchOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_cancel_001",
"--task", "T1",
)
var dispatchResp map[string]any
mustDecodeJSON(t, dispatchOut, &dispatchResp)
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
runOrchCommand(
t,
"--db", dbPath,
"--json",
"cancel",
"--run", "run_blog_cancel_001",
"--task", "T1",
"--reason", "Task is no longer needed.",
)
statusOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"status",
"--run", "run_blog_cancel_001",
)
var statusResp map[string]any
mustDecodeJSON(t, statusOut, &statusResp)
tasks := nestedArray(t, statusResp, "data", "tasks")
taskStatuses := map[string]string{}
for _, item := range tasks {
task, ok := item.(map[string]any)
if !ok {
t.Fatalf("expected task object, got %#v", item)
}
taskStatuses[task["task_id"].(string)] = task["status"].(string)
}
if taskStatuses["T1"] != "cancelled" {
t.Fatalf("expected T1 cancelled, got %q", taskStatuses["T1"])
}
if taskStatuses["T2"] == "cancelled" {
t.Fatalf("expected T2 to remain active before run cancel, got %q", taskStatuses["T2"])
}
showOut := runInboxCommand(
t,
"--db", dbPath,
"--json",
"show",
"--thread", threadID,
)
var showResp map[string]any
mustDecodeJSON(t, showOut, &showResp)
if got := nestedString(t, showResp, "data", "thread", "status"); got != "cancelled" {
t.Fatalf("expected cancelled task thread to be cancelled, got %q", got)
}
runOrchCommand(
t,
"--db", dbPath,
"--json",
"cancel",
"--run", "run_blog_cancel_001",
"--reason", "Stop the run.",
)
statusOut = runOrchCommand(
t,
"--db", dbPath,
"--json",
"status",
"--run", "run_blog_cancel_001",
)
mustDecodeJSON(t, statusOut, &statusResp)
if got := nestedString(t, statusResp, "data", "run", "status"); got != "cancelled" {
t.Fatalf("expected cancelled run, got %q", got)
}
tasks = nestedArray(t, statusResp, "data", "tasks")
for _, item := range tasks {
task, ok := item.(map[string]any)
if !ok {
t.Fatalf("expected task object, got %#v", item)
}
if got, _ := task["status"].(string); got != "cancelled" {
t.Fatalf("expected all tasks cancelled after run cancel, got %#v", task["status"])
}
}
}
func TestOrchCleanupRemovesCompletedWorktree(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
repoPath := initGitRepo(t)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"run", "init",
"--run", "run_blog_cleanup_001",
"--goal", "Validate cleanup behavior",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"task", "add",
"--run", "run_blog_cleanup_001",
"--task", "T1",
"--title", "Implement backend",
"--default-to", "worker-a",
)
dispatchOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"dispatch",
"--run", "run_blog_cleanup_001",
"--task", "T1",
"--repo-path", repoPath,
"--workspace-root", ".orch/worktrees",
"--strict-worktree",
)
var dispatchResp map[string]any
mustDecodeJSON(t, dispatchOut, &dispatchResp)
threadID := nestedString(t, dispatchResp, "data", "attempt", "thread_id")
worktreePath := nestedString(t, dispatchResp, "data", "attempt", "worktree_path")
runInboxCommand(
t,
"--db", dbPath,
"--json",
"claim",
"--agent", "worker-a",
"--thread", threadID,
)
runInboxCommand(
t,
"--db", dbPath,
"--json",
"done",
"--agent", "worker-a",
"--thread", threadID,
"--summary", "Backend complete",
)
runOrchCommand(
t,
"--db", dbPath,
"--json",
"reconcile",
"--run", "run_blog_cleanup_001",
)
cleanupOut := runOrchCommand(
t,
"--db", dbPath,
"--json",
"cleanup",
"--run", "run_blog_cleanup_001",
"--task", "T1",
)
var cleanupResp map[string]any
mustDecodeJSON(t, cleanupOut, &cleanupResp)
cleaned := nestedArray(t, cleanupResp, "data", "cleaned")
if len(cleaned) != 1 {
t.Fatalf("expected one cleaned attempt, got %#v", cleaned)
}
if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
t.Fatalf("expected cleaned worktree path to be removed, err=%v", err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type reassignOptions struct {
runID string
taskID string
toAgent string
reason string
}
func newReassignCmd(root *rootOptions) *cobra.Command {
opts := &reassignOptions{}
cmd := &cobra.Command{
Use: "reassign",
Short: "Reassign a blocked or failed task to another worker",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
sqlDB, err := openOrchDB(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
s := store.NewOrchStore(sqlDB)
task, attempt, err := s.GetTaskWithLatestAttempt(ctx, opts.runID, opts.taskID)
if err != nil {
return err
}
result, err := s.ReassignTask(ctx, store.ReassignInput{
RunID: opts.runID,
TaskID: opts.taskID,
ToAgent: opts.toAgent,
Reason: opts.reason,
PrepareWorkspace: newAttemptReuseWorkspacePreparer(cmd, task, attempt),
})
if err != nil {
return err
}
resp := protocol.Success{
OK: true,
Command: "reassign",
Data: map[string]any{
"task": result.Task,
"attempt": result.Attempt,
"thread": result.Thread,
"message": result.Message,
"previous_attempt": result.PreviousAttempt,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "reassigned task %s to %s as attempt %d\n", result.Task.TaskID, result.Attempt.AssignedTo, result.Attempt.AttemptNo)
return err
},
}
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
cmd.Flags().StringVar(&opts.toAgent, "to", "", "Destination worker agent")
cmd.Flags().StringVar(&opts.reason, "reason", "", "Reason for reassignment")
_ = cmd.MarkFlagRequired("run")
_ = cmd.MarkFlagRequired("task")
_ = cmd.MarkFlagRequired("to")
return cmd
}
+85
View File
@@ -0,0 +1,85 @@
package orch
import (
"fmt"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type retryOptions struct {
runID string
taskID string
toAgent string
body string
bodyFile string
}
func newRetryCmd(root *rootOptions) *cobra.Command {
opts := &retryOptions{}
cmd := &cobra.Command{
Use: "retry",
Short: "Retry a failed task by creating a new attempt",
RunE: func(cmd *cobra.Command, args []string) error {
body, err := resolveBodyValue(opts.body, opts.bodyFile)
if err != nil {
return err
}
ctx := cmd.Context()
sqlDB, err := openOrchDB(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
s := store.NewOrchStore(sqlDB)
task, attempt, err := s.GetTaskWithLatestAttempt(ctx, opts.runID, opts.taskID)
if err != nil {
return err
}
result, err := s.RetryTask(ctx, store.RetryInput{
RunID: opts.runID,
TaskID: opts.taskID,
ToAgent: opts.toAgent,
Body: body,
PrepareWorkspace: newAttemptReuseWorkspacePreparer(cmd, task, attempt),
})
if err != nil {
return err
}
resp := protocol.Success{
OK: true,
Command: "retry",
Data: map[string]any{
"task": result.Task,
"attempt": result.Attempt,
"thread": result.Thread,
"message": result.Message,
"previous_attempt": result.PreviousAttempt,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "retried task %s as attempt %d\n", result.Task.TaskID, result.Attempt.AttemptNo)
return err
},
}
cmd.Flags().StringVar(&opts.runID, "run", "", "Run ID")
cmd.Flags().StringVar(&opts.taskID, "task", "", "Task ID")
cmd.Flags().StringVar(&opts.toAgent, "to", "", "Optional worker agent override")
cmd.Flags().StringVar(&opts.body, "body", "", "Retry instruction body")
cmd.Flags().StringVar(&opts.bodyFile, "body-file", "", "Read retry instruction body from file")
_ = cmd.MarkFlagRequired("run")
_ = cmd.MarkFlagRequired("task")
return cmd
}
+4
View File
@@ -29,6 +29,10 @@ func NewRootCmd() *cobra.Command {
cmd.AddCommand(newDispatchCmd(opts))
cmd.AddCommand(newReconcileCmd(opts))
cmd.AddCommand(newWaitCmd(opts))
cmd.AddCommand(newRetryCmd(opts))
cmd.AddCommand(newReassignCmd(opts))
cmd.AddCommand(newCancelCmd(opts))
cmd.AddCommand(newCleanupCmd(opts))
cmd.AddCommand(newBlockedCmd(opts))
cmd.AddCommand(newAnswerCmd(opts))
cmd.AddCommand(newStatusCmd(opts))
+108 -2
View File
@@ -26,6 +26,31 @@ func newDispatchWorkspacePreparer(cmd *cobra.Command, opts dispatchOptions) stor
}
}
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.repoPath) != "" ||
strings.TrimSpace(opts.workspaceRoot) != "" ||
@@ -94,11 +119,15 @@ func resolveRepoRoot(ctx context.Context, repoPath string) (string, error) {
return "", fmt.Errorf("resolve repo path: %w", err)
}
stdout, _, err := runGit(ctx, absPath, "rev-parse", "--show-toplevel")
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 strings.TrimSpace(stdout), nil
return filepath.Dir(commonDir), nil
}
func resolveDispatchBase(ctx context.Context, repoRoot, workspaceRoot, requestedBaseRef string, strict bool) (string, string, error) {
@@ -242,6 +271,27 @@ func buildAttemptWorktreePath(workspaceRoot, runID, taskID string, attemptNo int
)
}
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)
}
@@ -301,3 +351,59 @@ func runGit(ctx context.Context, repoRoot string, args ...string) (string, strin
}
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
}