From 1d47ae0e5817d5c7157f17eba78cad62d1842f96 Mon Sep 17 00:00:00 2001 From: Louis FRADIN Date: Thu, 27 Nov 2025 16:36:05 +0100 Subject: [PATCH 1/3] fix: use remote URL for detached clone when branch doesn't exist locally Fixed issue where worktree load command failed when loading branches from remote. The problem was that detached clones were trying to clone from local path with --branch flag, which failed when the branch only existed on the remote. Changes: - Modified createDetachedClone to check if branch exists locally - If branch exists locally, use CloneToPath (clones from local path) - If branch doesn't exist locally, clone from remote URL and checkout branch - Updated CheckoutBranch to handle remote tracking branches when branch doesn't exist locally - Updated unit test to match new implementation This fixes the issue where 'cm wt load -r ' would fail with: 'fatal: Remote branch not found in upstream origin' All tests pass: lint, unit, integration, and e2e. --- pkg/git/checkout_branch.go | 17 +++++++++--- pkg/worktree/create.go | 53 +++++++++++++++++++++++++++++++++---- pkg/worktree/create_test.go | 12 +++++++-- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/pkg/git/checkout_branch.go b/pkg/git/checkout_branch.go index c6559e2..395f11d 100644 --- a/pkg/git/checkout_branch.go +++ b/pkg/git/checkout_branch.go @@ -6,13 +6,24 @@ import ( ) // CheckoutBranch checks out a branch in the specified worktree. +// If the branch doesn't exist locally, it will try to checkout from origin/branch. func (g *realGit) CheckoutBranch(worktreePath, branch string) error { + // First try to checkout the branch directly cmd := exec.Command("git", "checkout", branch) cmd.Dir = worktreePath output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("git checkout failed: %w (command: git checkout %s, output: %s)", - err, branch, string(output)) + if err == nil { + return nil + } + + // If that fails, try to checkout with tracking from origin + cmd = exec.Command("git", "checkout", "-b", branch, "origin/"+branch) + cmd.Dir = worktreePath + output2, err2 := cmd.CombinedOutput() + if err2 != nil { + // Return the original error if both attempts fail + return fmt.Errorf("git checkout failed: %w (command: git checkout %s, output: %s; fallback: git checkout -b %s origin/%s, output: %s)", + err, branch, string(output), branch, branch, string(output2)) } return nil } diff --git a/pkg/worktree/create.go b/pkg/worktree/create.go index 7e9b20f..5ee615b 100644 --- a/pkg/worktree/create.go +++ b/pkg/worktree/create.go @@ -4,6 +4,7 @@ package worktree import ( "fmt" + "github.com/lerenn/code-manager/pkg/git" "github.com/lerenn/code-manager/pkg/logger" ) @@ -45,13 +46,55 @@ func (w *realWorktree) Create(params CreateParams) error { // createDetachedClone creates a standalone clone for detached worktrees. func (w *realWorktree) createDetachedClone(params CreateParams) error { - if err := w.git.CloneToPath(params.RepoPath, params.WorktreePath, params.Branch); err != nil { - // Clean up directory on failure - if cleanupErr := w.cleanupWorktreeDirectory(params.WorktreePath); cleanupErr != nil { - w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) + // Check if branch exists locally - if so, clone from local path + // Otherwise, clone from remote URL + branchExists, err := w.git.BranchExists(params.RepoPath, params.Branch) + if err != nil { + return fmt.Errorf("failed to check if branch exists: %w", err) + } + + if branchExists { + // Branch exists locally, clone from local path + w.logger.Logf("Branch exists locally, cloning from local path: %s", params.RepoPath) + if err := w.git.CloneToPath(params.RepoPath, params.WorktreePath, params.Branch); err != nil { + // Clean up directory on failure + if cleanupErr := w.cleanupWorktreeDirectory(params.WorktreePath); cleanupErr != nil { + w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) + } + return fmt.Errorf("failed to create detached clone: %w", err) + } + } else { + // Branch doesn't exist locally, clone from remote URL + remoteURL, err := w.git.GetRemoteURL(params.RepoPath, params.Remote) + if err != nil { + return fmt.Errorf("failed to get remote URL for %s: %w", params.Remote, err) + } + + w.logger.Logf("Branch doesn't exist locally, cloning from remote URL: %s", remoteURL) + + // Clone from the remote URL (this will fetch all branches) + if err := w.git.Clone(git.CloneParams{ + RepoURL: remoteURL, + TargetPath: params.WorktreePath, + Recursive: true, + }); err != nil { + // Clean up directory on failure + if cleanupErr := w.cleanupWorktreeDirectory(params.WorktreePath); cleanupErr != nil { + w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) + } + return fmt.Errorf("failed to clone from remote: %w", err) + } + + // Checkout the specific branch + if err := w.git.CheckoutBranch(params.WorktreePath, params.Branch); err != nil { + // Clean up directory on failure + if cleanupErr := w.cleanupWorktreeDirectory(params.WorktreePath); cleanupErr != nil { + w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) + } + return fmt.Errorf("failed to checkout branch %s: %w", params.Branch, err) } - return fmt.Errorf("failed to create detached clone: %w", err) } + w.logger.Logf("✓ Detached clone created successfully for %s:%s", params.Remote, params.Branch) return nil } diff --git a/pkg/worktree/create_test.go b/pkg/worktree/create_test.go index acd69fc..e0d4951 100644 --- a/pkg/worktree/create_test.go +++ b/pkg/worktree/create_test.go @@ -209,8 +209,16 @@ func TestWorktree_Create_DetachedMode(t *testing.T) { mockGit.EXPECT().CheckReferenceConflict(params.RepoPath, params.Branch).Return(nil) mockGit.EXPECT().BranchExists(params.RepoPath, params.Branch).Return(true, nil) mockFS.EXPECT().MkdirAll(params.WorktreePath, gomock.Any()).Return(nil) - // For detached mode, should call CloneToPath instead of CreateWorktreeWithNoCheckout - mockGit.EXPECT().CloneToPath(params.RepoPath, params.WorktreePath, params.Branch).Return(nil) + // For detached mode, should get remote URL, clone from remote, and checkout branch + remoteURL := "https://github.com/octocat/Hello-World.git" + mockGit.EXPECT().GetRemoteURL(params.RepoPath, params.Remote).Return(remoteURL, nil) + mockGit.EXPECT().Clone(gomock.Any()).DoAndReturn(func(cloneParams git.CloneParams) error { + assert.Equal(t, remoteURL, cloneParams.RepoURL) + assert.Equal(t, params.WorktreePath, cloneParams.TargetPath) + assert.True(t, cloneParams.Recursive) + return nil + }) + mockGit.EXPECT().CheckoutBranch(params.WorktreePath, params.Branch).Return(nil) err := worktree.Create(params) assert.NoError(t, err) From 10a6aa45ada9c04e6726b02acd069019d209e407 Mon Sep 17 00:00:00 2001 From: Louis FRADIN Date: Fri, 28 Nov 2025 14:16:05 +0100 Subject: [PATCH 2/3] fix: resolve lint errors - Fix line length issue in checkout_branch.go (split long error message) - Reduce complexity in create.go by extracting helper functions --- pkg/git/checkout_branch.go | 3 +- pkg/worktree/create.go | 81 +++++++++++++++++++++----------------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/pkg/git/checkout_branch.go b/pkg/git/checkout_branch.go index 395f11d..439c926 100644 --- a/pkg/git/checkout_branch.go +++ b/pkg/git/checkout_branch.go @@ -22,7 +22,8 @@ func (g *realGit) CheckoutBranch(worktreePath, branch string) error { output2, err2 := cmd.CombinedOutput() if err2 != nil { // Return the original error if both attempts fail - return fmt.Errorf("git checkout failed: %w (command: git checkout %s, output: %s; fallback: git checkout -b %s origin/%s, output: %s)", + return fmt.Errorf( + "git checkout failed: %w (command: git checkout %s, output: %s; fallback: git checkout -b %s origin/%s, output: %s)", err, branch, string(output), branch, branch, string(output2)) } return nil diff --git a/pkg/worktree/create.go b/pkg/worktree/create.go index 5ee615b..f8b321f 100644 --- a/pkg/worktree/create.go +++ b/pkg/worktree/create.go @@ -54,51 +54,58 @@ func (w *realWorktree) createDetachedClone(params CreateParams) error { } if branchExists { - // Branch exists locally, clone from local path - w.logger.Logf("Branch exists locally, cloning from local path: %s", params.RepoPath) - if err := w.git.CloneToPath(params.RepoPath, params.WorktreePath, params.Branch); err != nil { - // Clean up directory on failure - if cleanupErr := w.cleanupWorktreeDirectory(params.WorktreePath); cleanupErr != nil { - w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) - } - return fmt.Errorf("failed to create detached clone: %w", err) - } - } else { - // Branch doesn't exist locally, clone from remote URL - remoteURL, err := w.git.GetRemoteURL(params.RepoPath, params.Remote) - if err != nil { - return fmt.Errorf("failed to get remote URL for %s: %w", params.Remote, err) - } + return w.createDetachedCloneFromLocal(params) + } + return w.createDetachedCloneFromRemote(params) +} - w.logger.Logf("Branch doesn't exist locally, cloning from remote URL: %s", remoteURL) - - // Clone from the remote URL (this will fetch all branches) - if err := w.git.Clone(git.CloneParams{ - RepoURL: remoteURL, - TargetPath: params.WorktreePath, - Recursive: true, - }); err != nil { - // Clean up directory on failure - if cleanupErr := w.cleanupWorktreeDirectory(params.WorktreePath); cleanupErr != nil { - w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) - } - return fmt.Errorf("failed to clone from remote: %w", err) - } +// createDetachedCloneFromLocal creates a detached clone from a local repository path. +func (w *realWorktree) createDetachedCloneFromLocal(params CreateParams) error { + w.logger.Logf("Branch exists locally, cloning from local path: %s", params.RepoPath) + if err := w.git.CloneToPath(params.RepoPath, params.WorktreePath, params.Branch); err != nil { + w.cleanupOnError(params.WorktreePath) + return fmt.Errorf("failed to create detached clone: %w", err) + } + w.logger.Logf("✓ Detached clone created successfully for %s:%s", params.Remote, params.Branch) + return nil +} - // Checkout the specific branch - if err := w.git.CheckoutBranch(params.WorktreePath, params.Branch); err != nil { - // Clean up directory on failure - if cleanupErr := w.cleanupWorktreeDirectory(params.WorktreePath); cleanupErr != nil { - w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) - } - return fmt.Errorf("failed to checkout branch %s: %w", params.Branch, err) - } +// createDetachedCloneFromRemote creates a detached clone from a remote URL. +func (w *realWorktree) createDetachedCloneFromRemote(params CreateParams) error { + remoteURL, err := w.git.GetRemoteURL(params.RepoPath, params.Remote) + if err != nil { + return fmt.Errorf("failed to get remote URL for %s: %w", params.Remote, err) + } + + w.logger.Logf("Branch doesn't exist locally, cloning from remote URL: %s", remoteURL) + + // Clone from the remote URL (this will fetch all branches) + if err := w.git.Clone(git.CloneParams{ + RepoURL: remoteURL, + TargetPath: params.WorktreePath, + Recursive: true, + }); err != nil { + w.cleanupOnError(params.WorktreePath) + return fmt.Errorf("failed to clone from remote: %w", err) + } + + // Checkout the specific branch + if err := w.git.CheckoutBranch(params.WorktreePath, params.Branch); err != nil { + w.cleanupOnError(params.WorktreePath) + return fmt.Errorf("failed to checkout branch %s: %w", params.Branch, err) } w.logger.Logf("✓ Detached clone created successfully for %s:%s", params.Remote, params.Branch) return nil } +// cleanupOnError attempts to clean up the worktree directory on error. +func (w *realWorktree) cleanupOnError(worktreePath string) { + if cleanupErr := w.cleanupWorktreeDirectory(worktreePath); cleanupErr != nil { + w.logger.Logf("Warning: failed to clean up worktree directory: %v", cleanupErr) + } +} + // createRegularWorktree creates a regular Git worktree. func (w *realWorktree) createRegularWorktree(params CreateParams) error { if err := w.git.CreateWorktreeWithNoCheckout(params.RepoPath, params.WorktreePath, params.Branch); err != nil { From 09510f6e8b85dd7d138cfd1a47774cffd06c653f Mon Sep 17 00:00:00 2001 From: Louis FRADIN Date: Fri, 28 Nov 2025 16:51:18 +0100 Subject: [PATCH 3/3] fix: skip EnsureBranchExists in detached mode to avoid duplicate BranchExists calls - Skip EnsureBranchExists when creating detached worktrees since createDetachedClone handles branch checking - Update TestWorktree_Create_DetachedMode to expect BranchExists to return false, matching the remote clone path behavior --- pkg/worktree/create.go | 11 ++++++----- pkg/worktree/create_test.go | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/worktree/create.go b/pkg/worktree/create.go index f8b321f..42948dc 100644 --- a/pkg/worktree/create.go +++ b/pkg/worktree/create.go @@ -27,11 +27,6 @@ func (w *realWorktree) Create(params CreateParams) error { return err } - // Ensure branch exists - if err := w.EnsureBranchExists(params.RepoPath, params.Branch); err != nil { - return err - } - // Create worktree directory if err := w.createWorktreeDirectory(params.WorktreePath); err != nil { return err @@ -41,6 +36,12 @@ func (w *realWorktree) Create(params CreateParams) error { if params.Detached { return w.createDetachedClone(params) } + + // Ensure branch exists (only for regular worktrees) + if err := w.EnsureBranchExists(params.RepoPath, params.Branch); err != nil { + return err + } + return w.createRegularWorktree(params) } diff --git a/pkg/worktree/create_test.go b/pkg/worktree/create_test.go index e0d4951..b6b61e5 100644 --- a/pkg/worktree/create_test.go +++ b/pkg/worktree/create_test.go @@ -206,8 +206,8 @@ func TestWorktree_Create_DetachedMode(t *testing.T) { mockFS.EXPECT().Exists(params.WorktreePath).Return(false, nil) mockStatus.EXPECT().GetWorktree(params.RepoURL, params.Branch).Return(nil, errors.New("not found")) mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil) - mockGit.EXPECT().CheckReferenceConflict(params.RepoPath, params.Branch).Return(nil) - mockGit.EXPECT().BranchExists(params.RepoPath, params.Branch).Return(true, nil) + // For detached mode, check if branch exists locally - if not, clone from remote + mockGit.EXPECT().BranchExists(params.RepoPath, params.Branch).Return(false, nil) mockFS.EXPECT().MkdirAll(params.WorktreePath, gomock.Any()).Return(nil) // For detached mode, should get remote URL, clone from remote, and checkout branch remoteURL := "https://github.com/octocat/Hello-World.git"