diff --git a/cmd/orchestrate.go b/cmd/orchestrate.go index 4d8f1a4e..c0c2b936 100644 --- a/cmd/orchestrate.go +++ b/cmd/orchestrate.go @@ -117,6 +117,9 @@ func runOrchestrate(cmd *cobra.Command, args []string) error { // git push retries, PR feedback polling, etc. This allows scheduled tasks // to be processed even when no orchestrator is running for a theWork. + // Create runner once for all tasks + runner := claude.NewRunner() + // Main orchestration loop: poll for ready tasks and execute them for { @@ -239,14 +242,14 @@ func runOrchestrate(cmd *cobra.Command, args []string) error { fmt.Printf("Warning: failed to update task activity at start: %v\n", err) } - if err := executeTask(proj, task, theWork); err != nil { + if err := executeTask(proj, task, theWork, runner); err != nil { return fmt.Errorf("task %s failed: %w", task.ID, err) } } } // executeTask executes a single task inline based on its type. -func executeTask(proj *project.Project, t *db.Task, work *db.Work) error { +func executeTask(proj *project.Project, t *db.Task, work *db.Work, runner claude.Runner) error { ctx := GetContext() // Create a context with timeout from configuration @@ -263,7 +266,7 @@ func executeTask(proj *project.Project, t *db.Task, work *db.Work) error { } // Execute Claude inline with timeout context - if err = claude.Run(taskCtx, proj.DB, t.ID, prompt, work.WorktreePath, proj.Config); err != nil { + if err = runner.Run(taskCtx, proj.DB, t.ID, prompt, work.WorktreePath, proj.Config); err != nil { // Check if it was a timeout error if errors.Is(err, context.DeadlineExceeded) { // Mark the task as failed due to timeout diff --git a/cmd/proj.go b/cmd/proj.go index df0eaf72..cb280751 100644 --- a/cmd/proj.go +++ b/cmd/proj.go @@ -95,7 +95,8 @@ func runProjDestroy(cmd *cobra.Command, args []string) error { defer proj.Close() // List worktrees - worktrees, err := worktree.List(ctx, proj.MainRepoPath()) + wtOps := worktree.NewOperations() + worktrees, err := wtOps.List(ctx, proj.MainRepoPath()) if err != nil { return fmt.Errorf("failed to list worktrees: %w", err) } @@ -131,7 +132,7 @@ func runProjDestroy(cmd *cobra.Command, args []string) error { // Remove all worktrees for _, wt := range taskWorktrees { fmt.Printf("Removing worktree %s...\n", wt.Path) - if err := worktree.RemoveForce(ctx, proj.MainRepoPath(), wt.Path); err != nil { + if err := wtOps.RemoveForce(ctx, proj.MainRepoPath(), wt.Path); err != nil { fmt.Printf("Warning: failed to remove worktree %s: %v\n", wt.Path, err) } } @@ -165,7 +166,8 @@ func runProjStatus(cmd *cobra.Command, args []string) error { fmt.Printf(" Path: %s\n", proj.MainRepoPath()) // List worktrees - worktrees, err := worktree.List(ctx, proj.MainRepoPath()) + wtOps := worktree.NewOperations() + worktrees, err := wtOps.List(ctx, proj.MainRepoPath()) if err != nil { return fmt.Errorf("failed to list worktrees: %w", err) } diff --git a/cmd/run.go b/cmd/run.go index 9c84b20a..10f4537f 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -117,7 +117,7 @@ func runTasks(cmd *cobra.Command, args []string) error { return fmt.Errorf("work %s has no worktree path configured", workRecord.ID) } - if !worktree.ExistsPath(workRecord.WorktreePath) { + if !worktree.NewOperations().ExistsPath(workRecord.WorktreePath) { return fmt.Errorf("work %s worktree does not exist at %s", workRecord.ID, workRecord.WorktreePath) } diff --git a/cmd/sync.go b/cmd/sync.go index 32768c2d..88395d37 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -35,7 +35,9 @@ func runSync(cmd *cobra.Command, args []string) error { fmt.Printf("Syncing project: %s\n", proj.Config.Project.Name) // Get all worktrees - worktrees, err := worktree.List(ctx, proj.MainRepoPath()) + wtOps := worktree.NewOperations() + gitOps := git.NewOperations() + worktrees, err := wtOps.List(ctx, proj.MainRepoPath()) if err != nil { return fmt.Errorf("failed to list worktrees: %w", err) } @@ -58,7 +60,7 @@ func runSync(cmd *cobra.Command, args []string) error { fmt.Printf(" Pulling %s [%s]... ", wt.Path, branchInfo) - if err := git.PullInDir(ctx, wt.Path); err != nil { + if err := gitOps.Pull(ctx, wt.Path); err != nil { fmt.Printf("FAILED: %v\n", err) failCount++ } else { diff --git a/cmd/task_processing.go b/cmd/task_processing.go index 6b06bb06..0ed9d17a 100644 --- a/cmd/task_processing.go +++ b/cmd/task_processing.go @@ -134,7 +134,7 @@ func getBeadsForTask(ctx context.Context, proj *project.Project, taskID string) // processTask processes a single task by ID using inline execution. // This blocks until the task is complete. -func processTask(proj *project.Project, taskID string) error { +func processTask(proj *project.Project, taskID string, runner claude.Runner) error { ctx := GetContext() // Get the task @@ -192,7 +192,7 @@ func processTask(proj *project.Project, taskID string) error { return fmt.Errorf("work %s has no worktree path configured", work.ID) } - if !worktree.ExistsPath(work.WorktreePath) { + if !worktree.NewOperations().ExistsPath(work.WorktreePath) { return fmt.Errorf("work %s worktree does not exist at %s", work.ID, work.WorktreePath) } @@ -203,7 +203,7 @@ func processTask(proj *project.Project, taskID string) error { } // Execute Claude inline (blocking) - if err := claude.Run(ctx, proj.DB, taskID, prompt, work.WorktreePath, proj.Config); err != nil { + if err := runner.Run(ctx, proj.DB, taskID, prompt, work.WorktreePath, proj.Config); err != nil { return fmt.Errorf("task %s failed: %w", taskID, err) } diff --git a/cmd/work.go b/cmd/work.go index ecab74bf..2ec382b2 100644 --- a/cmd/work.go +++ b/cmd/work.go @@ -213,6 +213,8 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { baseBranch := proj.Config.Repo.GetBaseBranch() mainRepoPath := proj.MainRepoPath() + gitOps := git.NewOperations() + wtOps := worktree.NewOperations() beadID := args[0] // Expand the bead (handles epics and transitive deps) @@ -251,7 +253,7 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { useExistingBranch = true // Validate the branch exists - existsLocal, existsRemote, err := git.ValidateExistingBranch(ctx, mainRepoPath, branchName) + existsLocal, existsRemote, err := gitOps.ValidateExistingBranch(ctx, mainRepoPath, branchName) if err != nil { return fmt.Errorf("failed to validate branch: %w", err) } @@ -301,41 +303,41 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { if useExistingBranch { // For existing branches, fetch the branch first - if err := git.FetchBranch(ctx, mainRepoPath, branchName); err != nil { + if err := gitOps.FetchBranch(ctx, mainRepoPath, branchName); err != nil { // Ignore fetch errors - branch might only exist locally fmt.Printf("Note: Could not fetch branch %s from origin (may only exist locally)\n", branchName) } // Create worktree from existing branch - if err := worktree.CreateFromExisting(ctx, mainRepoPath, worktreePath, branchName); err != nil { + if err := wtOps.CreateFromExisting(ctx, mainRepoPath, worktreePath, branchName); err != nil { os.RemoveAll(workDir) return err } // Only push if branch doesn't exist on remote yet if !branchExistsOnRemote { - if err := git.PushSetUpstreamInDir(ctx, branchName, worktreePath); err != nil { - worktree.RemoveForce(ctx, mainRepoPath, worktreePath) + if err := gitOps.PushSetUpstream(ctx, branchName, worktreePath); err != nil { + _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) os.RemoveAll(workDir) return err } } } else { // Fetch latest from origin for the base branch - if err := git.FetchBranch(ctx, mainRepoPath, baseBranch); err != nil { + if err := gitOps.FetchBranch(ctx, mainRepoPath, baseBranch); err != nil { os.RemoveAll(workDir) return fmt.Errorf("failed to fetch base branch: %w", err) } // Create worktree with new branch based on origin/ - if err := worktree.Create(ctx, mainRepoPath, worktreePath, branchName, "origin/"+baseBranch); err != nil { + if err := wtOps.Create(ctx, mainRepoPath, worktreePath, branchName, "origin/"+baseBranch); err != nil { os.RemoveAll(workDir) return err } // Push branch and set upstream - if err := git.PushSetUpstreamInDir(ctx, branchName, worktreePath); err != nil { - worktree.RemoveForce(ctx, mainRepoPath, worktreePath) + if err := gitOps.PushSetUpstream(ctx, branchName, worktreePath); err != nil { + _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) os.RemoveAll(workDir) return err } @@ -354,14 +356,14 @@ func runWorkCreate(cmd *cobra.Command, args []string) error { // Create work record in database with the root issue ID (the original bead that was expanded) if err := proj.DB.CreateWork(ctx, workID, workerName, worktreePath, branchName, baseBranch, beadID, flagAutoRun); err != nil { - worktree.RemoveForce(ctx, mainRepoPath, worktreePath) + _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) os.RemoveAll(workDir) return fmt.Errorf("failed to create work record: %w", err) } // Add beads to work_beads if err := work.AddBeadsToWorkInternal(ctx, proj, workID, expandedIssueIDs); err != nil { - worktree.RemoveForce(ctx, mainRepoPath, worktreePath) + _ = wtOps.RemoveForce(ctx, mainRepoPath, worktreePath) os.RemoveAll(workDir) return fmt.Errorf("failed to add beads to work: %w", err) } @@ -892,7 +894,8 @@ func runWorkPR(cmd *cobra.Command, args []string) error { // Auto-run the PR task fmt.Printf("Running PR task...\n") - if err := processTask(proj, result.TaskID); err != nil { + runner := claude.NewRunner() + if err := processTask(proj, result.TaskID, runner); err != nil { return err } @@ -973,6 +976,7 @@ func runWorkReview(cmd *cobra.Command, args []string) error { } // Run review-fix loop if --auto is set + runner := claude.NewRunner() maxIterations := proj.Config.Workflow.GetMaxReviewIterations() for iteration := 0; ; iteration++ { // Check max iterations @@ -999,7 +1003,7 @@ func runWorkReview(cmd *cobra.Command, args []string) error { // Run the review task fmt.Printf("Running review task...\n") - if err := processTask(proj, reviewTaskID); err != nil { + if err := processTask(proj, reviewTaskID, runner); err != nil { return fmt.Errorf("review task failed: %w", err) } @@ -1057,7 +1061,7 @@ func runWorkReview(cmd *cobra.Command, args []string) error { fmt.Printf("Created fix task %s for bead %s: %s\n", taskID, b.ID, b.Title) // Run the fix task - if err := processTask(proj, taskID); err != nil { + if err := processTask(proj, taskID, runner); err != nil { return fmt.Errorf("fix task %s failed: %w", taskID, err) } } diff --git a/internal/claude/inline.go b/internal/claude/inline.go index 8b1e94a1..c22d4e8f 100644 --- a/internal/claude/inline.go +++ b/internal/claude/inline.go @@ -16,10 +16,26 @@ import ( trackingwatcher "github.com/newhook/co/internal/tracking/watcher" ) -// Run executes Claude directly in the current terminal (fork/exec). -// This blocks until Claude exits or the task is marked complete in the database. -// The config parameter controls Claude settings like --dangerously-skip-permissions. -func Run(ctx context.Context, database *db.DB, taskID string, prompt string, workDir string, cfg *project.Config) error { +// Runner defines the interface for running Claude. +// This abstraction enables testing without spawning the actual claude CLI. +type Runner interface { + // Run executes Claude directly in the current terminal (fork/exec). + Run(ctx context.Context, database *db.DB, taskID string, prompt string, workDir string, cfg *project.Config) error +} + +// CLIRunner implements Runner using the claude CLI. +type CLIRunner struct{} + +// Compile-time check that CLIRunner implements Runner. +var _ Runner = (*CLIRunner)(nil) + +// NewRunner creates a new Runner that uses the claude CLI. +func NewRunner() Runner { + return &CLIRunner{} +} + +// Run implements Runner.Run. +func (r *CLIRunner) Run(ctx context.Context, database *db.DB, taskID string, prompt string, workDir string, cfg *project.Config) error { // Get task to verify it exists task, err := database.GetTask(ctx, taskID) if err != nil { diff --git a/internal/control/handler_create_worktree.go b/internal/control/handler_create_worktree.go index 4bbbbfba..5226abeb 100644 --- a/internal/control/handler_create_worktree.go +++ b/internal/control/handler_create_worktree.go @@ -47,11 +47,13 @@ func HandleCreateWorktreeTask(ctx context.Context, proj *project.Project, task * } mainRepoPath := proj.MainRepoPath() + gitOps := git.NewOperations() + wtOps := worktree.NewOperations() var branchExistsOnRemote bool // For existing branches, check if branch exists on remote if useExisting { - _, branchExistsOnRemote, _ = git.ValidateExistingBranch(ctx, mainRepoPath, branchName) + _, branchExistsOnRemote, _ = gitOps.ValidateExistingBranch(ctx, mainRepoPath, branchName) } // If worktree path is already set and exists, skip creation @@ -70,25 +72,25 @@ func HandleCreateWorktreeTask(ctx context.Context, proj *project.Project, task * if useExisting { // For existing branches, fetch the branch first - if err := git.FetchBranch(ctx, mainRepoPath, branchName); err != nil { + if err := gitOps.FetchBranch(ctx, mainRepoPath, branchName); err != nil { // Ignore fetch errors - branch might only exist locally logging.Debug("Could not fetch branch from origin (may only exist locally)", "branch", branchName) } // Create worktree from existing branch - if err := worktree.CreateFromExisting(ctx, mainRepoPath, worktreePath, branchName); err != nil { + if err := wtOps.CreateFromExisting(ctx, mainRepoPath, worktreePath, branchName); err != nil { _ = os.RemoveAll(workDir) return fmt.Errorf("failed to create worktree from existing branch: %w", err) } } else { // Fetch latest from origin for the base branch - if err := git.FetchBranch(ctx, mainRepoPath, baseBranch); err != nil { + if err := gitOps.FetchBranch(ctx, mainRepoPath, baseBranch); err != nil { _ = os.RemoveAll(workDir) return fmt.Errorf("failed to fetch base branch: %w", err) } // Create git worktree with new branch based on origin/ - if err := worktree.Create(ctx, mainRepoPath, worktreePath, branchName, "origin/"+baseBranch); err != nil { + if err := wtOps.Create(ctx, mainRepoPath, worktreePath, branchName, "origin/"+baseBranch); err != nil { _ = os.RemoveAll(workDir) return fmt.Errorf("failed to create worktree: %w", err) } @@ -112,7 +114,7 @@ func HandleCreateWorktreeTask(ctx context.Context, proj *project.Project, task * if useExisting && branchExistsOnRemote { logging.Info("Skipping git push - branch already exists on remote", "branch", branchName) } else { - if err := git.PushSetUpstreamInDir(ctx, branchName, work.WorktreePath); err != nil { + if err := gitOps.PushSetUpstream(ctx, branchName, work.WorktreePath); err != nil { return fmt.Errorf("git push failed: %w", err) } } diff --git a/internal/control/handler_destroy_worktree.go b/internal/control/handler_destroy_worktree.go index dd9e2eb6..540e9dd8 100644 --- a/internal/control/handler_destroy_worktree.go +++ b/internal/control/handler_destroy_worktree.go @@ -57,7 +57,7 @@ func HandleDestroyWorktreeTask(ctx context.Context, proj *project.Project, task // but the directory might still exist. The os.RemoveAll below will clean up the directory. if work.WorktreePath != "" { logging.Info("Removing git worktree", "work_id", workID, "path", work.WorktreePath) - if err := worktree.RemoveForce(ctx, proj.MainRepoPath(), work.WorktreePath); err != nil { + if err := worktree.NewOperations().RemoveForce(ctx, proj.MainRepoPath(), work.WorktreePath); err != nil { logging.Warn("failed to remove git worktree (continuing with directory removal)", "error", err, "work_id", workID) } } diff --git a/internal/control/handler_git_push.go b/internal/control/handler_git_push.go index c45315d2..1d182795 100644 --- a/internal/control/handler_git_push.go +++ b/internal/control/handler_git_push.go @@ -34,7 +34,7 @@ func HandleGitPushTask(ctx context.Context, proj *project.Project, task *db.Sche logging.Info("Executing git push", "branch", branch, "dir", dir, "attempt", task.AttemptCount+1) - if err := git.PushSetUpstreamInDir(ctx, branch, dir); err != nil { + if err := git.NewOperations().PushSetUpstream(ctx, branch, dir); err != nil { return err } diff --git a/internal/git/git.go b/internal/git/git.go index 74f59086..87ce51f0 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -26,17 +26,19 @@ type Operations interface { ListBranches(ctx context.Context, repoPath string) ([]string, error) } -// cliOperations implements Operations using the git CLI. -type cliOperations struct{} +// CLIOperations implements Operations using the git CLI. +type CLIOperations struct{} -// Compile-time check that cliOperations implements Operations. -var _ Operations = (*cliOperations)(nil) +// Compile-time check that CLIOperations implements Operations. +var _ Operations = (*CLIOperations)(nil) -// Default is the default Operations implementation using the git CLI. -var Default Operations = &cliOperations{} +// NewOperations creates a new Operations implementation using the git CLI. +func NewOperations() Operations { + return &CLIOperations{} +} // PushSetUpstream implements Operations.PushSetUpstream. -func (c *cliOperations) PushSetUpstream(ctx context.Context, branch, dir string) error { +func (c *CLIOperations) PushSetUpstream(ctx context.Context, branch, dir string) error { cmd := exec.CommandContext(ctx, "git", "push", "--set-upstream", "origin", branch) if dir != "" { cmd.Dir = dir @@ -47,14 +49,8 @@ func (c *cliOperations) PushSetUpstream(ctx context.Context, branch, dir string) return nil } -// PushSetUpstreamInDir pushes the specified branch and sets upstream tracking. -// Deprecated: Use Default.PushSetUpstream instead. -func PushSetUpstreamInDir(ctx context.Context, branch, dir string) error { - return Default.PushSetUpstream(ctx, branch, dir) -} - // Pull implements Operations.Pull. -func (c *cliOperations) Pull(ctx context.Context, dir string) error { +func (c *CLIOperations) Pull(ctx context.Context, dir string) error { cmd := exec.CommandContext(ctx, "git", "pull") if dir != "" { cmd.Dir = dir @@ -65,14 +61,8 @@ func (c *cliOperations) Pull(ctx context.Context, dir string) error { return nil } -// PullInDir pulls the latest changes in a specific directory. -// Deprecated: Use Default.Pull instead. -func PullInDir(ctx context.Context, dir string) error { - return Default.Pull(ctx, dir) -} - // Clone implements Operations.Clone. -func (c *cliOperations) Clone(ctx context.Context, source, dest string) error { +func (c *CLIOperations) Clone(ctx context.Context, source, dest string) error { cmd := exec.CommandContext(ctx, "git", "clone", source, dest) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to clone repository: %w\n%s", err, output) @@ -80,14 +70,8 @@ func (c *cliOperations) Clone(ctx context.Context, source, dest string) error { return nil } -// Clone clones a repository from source to dest. -// Deprecated: Use Default.Clone instead. -func Clone(ctx context.Context, source, dest string) error { - return Default.Clone(ctx, source, dest) -} - // FetchBranch implements Operations.FetchBranch. -func (c *cliOperations) FetchBranch(ctx context.Context, repoPath, branch string) error { +func (c *CLIOperations) FetchBranch(ctx context.Context, repoPath, branch string) error { cmd := exec.CommandContext(ctx, "git", "fetch", "origin", branch) cmd.Dir = repoPath if output, err := cmd.CombinedOutput(); err != nil { @@ -96,14 +80,8 @@ func (c *cliOperations) FetchBranch(ctx context.Context, repoPath, branch string return nil } -// FetchBranch fetches a specific branch from origin. -// Deprecated: Use Default.FetchBranch instead. -func FetchBranch(ctx context.Context, repoPath, branch string) error { - return Default.FetchBranch(ctx, repoPath, branch) -} - // BranchExists implements Operations.BranchExists. -func (c *cliOperations) BranchExists(ctx context.Context, repoPath, branchName string) bool { +func (c *CLIOperations) BranchExists(ctx context.Context, repoPath, branchName string) bool { // Check local branches cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) cmd.Dir = repoPath @@ -117,14 +95,8 @@ func (c *cliOperations) BranchExists(ctx context.Context, repoPath, branchName s return cmd.Run() == nil } -// BranchExists checks if a branch exists locally or remotely. -// Deprecated: Use Default.BranchExists instead. -func BranchExists(ctx context.Context, repoPath, branchName string) bool { - return Default.BranchExists(ctx, repoPath, branchName) -} - // ValidateExistingBranch implements Operations.ValidateExistingBranch. -func (c *cliOperations) ValidateExistingBranch(ctx context.Context, repoPath, branchName string) (bool, bool, error) { +func (c *CLIOperations) ValidateExistingBranch(ctx context.Context, repoPath, branchName string) (bool, bool, error) { // Check local branches cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) cmd.Dir = repoPath @@ -138,14 +110,8 @@ func (c *cliOperations) ValidateExistingBranch(ctx context.Context, repoPath, br return existsLocal, existsRemote, nil } -// ValidateExistingBranch checks if a branch exists locally, remotely, or both. -// Deprecated: Use Default.ValidateExistingBranch instead. -func ValidateExistingBranch(ctx context.Context, repoPath, branchName string) (bool, bool, error) { - return Default.ValidateExistingBranch(ctx, repoPath, branchName) -} - // ListBranches implements Operations.ListBranches. -func (c *cliOperations) ListBranches(ctx context.Context, repoPath string) ([]string, error) { +func (c *CLIOperations) ListBranches(ctx context.Context, repoPath string) ([]string, error) { // Get local branches cmd := exec.CommandContext(ctx, "git", "branch", "--format=%(refname:short)") cmd.Dir = repoPath @@ -207,9 +173,3 @@ func (c *cliOperations) ListBranches(ctx context.Context, repoPath string) ([]st return branches, nil } - -// ListBranches returns a deduplicated list of all branches (local and remote). -// Deprecated: Use Default.ListBranches instead. -func ListBranches(ctx context.Context, repoPath string) ([]string, error) { - return Default.ListBranches(ctx, repoPath) -} diff --git a/internal/linear/client.go b/internal/linear/client.go index b6f5441f..0beab3a2 100644 --- a/internal/linear/client.go +++ b/internal/linear/client.go @@ -20,6 +20,19 @@ const ( DefaultTimeout = 30 * time.Second ) +// ClientInterface defines the interface for Linear API operations. +// This abstraction enables testing without HTTP calls. +type ClientInterface interface { + // GetIssue fetches a single issue by ID or URL. + GetIssue(ctx context.Context, issueIDOrURL string) (*Issue, error) + // SearchIssues searches for issues using a text query. + SearchIssues(ctx context.Context, searchQuery string, filters map[string]any) ([]*Issue, error) + // ListIssues lists issues with optional filters. + ListIssues(ctx context.Context, filters map[string]any) ([]*Issue, error) + // GetIssueComments fetches comments for an issue. + GetIssueComments(ctx context.Context, issueID string) ([]Comment, error) +} + // Client is a Linear GraphQL API client type Client struct { endpoint string @@ -27,6 +40,9 @@ type Client struct { httpClient *http.Client } +// Compile-time check that Client implements ClientInterface. +var _ ClientInterface = (*Client)(nil) + // NewClient creates a new Linear GraphQL API client func NewClient(apiKey string) (*Client, error) { if apiKey == "" { diff --git a/internal/linear/fetcher.go b/internal/linear/fetcher.go index d636ef30..2d7a0bee 100644 --- a/internal/linear/fetcher.go +++ b/internal/linear/fetcher.go @@ -12,7 +12,7 @@ import ( // Fetcher orchestrates fetching Linear issues and importing them into Beads type Fetcher struct { - client *Client + client ClientInterface beadsDir string beadsCache map[string]string // linearID -> beadID cache } @@ -31,6 +31,16 @@ func NewFetcher(apiKey string, beadsDir string) (*Fetcher, error) { }, nil } +// NewFetcherWithClient creates a new fetcher with a provided client implementation. +// This is useful for testing with a mock client. +func NewFetcherWithClient(client ClientInterface, beadsDir string) *Fetcher { + return &Fetcher{ + client: client, + beadsDir: beadsDir, + beadsCache: make(map[string]string), + } +} + // FetchAndImport fetches a Linear issue and imports it into Beads // Returns the created bead ID and any error func (f *Fetcher) FetchAndImport(ctx context.Context, linearIDOrURL string, opts *ImportOptions) (*ImportResult, error) { diff --git a/internal/project/project.go b/internal/project/project.go index 3b3ebb0b..f737a814 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -211,7 +211,7 @@ const BeadsPathProject = ".co/.beads" func cloneRepo(ctx context.Context, source, mainPath string) (repoType string, err error) { if isGitHubURL(source) { // Clone from GitHub - if err := git.Clone(ctx, source, mainPath); err != nil { + if err := git.NewOperations().Clone(ctx, source, mainPath); err != nil { return "", err } return RepoTypeGitHub, nil diff --git a/internal/tui/tui_plan.go b/internal/tui/tui_plan.go index 43867f1b..b4ac1bee 100644 --- a/internal/tui/tui_plan.go +++ b/internal/tui/tui_plan.go @@ -1408,7 +1408,7 @@ func (m *planModel) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { branchName := generateBranchNameFromBeadsForBranch(branchBeads) m.createWorkPanel.Reset(bead.ID, branchName) // Load available branches for the "existing branch" mode - if branches, err := git.ListBranches(m.ctx, m.proj.MainRepoPath()); err == nil { + if branches, err := git.NewOperations().ListBranches(m.ctx, m.proj.MainRepoPath()); err == nil { m.createWorkPanel.SetBranches(branches) } m.viewMode = ViewCreateWork diff --git a/internal/work/branch.go b/internal/work/branch.go index 1efbc8a7..2230fcf8 100644 --- a/internal/work/branch.go +++ b/internal/work/branch.go @@ -95,15 +95,17 @@ func GenerateBranchNameFromIssues(issues []*beads.Bead) string { // EnsureUniqueBranchName checks if a branch already exists and appends a suffix if needed. // Returns a unique branch name that doesn't conflict with existing branches. func EnsureUniqueBranchName(ctx context.Context, repoPath, baseName string) (string, error) { + gitOps := git.NewOperations() + // Check if the base name is available - if !git.BranchExists(ctx, repoPath, baseName) { + if !gitOps.BranchExists(ctx, repoPath, baseName) { return baseName, nil } // Try appending suffixes until we find an available name for i := 2; i <= 100; i++ { candidate := fmt.Sprintf("%s-%d", baseName, i) - if !git.BranchExists(ctx, repoPath, candidate) { + if !gitOps.BranchExists(ctx, repoPath, candidate) { return candidate, nil } } diff --git a/internal/work/run.go b/internal/work/run.go index 5eda5961..2e0ceea3 100644 --- a/internal/work/run.go +++ b/internal/work/run.go @@ -44,7 +44,7 @@ func RunWorkWithOptions(ctx context.Context, proj *project.Project, workID strin return nil, fmt.Errorf("work %s has no worktree path configured", work.ID) } - if !worktree.ExistsPath(work.WorktreePath) { + if !worktree.NewOperations().ExistsPath(work.WorktreePath) { return nil, fmt.Errorf("work %s worktree does not exist at %s", work.ID, work.WorktreePath) } @@ -95,7 +95,7 @@ func RunWorkAuto(ctx context.Context, proj *project.Project, workID string, w io return nil, fmt.Errorf("work %s has no worktree path configured", work.ID) } - if !worktree.ExistsPath(work.WorktreePath) { + if !worktree.NewOperations().ExistsPath(work.WorktreePath) { return nil, fmt.Errorf("work %s worktree does not exist at %s", work.ID, work.WorktreePath) } diff --git a/internal/work/work.go b/internal/work/work.go index fe318eae..18086c52 100644 --- a/internal/work/work.go +++ b/internal/work/work.go @@ -255,7 +255,7 @@ func DestroyWork(ctx context.Context, proj *project.Project, workID string, w io // Remove git worktree if it exists if work.WorktreePath != "" { - if err := worktree.RemoveForce(ctx, proj.MainRepoPath(), work.WorktreePath); err != nil { + if err := worktree.NewOperations().RemoveForce(ctx, proj.MainRepoPath(), work.WorktreePath); err != nil { fmt.Fprintf(w, "Warning: failed to remove worktree: %v\n", err) } } diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 6676e1c7..f34459ad 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -16,10 +16,34 @@ type Worktree struct { Branch string // Branch name (empty if detached) } -// Create creates a new worktree at worktreePath from repoPath with a new branch. -// If baseBranch is non-empty, the new branch is created from that base. -// Uses: git -C worktree add -b [] -func Create(ctx context.Context, repoPath, worktreePath, branch, baseBranch string) error { +// Operations defines the interface for worktree operations. +// This abstraction enables testing without actual git commands. +type Operations interface { + // Create creates a new worktree at worktreePath from repoPath with a new branch. + Create(ctx context.Context, repoPath, worktreePath, branch, baseBranch string) error + // CreateFromExisting creates a worktree at worktreePath for an existing branch. + CreateFromExisting(ctx context.Context, repoPath, worktreePath, branch string) error + // RemoveForce forcefully removes a worktree even if it has uncommitted changes. + RemoveForce(ctx context.Context, repoPath, worktreePath string) error + // List returns all worktrees for the given repository. + List(ctx context.Context, repoPath string) ([]Worktree, error) + // ExistsPath checks if the worktree path exists on disk. + ExistsPath(worktreePath string) bool +} + +// CLIOperations implements Operations using the git CLI. +type CLIOperations struct{} + +// Compile-time check that CLIOperations implements Operations. +var _ Operations = (*CLIOperations)(nil) + +// NewOperations creates a new Operations implementation using the git CLI. +func NewOperations() Operations { + return &CLIOperations{} +} + +// Create implements Operations.Create. +func (c *CLIOperations) Create(ctx context.Context, repoPath, worktreePath, branch, baseBranch string) error { args := []string{"-C", repoPath, "worktree", "add", worktreePath, "-b", branch} if baseBranch != "" { args = append(args, baseBranch) @@ -31,10 +55,8 @@ func Create(ctx context.Context, repoPath, worktreePath, branch, baseBranch stri return nil } -// CreateFromExisting creates a worktree at worktreePath for an existing branch. -// If the branch only exists on remote (not locally), git will auto-track origin/. -// Uses: git -C worktree add -func CreateFromExisting(ctx context.Context, repoPath, worktreePath, branch string) error { +// CreateFromExisting implements Operations.CreateFromExisting. +func (c *CLIOperations) CreateFromExisting(ctx context.Context, repoPath, worktreePath, branch string) error { args := []string{"-C", repoPath, "worktree", "add", worktreePath, branch} cmd := exec.CommandContext(ctx, "git", args...) if output, err := cmd.CombinedOutput(); err != nil { @@ -43,9 +65,8 @@ func CreateFromExisting(ctx context.Context, repoPath, worktreePath, branch stri return nil } -// RemoveForce forcefully removes a worktree even if it has uncommitted changes. -// Uses: git -C worktree remove --force -func RemoveForce(ctx context.Context, repoPath, worktreePath string) error { +// RemoveForce implements Operations.RemoveForce. +func (c *CLIOperations) RemoveForce(ctx context.Context, repoPath, worktreePath string) error { cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "worktree", "remove", "--force", worktreePath) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to force remove worktree: %w\n%s", err, output) @@ -53,9 +74,8 @@ func RemoveForce(ctx context.Context, repoPath, worktreePath string) error { return nil } -// List returns all worktrees for the given repository. -// Uses: git -C worktree list --porcelain -func List(ctx context.Context, repoPath string) ([]Worktree, error) { +// List implements Operations.List. +func (c *CLIOperations) List(ctx context.Context, repoPath string) ([]Worktree, error) { cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "worktree", "list", "--porcelain") output, err := cmd.Output() if err != nil { @@ -108,8 +128,8 @@ func parseWorktreeList(output string) ([]Worktree, error) { return worktrees, scanner.Err() } -// ExistsPath checks if the worktree path exists on disk. -func ExistsPath(worktreePath string) bool { +// ExistsPath implements Operations.ExistsPath. +func (c *CLIOperations) ExistsPath(worktreePath string) bool { info, err := os.Stat(worktreePath) if err != nil { return false